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 056d2a739..115ea32e6 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 && (
({
+ query: ({ examId, profileId }) =>
+ `/exam-attempts/exam/${examId}/candidate/${profileId}/grading-sheet`,
+ providesTags: ['Api'],
+ }),
}),
overrideExisting: false,
});
@@ -119,4 +129,5 @@ export const {
useRecordIncidentMutation,
useResolveIncidentMutation,
useRegradeAttemptMutation,
+ useGetGradingSheetQuery,
} = examApi;
diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
index a17673197..ff8759458 100644
--- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
+++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
@@ -22,6 +22,7 @@ import {
TextInput,
ThemeIcon,
Box,
+ Tooltip,
rem,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
@@ -48,6 +49,7 @@ import {
useUpdateExamMutation,
useAssignQuestionsMutation,
useSelectRandomQuestionsMutation,
+ useGetExamRegistrationsQuery,
} from '../api/exam-api';
import { useGetQuestionsQuery } from '../../question/api/question-api';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
@@ -114,6 +116,12 @@ export function ExamDetailPage() {
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
+ // The backend locks the paper the moment the first candidate registers
+ // (ExamService.assertPaperEditable) — every candidate must sit the same
+ // paper. Same query ExamCandidatesPanel already runs, so RTK Query serves
+ // it from cache rather than issuing a second request.
+ const { data: registrations } = useGetExamRegistrationsQuery(id ?? '', { skip: !id });
+ const paperLocked = (registrations?.length ?? 0) > 0;
const { data: qRes } = useGetQuestionsQuery();
const { data: certRes } = useGetCertificationsQuery();
const allQuestions = qRes?.items ?? [];
@@ -122,10 +130,14 @@ export function ExamDetailPage() {
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
// must not offer drafts or retired questions either.
//
- // BOTH describes a mixed paper — a question itself is never "BOTH" (see
+ // Filters on exam.form alone, not administrationMethod: the backend no
+ // longer restricts ONLINE to CHOICE (ExamService no longer has an
+ // assertOnlineIsChoiceOnly gate), so exam.form is now the sole source of
+ // truth for what belongs on the paper, ONLINE or OFFLINE alike. BOTH
+ // describes a mixed paper — a question itself is never "BOTH" (see
// QuestionForm), so an equality check against it would match nothing and
- // silently offer zero questions. Same skip-condition as the backend's own
- // random draw (ExamService.selectRandomQuestions).
+ // silently offer zero questions; skipped the same way the backend's own
+ // random draw does (ExamService.selectRandomQuestions).
const eligibleQuestions = useMemo(() => {
if (!exam) return [];
return allQuestions
@@ -179,7 +191,9 @@ export function ExamDetailPage() {
} catch (error) {
const key = extractErrorMessage(error, t('exam.randomError'));
notify.error(
- key.startsWith('insufficient_approved_questions')
+ key === 'paper_locked_after_registration'
+ ? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
+ : key.startsWith('insufficient_approved_questions')
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
: key.startsWith('paper_cannot_reach_cutting_point')
? t('exam.cannotReachCuttingPoint', {
@@ -200,7 +214,9 @@ export function ExamDetailPage() {
} catch (error) {
const key = extractErrorMessage(error, 'Failed to assign questions');
notify.error(
- key.startsWith('question_not_approved')
+ key === 'paper_locked_after_registration'
+ ? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
+ : key.startsWith('question_not_approved')
? t('question.qc.onlyApprovedUsable')
: key.startsWith('paper_cannot_reach_cutting_point')
? t('exam.cannotReachCuttingPoint', {
@@ -452,19 +468,45 @@ export function ExamDetailPage() {
{t("exam.detail.questionsSection", { pts: totalPoints })}
- }
- onClick={openAssignModal}
- >
- {t("exam.manageQuestions")}
-
+
+ {paperLocked && (
+
+ {t("exam.paperLocked")}
+
+ )}
+
+ {/* Wrapped: a disabled Mantine Button fires no pointer events,
+ so the tooltip needs an enabled element to hang off. */}
+
+ }
+ onClick={openAssignModal}
+ disabled={paperLocked}
+ >
+ {t("exam.manageQuestions")}
+
+
+
+
{(exam.questions ?? []).length === 0 ? (
- }>
- {t("exam.noQuestionsAssigned")}
+ }
+ >
+ {paperLocked
+ ? t("exam.paperLockedHint", { count: registrations?.length ?? 0 })
+ : t("exam.noQuestionsAssigned")}
) : (
diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx
index 034d203ae..c4578d308 100644
--- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx
+++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx
@@ -24,7 +24,7 @@ import {
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
-import { RANK_KEY_OPTIONS } from "../../../certification/types/certification";
+import { useGetRanksQuery, useLocalized } from "@ema-platform/api";
import {
useGetExamsQuery,
useCreateExamMutation,
@@ -79,21 +79,44 @@ function ExamForm({
const [status, setStatus] = useState(editing?.status ?? null);
const [activeTab, setActiveTab] = useState("basic");
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
+ /**
+ * Split per tab so "Next" can check just the tab in front of the user.
+ * Submitting from Basic Info used to complain about Settings fields the
+ * user had not been shown yet — the error was correct and unactionable at
+ * the same time. Each returns the message key for what is missing, or null.
+ */
+ const validateBasic = (): string | null => {
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
- setActiveTab("basic");
- notify.error(t("exam.form.fillRequiredBasic"));
- return;
+ return "exam.form.fillRequiredBasic";
}
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
- setActiveTab("basic");
- notify.error(t("exam.form.directionBothLanguages"));
+ return "exam.form.directionBothLanguages";
+ }
+ return null;
+ };
+
+ const validateSettings = (): string | null =>
+ !type || !form || !adminMethod || !evalMethod || !cuttingPoint
+ ? "exam.form.fillRequiredSettings"
+ : null;
+
+ const goNext = () => {
+ const error = validateBasic();
+ if (error) {
+ notify.error(t(error));
return;
}
- if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
- setActiveTab("settings");
- notify.error(t("exam.form.fillRequiredSettings"));
+ setActiveTab("settings");
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ // Still checks both: the tabs are clickable, so a user can reach Settings
+ // without going through Next.
+ const error = validateBasic() ?? validateSettings();
+ if (error) {
+ setActiveTab(error === "exam.form.fillRequiredSettings" ? "settings" : "basic");
+ notify.error(t(error));
return;
}
onSubmit(
@@ -255,12 +278,6 @@ function ExamForm({
onChange={setForm}
size="sm"
required
- disabled={adminMethod === "ONLINE"}
- description={
- adminMethod === "ONLINE"
- ? t("exam.form.onlineChoiceOnlyHint")
- : undefined
- }
/>
+ {/* 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/result/components/RecordResultModal/columns.tsx b/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx
index f224aa828..9503b1120 100644
--- a/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx
+++ b/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx
@@ -1,7 +1,7 @@
-import { NumberInput, Text, TextInput } from '@mantine/core';
+import { Badge, Group, NumberInput, Text, TextInput } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
-import type { QuestionBrief } from '../../../exam/types/exam';
+import type { GradingSheetQuestion, QuestionBrief } from '../../../exam/types/exam';
export function recordResultColumns(
t: TFunction,
@@ -11,6 +11,9 @@ export function recordResultColumns(
questionRemarks: Record;
onScoreChange: (questionId: string, value: number) => void;
onRemarkChange: (questionId: string, value: string) => void;
+ /** The candidate's own answer + auto-score, when available (empty for
+ * an OFFLINE candidate or one who hasn't sat an online attempt). */
+ answersByQuestion: Map;
},
): AdvancedColumn[] {
return [
@@ -22,6 +25,20 @@ export function recordResultColumns(
),
},
+ {
+ header: t('result.recordModal.candidateAnswer'),
+ cell: ({ row }) => {
+ const answer = handlers.answersByQuestion.get(row.original.id);
+ if (!answer || (!answer.answerText && !answer.selectedOptionText)) {
+ return {t('result.recordModal.noAnswer')};
+ }
+ return (
+
+ {answer.selectedOptionText?.[locale] ?? answer.answerText}
+
+ );
+ },
+ },
{
header: t('result.recordModal.maxPoints'),
cell: ({ row }) => (
@@ -32,16 +49,26 @@ export function recordResultColumns(
},
{
header: t('result.recordModal.score'),
- cell: ({ row }) => (
- handlers.onScoreChange(row.original.id, Number(v))}
- min={0}
- max={row.original.points}
- size="xs"
- style={{ width: 80 }}
- />
- ),
+ cell: ({ row }) => {
+ const autoGraded = handlers.answersByQuestion.get(row.original.id)?.autoScore != null;
+ return (
+
+ handlers.onScoreChange(row.original.id, Number(v))}
+ min={0}
+ max={row.original.points}
+ size="xs"
+ style={{ width: 80 }}
+ />
+ {autoGraded && (
+
+ {t('result.recordModal.autoGraded')}
+
+ )}
+
+ );
+ },
},
{
header: t('result.recordModal.remark'),
diff --git a/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx b/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx
index 15ed50335..b073b1ca1 100644
--- a/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx
+++ b/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
@@ -20,7 +20,7 @@ import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { recordResultColumns } from './columns';
import { useCreateResultMutation } from '../../api/result-api';
-import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api';
+import { useGetExamRegistrationsQuery, useGetGradingSheetQuery } from '../../../exam/api/exam-api';
import type { Exam } from '../../../exam/types/exam';
function InfoRow({ label, value }: { label: string; value: string }) {
@@ -55,12 +55,39 @@ export function RecordResultModal({
const { data: registrations } = useGetExamRegistrationsQuery(exam.id, {
skip: !opened,
});
+ // The candidate's own answers plus whatever score auto-grading could
+ // already compute for the CHOICE portion — degrades to "no data" for an
+ // OFFLINE candidate or one who never sat an online attempt, same as
+ // before this existed.
+ const { data: gradingSheet } = useGetGradingSheetQuery(
+ { examId: exam.id, profileId: selectedSeafarerId ?? '' },
+ { skip: !opened || !selectedSeafarerId },
+ );
+ const answersByQuestion = new Map(
+ (gradingSheet?.questions ?? []).map((q) => [q.questionId, q]),
+ );
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
const table = useServerTable();
const questions = exam.questions ?? [];
const pagedQuestions = table.paginate(questions);
+ // Prefill (never override) the CHOICE questions auto-grading already
+ // scored — the examiner only has to key in the ESSAY marks. A fresh
+ // seafarer selection always starts from an empty scores map, so this
+ // only ever fills in blanks, never stomps a manual edit already made.
+ useEffect(() => {
+ if (!gradingSheet) return;
+ const autoScores: Record = {};
+ for (const q of gradingSheet.questions) {
+ if (q.autoScore !== null) autoScores[q.questionId] = q.autoScore;
+ }
+ if (Object.keys(autoScores).length) {
+ setScores((prev) => ({ ...autoScores, ...prev }));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [gradingSheet]);
+
const seafarerOptions = (registrations ?? [])
.filter((registration) =>
['PRESENT', 'LATE'].includes(registration.attendanceStatus),
@@ -175,6 +202,7 @@ export function RecordResultModal({
questionRemarks,
onScoreChange: handleScoreChange,
onRemarkChange: handleQuestionRemarkChange,
+ answersByQuestion,
})}
data={pagedQuestions.rows}
itemCount={pagedQuestions.itemCount}
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 20d277399..ff61ae868 100644
--- a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx
+++ b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx
@@ -100,6 +100,11 @@ const UM_CONFIG: DesignConfig = {
const UM_RUNTIME = {
basename: '/um',
+ // Keep the embedded IAM module on the same API as the backoffice client.
+ // Without a fallback, passing undefined made iamui fall back to its remote
+ // development server, where the local JWT is rejected and the module
+ // redirects to its login page. BASE_API_URL is the one shared resolution of
+ // VITE_BASE_API_URL and carries the local fallback.
apiUrl: BASE_API_URL,
};
diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts
index 458b638e6..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: "የመርከብ ባለቤትነት ዝውውር",
@@ -260,7 +260,6 @@ export const am: Translations = {
both: "ሁለቱም",
offline: "ከመስመር ውጪ",
online: "በመስመር",
- onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
sum: "ድምር",
average: "አማካይ",
percentage: "መቶኛ",
@@ -272,6 +271,8 @@ export const am: Translations = {
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
+ next: "ቀጣይ",
+ back: "ተመለስ",
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
status: "ሁኔታ",
statusPlaceholder: "የፈተና ሁኔታ",
@@ -388,6 +389,9 @@ export const am: Translations = {
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
cannotReachCuttingPoint:
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
+ paperLocked: "ወረቀቱ ተቆልፏል",
+ paperLockedHint:
+ "ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።",
},
country: {
@@ -658,6 +662,9 @@ export const am: Translations = {
seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ",
scorePerQuestion: "በጥያቄ ውጤት",
question: "ጥያቄ",
+ candidateAnswer: "የተፈታኙ መልስ",
+ noAnswer: "የተመዘገበ መልስ የለም",
+ autoGraded: "በራስ-ሰር የተመዘነ",
maxPoints: "ከፍተኛ ውጤት",
score: "ውጤት",
remark: "ማስታወሻ",
@@ -899,6 +906,11 @@ export const am: Translations = {
submitted: "የቀረበበት",
sla: "ዕድሜ / የጊዜ ገደብ",
claim: "ውሰድ",
+ assign: "መድብ",
+ assigned: "ተመድቧል",
+ assignedBody: "ባለሙያው ተነግሮታል፤ ግምገማው ተጀምሯል።",
+ assignFailed: "መመደብ አልተቻለም",
+ assignError: "ማመልከቻውን መመደብ አልተቻለም።",
review: "ገምግም",
claimed: "ተወስዷል",
claimedBody: "ማመልከቻው አሁን ለእርስዎ ተመድቧል።",
@@ -974,9 +986,14 @@ export const am: Translations = {
unscheduled: "አልተያዘም",
inspectionResult: "የምርመራ ውጤት",
findings: "ግኝቶች",
+ findingsEvidence: "አጋዥ ሰነዶች",
+ uploadEvidence: "ሰነድ መጫን",
dateTime: "ቀን እና ሰዓት",
+ date: "ቀን",
+ morning: "ጠዋት",
+ afternoon: "ከሰዓት በኋላ",
schedule: "ያዝ",
- pickDate: "መጀመሪያ ቀን እና ሰዓት ይምረጡ",
+ pickDate: "መጀመሪያ ቀን ይምረጡ",
passed: "አልፏል",
failed: "ወድቋል",
round_one: "ዙር {{count}}",
@@ -985,6 +1002,10 @@ export const am: Translations = {
linkCopied: "አገናኝ ተቀድቷል",
actionFailed: "ተግባሩ አልተሳካም",
errorTitle: "ይህን ማመልከቻ መጫን አልተቻለም",
+ certificate: "ምስክር ወረቀት",
+ view: "ይመልከቱ",
+ download: "አውርድ",
+ certificateError: "ምስክር ወረቀቱን መክፈት አልተቻለም",
hideActivity: "እንቅስቃሴ ደብቅ",
showActivity: "እንቅስቃሴ አሳይ",
awaitingPayment: "አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።",
@@ -998,6 +1019,10 @@ export const am: Translations = {
actions: {
claim: "ውሰድ",
assign: "መድብ",
+ reportReview: "ለቡድን መሪ አሳውቅ",
+ assignInspector: "ምርመራ መድብ",
+ reportInspection: "የምርመራ ውጤት አሳውቅ",
+ assignReviewer: "ገምጋሚ መድብ",
escalate: "ወደ ላይ አሳድግ",
hold: "አግድ",
resume: "ቀጥል",
@@ -1026,10 +1051,14 @@ export const am: Translations = {
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
+ inspectionNotYetDue:
+ "ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
needsDocumentReviews:
"መጀመሪያ ሁሉንም ሰነዶች ይቀበሉ — ከ{{total}} {{accepted}} ተቀብለዋል። የሰነዶች ትር ከፍተው ቀሪዎቹን ይቀበሉ።",
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
},
+ inspectionFailedBlocked:
+ "ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
reasons: {
incompleteDocuments: "ያልተሟሉ ሰነዶች",
belowCapital: "ካፒታል ከሚያስፈልገው በታች",
@@ -1056,6 +1085,7 @@ export const am: Translations = {
hold: "ለ{{applicant}} ማመልከቻ {{number}} ያግዳል። ለእርስዎ ተመድቦ ይቆያል።",
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
+ "assign-reviewer": "ማመልከቻ {{number}} ለተመረጠው ሹም ሰጥቶ ግምገማውን ያስጀምራል።",
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
},
@@ -1150,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 73a9d96d7..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',
@@ -259,7 +259,6 @@ export const en = {
both: 'Both',
offline: 'Offline',
online: 'Online',
- onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
sum: 'Sum',
average: 'Average',
percentage: 'Percentage',
@@ -272,6 +271,8 @@ export const en = {
fillRequiredBasic: 'Please fill all required fields in Basic Info.',
fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.',
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
+ next: 'Next',
+ back: 'Back',
status: 'Status',
statusPlaceholder: 'Exam status',
pending: 'Pending',
@@ -387,6 +388,9 @@ export const en = {
'Not enough approved questions in the bank for this subject.',
cannotReachCuttingPoint:
'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.',
+ paperLocked: 'Paper locked',
+ paperLockedHint:
+ '{{count}} candidate(s) have registered for this session. Every candidate must sit the same paper, so questions can no longer be changed.',
},
country: {
@@ -658,6 +662,9 @@ export const en = {
seafarerPlaceholder: 'Search and select a seafarer',
scorePerQuestion: 'Score per Question',
question: 'Question',
+ candidateAnswer: "Candidate's Answer",
+ noAnswer: 'No answer on file',
+ autoGraded: 'Auto-graded',
maxPoints: 'Max Points',
score: 'Score',
remark: 'Remark',
@@ -908,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.',
@@ -983,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}}',
@@ -994,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}}.',
@@ -1007,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',
@@ -1035,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',
@@ -1064,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}}.',
},
@@ -1155,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. */}
+
+ >
+ )}
+ )}
+ {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 98673b6b7..65ea5cd1c 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: 'ላይ ቆሟል',
@@ -1000,6 +1001,10 @@ export const am: Translations = {
timeExpired: 'ጊዜው አልቋል',
resumeExam: 'ፈተና ይቀጥሉ',
takeExam: 'ፈተና ይውሰዱ',
+ awaitingAttendance: 'መገኘት በመጠባበቅ ላይ',
+ awaitingAttendanceHint:
+ 'ፈተናው ከመከፈቱ በፊት ተቆጣጣሪ መገኘትዎን ማረጋገጥ አለበት።',
+ notSitting: 'አይፈተኑም',
attendanceStatus: {
REGISTERED: 'አልተጠራም',
PRESENT: 'ተገኝቷል',
@@ -1030,8 +1035,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 d6d578b06..63217dcf1 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',
@@ -1002,6 +1003,10 @@ export const en = {
timeExpired: 'Time expired',
resumeExam: 'Resume exam',
takeExam: 'Take exam',
+ awaitingAttendance: 'Awaiting attendance',
+ awaitingAttendanceHint:
+ 'An invigilator must confirm you are present before the exam opens.',
+ notSitting: 'Not sitting',
attendanceStatus: {
REGISTERED: 'Not called',
PRESENT: 'Present',
@@ -1032,8 +1037,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 e5833387d..0a2d180ba 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,
@@ -63,7 +64,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',
@@ -90,7 +94,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',
@@ -123,7 +133,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,
@@ -177,6 +191,7 @@ export const APPLICANT_NAME_TYPE_KEYS = [
'CERTIFICATE_OF_PROFICIENCY',
'VESSEL_REGISTRATION',
'VESSEL_OWNERSHIP_TRANSFER',
+ 'ENDORSEMENT_SEAFARER',
'ENDORSEMENT_COC',
'ENDORSEMENT_GOC',
];
@@ -188,6 +203,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',
@@ -371,6 +387,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.',
};
@@ -555,7 +575,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;
@@ -567,7 +594,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",