diff --git a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx
index 8e8684350..b0f91cab9 100644
--- a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx
+++ b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx
@@ -1,5 +1,6 @@
import {
ActionIcon,
+ Button,
ColorInput,
Group,
NumberInput,
@@ -11,7 +12,7 @@ import {
TextInput,
Tooltip,
} from '@mantine/core';
-import { IconTrash } from '@tabler/icons-react';
+import { IconBold, IconItalic, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api';
@@ -43,7 +44,17 @@ export function BlockPropertiesPanel({
);
}
- const isLiteral = block.variable === null;
+ const current = block;
+ const isLiteral = current.variable === null;
+ const isImage = current.type === 'image';
+ const variableByKey = new Map(variables.map((v) => [v.key, v]));
+
+ const selectVariable = (key: string | null) => {
+ if (!key) return;
+ const kind: 'text' | 'image' =
+ variableByKey.get(key)?.kind === 'image' ? 'image' : 'text';
+ onChange({ ...current, variable: key, type: kind });
+ };
return (
@@ -70,11 +81,9 @@ export function BlockPropertiesPanel({
value={isLiteral ? 'text' : 'variable'}
disabled={disabled}
onChange={(value) =>
- onChange(
- value === 'text'
- ? { ...block, variable: null, text: block.text ?? '' }
- : { ...block, variable: variables[0]?.key ?? 'companyName' },
- )
+ value === 'text'
+ ? onChange({ ...block, variable: null, type: 'text', text: block.text ?? '' })
+ : selectVariable(variables[0]?.key ?? 'companyName')
}
data={[
{ value: 'variable', label: t('designer.blockVariable', 'Variable') },
@@ -94,24 +103,26 @@ export function BlockPropertiesPanel({
label={t('designer.blockVariableLabel', 'Variable')}
data={variables.map((variable) => ({
value: variable.key,
- label: variable.label,
+ label: variable.kind === 'image' ? `🖼 ${variable.label}` : variable.label,
}))}
value={block.variable}
- onChange={(value) => onChange({ ...block, variable: value })}
+ onChange={selectVariable}
searchable
disabled={disabled}
/>
)}
- onChange({ ...block, fontSize: Number(value) || 14 })}
- min={4}
- max={200}
- disabled={disabled}
- />
+ {!isImage && (
+ onChange({ ...block, fontSize: Number(value) || 14 })}
+ min={4}
+ max={200}
+ disabled={disabled}
+ />
+ )}
-
- onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
- }
- data={[
- { value: 'left', label: t('designer.alignLeft', 'Left') },
- { value: 'center', label: t('designer.alignCenter', 'Centre') },
- { value: 'right', label: t('designer.alignRight', 'Right') },
- ]}
- />
+ {!isImage && (
+ <>
+
+ onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
+ }
+ data={[
+ { value: 'left', label: t('designer.alignLeft', 'Left') },
+ { value: 'center', label: t('designer.alignCenter', 'Centre') },
+ { value: 'right', label: t('designer.alignRight', 'Right') },
+ { value: 'justify', label: t('designer.alignJustify', 'Justify') },
+ ]}
+ />
-
- onChange({ ...block, fontWeight: value as TemplateFieldPlacement['fontWeight'] })
- }
- data={[
- { value: 'normal', label: t('designer.weightNormal', 'Normal') },
- { value: 'bold', label: t('designer.weightBold', 'Bold') },
- ]}
- />
+
+
+
+
- onChange({ ...block, color: value })}
- disabled={disabled}
- format="hex"
- />
+ onChange({ ...block, color: value })}
+ disabled={disabled}
+ format="hex"
+ />
+ >
+ )}
);
diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx
index 70e603189..3c5bbea86 100644
--- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx
+++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx
@@ -23,6 +23,10 @@ interface Props {
onLogoPlacementChange: (placement: TemplateLogoPlacement) => void;
landscape: boolean;
onLandscapeChange: (landscape: boolean) => void;
+ pageWidth: string;
+ onPageWidthChange: (width: string) => void;
+ pageHeight: string;
+ onPageHeightChange: (height: string) => void;
disabled: boolean;
}
@@ -51,6 +55,10 @@ export function TemplateBackgroundPanel({
onLogoPlacementChange,
landscape,
onLandscapeChange,
+ pageWidth,
+ onPageWidthChange,
+ pageHeight,
+ onPageHeightChange,
disabled,
}: Props) {
const { t } = useTranslation();
@@ -91,9 +99,40 @@ export function TemplateBackgroundPanel({
]}
/>
- {landscape
- ? t('designer.a4Landscape', 'A4 — 297 × 210 mm')
- : t('designer.a4Portrait', 'A4 — 210 × 297 mm')}
+ {pageWidth && pageHeight
+ ? t('designer.customSize', 'Custom size — see below')
+ : landscape
+ ? t('designer.a4Landscape', 'A4 — 297 × 210 mm')
+ : t('designer.a4Portrait', 'A4 — 210 × 297 mm')}
+
+
+
+
+
+ {t('designer.customPageSize', 'Custom page size')}
+
+
+ onPageWidthChange(e.currentTarget.value)}
+ disabled={disabled}
+ w={100}
+ />
+ ×
+ onPageHeightChange(e.currentTarget.value)}
+ disabled={disabled}
+ w={100}
+ />
+
+
+ {t(
+ 'designer.customSizeHint',
+ 'e.g. 4.92in × 3.46in. Leave both blank to use A4. Overrides orientation\'s A4 size when set.',
+ )}
diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx
index db0530f2f..57c3912b7 100644
--- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx
+++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Paper, Text } from '@mantine/core';
+import { IconPhoto } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
@@ -8,6 +9,8 @@ interface Props {
logoUrl: string;
logoPlacement: TemplateLogoPlacement;
landscape: boolean;
+ pageWidth?: string;
+ pageHeight?: string;
placements: TemplateFieldPlacement[];
selectedId: string | null;
onSelect: (id: string | null) => void;
@@ -15,9 +18,17 @@ interface Props {
disabled: boolean;
}
-/** A4 aspect ratio, the only page size the renderer is configured for. */
+/** A4 aspect ratio, the fallback for a version with no custom page size. */
const A4_RATIO = 297 / 210;
+/** Parses a CSS length like "4.92in" or "125mm" into a unitless number, unit-agnostic — only the ratio between width and height matters here. */
+function parseLength(value: string): number | null {
+ const match = value.trim().match(/^([\d.]+)/);
+ if (!match) return null;
+ const n = Number(match[1]);
+ return Number.isFinite(n) && n > 0 ? n : null;
+}
+
const LOGO_CORNER_STYLE: Record React.CSSProperties> = {
TOP_LEFT: (o) => ({ top: `${o}%`, left: `${o}%` }),
TOP_CENTER: (o) => ({ top: `${o}%`, left: '50%', transform: 'translateX(-50%)' }),
@@ -53,6 +64,8 @@ export function TemplateCanvas({
logoUrl,
logoPlacement,
landscape,
+ pageWidth,
+ pageHeight,
placements,
selectedId,
onSelect,
@@ -180,6 +193,18 @@ export function TemplateCanvas({
logoPlacement.offsetPct ?? 5,
);
+ // A custom size already states its own orientation (4.92in × 3.46in is
+ // landscape on its own), so it is used as-is rather than flipped again by
+ // `landscape` — that flag only disambiguates the A4 fallback below.
+ const customWidth = pageWidth ? parseLength(pageWidth) : null;
+ const customHeight = pageHeight ? parseLength(pageHeight) : null;
+ const aspectRatio =
+ customWidth && customHeight
+ ? customWidth / customHeight
+ : landscape
+ ? A4_RATIO
+ : 1 / A4_RATIO;
+
return (
@@ -195,7 +220,7 @@ export function TemplateCanvas({
style={{
position: 'relative',
width: '100%',
- aspectRatio: landscape ? String(A4_RATIO) : String(1 / A4_RATIO),
+ aspectRatio: String(aspectRatio),
background: '#ffffff',
border: '1px solid var(--mantine-color-gray-4)',
overflow: 'hidden',
@@ -233,6 +258,7 @@ export function TemplateCanvas({
{placements.map((block) => {
const isSelected = block.id === selectedId;
+ const isImage = block.type === 'image';
return (
- {block.variable ? `{{${block.variable}}}` : block.text || ' '}
+ {isImage ? (
+
+ ) : block.variable ? (
+ `{{${block.variable}}}`
+ ) : (
+ block.text || ' '
+ )}
{isSelected && !disabled && (
void;
- onAddBlock: (key: string) => void;
+ onAddBlock: (key: string, kind: 'text' | 'image') => void;
onAddTextBlock: () => void;
}
@@ -60,8 +56,13 @@ export function TemplateVariableList({
variant="default"
justify="flex-start"
disabled={disabled}
+ leftSection={
+ variable.kind === 'image' ? : undefined
+ }
onClick={() =>
- canvasMode ? onAddBlock(variable.key) : onInsert(variable.key)
+ canvasMode
+ ? onAddBlock(variable.key, variable.kind === 'image' ? 'image' : 'text')
+ : onInsert(variable.key)
}
>
{`{{${variable.key}}}`}
diff --git a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts
index f6fd5abae..e9f4d2892 100644
--- a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts
+++ b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts
@@ -11,8 +11,16 @@ export const STATUS_COLOR: Record = {
ARCHIVED: 'dark',
};
-/** Page options sent with every save and preview — A4, background printed. */
-export function pageOptionsFor(landscape: boolean) {
+/**
+ * Page options sent with every save and preview.
+ *
+ * A4 unless the version carries an explicit page size — set for documents
+ * like the Seaman Book, whose ICAO 9303 passport-booklet dimensions have no
+ * named `format` preset. `width`/`height` win over `format` in Puppeteer, so
+ * a custom size is sent alone rather than alongside `format: 'A4'`.
+ */
+export function pageOptionsFor(landscape: boolean, size?: { width: string; height: string }) {
+ if (size) return { width: size.width, height: size.height, landscape, printBackground: true };
return { format: 'A4' as const, landscape, printBackground: true };
}
diff --git a/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts b/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts
index 9488bcd2f..e21bd7a17 100644
--- a/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts
+++ b/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts
@@ -41,14 +41,43 @@ function logoHtml(logoUrl: string, placement: TemplateLogoPlacement): string {
return `
\n`;
}
+/**
+ * Variable keys the renderer fills with a data URI — the fallback for a
+ * block placed before `type` existed on it. Keep in sync with
+ * `IMAGE_VARIABLE_KEYS` in the server's template-variables.ts; a new image
+ * variable added there should be added here too.
+ */
+const IMAGE_VARIABLE_KEYS = new Set([
+ 'logo',
+ 'holderPhoto',
+ 'qrImage',
+ 'sealImage',
+ 'signatureImage',
+ 'seafarerSignature',
+]);
+
+function isImageBlock(block: TemplateFieldPlacement): boolean {
+ if (block.type) return block.type === 'image';
+ return !!block.variable && IMAGE_VARIABLE_KEYS.has(block.variable);
+}
+
function blockHtml(block: TemplateFieldPlacement): string {
const x = pct(block.xPct, 0);
const y = pct(block.yPct, 0);
// Minimum 1%, matching the server compiler: a zero-width block would render
// as an invisible sliver rather than as the mistake it is.
const width = pct(block.widthPct, 30, 1);
+
+ if (isImageBlock(block) && block.variable) {
+ // Triple-brace: the value is a data URI, not markup — escaping it turns
+ // every "&" into "&" and corrupts the src.
+ const style = `position:absolute;left:${x}%;top:${y}%;width:${width}%;object-fit:contain;`;
+ return `
\n`;
+ }
+
const size = block.fontSize ?? 14;
const weight = block.fontWeight === 'bold' ? 'bold' : 'normal';
+ const style_ = block.fontStyle === 'italic' ? 'italic' : 'normal';
const align = block.align ?? 'left';
const color = escapeHtml(block.color ?? '#111111');
@@ -58,7 +87,7 @@ function blockHtml(block: TemplateFieldPlacement): string {
const style =
`position:absolute;left:${x}%;top:${y}%;width:${width}%;` +
- `font-size:${size}px;font-weight:${weight};text-align:${align};color:${color};`;
+ `font-size:${size}px;font-weight:${weight};font-style:${style_};text-align:${align};color:${color};`;
return ` ${content}
\n`;
}
diff --git a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts
index 19a28fe34..abfd1ad01 100644
--- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts
+++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts
@@ -22,6 +22,11 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
const [source, setSource] = useState('');
const [name, setName] = useState('');
const [landscape, setLandscape] = useState(true);
+ // Empty string means "use the A4 default" — only a version whose
+ // pageOptions already carries a custom size (e.g. the Seaman Book) starts
+ // with these populated.
+ const [pageWidth, setPageWidth] = useState('');
+ const [pageHeight, setPageHeight] = useState('');
const [backgroundUrl, setBackgroundUrl] = useState('');
const [logoUrl, setLogoUrl] = useState('');
const [logoPlacement, setLogoPlacement] = useState({});
@@ -49,6 +54,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
setSource(selected.hbsSource);
setName(selected.name);
setLandscape(selected.pageOptions?.landscape ?? true);
+ setPageWidth(selected.pageOptions?.width ?? '');
+ setPageHeight(selected.pageOptions?.height ?? '');
setBackgroundUrl(selected.backgroundUrl ?? '');
setLogoUrl(selected.logoUrl ?? '');
setLogoPlacement(selected.logoPlacement ?? {});
@@ -70,6 +77,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
(source !== selected?.hbsSource ||
name !== selected?.name ||
landscape !== (selected?.pageOptions?.landscape ?? true) ||
+ pageWidth !== (selected?.pageOptions?.width ?? '') ||
+ pageHeight !== (selected?.pageOptions?.height ?? '') ||
backgroundUrl !== (selected?.backgroundUrl ?? '') ||
logoUrl !== (selected?.logoUrl ?? '') ||
logoPlacementChanged ||
@@ -81,23 +90,46 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
const selectedBlock =
placements.find((block) => block.id === selectedBlockId) ?? null;
- /** Drops a new block near the top-left, where it is immediately visible. */
- const addBlock = useCallback((variable: string | null, text?: string) => {
- const block: TemplateFieldPlacement = {
- id: blockId(),
- variable,
- text,
- xPct: 10,
- yPct: 10,
- widthPct: 30,
- fontSize: 14,
- fontWeight: 'normal',
- align: 'left',
- color: '#111111',
- };
- setPlacements((prev) => [...prev, block]);
- setSelectedBlockId(block.id);
- }, []);
+ /**
+ * Drops a new block near the top-left, where it is immediately visible.
+ *
+ * An image block gets a square-ish default footprint instead of the text
+ * defaults (fontSize/color/align mean nothing on an `
`) — a seal or
+ * signature dropped at 30% width and no explicit height would otherwise
+ * stretch to whatever the image's own aspect ratio makes of that width,
+ * which reads as broken until the author manually resizes it.
+ */
+ const addBlock = useCallback(
+ (variable: string | null, text?: string, kind: 'text' | 'image' = 'text') => {
+ const block: TemplateFieldPlacement =
+ kind === 'image'
+ ? {
+ id: blockId(),
+ variable,
+ type: 'image',
+ xPct: 10,
+ yPct: 10,
+ widthPct: 15,
+ }
+ : {
+ id: blockId(),
+ variable,
+ text,
+ type: 'text',
+ xPct: 10,
+ yPct: 10,
+ widthPct: 30,
+ fontSize: 14,
+ fontWeight: 'normal',
+ fontStyle: 'normal',
+ align: 'left',
+ color: '#111111',
+ };
+ setPlacements((prev) => [...prev, block]);
+ setSelectedBlockId(block.id);
+ },
+ [],
+ );
const updateBlock = useCallback((next: TemplateFieldPlacement) => {
setPlacements((prev) =>
@@ -137,6 +169,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
setName,
landscape,
setLandscape,
+ pageWidth,
+ setPageWidth,
+ pageHeight,
+ setPageHeight,
backgroundUrl,
setBackgroundUrl,
logoUrl,
diff --git a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts
index c2714f856..975e03459 100644
--- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts
+++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts
@@ -9,6 +9,8 @@ interface PreviewArgs {
hbsSource: string;
licenseTypeId: string | null;
landscape: boolean;
+ pageWidth?: string;
+ pageHeight?: string;
}
/**
@@ -21,7 +23,7 @@ export function useTemplatePreview() {
const [previewUrl, setPreviewUrl] = useState(null);
const open = useCallback(
- async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => {
+ async ({ hbsSource, licenseTypeId, landscape, pageWidth, pageHeight }: PreviewArgs) => {
try {
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
// and calls the API directly — which means spelling out the base URL and
@@ -36,7 +38,10 @@ export function useTemplatePreview() {
body: JSON.stringify({
hbsSource,
licenseTypeId,
- pageOptions: pageOptionsFor(landscape),
+ pageOptions: pageOptionsFor(
+ landscape,
+ pageWidth && pageHeight ? { width: pageWidth, height: pageHeight } : undefined,
+ ),
}),
});
if (!response.ok) throw new Error(await response.text());
diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
index 73f5f9c91..f9401f3e4 100644
--- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
+++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
@@ -221,6 +221,10 @@ export function CertificateDesignerPage() {
onLogoPlacementChange={draft.setLogoPlacement}
landscape={draft.landscape}
onLandscapeChange={draft.setLandscape}
+ pageWidth={draft.pageWidth}
+ onPageWidthChange={draft.setPageWidth}
+ pageHeight={draft.pageHeight}
+ onPageHeightChange={draft.setPageHeight}
disabled={editingLocked}
/>
@@ -278,6 +282,8 @@ export function CertificateDesignerPage() {
logoUrl={draft.logoUrl}
logoPlacement={draft.logoPlacement}
landscape={draft.landscape}
+ pageWidth={draft.pageWidth}
+ pageHeight={draft.pageHeight}
placements={draft.placements}
selectedId={draft.selectedBlockId}
onSelect={draft.setSelectedBlockId}
@@ -345,6 +351,8 @@ export function CertificateDesignerPage() {
: draft.source,
licenseTypeId: typeId,
landscape: draft.landscape,
+ pageWidth: draft.pageWidth || undefined,
+ pageHeight: draft.pageHeight || undefined,
})
}
onSave={() =>
@@ -357,7 +365,12 @@ export function CertificateDesignerPage() {
// canvas layout is present, so sending the stale source
// alongside it would only fight that.
hbsSource: draft.usesCanvas ? undefined : draft.source,
- pageOptions: pageOptionsFor(draft.landscape),
+ pageOptions: pageOptionsFor(
+ draft.landscape,
+ draft.pageWidth && draft.pageHeight
+ ? { width: draft.pageWidth, height: draft.pageHeight }
+ : undefined,
+ ),
backgroundUrl: draft.backgroundUrl || undefined,
logoUrl: draft.logoUrl || undefined,
logoPlacement: draft.logoPlacement,
@@ -392,7 +405,7 @@ export function CertificateDesignerPage() {
disabled={editingLocked}
canvasMode={mode === 'canvas'}
onInsert={draft.insertVariable}
- onAddBlock={(key) => draft.addBlock(key)}
+ onAddBlock={(key, kind) => draft.addBlock(key, undefined, kind)}
onAddTextBlock={() => draft.addBlock(null, 'Text')}
/>
diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx
index 5baba8e99..1efa584e7 100644
--- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx
+++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx
@@ -94,10 +94,16 @@ export function DocumentRequirementEditorDrawer({
return;
}
if (!draft.name.en?.trim()) return;
+ // A condition is either a single-field check or an anyOf list — the CoP
+ // watch_rating_certificate requirement is seeded with anyOf and no field.
const hasCondition =
Boolean(draft.conditionExpression?.field) ||
Boolean(draft.conditionExpression?.anyOf?.length);
- if (draft.mode === 'CONDITIONAL' && !hasCondition) {
+ if (
+ draft.mode === 'CONDITIONAL' &&
+ !hasCondition &&
+ !draft.conditionExpression?.anyOf?.length
+ ) {
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
return;
}
diff --git a/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx
index e3f9ed214..323703738 100644
--- a/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx
+++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx
@@ -4,23 +4,26 @@ import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import {IconPlus} from '@tabler/icons-react';
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
+import { useGetRanksQuery, useLocalized } from '@ema-platform/api';
import {
useGetCertificationsQuery,
useCreateCertificationMutation,
useUpdateCertificationMutation,
useDeleteCertificationMutation,
} from '../../api/certification-api';
-import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification';
+import { type Certification } from '../../types/certification';
import { certificationColumns } from './columns';
import { certificationActionsColumn } from './actions';
function CertificationForm({
editing,
+ rankOptions,
isSubmitting,
onSubmit,
onCancel,
}: {
editing: Certification | null;
+ rankOptions: { value: string; label: string }[];
isSubmitting: boolean;
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
onCancel: () => void;
@@ -53,7 +56,7 @@ function CertificationForm({
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')}
description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')}
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
- data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]}
+ data={rankOptions}
value={rankKey}
onChange={setRankKey}
size="sm"
@@ -74,7 +77,10 @@ export function CertificationPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
+ const localized = useLocalized();
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
+ const { data: rankRes } = useGetRanksQuery();
+ const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
@@ -154,6 +160,7 @@ export function CertificationPage() {
{showForm && (
[r.key, localized(r.name)]));
const { data, isFetching, isError, refetch } = useGetExamsQuery();
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
@@ -418,7 +421,7 @@ export function ExamPage() {
const certOptions = certifications
.filter((c) => c.isActive)
.map((c) => {
- const rank = RANK_KEY_OPTIONS.find((r) => r.value === c.rankKey)?.label;
+ const rank = c.rankKey ? rankLabelByKey.get(c.rankKey) : undefined;
return {
value: c.id,
label: rank ? `${c.name[locale]} — ${rank}` : c.name[locale],
diff --git a/apps/backoffice/src/app/features/license-review/components/AssignDialog.tsx b/apps/backoffice/src/app/features/license-review/components/AssignDialog.tsx
new file mode 100644
index 000000000..9afca696c
--- /dev/null
+++ b/apps/backoffice/src/app/features/license-review/components/AssignDialog.tsx
@@ -0,0 +1,113 @@
+import { useEffect, useState } from 'react';
+import { Modal, Select, Stack, Text, Textarea } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { useGetAssignableOfficersQuery } from '@ema-platform/api';
+import { ModalFooter } from '@ema-platform/ui';
+import { Button } from '@mantine/core';
+
+export type AssignKind = 'review' | 'inspection';
+
+interface AssignDialogProps {
+ opened: boolean;
+ onClose: () => void;
+ /** Which stage is being handed out — changes the wording, not the mechanics. */
+ kind: AssignKind;
+ /** Reference of the application being dispatched, shown for confirmation. */
+ applicationNumber?: string;
+ loading?: boolean;
+ onConfirm: (officerId: string, remark?: string) => void;
+}
+
+/**
+ * The team leader handing work to an employee.
+ *
+ * One dialog for both stages because the decision is identical — pick a person,
+ * optionally say why — and two near-identical modals would drift apart. The
+ * `kind` only selects wording.
+ *
+ * Confirm stays disabled until someone is picked: an assignment with no
+ * assignee is the one mistake this dialog exists to prevent.
+ */
+export function AssignDialog({
+ opened,
+ onClose,
+ kind,
+ applicationNumber,
+ loading,
+ onConfirm,
+}: AssignDialogProps) {
+ const { t } = useTranslation();
+ const { data: officers = [], isLoading } = useGetAssignableOfficersQuery();
+ const [officerId, setOfficerId] = useState(null);
+ const [remark, setRemark] = useState('');
+
+ // Reopening for a different application must not offer the previous
+ // dialog's answers as if they had been chosen for this one.
+ useEffect(() => {
+ if (opened) {
+ setOfficerId(null);
+ setRemark('');
+ }
+ }, [opened]);
+
+ const title =
+ kind === 'review'
+ ? t('queue.assignReview', 'Assign document review')
+ : t('queue.assignInspection', 'Assign inspection');
+
+ return (
+
+
+ {applicationNumber && (
+
+ {applicationNumber}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx b/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx
index 2c34fc9cd..8c863538f 100644
--- a/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx
+++ b/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx
@@ -134,7 +134,13 @@ export function DecisionConfirmModal({
if (!action) return null;
- const needsOfficer = action.id === 'assign' || action.id === 'escalate';
+ // The push-model assignments name a person the same way Assign does, so they
+ // reuse this picker rather than each carrying a dialog of their own.
+ const needsOfficer =
+ action.id === 'assign' ||
+ action.id === 'escalate' ||
+ action.id === 'assign-reviewer' ||
+ action.id === 'assign-inspector';
const reasonMissing = action.requiresReason && !reason.trim() && !reasonCode;
const blocked =
reasonMissing ||
@@ -177,7 +183,11 @@ export function DecisionConfirmModal({
label={
action.id === 'escalate'
? t('review.supervisor', 'Supervisor')
- : t('review.officer', 'Officer')
+ : action.id === 'assign-inspector'
+ ? t('review.inspector', 'Inspector')
+ : action.id === 'assign-reviewer'
+ ? t('review.reviewer', 'Reviewer')
+ : t('review.officer', 'Officer')
}
placeholder={t('review.officerPlaceholder', 'Select who takes this on')}
data={officers.map((officer) => ({
diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts
index 2ce63ea65..8511b7b29 100644
--- a/apps/backoffice/src/app/features/license-review/config/actions.ts
+++ b/apps/backoffice/src/app/features/license-review/config/actions.ts
@@ -18,6 +18,10 @@ export type ActionTier =
export type ActionId =
| 'claim'
| 'assign'
+ | 'assign-reviewer'
+ | 'report-review'
+ | 'assign-inspector'
+ | 'report-inspection'
| 'escalate'
| 'hold'
| 'resume'
@@ -65,6 +69,56 @@ export interface ActionDefinition {
*/
export const ACTIONS: ActionDefinition[] = [
// ------------------------------------------------------------- workflow
+ {
+ id: 'claim',
+ tier: 'workflow',
+ labelKey: 'review.actions.claim',
+ // Mirrors the CLAIM transition's `from` list: an examined cert (CoC/CoP)
+ // sits in ELIGIBILITY_PAID once its eligibility fee clears, not SUBMITTED.
+ from: ['SUBMITTED', 'ELIGIBILITY_PAID'],
+ permissions: ['can:claim:license-application'],
+ emphasis: 'light',
+ },
+ /**
+ * The push-model actions.
+ *
+ * `assign-reviewer` and `assign-inspector` start a stage; `report-review`
+ * and `report-inspection` hand it back. An employee sees only the two report
+ * actions — the assign pair is gated on ASSIGN_APPLICATION, which their
+ * position type does not carry.
+ */
+ {
+ id: 'assign-reviewer',
+ tier: 'workflow',
+ labelKey: 'review.actions.assignReviewer',
+ from: ['SUBMITTED', 'ELIGIBILITY_PAID'],
+ permissions: ['can:assign:license-application'],
+ emphasis: 'filled',
+ },
+ {
+ id: 'report-review',
+ tier: 'workflow',
+ labelKey: 'review.actions.reportReview',
+ from: ['UNDER_REVIEW', 'UNDER_EVALUATION'],
+ permissions: ['can:review:license-application'],
+ emphasis: 'filled',
+ },
+ {
+ id: 'assign-inspector',
+ tier: 'workflow',
+ labelKey: 'review.actions.assignInspector',
+ from: ['REVIEW_REPORTED', 'UNDER_EVALUATION'],
+ permissions: ['can:assign:license-application'],
+ emphasis: 'filled',
+ },
+ {
+ id: 'report-inspection',
+ tier: 'workflow',
+ labelKey: 'review.actions.reportInspection',
+ from: ['INSPECTION_COMPLETED'],
+ permissions: ['can:update:inspection'],
+ emphasis: 'filled',
+ },
// Claim is deliberately absent here: an officer claims from the queue
// (LicenseQueuePage), not from this detail page. That implementation is
// separate — see LicenseQueuePage/actions.tsx — and is unaffected by this.
@@ -78,6 +132,7 @@ export const ACTIONS: ActionDefinition[] = [
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
+ 'INSPECTION_FAILED',
'ON_HOLD',
],
permissions: ['can:assign:license-application'],
@@ -101,6 +156,7 @@ export const ACTIONS: ActionDefinition[] = [
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
+ 'INSPECTION_FAILED',
],
permissions: ['can:hold:license-application'],
emphasis: 'subtle',
@@ -139,7 +195,8 @@ export const ACTIONS: ActionDefinition[] = [
id: 'schedule-inspection',
tier: 'primary',
labelKey: 'review.actions.scheduleInspection',
- from: ['INSPECTION_PENDING'],
+ // INSPECTION_FAILED: booking the re-inspection after a failed visit.
+ from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
permissions: ['can:create:inspection'],
emphasis: 'filled',
},
@@ -147,7 +204,7 @@ export const ACTIONS: ActionDefinition[] = [
id: 'record-inspection',
tier: 'primary',
labelKey: 'review.actions.recordInspection',
- from: ['INSPECTION_PENDING'],
+ from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
permissions: ['can:update:inspection'],
emphasis: 'filled',
},
@@ -155,7 +212,15 @@ export const ACTIONS: ActionDefinition[] = [
id: 'final-approve',
tier: 'primary',
labelKey: 'review.actions.finalApprove',
- from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION'],
+ // ELIGIBILITY_PAID: an examined certificate (CoC/CoP) is decided straight
+ // off the eligibility queue — no assignment step.
+ from: [
+ 'INSPECTION_COMPLETED',
+ 'REVIEW_REPORTED',
+ 'INSPECTION_REPORTED',
+ 'UNDER_EVALUATION',
+ 'ELIGIBILITY_PAID',
+ ],
permissions: ['can:approve:license-application'],
emphasis: 'filled',
color: 'teal',
@@ -165,7 +230,15 @@ export const ACTIONS: ActionDefinition[] = [
id: 'request-adjustment',
tier: 'primary',
labelKey: 'review.actions.requestAdjustment',
- from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
+ from: [
+ 'UNDER_REVIEW',
+ 'UNDER_EVALUATION',
+ 'INSPECTION_COMPLETED',
+ 'REVIEW_REPORTED',
+ 'INSPECTION_REPORTED',
+ 'INSPECTION_FAILED',
+ 'ELIGIBILITY_PAID',
+ ],
permissions: ['can:request-adjustment:license-application'],
emphasis: 'light',
color: 'orange',
@@ -180,6 +253,10 @@ export const ACTIONS: ActionDefinition[] = [
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
+ 'REVIEW_REPORTED',
+ 'INSPECTION_REPORTED',
+ 'INSPECTION_FAILED',
+ 'ELIGIBILITY_PAID',
],
permissions: ['can:reject:license-application'],
emphasis: 'light',
@@ -279,11 +356,18 @@ export interface ResolveContext {
needsCapital: string;
needsInspection: string;
needsDocumentReviews: string;
+ inspectionNotYetDue: string;
};
/** Number of sections/documents the officer has flagged for correction. */
flaggedCount: number;
/** True when an inspection is scheduled and awaiting a result. */
hasPendingInspection: boolean;
+ /**
+ * True while the booked inspection's scheduled instant is still in the
+ * future — a visit cannot have an outcome before it happens, so the record
+ * button waits (the server refuses early results the same way).
+ */
+ inspectionNotYetDue: boolean;
/**
* False while any uploaded document is still unjudged or rejected. Approving
* is a statement that every document was checked, so the button stays dead
@@ -358,12 +442,30 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
+ // The transition table doesn't know which types need an inspection, so
+ // `availableEvents` lists approve-documents at UNDER_EVALUATION even for
+ // types without one — where the server refuses it
+ // (`inspection_not_required_use_final_approve`). Final Approve is the
+ // real action there; don't render its dead twin.
+ if (
+ action.id === 'approve-documents' &&
+ !app.licenseType?.inspectionRequired
+ ) {
+ return [];
+ }
+
const disabled = (reason: string): ResolvedAction => ({
...action,
enabled: false,
disabledReason: reason,
});
+ // Booked but not yet due: keep the button visible so the officer sees
+ // the next step, disabled with the scheduled time as the reason.
+ if (action.id === 'record-inspection' && ctx.inspectionNotYetDue) {
+ return disabled(reasons.inspectionNotYetDue);
+ }
+
// Decisions belong to whoever holds the application.
const needsOwnership =
action.tier === 'primary' && action.id !== 'confirm-payment';
diff --git a/apps/backoffice/src/app/features/license-review/config/license-types.ts b/apps/backoffice/src/app/features/license-review/config/license-types.ts
index 379d74c28..7e920efa5 100644
--- a/apps/backoffice/src/app/features/license-review/config/license-types.ts
+++ b/apps/backoffice/src/app/features/license-review/config/license-types.ts
@@ -116,11 +116,21 @@ const PRESENTATION: Record = {
// no capital threshold, no staff roles.
detailSections: ['overview', 'documents'],
},
+ ENDORSEMENT_SEAFARER: {
+ key: 'ENDORSEMENT_SEAFARER',
+ icon: IconRubberStamp,
+ // Person-centric, same as seafarer registration: no company entity, no
+ // capital threshold, no staff roles, no inspection. Covers CoC and GOC
+ // together — the `endorsementScope` field on the application decides
+ // which certificate section(s) actually have data.
+ detailSections: ['overview', 'documents'],
+ },
+ // Retired by ENDORSEMENT_SEAFARER (see endorsements.seed-data.ts). Kept so
+ // an application filed before the switch still renders with the right
+ // presentation instead of falling back to the generic company layout.
ENDORSEMENT_COC: {
key: 'ENDORSEMENT_COC',
icon: IconRubberStamp,
- // Person-centric, same as seafarer registration: no company entity, no
- // capital threshold, no staff roles, no inspection.
detailSections: ['overview', 'documents'],
},
ENDORSEMENT_GOC: {
diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx
index bd3eb3753..ac28d13ab 100644
--- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx
+++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx
@@ -7,11 +7,12 @@ import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
export function licenseQueueActionsColumn(
t: TFunction,
handlers: {
- claiming: boolean;
- onClaim: (id: string) => void;
+ assigning: boolean;
+ /** Opens the assign dialog. Team leaders only — see below. */
+ onAssign: (application: LicenseApplication) => void;
onOpen: (id: string) => void;
- /** False for a non-logistics queue — there's no unclaimed pool to claim from. */
- claimable?: boolean;
+ /** False for a non-logistics queue — nothing there is dispatched. */
+ assignable?: boolean;
},
): AdvancedColumn {
return {
@@ -19,24 +20,32 @@ export function licenseQueueActionsColumn(
label: t("queue.actionsColumn", "Actions"),
align: "right",
size: 140,
+ /**
+ * "Assign", not "Claim".
+ *
+ * Work is pushed, not picked up: an unassigned application is waiting for
+ * the team leader to hand it to someone, and no seeded position type holds
+ * `CLAIM_APPLICATION` any more. Guarded on `ASSIGN_APPLICATION`, so an
+ * employee sees only "Review" on the files that are already theirs.
+ */
cell: ({ row }) =>
- handlers.claimable !== false &&
+ handlers.assignable !== false &&
row.original.assignedOfficerId === null &&
- // Mirrors the CLAIM transition's `from` list: an examined cert
+ // Mirrors the ASSIGN_REVIEWER transition's `from` list: an examined cert
// (CoC/CoP) sits in ELIGIBILITY_PAID once its eligibility fee clears,
// not SUBMITTED.
(row.original.status === "SUBMITTED" ||
row.original.status === "ELIGIBILITY_PAID") ? (
) : (
diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx
index 856b30581..d07d859a7 100644
--- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx
+++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx
@@ -35,7 +35,7 @@ import {
familyLabels,
localized,
resolveFamilyKind,
- useClaimApplicationMutation,
+ useAssignReviewerMutation,
useGetAllApplicationsQuery,
useGetAssignedToMeQuery,
useGetLicenseTypesQuery,
@@ -54,12 +54,12 @@ import {
AmharicDatePicker,
type AdvancedColumn,
} from "@ema-platform/ui";
-import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import {
DEFAULT_VIEW,
SAVED_VIEWS,
filterFromSearchParams,
readLastView,
+ hasUnclaimedPool,
savedViewsForFamily,
searchParamsFromFilter,
writeLastView,
@@ -70,6 +70,7 @@ import { setDensity } from "../../../../store/preferences.slice";
import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
import { licenseQueueColumns } from "./columns";
+import { AssignDialog } from "../../components/AssignDialog";
import { licenseQueueActionsColumn } from "./actions";
import { PageHeader } from '@ema-platform/ui';
@@ -159,12 +160,16 @@ export function LicenseQueuePage() {
const isLogistics = typeCode
? resolveFamilyKind(typeCode) === "LOGISTICS_LICENSE"
: undefined;
- const visibleViews = savedViewsForFamily(isLogistics !== false);
+ // Claiming is a workflow property, not a label one — Vessel Registration is
+ // a DOCUMENT family but is still triaged off a shared unclaimed pool, so it
+ // keeps the Unassigned tab and the claim actions.
+ const claimable = hasUnclaimedPool(typeCode);
+ const visibleViews = savedViewsForFamily(claimable);
const [view, setView] = useState(
() =>
(searchParams.get("view") as SavedViewId) ||
- (isLogistics === false ? "all" : readLastView()),
+ (claimable ? readLastView() : "all"),
);
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
@@ -174,21 +179,17 @@ export function LicenseQueuePage() {
const [helpOpen, setHelpOpen] = useState(false);
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
- // Non-logistics queues have no unassigned/unclaimed pool (see
- // `savedViewsForFamily`), so a stale "unassigned" view — e.g. restored from
+ // Queues with no unclaimed pool (see `hasUnclaimedPool`) have no unassigned
+ // tab, so a stale "unassigned" view — e.g. restored from
// `readLastView()` — must fall back to "all" rather than land on a tab that
// no longer exists. Auto-created BTC requests specifically start at
// PAYMENT_PENDING, outside "mine" too, so "all" is the one view guaranteed
// to show them.
useEffect(() => {
- if (
- isLogistics === false &&
- !searchParams.has("view") &&
- view === "unassigned"
- ) {
+ if (!claimable && !searchParams.has("view") && view === "unassigned") {
setView("all");
}
- }, [isLogistics, searchParams, view]);
+ }, [claimable, searchParams, view]);
const urlFilter = useMemo(
() => filterFromSearchParams(searchParams),
@@ -267,7 +268,8 @@ export function LicenseQueuePage() {
? mineQuery
: allQuery;
- const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
+ const [assignReviewer, { isLoading: assigning }] = useAssignReviewerMutation();
+ const [assignTarget, setAssignTarget] = useState(null);
const [runExport, { isFetching: exporting }] =
useLazyExportApplicationsQuery();
@@ -354,58 +356,40 @@ export function LicenseQueuePage() {
setFacet({ sortBy: field, sortDir: dir });
};
- async function handleClaim(id: string) {
+ /**
+ * The team leader dispatching one application.
+ *
+ * Replaces the old self-service claim: nothing is picked up any more, so the
+ * queue's primary action is handing a file to an employee. The dialog holds
+ * the choice; this only sends it.
+ */
+ async function handleAssign(officerId: string, remark?: string) {
+ if (!assignTarget) return;
try {
- await claim(id).unwrap();
+ await assignReviewer({ id: assignTarget.id, officerId, remark }).unwrap();
notifications.show({
color: "teal",
- title: t("queue.claimed", "Claimed"),
+ title: t("queue.assigned", "Assigned"),
message: t(
- "queue.claimedBody",
- "The application is now assigned to you.",
+ "queue.assignedBody",
+ "The employee has been notified and the review has started.",
),
});
- changeView("mine");
+ setAssignTarget(null);
+ active.refetch();
} catch (err) {
- // A 409 means another officer got there first — refresh so the queue
- // stops showing work that is no longer available.
notifications.show({
color: "red",
- title: t("queue.claimFailed", "Could not claim"),
+ title: t("queue.assignFailed", "Could not assign"),
message: extractErrorMessage(
err,
- t("queue.claimRace", "Another officer already claimed it."),
+ t("queue.assignError", "The application could not be assigned."),
),
});
active.refetch();
}
}
- async function handleBulkClaim() {
- const results = await Promise.allSettled(
- selected.map((id) => claim(id).unwrap()),
- );
- const claimed = results.filter((r) => r.status === "fulfilled").length;
- const lost = results.length - claimed;
- notifications.show({
- color: lost ? "yellow" : "teal",
- title: t("queue.bulkClaimed", {
- count: claimed,
- defaultValue: "{{count}} claimed",
- }),
- // Partial success is the normal case in a shared queue, so it is
- // reported rather than swallowed or treated as total failure.
- message: lost
- ? t("queue.bulkClaimPartial", {
- count: lost,
- defaultValue: "{{count}} were already taken by another officer.",
- })
- : "",
- });
- setSelected([]);
- active.refetch();
- }
-
const cursorRow = items[cursor];
useQueueKeyboard({
enabled: !helpOpen,
@@ -414,9 +398,15 @@ export function LicenseQueuePage() {
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
onClaim: () => {
- // Only unclaimed rows on a logistics queue can be claimed; pressing c
- // elsewhere is a no-op rather than an error the officer has to read.
+ // "c" now opens the assign dialog on an undispatched row. Kept on the
+ // same key: it is still "do the queue's primary action to this row",
+ // and rebinding a shortcut officers have in their fingers costs more
+ // than the name mismatch.
if (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null)
+ setAssignTarget(cursorRow);
+ // Only unclaimed rows on a claimable queue can be claimed; pressing c
+ // elsewhere is a no-op rather than an error the officer has to read.
+ if (claimable && cursorRow && cursorRow.assignedOfficerId === null)
handleClaim(cursorRow.id);
},
onEscape: () => setSelected([]),
@@ -478,12 +468,15 @@ export function LicenseQueuePage() {
isLogistics,
}),
licenseQueueActionsColumn(t, {
- claiming,
- onClaim: handleClaim,
+ assigning,
+ onAssign: setAssignTarget,
onOpen: (id) => navigate(`/licence-review/${id}`),
- // Non-logistics applications aren't claimed off a shared queue (see
+ // Non-logistics applications aren't dispatched off a shared queue (see
// `savedViewsForFamily`) — every row opens straight to Review.
- claimable: isLogistics !== false,
+ assignable: isLogistics !== false,
+ // Applications with no unclaimed pool (see `hasUnclaimedPool`) are
+ // never claimed — every row opens straight to Review.
+ claimable,
}),
],
[
@@ -494,8 +487,9 @@ export function LicenseQueuePage() {
selected,
allSelected,
items,
- claiming,
+ assigning,
isLogistics,
+ claimable,
],
);
@@ -763,7 +757,7 @@ export function LicenseQueuePage() {
>
{t("queue.export", "Export CSV")}
- {isLogistics !== false && (
+ {claimable && (
)}
+ setAssignTarget(null)}
+ kind="review"
+ applicationNumber={assignTarget?.applicationNumber}
+ loading={assigning}
+ onConfirm={handleAssign}
+ />
);
}
diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx
index c26ec2cc8..1977689ae 100644
--- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx
+++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx
@@ -4,9 +4,12 @@ import {
ActionIcon,
Alert,
Badge,
+ Button,
Container,
+ FileButton,
Grid,
Group,
+ Loader,
Modal,
NumberInput,
Paper,
@@ -24,8 +27,11 @@ import {
import {
IconAlertTriangle,
IconCheck,
+ IconEye,
+ IconFileDownload,
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
+ IconPaperclip,
IconQuestionMark,
IconX,
} from "@tabler/icons-react";
@@ -39,6 +45,10 @@ import {
useLocalized,
useApproveDocumentsMutation,
useAssignApplicationMutation,
+ useAssignReviewerMutation,
+ useAssignInspectorMutation,
+ useReportReviewMutation,
+ useReportInspectionMutation,
useClaimApplicationMutation,
useCompleteReviewMutation,
useConfirmPaymentMutation,
@@ -60,6 +70,8 @@ import {
useRequestAdjustmentMutation,
useResumeApplicationMutation,
useScheduleInspectionMutation,
+ useGetCertificateUrlForOfficerMutation,
+ uploadDocument,
type RemarkTargetType,
type StaffEvidenceRequirement,
} from "@ema-platform/api";
@@ -210,6 +222,11 @@ export function LicenseReviewPage() {
const [confirmPayment] = useConfirmPaymentMutation();
const [scheduleIssuance] = useScheduleIssuanceMutation();
const [issueCertificate] = useIssueCertificateMutation();
+ const [getCertificateUrlForOfficer] = useGetCertificateUrlForOfficerMutation();
+ const [certificateBusy, setCertificateBusy] = useState<"view" | "download" | null>(
+ null,
+ );
+ const [certificatePreview, setCertificatePreview] = useState(null);
const [scheduleExam, { isLoading: schedulingExam }] =
useScheduleExamMutation();
const [recordExamOutcome] = useRecordExamOutcomeMutation();
@@ -217,6 +234,13 @@ export function LicenseReviewPage() {
const [resumeApplication] = useResumeApplicationMutation();
const [escalateApplication] = useEscalateApplicationMutation();
const [assignApplication] = useAssignApplicationMutation();
+ const [assignReviewer, { isLoading: assigningReviewer }] =
+ useAssignReviewerMutation();
+ const [assignInspector, { isLoading: assigningInspector }] =
+ useAssignInspectorMutation();
+ const [reportReview] = useReportReviewMutation();
+ const [reportInspection] = useReportInspectionMutation();
+
// Real officer list, so Assign and Escalate name a person instead of
// silently reassigning to whoever already held the application.
const { data: officers = [] } = useGetAssignableOfficersQuery();
@@ -234,6 +258,9 @@ export function LicenseReviewPage() {
const [railOpen, setRailOpen] = useState(true);
const [inspectionOpen, setInspectionOpen] = useState(false);
const [inspectionDate, setInspectionDate] = useState("");
+ const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">(
+ "MORNING",
+ );
const [issuanceOpen, setIssuanceOpen] = useState(false);
const [issuanceDate, setIssuanceDate] = useState("");
const [resultOpen, setResultOpen] = useState(false);
@@ -241,6 +268,10 @@ export function LicenseReviewPage() {
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
const [examScore, setExamScore] = useState();
const [findings, setFindings] = useState("");
+ const [findingsUploadBusy, setFindingsUploadBusy] = useState(false);
+ const [findingsPreview, setFindingsPreview] = useState<
+ { url: string; title: string } | null
+ >(null);
const [checklist, setChecklist] = useState<
Record
>({});
@@ -279,6 +310,18 @@ export function LicenseReviewPage() {
}, [flags]);
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
+ // A visit cannot have an outcome before it happens — mirror of the server's
+ // inspection_not_yet_due guard, compared instant-to-instant.
+ const inspectionNotYetDue = Boolean(
+ pendingInspection?.scheduledDate &&
+ new Date(pendingInspection.scheduledDate) > new Date(),
+ );
+
+ const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
+ useGetAttachmentsQuery(
+ { ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' },
+ { skip: !pendingInspection },
+ );
// Approving means every uploaded document was accepted — one unjudged or
// rejected file is enough to keep the decision buttons dead. The counts feed
@@ -335,6 +378,7 @@ export function LicenseReviewPage() {
can,
flaggedCount: flagged.length,
hasPendingInspection: Boolean(pendingInspection),
+ inspectionNotYetDue,
allDocumentsAccepted,
reasons: {
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
@@ -343,6 +387,13 @@ export function LicenseReviewPage() {
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
+ inspectionNotYetDue: t('review.disabled.inspectionNotYetDue', {
+ date: pendingInspection?.scheduledDate
+ ? showDate(pendingInspection.scheduledDate)
+ : '',
+ defaultValue:
+ 'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.',
+ }),
needsDocumentReviews:
documentProgress.total === 0
? t(
@@ -357,7 +408,7 @@ export function LicenseReviewPage() {
}),
},
});
- }, [data, currentUserId, can, flagged.length, pendingInspection, t]);
+ }, [data, currentUserId, can, flagged.length, pendingInspection, inspectionNotYetDue, showDate, t]);
// Location answers are tree ids. The picker the applicant used resolves them
// client-side from the same list, so the reviewer reads the place rather than
@@ -479,6 +530,34 @@ export function LicenseReviewPage() {
}
}
+ /**
+ * Fetches a fresh presigned link and either opens the in-app PDF preview
+ * or a new tab, depending which button was pressed — the link itself is
+ * short-lived, so each click gets its own rather than caching one.
+ */
+ async function openCertificate(mode: "view" | "download") {
+ if (!app.issuedLicenseId) return;
+ setCertificateBusy(mode);
+ try {
+ const { url } = await getCertificateUrlForOfficer(
+ app.issuedLicenseId,
+ ).unwrap();
+ if (mode === "view") {
+ setCertificatePreview(url);
+ } else {
+ window.open(url, "_blank", "noopener");
+ }
+ } catch (err) {
+ notifications.show({
+ color: "red",
+ title: t("review.certificateError", "Could not open the certificate"),
+ message: extractErrorMessage(err),
+ });
+ } finally {
+ setCertificateBusy(null);
+ }
+ }
+
/** Actions with their own dedicated form open that; the rest confirm. */
function handleAction(action: ResolvedAction) {
switch (action.id) {
@@ -533,11 +612,51 @@ export function LicenseReviewPage() {
try {
switch (action.id) {
case "claim":
- // Usually fired from the queue, but an officer can also open an
- // unclaimed application directly and claim it from here.
+ // Usually fired from the queue, but an officer who opened an
+ // unclaimed application directly claims it from here.
await run(
() => claimApplication(id).unwrap(),
- t("review.done.claim", "Application claimed"),
+ t("review.done.claim", "Claimed — the application is now yours"),
+ );
+ break;
+ // Handing work out. Reuses the decision modal's officer picker rather
+ // than a dialog of its own — it already resolves the real officer list.
+ case "assign-reviewer":
+ if (!submission.officerId) return;
+ await run(
+ () =>
+ assignReviewer({
+ id,
+ officerId: submission.officerId as string,
+ remark: submission.reason,
+ }).unwrap(),
+ t("review.done.assignReviewer", "Review assigned"),
+ );
+ break;
+ case "assign-inspector":
+ if (!submission.officerId) return;
+ await run(
+ () =>
+ assignInspector({
+ id,
+ inspectorId: submission.officerId as string,
+ remark: submission.reason,
+ }).unwrap(),
+ t("review.done.assignInspector", "Inspection assigned"),
+ );
+ break;
+ // Handing work back. Parks the file with the team leader — an employee
+ // finishing their task decides nothing.
+ case "report-review":
+ await run(
+ () => reportReview({ id, remark: submission.reason }).unwrap(),
+ t("review.done.reportReview", "Sent to your team leader"),
+ );
+ break;
+ case "report-inspection":
+ await run(
+ () => reportInspection({ id, remark: submission.reason }).unwrap(),
+ t("review.done.reportInspection", "Inspection result sent"),
);
break;
case "complete-review":
@@ -830,6 +949,38 @@ export function LicenseReviewPage() {
+ {/* Only once the certificate actually exists — before that there
+ is nothing to view or download yet. */}
+ {app.issuedLicenseId && (
+
+
+ {t("review.certificate", "Certificate")}
+
+
+ }
+ loading={certificateBusy === "view"}
+ disabled={Boolean(certificateBusy)}
+ onClick={() => openCertificate("view")}
+ >
+ {t("review.view", "View")}
+
+ }
+ loading={certificateBusy === "download"}
+ disabled={Boolean(certificateBusy)}
+ onClick={() => openCertificate("download")}
+ >
+ {t("review.download", "Download")}
+
+
+
+ )}
+
{/* Eligibility, checked and shown — not applied invisibly. */}
{eligibility.length > 0 && (
@@ -1063,6 +1214,18 @@ export function LicenseReviewPage() {
+ {status === "INSPECTION_FAILED" && (
+ }
+ >
+ {t(
+ "review.inspectionFailedBlocked",
+ "Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.",
+ )}
+
+ )}
{inspections.length === 0 ? (
@@ -1078,7 +1241,15 @@ export function LicenseReviewPage() {
{inspection.scheduledDate
- ? showDate(inspection.scheduledDate)
+ ? `${showDate(inspection.scheduledDate)}${
+ inspection.timeSlot
+ ? ` — ${
+ inspection.timeSlot === "MORNING"
+ ? t("review.morning", "Morning")
+ : t("review.afternoon", "Afternoon")
+ }`
+ : ""
+ }`
: t("review.unscheduled", "Not scheduled")}
{inspection.findings && (
@@ -1263,14 +1434,21 @@ export function LicenseReviewPage() {
>
+ setInspectionTimeSlot(value as "MORNING" | "AFTERNOON")}
+ data={[
+ { value: "MORNING", label: t("review.morning", "Morning") },
+ { value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
+ ]}
/>
@@ -1288,6 +1466,7 @@ export function LicenseReviewPage() {
await scheduleInspection({
applicationId: id,
scheduledDate: inspectionDate,
+ timeSlot: inspectionTimeSlot,
}).unwrap();
setInspectionOpen(false);
},
@@ -1386,12 +1565,77 @@ export function LicenseReviewPage() {
autosize
minRows={3}
/>
+
+
+ {t("review.findingsEvidence", "Supporting documents")}
+
+
+ {findingsEvidence.flatMap((attachment) =>
+ (attachment.files ?? []).map((file) => (
+
+ file.url &&
+ setFindingsPreview({ url: file.url, title: file.originalName })
+ }
+ >
+
+
+ )),
+ )}
+ {
+ if (!file || !pendingInspection) return;
+ setFindingsUploadBusy(true);
+ await uploadDocument({
+ ownerType: "INSPECTION",
+ ownerId: pendingInspection.id,
+ documentKey: `evidence-${Date.now()}`,
+ file,
+ });
+ setFindingsUploadBusy(false);
+ refetchFindingsEvidence();
+ }}
+ >
+ {(props) => (
+
+ {findingsUploadBusy ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {inspectionNotYetDue && (
+ }>
+ {t("review.disabled.inspectionNotYetDue", {
+ date: pendingInspection?.scheduledDate
+ ? showDate(pendingInspection.scheduledDate)
+ : "",
+ defaultValue:
+ "Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.",
+ })}
+
+ )}
run(
@@ -1418,7 +1662,7 @@ export function LicenseReviewPage() {
variant="light"
color="red"
size="lg"
- disabled={!findings.trim() || !pendingInspection}
+ disabled={!findings.trim() || !pendingInspection || inspectionNotYetDue}
aria-label={t("review.failed", "Failed")}
onClick={() =>
run(
@@ -1444,6 +1688,20 @@ export function LicenseReviewPage() {
+
+
setFindingsPreview(null)}
+ url={findingsPreview?.url ?? ""}
+ title={findingsPreview?.title}
+ />
+
+ setCertificatePreview(null)}
+ url={certificatePreview ?? ""}
+ title={t("review.certificate", "Certificate")}
+ />
);
}
diff --git a/apps/backoffice/src/app/features/license-review/queue-views.test.ts b/apps/backoffice/src/app/features/license-review/queue-views.test.ts
index 1d423bce9..dbf349813 100644
--- a/apps/backoffice/src/app/features/license-review/queue-views.test.ts
+++ b/apps/backoffice/src/app/features/license-review/queue-views.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveFamilyKind } from "@ema-platform/api";
+import { hasUnclaimedPool, savedViewsForFamily } from "./queue-views";
describe("resolveFamilyKind", () => {
it("treats person-centric seafarer applications as document queues", () => {
@@ -8,3 +9,28 @@ describe("resolveFamilyKind", () => {
expect(resolveFamilyKind("BTC_BASIC_TRAINING")).toBe("CERTIFICATE");
});
});
+
+describe("hasUnclaimedPool", () => {
+ it("keeps the unassigned pool for logistics licences", () => {
+ expect(hasUnclaimedPool("FREIGHT_FORWARDER")).toBe(true);
+ });
+
+ it("keeps it for vessel registration, a DOCUMENT queue that is still claimed", () => {
+ expect(resolveFamilyKind("VESSEL_REGISTRATION")).toBe("DOCUMENT");
+ expect(hasUnclaimedPool("VESSEL_REGISTRATION")).toBe(true);
+ expect(savedViewsForFamily(hasUnclaimedPool("VESSEL_REGISTRATION")).map((v) => v.id)).toContain(
+ "unassigned",
+ );
+ });
+
+ it("drops it for the person-centric services", () => {
+ expect(hasUnclaimedPool("SEAMAN_BOOK")).toBe(false);
+ expect(savedViewsForFamily(hasUnclaimedPool("SEAMAN_BOOK")).map((v) => v.id)).not.toContain(
+ "unassigned",
+ );
+ });
+
+ it("keeps it for the mixed All/Mine grids, where no type is pinned", () => {
+ expect(hasUnclaimedPool(undefined)).toBe(true);
+ });
+});
diff --git a/apps/backoffice/src/app/features/license-review/queue-views.ts b/apps/backoffice/src/app/features/license-review/queue-views.ts
index 6689b73a0..42e9d0eb3 100644
--- a/apps/backoffice/src/app/features/license-review/queue-views.ts
+++ b/apps/backoffice/src/app/features/license-review/queue-views.ts
@@ -1,3 +1,4 @@
+import { resolveFamilyKind } from '@ema-platform/api';
import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api';
export type SavedViewId =
@@ -78,12 +79,39 @@ export const SAVED_VIEWS: SavedView[] = [
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
/**
- * Non-logistics queues (Seafarer Registration, Seaman Book, BTC, CoC, ...)
- * have no unclaimed pool to triage — those applications aren't claimed off a
- * shared queue — so the tab that lists it doesn't apply there.
+ * Type keys reviewed off a shared unclaimed pool despite not being logistics
+ * licences. Whether a queue is claimed is an officer-workflow property, not a
+ * label one: vessel registrations arrive unassigned and officers claim them,
+ * even though the family kind is DOCUMENT because the certificate they produce
+ * is a document rather than a licence.
*/
-export function savedViewsForFamily(isLogistics: boolean): SavedView[] {
- return isLogistics
+const CLAIMABLE_NON_LOGISTICS = new Set([
+ 'VESSEL_REGISTRATION',
+ // Endorsements run the standard claim-first workflow: applications arrive
+ // unassigned and an officer claims them, even though the family kind is
+ // CERTIFICATE.
+ 'ENDORSEMENT_SEAFARER',
+]);
+
+/**
+ * Does this queue have an unclaimed pool to triage?
+ *
+ * True for the mixed All/Mine grids (no type pinned) — nothing is hidden when
+ * the queue spans every type. False for the person-centric services (Seafarer
+ * Registration, Seaman Book, BTC, CoC, ...), whose applications aren't claimed
+ * off a shared queue, so the tab and the claim actions don't apply there.
+ */
+export function hasUnclaimedPool(typeCode: string | undefined): boolean {
+ if (!typeCode) return true;
+ return (
+ resolveFamilyKind(typeCode) === 'LOGISTICS_LICENSE' ||
+ CLAIMABLE_NON_LOGISTICS.has(typeCode)
+ );
+}
+
+/** Drops the Unassigned tab on queues with no unclaimed pool. */
+export function savedViewsForFamily(claimable: boolean): SavedView[] {
+ return claimable
? SAVED_VIEWS
: SAVED_VIEWS.filter((v) => v.id !== 'unassigned');
}
diff --git a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx
index 6c9a7f1b5..4a3787697 100644
--- a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx
+++ b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx
@@ -7,7 +7,9 @@ import {
SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS,
displaySeafarerAnswer,
+ useGetActiveDepartmentsQuery,
useListSeafarerRegistrationsQuery,
+ useLocalized,
type SeafarerRegistration,
type SeafarerRegistrationStatus,
} from '@ema-platform/api';
@@ -28,12 +30,16 @@ export function applicantName(r: Pick(null);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
+ const { data: departments } = useGetActiveDepartmentsQuery();
+ const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
+
const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({
status: status ?? undefined,
search: debouncedSearch || undefined,
@@ -69,7 +75,11 @@ export function SeafarerRegistrationQueuePage() {
{
header: 'Department',
accessorKey: 'department',
- cell: ({ row }) => {displaySeafarerAnswer('department', row.original.department)},
+ cell: ({ row }) => (
+
+ {displaySeafarerAnswer('department', row.original.department, departmentOptions)}
+
+ ),
},
{
header: 'Submitted',
@@ -100,7 +110,7 @@ export function SeafarerRegistrationQueuePage() {
),
},
],
- [showDate],
+ [showDate, departmentOptions],
);
return (
diff --git a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx
index d8f99215b..536367886 100644
--- a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx
+++ b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx
@@ -11,7 +11,9 @@ import {
displaySeafarerAnswer,
extractErrorMessage,
useApproveSeafarerRegistrationMutation,
+ useGetActiveDepartmentsQuery,
useGetSeafarerRegistrationReviewQuery,
+ useLocalized,
useRejectSeafarerRegistrationMutation,
useRequestSeafarerRegistrationChangesMutation,
} from '@ema-platform/api';
@@ -31,7 +33,10 @@ const DECISION_COPY: Record ({ value: d.code, label: localized(d.name) }));
const [approve, { isLoading: approving }] = useApproveSeafarerRegistrationMutation();
const [reject, { isLoading: rejecting }] = useRejectSeafarerRegistrationMutation();
@@ -166,7 +171,9 @@ export function SeafarerRegistrationReviewPage() {
- {displaySeafarerAnswer(field, registration[field])}
+
+ {displaySeafarerAnswer(field, registration[field], departmentOptions)}
+
))}
diff --git a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx
index 6a96bba84..a3e3d8f90 100644
--- a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx
+++ b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx
@@ -99,7 +99,11 @@ const UM_CONFIG: DesignConfig = {
const UM_RUNTIME = {
basename: '/um',
- apiUrl: import.meta.env.VITE_BASE_API_URL,
+ // Keep the embedded IAM module on the same API as the backoffice client.
+ // When VITE_BASE_API_URL is absent locally, passing undefined makes iamui
+ // fall back to its remote development server, where the local JWT is
+ // rejected and the module redirects to its login page.
+ apiUrl: import.meta.env.VITE_BASE_API_URL ?? 'http://localhost:3000/api',
};
const buttonStyle: React.CSSProperties = {
diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts
index 9bdb4e114..78a863a6d 100644
--- a/apps/backoffice/src/app/i18n/locales/am.ts
+++ b/apps/backoffice/src/app/i18n/locales/am.ts
@@ -48,6 +48,7 @@ export const am: Translations = {
typeVESSEL_OWNERSHIP_TRANSFER: "የመርከብ ባለቤትነት ዝውውር",
typeCERTIFICATE_OF_COMPETENCY: "የብቃት ማረጋገጫ ምስክር ወረቀት",
typeCERTIFICATE_OF_PROFICIENCY: "የብቃት ምስክር ወረቀት",
+ typeENDORSEMENT_SEAFARER: "የባህረኛ ማስተያየት (CoC / GOC)",
typeENDORSEMENT_COC: "የCoC ማረጋገጫ",
typeENDORSEMENT_GOC: "የGOC ማረጋገጫ",
primary: "ዋና",
@@ -87,8 +88,7 @@ export const am: Translations = {
btcQueue: "የBTC ወረፋ",
cocQueue: "የCoC ወረፋ",
copQueue: "የCoP ወረፋ",
- endorsementCocQueue: "የCoC ማረጋገጫ ወረፋ",
- endorsementGocQueue: "የGOC ማረጋገጫ ወረፋ",
+ endorsementQueue: "የማስተያየት ወረፋ",
vesselRegistrations: "የመርከብ ምዝገባ",
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
@@ -906,6 +906,11 @@ export const am: Translations = {
submitted: "የቀረበበት",
sla: "ዕድሜ / የጊዜ ገደብ",
claim: "ውሰድ",
+ assign: "መድብ",
+ assigned: "ተመድቧል",
+ assignedBody: "ባለሙያው ተነግሮታል፤ ግምገማው ተጀምሯል።",
+ assignFailed: "መመደብ አልተቻለም",
+ assignError: "ማመልከቻውን መመደብ አልተቻለም።",
review: "ገምግም",
claimed: "ተወስዷል",
claimedBody: "ማመልከቻው አሁን ለእርስዎ ተመድቧል።",
@@ -981,9 +986,14 @@ export const am: Translations = {
unscheduled: "አልተያዘም",
inspectionResult: "የምርመራ ውጤት",
findings: "ግኝቶች",
+ findingsEvidence: "አጋዥ ሰነዶች",
+ uploadEvidence: "ሰነድ መጫን",
dateTime: "ቀን እና ሰዓት",
+ date: "ቀን",
+ morning: "ጠዋት",
+ afternoon: "ከሰዓት በኋላ",
schedule: "ያዝ",
- pickDate: "መጀመሪያ ቀን እና ሰዓት ይምረጡ",
+ pickDate: "መጀመሪያ ቀን ይምረጡ",
passed: "አልፏል",
failed: "ወድቋል",
round_one: "ዙር {{count}}",
@@ -992,6 +1002,10 @@ export const am: Translations = {
linkCopied: "አገናኝ ተቀድቷል",
actionFailed: "ተግባሩ አልተሳካም",
errorTitle: "ይህን ማመልከቻ መጫን አልተቻለም",
+ certificate: "ምስክር ወረቀት",
+ view: "ይመልከቱ",
+ download: "አውርድ",
+ certificateError: "ምስክር ወረቀቱን መክፈት አልተቻለም",
hideActivity: "እንቅስቃሴ ደብቅ",
showActivity: "እንቅስቃሴ አሳይ",
awaitingPayment: "አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።",
@@ -1005,6 +1019,10 @@ export const am: Translations = {
actions: {
claim: "ውሰድ",
assign: "መድብ",
+ reportReview: "ለቡድን መሪ አሳውቅ",
+ assignInspector: "ምርመራ መድብ",
+ reportInspection: "የምርመራ ውጤት አሳውቅ",
+ assignReviewer: "ገምጋሚ መድብ",
escalate: "ወደ ላይ አሳድግ",
hold: "አግድ",
resume: "ቀጥል",
@@ -1033,10 +1051,14 @@ export const am: Translations = {
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
+ inspectionNotYetDue:
+ "ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
needsDocumentReviews:
"መጀመሪያ ሁሉንም ሰነዶች ይቀበሉ — ከ{{total}} {{accepted}} ተቀብለዋል። የሰነዶች ትር ከፍተው ቀሪዎቹን ይቀበሉ።",
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
},
+ inspectionFailedBlocked:
+ "ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
reasons: {
incompleteDocuments: "ያልተሟሉ ሰነዶች",
belowCapital: "ካፒታል ከሚያስፈልገው በታች",
@@ -1063,6 +1085,7 @@ export const am: Translations = {
hold: "ለ{{applicant}} ማመልከቻ {{number}} ያግዳል። ለእርስዎ ተመድቦ ይቆያል።",
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
+ "assign-reviewer": "ማመልከቻ {{number}} ለተመረጠው ሹም ሰጥቶ ግምገማውን ያስጀምራል።",
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
},
@@ -1157,6 +1180,7 @@ export const am: Translations = {
resume: "ማመልከቻው ቀጥሏል",
escalate: "ወደ ላይ አድጓል",
assign: "እንደገና ተመድቧል",
+ assignReviewer: "ግምገማ ተመድቧል",
scheduled: "ምርመራ ተይዟል",
inspectionPassed: "ምርመራ አልፏል",
inspectionFailed: "ምርመራ ወድቋል",
diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts
index ceb6e6c38..e9342a8a6 100644
--- a/apps/backoffice/src/app/i18n/locales/en.ts
+++ b/apps/backoffice/src/app/i18n/locales/en.ts
@@ -48,6 +48,7 @@ export const en = {
typeVESSEL_OWNERSHIP_TRANSFER: 'Vessel Ownership Transfer',
typeCERTIFICATE_OF_COMPETENCY: 'Certificate of Competency',
typeCERTIFICATE_OF_PROFICIENCY: 'Certificate of Proficiency',
+ typeENDORSEMENT_SEAFARER: 'Seafarer Endorsement (CoC / GOC)',
typeENDORSEMENT_COC: 'CoC Endorsement',
typeENDORSEMENT_GOC: 'GOC Endorsement',
primary: 'Primary',
@@ -87,8 +88,7 @@ export const en = {
postWaiverQueue: 'Post-Waiver Queue',
cocQueue: 'CoC Queue',
copQueue: 'CoP Queue',
- endorsementCocQueue: 'CoC Endorsement Queue',
- endorsementGocQueue: 'GOC Endorsement Queue',
+ endorsementQueue: 'Endorsement Queue',
vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Ownership Transfer',
seafarerRegistry: 'Seafarer Registry',
@@ -915,6 +915,11 @@ export const en = {
submitted: 'Submitted',
sla: 'Age / SLA',
claim: 'Claim',
+ assign: 'Assign',
+ assigned: 'Assigned',
+ assignedBody: 'The employee has been notified and the review has started.',
+ assignFailed: 'Could not assign',
+ assignError: 'The application could not be assigned.',
review: 'Review',
claimed: 'Claimed',
claimedBody: 'The application is now assigned to you.',
@@ -990,9 +995,14 @@ export const en = {
unscheduled: 'Not scheduled',
inspectionResult: 'Inspection result',
findings: 'Findings',
+ findingsEvidence: 'Supporting documents',
+ uploadEvidence: 'Upload document',
dateTime: 'Date and time',
+ date: 'Date',
+ morning: 'Morning',
+ afternoon: 'Afternoon',
schedule: 'Schedule',
- pickDate: 'Pick a date and time first',
+ pickDate: 'Pick a date first',
passed: 'Passed',
failed: 'Failed',
round_one: 'round {{count}}',
@@ -1001,6 +1011,10 @@ export const en = {
linkCopied: 'Link copied',
actionFailed: 'Action failed',
errorTitle: 'Could not load this application',
+ certificate: 'Certificate',
+ view: 'View',
+ download: 'Download',
+ certificateError: 'Could not open the certificate',
hideActivity: 'Hide activity',
showActivity: 'Show activity',
awaitingPayment: 'Waiting for the applicant to pay {{amount}} {{currency}}.',
@@ -1014,6 +1028,10 @@ export const en = {
actions: {
claim: 'Claim',
assign: 'Assign',
+ reportReview: 'Report to team leader',
+ assignInspector: 'Assign inspection',
+ reportInspection: 'Report inspection result',
+ assignReviewer: 'Assign reviewer',
escalate: 'Escalate',
hold: 'Put on hold',
resume: 'Resume',
@@ -1042,10 +1060,14 @@ export const en = {
needsFlags: 'Flag at least one item to request a correction',
needsCapital: 'Record the verified capital first',
needsInspection: 'Requires an inspection result',
+ inspectionNotYetDue:
+ 'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.',
needsDocumentReviews:
'Accept all documents first — {{accepted}} of {{total}} accepted. Open the Documents tab and accept the rest.',
needsDocumentsUploaded: 'No documents uploaded to review yet',
},
+ inspectionFailedBlocked:
+ 'Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.',
reasons: {
incompleteDocuments: 'Incomplete documents',
belowCapital: 'Capital below the required minimum',
@@ -1071,6 +1093,8 @@ export const en = {
hold: 'Parks application {{number}} for {{applicant}}. It stays assigned to you.',
resume: 'Returns application {{number}} to the stage it was held from.',
escalate: 'Raises application {{number}} to a supervisor for a decision.',
+ 'assign-reviewer':
+ 'Hands application {{number}} to the chosen officer and starts the review.',
'confirm-payment': 'Confirms settlement for application {{number}}.',
'schedule-exam': 'Assigns {{applicant}} to an exam session for application {{number}}.',
},
@@ -1162,6 +1186,7 @@ export const en = {
resume: 'Application resumed',
escalate: 'Escalated',
assign: 'Reassigned',
+ assignReviewer: 'Review assigned',
scheduled: 'Inspection scheduled',
inspectionPassed: 'Inspection passed',
inspectionFailed: 'Inspection failed',
diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts
index 3878efa3f..d9ba3abee 100644
--- a/apps/backoffice/src/app/layouts/nav-config.ts
+++ b/apps/backoffice/src/app/layouts/nav-config.ts
@@ -101,8 +101,7 @@ export const NAV_SECTIONS: NavSection[] = [
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
- { to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
- { to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
+ { to: '/licence-review/type/ENDORSEMENT_SEAFARER', label: 'nav.endorsementQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
],
@@ -112,8 +111,8 @@ export const NAV_SECTIONS: NavSection[] = [
items: [
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
- { to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: APPLICATION_QUEUE },
- { to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: APPLICATION_QUEUE },
+ { to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
+ { to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
],
},
diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx
index 46135bab4..dde6d1e2e 100644
--- a/apps/backoffice/src/app/router/index.tsx
+++ b/apps/backoffice/src/app/router/index.tsx
@@ -90,7 +90,11 @@ const router = createBrowserRouter([
{ path: 'coc-queue', element: },
{ path: 'coc-queue/:id', element: },
// Endorsement review happens in the config-driven licence queue.
- { path: 'endorsement-queue', element: },
+ // CoC and GOC endorsement now share one combined type; the two old
+ // type queues (ENDORSEMENT_COC / ENDORSEMENT_GOC) are left routing
+ // through the generic `:typeCode` queue below rather than redirected,
+ // so an application filed before the switch stays reachable there.
+ { path: 'endorsement-queue', element: },
{ path: 'endorsement-queue/:id', element: },
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], ) },
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], ) },
diff --git a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx
index dda909899..c6ce7252d 100644
--- a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx
+++ b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx
@@ -24,6 +24,7 @@ import {
TERMINAL_STATUSES,
extractErrorMessage,
useLocalized,
+ useBypassPaymentMutation,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
@@ -31,9 +32,17 @@ import {
import { useCurrentProfile } from '@ema-platform/auth';
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
+import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
import { endorsementColumns } from './columns';
-const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
+// ENDORSEMENT_SEAFARER covers CoC and GOC together and is the only type new
+// applications file against; the other two stay listed so an application or
+// licence filed before the switch keeps showing up here.
+const ENDORSEMENT_TYPE_KEYS = [
+ 'ENDORSEMENT_SEAFARER',
+ 'ENDORSEMENT_COC',
+ 'ENDORSEMENT_GOC',
+];
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
return (
@@ -74,6 +83,8 @@ export function EndorsementPage() {
const showDate = useDateDisplayer();
const localized = useLocalized();
const [getCertificateUrl] = useGetCertificateUrlMutation();
+ const { pay, isPaying } = useApplicationPayment();
+ const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const issuedTable = useServerTable();
const registered =
@@ -90,6 +101,22 @@ export function EndorsementPage() {
);
const issuedPage = issuedTable.paginate(issued);
+ async function handleBypass(applicationId: string) {
+ try {
+ const result = await bypassPayment(applicationId).unwrap();
+ notify.success(
+ result.certificateIssued
+ ? t('endorsement.bypassIssued', 'Payment bypassed — the endorsement has been issued.')
+ : t('endorsement.bypassOk', {
+ defaultValue: 'Payment bypassed — application is now {{status}}.',
+ status: result.status.replace(/_/g, ' ').toLowerCase(),
+ }),
+ );
+ } catch (err) {
+ notify.error(extractErrorMessage(err, t('endorsement.bypassFailed', 'Bypass failed')));
+ }
+ }
+
async function download(licenseId: string) {
try {
const result = await getCertificateUrl(licenseId).unwrap();
@@ -132,21 +159,13 @@ export function EndorsementPage() {
/>
-
- }
- onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')}
- >
- {t('endorsement.endorseCoc', 'Endorse a CoC')}
-
- }
- onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')}
- >
- {t('endorsement.endorseGoc', 'Endorse a GOC')}
-
-
+ }
+ onClick={() => navigate('/licensing/ENDORSEMENT_SEAFARER/apply')}
+ >
+ {t('endorsement.apply', 'Apply for an endorsement')}
+
{!registered && (
}>
@@ -183,6 +202,36 @@ export function EndorsementPage() {
{t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
+ {app.status === 'PAYMENT_PENDING' && (
+ <>
+
+ {/* ponytail: shown unconditionally for the testing
+ phase — the server still refuses it unless
+ ALLOW_PAYMENT_BYPASS is set and NODE_ENV is not
+ production. Re-gate on useGetPaymentCapabilitiesQuery
+ (like MyApplicationsPage) before prod. */}
+
+ >
+ )}
- )}
+
+ {registration.status === 'DRAFT' && (
+ }
+ onClick={() => setConfirmingCancel(true)}
+ >
+ Cancel & discard draft
+
+ )}
+ {showSummary && !readOnly && (
+ } onClick={() => setViewingSummary(false)}>
+ Edit details
+
+ )}
+
{registration.status === 'APPROVED' && (
} title="Registered" mb="md">
You are a registered seafarer. Your seafarer number is {registration.seafarerNumber}.
- Your Seaman Book and Basic Training Certificate applications have been opened for you.
+ You can now apply for a certificate endorsement from the Endorsement Seafarer page.
)}
{registration.status === 'REJECTED' && (
@@ -386,6 +436,7 @@ export function SeafarerRegistrationPage() {
)}
+
+ setConfirmingCancel(false)} title="Discard this draft?" centered>
+
+
+ Everything you have entered will be deleted, including any documents already uploaded. This cannot be
+ undone. You can start a new registration at any time.
+
+
+ setConfirmingCancel(false)} disabled={cancelling}>
+ Keep draft
+
+
+ Discard draft
+
+
+
+
);
}
diff --git a/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx b/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx
index bdf96eef2..286c3f4fd 100644
--- a/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx
+++ b/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx
@@ -298,6 +298,22 @@ function SeaServiceTab() {
})
: null;
+ // Shown once the field has something in it — a blank required field is left
+ // to the button being disabled, same as the rest of the form; only an
+ // actual too-short value gets called out.
+ const vesselNameError =
+ form.vesselName && form.vesselName.trim().length <= 1
+ ? t('seaRecords.seaService.fields.vesselNameTooShort', {
+ defaultValue: 'Must be at least 2 characters.',
+ })
+ : null;
+ const rankError =
+ form.rank && form.rank.trim().length <= 1
+ ? t('seaRecords.seaService.fields.rankTooShort', {
+ defaultValue: 'Must be at least 2 characters.',
+ })
+ : null;
+
const valid =
form.vesselName.trim().length > 1 &&
form.rank.trim().length > 1 &&
@@ -391,6 +407,7 @@ function SeaServiceTab() {
required
value={form.vesselName}
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
+ error={vesselNameError}
/>
setForm({ ...form, rank: e.target.value })}
+ error={rankError}
/>
void },
+ deps: {
+ onOpen: (app: LicenseApplication) => void;
+ onPay: (app: LicenseApplication) => void;
+ onBypass: (app: LicenseApplication) => void;
+ isPaying: boolean;
+ bypassing: boolean;
+ },
): AdvancedColumn[] {
return [
{
@@ -38,15 +44,50 @@ export function inFlightColumns(
},
{
header: t('applications.table.progress'),
- size: 140,
- cell: ({ row }) => (
-
- ),
+ size: 300,
+ cell: ({ row }) => {
+ const app = row.original;
+ return (
+
+
+ {/* The fee stops the registration dead, so the payment action sits
+ on the bar rather than being hidden behind View. */}
+ {app.status === 'PAYMENT_PENDING' && (
+ <>
+ deps.onPay(app)}
+ >
+ {t('applications.actions.pay', {
+ amount: Number(app.feeAmount ?? 0).toLocaleString(),
+ currency: app.feeCurrency,
+ })}
+
+ {/* ponytail: shown unconditionally — the licensing page hides
+ this behind the API's bypassEnabled capability flag, which
+ is off here. Re-gate on capabilities before prod. */}
+ deps.onBypass(app)}
+ title="Testing only — marks the fee paid"
+ >
+ {t('applications.actions.bypass')}
+
+ >
+ )}
+
+ );
+ },
},
{
header: '',
diff --git a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx
index 7f896900b..a2c2e32ec 100644
--- a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx
+++ b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx
@@ -30,11 +30,15 @@ import {
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
import { inFlightColumns } from '../inFlightColumns';
import {
+ extractErrorMessage,
TERMINAL_STATUSES,
useApiMutation,
+ useBypassPaymentMutation,
useGetMyApplicationsQuery,
} from '@ema-platform/api';
+import { notifications } from '@mantine/notifications';
import { authStorage } from '@ema-platform/auth';
+import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
// ---------------------------------------------------------------------------
// Types
@@ -132,6 +136,8 @@ export function VesselRegistrationPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery();
+ const { pay, isPaying } = useApplicationPayment();
+ const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const [page, setPage] = useState(0);
const [registration, setRegistration] = useState(null);
const [fetchTrigger] = useApiMutation();
@@ -155,9 +161,32 @@ export function VesselRegistrationPage() {
!TERMINAL_STATUSES.includes(app.status),
);
+ async function handleBypass(applicationId: string) {
+ try {
+ const result = await bypassPayment(applicationId).unwrap();
+ notifications.show({
+ color: 'teal',
+ title: 'Payment bypassed',
+ message: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
+ });
+ refetch();
+ } catch (err) {
+ notifications.show({
+ color: 'red',
+ title: 'Bypass failed',
+ message: extractErrorMessage(err),
+ });
+ }
+ }
+
const columns = inFlightColumns(t, {
onOpen: (app) =>
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`),
+ // Paying leaves the SPA for Telebirr — a provider hand-off, not a route change.
+ onPay: (app) => pay(app.id),
+ onBypass: (app) => handleBypass(app.id),
+ isPaying,
+ bypassing,
});
const certs = registration?.category === 'Sea-going Vessel (International)'
diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts
index fabde4809..f64799bec 100644
--- a/apps/portal/src/app/i18n/locales/am.ts
+++ b/apps/portal/src/app/i18n/locales/am.ts
@@ -63,7 +63,7 @@ export const am: Translations = {
certificates: 'የምስክር ወረቀቶች',
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት',
- endorsements: 'ማረጋገጫዎች',
+ endorsements: 'የባህረኛ ማስተያየት',
vesselRegistrations: 'የመርከብ ምዝገባ',
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
documents: 'ሰነዶቼ',
@@ -242,6 +242,7 @@ export const am: Translations = {
RESUBMIT_REQUIRED: 'እንደገና ማስገባት ያስፈልጋል',
INSPECTION_PENDING: 'ቁጥጥር በመጠባበቅ ላይ',
INSPECTION_COMPLETED: 'ቁጥጥር ተጠናቋል',
+ INSPECTION_FAILED: 'ቁጥጥር አልተሳካም',
APPROVED: 'ጸድቋል',
REJECTED: 'ውድቅ ተደርጓል',
ON_HOLD: 'ላይ ቆሟል',
@@ -1012,8 +1013,7 @@ export const am: Translations = {
registered: "የተመዘገበ መርከበኛ ({{number}})",
registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል",
},
- endorseCoc: "CoC ያረጋግጡ",
- endorseGoc: "GOC ያረጋግጡ",
+ apply: "ለማስተያየት ያመልክቱ",
registrationNotice: {
prefix: "መጀመሪያ የ",
link: "መርከበኛ ምዝገባዎን",
diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts
index 77c72eb88..afbb4f022 100644
--- a/apps/portal/src/app/i18n/locales/en.ts
+++ b/apps/portal/src/app/i18n/locales/en.ts
@@ -63,7 +63,7 @@ export const en = {
certificates: 'Certificates',
seamanBook: 'SeamanBook and BTC',
btc: 'Basic Training Certificate',
- endorsements: 'Endorsements',
+ endorsements: 'Endorsement Seafarer',
vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Transfers',
documents: 'My Documents',
@@ -242,6 +242,7 @@ export const en = {
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
INSPECTION_COMPLETED: 'Inspection Completed',
+ INSPECTION_FAILED: 'Inspection Failed',
APPROVED: 'Approved',
REJECTED: 'Rejected',
ON_HOLD: 'On Hold',
@@ -1014,8 +1015,7 @@ export const en = {
registered: 'Registered seafarer ({{number}})',
registrationRequired: 'Active seafarer registration required',
},
- endorseCoc: 'Endorse a CoC',
- endorseGoc: 'Endorse a GOC',
+ apply: 'Apply for an endorsement',
registrationNotice: {
prefix: 'Complete your',
link: 'seafarer registration',
diff --git a/apps/portal/src/app/layouts/PortalLayout.tsx b/apps/portal/src/app/layouts/PortalLayout.tsx
index 544fe01a9..c6ffb7fb4 100644
--- a/apps/portal/src/app/layouts/PortalLayout.tsx
+++ b/apps/portal/src/app/layouts/PortalLayout.tsx
@@ -140,7 +140,7 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
},
{
to: "/endorsements",
- label: "Endorsements",
+ label: "Endorsement Seafarer",
i18nKey: "nav.endorsements",
icon: IconRubberStamp,
permissions: [P.VIEW_OWN_CERTIFICATES],
diff --git a/apps/portal/src/app/providers/AppProviders.tsx b/apps/portal/src/app/providers/AppProviders.tsx
index 886fd7138..ac1b13894 100644
--- a/apps/portal/src/app/providers/AppProviders.tsx
+++ b/apps/portal/src/app/providers/AppProviders.tsx
@@ -21,9 +21,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
value={{
appName: 'Portal',
storagePrefix: 'ema-portal',
- // Applicants land on the licence list rather than the seafarer
- // dashboard: signing up here is the first step of applying.
- loginRedirectPath: '/licensing/applications',
+ loginRedirectPath: '/dashboard',
enableSignup: true,
enableForgotPassword: true,
}}
diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts
index 3e17464ff..d6c518fd2 100644
--- a/libs/api/src/lib/features/licensing/licensing-api.ts
+++ b/libs/api/src/lib/features/licensing/licensing-api.ts
@@ -511,6 +511,16 @@ export const licensingApi = baseApi
query: (id) => ({ url: `/licenses/${id}/certificate` }),
}),
+ /**
+ * Same download link, backoffice side. Separate endpoint from
+ * `getCertificateUrl`: the applicant route only ever hands the
+ * certificate to its holder, and an officer reviewing what they just
+ * issued is never the holder.
+ */
+ getCertificateUrlForOfficer: builder.mutation<{ url: string }, string>({
+ query: (id) => ({ url: `/licenses/${id}/certificate-backoffice` }),
+ }),
+
// ------------------------------------------------------------- review
getQueue: builder.query, QueueFilter | void>({
query: (params) => ({
@@ -842,6 +852,53 @@ export const licensingApi = baseApi
query: () => ({ url: '/license-application-review/officers' }),
}),
+ // ------------------------------------------------- team-leader dispatch
+ /**
+ * Handing work out, and handing it back.
+ *
+ * `assignApplication` below only re-points an application already in
+ * flight; these two *start* a stage, because under the push model
+ * assignment is how work begins — nothing is claimed from a queue.
+ */
+ assignInspector: builder.mutation<
+ LicenseApplication,
+ { id: string; inspectorId: string; remark?: string }
+ >({
+ query: ({ id, ...body }) => ({
+ url: `/license-application-review/${id}/assign-inspector`,
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (_r, error, { id }) =>
+ error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
+ }),
+
+ reportReview: builder.mutation<
+ LicenseApplication,
+ { id: string; remark?: string }
+ >({
+ query: ({ id, ...body }) => ({
+ url: `/license-application-review/${id}/report-review`,
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (_r, error, { id }) =>
+ error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
+ }),
+
+ reportInspection: builder.mutation<
+ LicenseApplication,
+ { id: string; remark?: string }
+ >({
+ query: ({ id, ...body }) => ({
+ url: `/license-application-review/${id}/report-inspection`,
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (_r, error, { id }) =>
+ error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
+ }),
+
// ----------------------------------------------------- workflow controls
assignApplication: builder.mutation<
LicenseApplication,
@@ -856,6 +913,23 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
+ /**
+ * Starts the review: the team leader hands the file to an employee.
+ * `assign` above only re-points an application already in flight.
+ */
+ assignReviewer: builder.mutation<
+ LicenseApplication,
+ { id: string; officerId: string; remark?: string }
+ >({
+ query: ({ id, ...body }) => ({
+ url: `/license-application-review/${id}/assign-reviewer`,
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (_r, error, { id }) =>
+ error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
+ }),
+
holdApplication: builder.mutation<
LicenseApplication,
{ id: string; reason: string }
@@ -929,7 +1003,12 @@ export const licensingApi = baseApi
// --------------------------------------------------------- inspection
scheduleInspection: builder.mutation<
Inspection,
- { applicationId: string; scheduledDate: string; location?: string }
+ {
+ applicationId: string;
+ scheduledDate: string;
+ timeSlot: 'MORNING' | 'AFTERNOON';
+ location?: string;
+ }
>({
query: (body) => ({ url: '/inspections', method: 'POST', body }),
invalidatesTags: (_r, error, { applicationId }) =>
@@ -1017,6 +1096,7 @@ export const {
useGetMyLicensesQuery,
useGetLicensesQuery,
useGetCertificateUrlMutation,
+ useGetCertificateUrlForOfficerMutation,
useGetApplicationPaymentQuery,
usePatchSectionMutation,
useAddStaffMutation,
@@ -1050,6 +1130,10 @@ export const {
useRevokeLicenseMutation,
useReinstateLicenseMutation,
useAssignApplicationMutation,
+ useAssignReviewerMutation,
+ useAssignInspectorMutation,
+ useReportReviewMutation,
+ useReportInspectionMutation,
useHoldApplicationMutation,
useResumeApplicationMutation,
useEscalateApplicationMutation,
diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts
index ecb0218d4..888b57be9 100644
--- a/libs/api/src/lib/features/licensing/licensing.helpers.ts
+++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts
@@ -3,6 +3,7 @@ import { resolveTokenFromStorage } from '../../session';
import type {
Bilingual,
FamilyKind,
+ FieldCondition,
FormFieldConfig,
FormSectionConfig,
LicenseApplication,
@@ -65,7 +66,10 @@ export const STATUS_LABELS: Record = {
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
+ REVIEW_REPORTED: 'Review Reported',
INSPECTION_COMPLETED: 'Inspection Completed',
+ INSPECTION_REPORTED: 'Inspection Reported',
+ INSPECTION_FAILED: 'Inspection Failed',
APPROVED: 'Approved',
REJECTED: 'Rejected',
ON_HOLD: 'On Hold',
@@ -92,7 +96,13 @@ export const STATUS_COLORS: Record = {
UNDER_EVALUATION: 'indigo',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'cyan',
+ // Both 'reported' states are waiting on the team leader, so they carry the
+ // same tone as anything else awaiting an officer decision.
+ REVIEW_REPORTED: 'orange',
INSPECTION_COMPLETED: 'cyan',
+ INSPECTION_REPORTED: 'orange',
+ // Orange, not red: recoverable — a re-inspection can still pass.
+ INSPECTION_FAILED: 'orange',
APPROVED: 'teal',
REJECTED: 'red',
ON_HOLD: 'gray',
@@ -125,7 +135,11 @@ export const STATUS_PROGRESS: Record = {
UNDER_EVALUATION: 45,
RESUBMIT_REQUIRED: 30,
INSPECTION_PENDING: 55,
+ REVIEW_REPORTED: 50,
INSPECTION_COMPLETED: 65,
+ INSPECTION_REPORTED: 70,
+ // A re-inspection returns to the pending step, so no further along than it.
+ INSPECTION_FAILED: 55,
APPROVED: 75,
// Parked, so it keeps the progress of wherever it was held from.
ON_HOLD: 45,
@@ -179,6 +193,7 @@ export const APPLICANT_NAME_TYPE_KEYS = [
'CERTIFICATE_OF_PROFICIENCY',
'VESSEL_REGISTRATION',
'VESSEL_OWNERSHIP_TRANSFER',
+ 'ENDORSEMENT_SEAFARER',
'ENDORSEMENT_COC',
'ENDORSEMENT_GOC',
];
@@ -190,6 +205,7 @@ const FAMILY_KIND_BY_KEY: Partial> = {
BTC_BASIC_TRAINING: 'CERTIFICATE',
CERTIFICATE_OF_COMPETENCY: 'CERTIFICATE',
CERTIFICATE_OF_PROFICIENCY: 'CERTIFICATE',
+ ENDORSEMENT_SEAFARER: 'CERTIFICATE',
ENDORSEMENT_COC: 'CERTIFICATE',
ENDORSEMENT_GOC: 'CERTIFICATE',
FREIGHT_FORWARDER: 'LOGISTICS_LICENSE',
@@ -373,6 +389,10 @@ const ERROR_MESSAGES: Record = {
application_not_awaiting_inspection:
'This application is not waiting for an inspection.',
inspection_already_completed: 'This inspection has already been recorded.',
+ inspection_not_yet_due:
+ 'Inspection results can be recorded after the scheduled inspection date and time.',
+ inspection_not_passed:
+ 'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
license_type_inactive: 'This licence type is not currently accepting applications.',
};
@@ -557,7 +577,14 @@ export function validateSections(
return errors;
}
-/** Evaluates a config condition against the current form answers. */
+/**
+ * Evaluates a config condition against the current form answers.
+ *
+ * Mirrors the server's `ApplicationValidationService.conditionHolds` —
+ * `anyOf` holds when any listed sub-condition holds, needed for an answer
+ * that can live on one of several mutually-exclusive fields (e.g. a CoP rank
+ * split by department).
+ */
interface ConditionLike {
field?: string;
equals?: unknown;
@@ -569,7 +596,7 @@ interface ConditionLike {
}
export function conditionHolds(
- condition: ConditionLike | undefined | null,
+ condition: FieldCondition | undefined | null,
formData: Record>,
): boolean {
if (!condition) return true;
diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts
index 9a5a2b8bd..0fac20c6e 100644
--- a/libs/api/src/lib/features/licensing/licensing.types.ts
+++ b/libs/api/src/lib/features/licensing/licensing.types.ts
@@ -26,9 +26,17 @@ export type LicenseStatus =
| "SUBMITTED"
| "UNDER_REVIEW"
| "UNDER_EVALUATION"
+ // Employee filed their review; parked with the team leader for a decision.
+ | "REVIEW_REPORTED"
| "RESUBMIT_REQUIRED"
| "INSPECTION_PENDING"
| "INSPECTION_COMPLETED"
+ // Inspector filed the result; parked with the team leader for a decision.
+ | "INSPECTION_REPORTED"
+ // The inspection was conducted and failed. Approval and issuance are
+ // unreachable until a re-inspection passes; the officer chooses between a
+ // repeat visit, an adjustment round, and rejection.
+ | "INSPECTION_FAILED"
| "APPROVED"
| "REJECTED"
| "ON_HOLD"
@@ -74,9 +82,9 @@ export interface FieldCondition {
in?: (string | number)[];
isSet?: boolean;
/**
- * Holds when ANY listed condition holds — for a value that can live on one
- * of several mutually-exclusive fields (e.g. a rank split by department).
- * `field`/`equals`/etc are ignored when this is present.
+ * Alternative to a single-field check: holds when ANY listed condition
+ * holds. `field`/`equals`/etc are ignored when this is present. Mirrors
+ * the server's `FieldCondition` (form-schema.type.ts).
*/
anyOf?: FieldCondition[];
}
@@ -446,6 +454,8 @@ export interface Inspection {
inspectorId: string | null;
inspectorName: string | null;
scheduledDate: string | null;
+ /** Half-day slot the site visit is booked into. */
+ timeSlot: "MORNING" | "AFTERNOON" | null;
conductedDate: string | null;
location: string | null;
status: "SCHEDULED" | "COMPLETED" | "CANCELLED";
@@ -543,6 +553,13 @@ export type TemplateStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export interface TemplatePageOptions {
format?: "A4" | "A5" | "Letter" | "Legal";
+ /**
+ * Explicit page dimensions (e.g. "4.92in"), for a size `format` has no
+ * named preset for — an ID-3 passport-booklet page, for one. Takes
+ * precedence over `format` when both are present.
+ */
+ width?: string;
+ height?: string;
landscape?: boolean;
printBackground?: boolean;
}
@@ -572,12 +589,15 @@ export interface TemplateFieldPlacement {
/** Variable rendered here, or null when the block carries literal `text`. */
variable: string | null;
text?: string;
+ /** Renders as `
` when "image" — see TemplateVariable.kind. */
+ type?: "text" | "image";
xPct: number;
yPct: number;
widthPct: number;
fontSize?: number;
fontWeight?: "normal" | "bold";
- align?: "left" | "center" | "right";
+ fontStyle?: "normal" | "italic";
+ align?: "left" | "center" | "right" | "justify";
color?: string;
}
@@ -639,6 +659,8 @@ export interface LicenseTemplate {
export interface TemplateVariable {
key: string;
label: string;
+ /** "image" means the value is a data URI to place as `
`, not text. */
+ kind?: "text" | "image";
}
export interface Paginated {
diff --git a/libs/api/src/lib/features/seafarer-registration/seafarer-registration-api.ts b/libs/api/src/lib/features/seafarer-registration/seafarer-registration-api.ts
index f1fa79373..4b42681eb 100644
--- a/libs/api/src/lib/features/seafarer-registration/seafarer-registration-api.ts
+++ b/libs/api/src/lib/features/seafarer-registration/seafarer-registration-api.ts
@@ -36,6 +36,11 @@ export const seafarerRegistrationApi = baseApi
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
+ cancelSeafarerRegistration: builder.mutation({
+ query: (id) => ({ url: `/seafarer-registrations/${id}`, method: 'DELETE' }),
+ invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
+ }),
+
saveSeafarerRegistration: builder.mutation<
SeafarerRegistration,
{ id: string; body: SaveSeafarerRegistration }
@@ -108,6 +113,7 @@ export const seafarerRegistrationApi = baseApi
export const {
useGetMySeafarerRegistrationQuery,
useStartSeafarerRegistrationMutation,
+ useCancelSeafarerRegistrationMutation,
useSaveSeafarerRegistrationMutation,
useSubmitSeafarerRegistrationMutation,
useListSeafarerRegistrationsQuery,
diff --git a/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts b/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts
index e3406a4f0..46e1e618a 100644
--- a/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts
+++ b/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts
@@ -64,13 +64,28 @@ export const PHYSICAL_BOUNDS = {
weightKg: { min: 30, max: 250 },
} as const;
+/**
+ * How the platform spells Ethiopia in the `nationality` free-text field
+ * (CountrySelect's country name — matches the API's ETHIOPIAN_NATIONALITY).
+ */
+export const ETHIOPIAN_NATIONALITY = 'Ethiopia';
+
+/** Whether a declared nationality is Ethiopian — drives National ID vs Passport requirements. */
+export function isEthiopianNationality(nationality: string | null | undefined): boolean {
+ return nationality === ETHIOPIAN_NATIONALITY;
+}
+
/** Upload slots, keyed as the API's submission check expects them. */
export const SEAFARER_REGISTRATION_DOCUMENTS: {
key: string;
name: string;
description?: string;
- /** `'passport'`: required only once a passport number is declared. */
- required: boolean | 'passport';
+ /**
+ * `'passport'`: required once a passport number is declared (always true
+ * for non-Ethiopians, who must declare one). `'ethiopian'`: required only
+ * for applicants who declared Ethiopian nationality.
+ */
+ required: boolean | 'passport' | 'ethiopian';
accept?: string;
}[] = [
{
@@ -80,7 +95,7 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
required: true,
accept: 'image/jpeg,image/png',
},
- { key: 'nationalId', name: 'National ID (Fayda) or Kebele ID', required: true },
+ { key: 'nationalId', name: 'National ID (Fayda) or Kebele ID', required: 'ethiopian' },
{ key: 'passport', name: 'Passport Copy', required: 'passport' },
{ key: 'graduation', name: 'Educational Certificate', required: false },
{
@@ -210,14 +225,22 @@ const OPTION_LABELS: Partial o.value === value)?.label ?? String(value);
return String(value);
}
diff --git a/package.json b/package.json
index de6d7a558..b8cf8ec94 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"backoffice": "nx serve @ema-platform/backoffice",
"portal": "nx serve @ema-platform/portal",
"dev:all": "nx run-many -t serve -p @ema-platform/portal @ema-platform/backoffice --parallel=2",
+ "build": "nx run-many -t build -p @ema-platform/portal @ema-platform/backoffice",
"build:backoffice": "nx build @ema-platform/backoffice",
"build:portal": "nx build @ema-platform/portal",
"lint": "nx run-many -t lint",