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/TemplateCanvas.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx
index db0530f2f..d04f360dc 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';
@@ -233,6 +234,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/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..14abcd909 100644
--- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts
+++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts
@@ -81,23 +81,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) =>
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..61d3f5ef9 100644
--- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
+++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
@@ -392,7 +392,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/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();
@@ -391,7 +394,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/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/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx b/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx
index 52e3cebc5..4dddf30bb 100644
--- a/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx
+++ b/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx
@@ -9,9 +9,14 @@ import {
} from '@mantine/core';
import {
conditionHolds,
+ useGetActiveDepartmentsQuery,
+ useGetRanksQuery,
useLocalized,
+ type Bilingual,
+ type Department,
type FormFieldConfig,
type FormSectionConfig,
+ type Rank,
type Vessel,
} from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
@@ -82,6 +87,41 @@ export function fillFromVessel(
}
}
+/**
+ * Options for a SELECT field.
+ *
+ * A department/rank field's seed `options` are a label cache that goes stale
+ * the moment a backoffice admin adds a department or rank — the field's
+ * value is already resolved server-side (profile department, or
+ * `eligibility.nextRank`), correct either way, but a value missing from a
+ * stale cache renders as a blank Select. Live-fetched departments/ranks take
+ * over the labels for these two `source`s; the seed's own `options` still
+ * cover every other SELECT unchanged.
+ */
+function selectOptions(
+ field: FormFieldConfig,
+ currentValue: string | undefined,
+ departments: Department[] | undefined,
+ ranks: Rank[],
+ localized: (v?: Bilingual) => string,
+): { value: string; label: string }[] {
+ if (field.source === 'profile.seafarerDepartment' && departments) {
+ return departments.map((d) => ({ value: d.code, label: localized(d.name) }));
+ }
+ if (field.source === 'eligibility.nextRank') {
+ const options = ranks.map((r) => ({ value: r.key, label: localized(r.name) }));
+ // The resolved rank might not be on THIS field's ladder (rank/rankEngine,
+ // proficiencyDeck/Engine share one `eligibility.nextRank` source but only
+ // one is ever populated) — still show it rather than a blank Select.
+ if (currentValue && !options.some((o) => o.value === currentValue)) {
+ const known = ranks.find((r) => r.key === currentValue);
+ options.push({ value: currentValue, label: known ? localized(known.name) : currentValue });
+ }
+ return options;
+ }
+ return (field.options ?? []).map((o) => ({ value: o.value, label: localized(o.label) }));
+}
+
/**
* Renders one form section from the license type's configuration.
*
@@ -105,6 +145,21 @@ export function ConfigDrivenSection({
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
);
+ // A department/rank SELECT ships with a hardcoded `options` label list in
+ // the seed, so a department or rank added later in the backoffice has no
+ // label there and would render as a blank Select even though the field's
+ // value (resolved server-side) is correct. Skipped when the section has
+ // neither kind of field, so most sections never pay for these two queries.
+ const needsDepartmentLabels = fields.some(
+ (f) => f.source === 'profile.seafarerDepartment',
+ );
+ const needsRankLabels = fields.some((f) => f.source === 'eligibility.nextRank');
+ const { data: departments } = useGetActiveDepartmentsQuery(undefined, {
+ skip: !needsDepartmentLabels,
+ });
+ const { data: rankRes } = useGetRanksQuery(undefined, { skip: !needsRankLabels });
+ const ranks = rankRes?.items ?? [];
+
return (
{fields.map((field) => {
@@ -183,10 +238,7 @@ export function ConfigDrivenSection({
) : field.type === 'SELECT' ? (
);
}
-/** Step 2 — Identity, Address and Physical Characteristics. */
+/**
+ * Step 2 — Identity, Address and Physical Characteristics.
+ *
+ * The department list is backoffice-managed (see the Ranks & Departments
+ * configuration tab), so this fetches the live set rather than a fixed
+ * three — falling back to it only until the query resolves, so the field
+ * is never an empty flash.
+ */
export function ApplicantDetailsStep(p: StepProps) {
+ const localized = useLocalized();
+ const { data: departments } = useGetActiveDepartmentsQuery();
+ const departmentOptions =
+ departments?.map((d) => ({ value: d.code, label: localized(d.name) })) ??
+ DEPARTMENT_OPTIONS;
+ // Non-Ethiopians already declare their passport number as their primary ID
+ // on the Identity step — asking again here would just duplicate the field.
+ const ethiopian = isEthiopianNationality(p.form.nationality);
+
return (
-
+ {ethiopian && (
+
+ )}
{p.form.passportNumber && (
)}
@@ -94,7 +127,7 @@ export function ApplicantDetailsStep(p: StepProps) {
name="department"
label="Department"
required
- options={DEPARTMENT_OPTIONS}
+ options={departmentOptions}
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
/>
diff --git a/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx b/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx
index bcef33bf2..838bc1e77 100644
--- a/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx
+++ b/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
@@ -8,13 +9,14 @@ import {
Grid,
Group,
Loader,
+ Modal,
Paper,
Stack,
Stepper,
Text,
Title,
} from '@mantine/core';
-import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil } from '@tabler/icons-react';
+import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil, IconTrash } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import {
PHYSICAL_BOUNDS,
@@ -23,6 +25,8 @@ import {
SEAFARER_REGISTRATION_STATUS_LABELS,
extractErrorMessage,
extractValidationIssues,
+ isEthiopianNationality,
+ useCancelSeafarerRegistrationMutation,
useGetAttachmentsQuery,
useGetMySeafarerRegistrationQuery,
useSaveSeafarerRegistrationMutation,
@@ -48,15 +52,26 @@ const STEPS = [
{ label: 'Review', description: 'Check & submit' },
];
-/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
+/**
+ * Which answers each step must have before "Continue" — mirrors the API's
+ * submission check. National ID vs Passport Number depends on the declared
+ * nationality, so that slot is added dynamically in `requiredForStep`.
+ */
const REQUIRED_BY_STEP: AnswerKey[][] = [
- ['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
+ ['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality'],
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
[],
['declarationAccepted'],
];
+/** Ethiopians must give a National ID; everyone else must give a Passport Number instead. */
+function requiredForStep(index: number, nationality: string | null | undefined): AnswerKey[] {
+ const base = REQUIRED_BY_STEP[index] ?? [];
+ if (index !== 0) return base;
+ return [...base, isEthiopianNationality(nationality) ? 'nationalIdNumber' : 'passportNumber'];
+}
+
const ANSWER_KEYS = Object.keys(SEAFARER_REGISTRATION_FIELD_LABELS) as AnswerKey[];
function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration {
@@ -119,15 +134,18 @@ function withProfileDefaults(
* still missing. A submitted registration opens to a read-only summary.
*/
export function SeafarerRegistrationPage() {
+ const navigate = useNavigate();
const accountUser = useAppSelector((state) => state.auth.user);
const { profile } = useCurrentProfile();
const { data, isLoading } = useGetMySeafarerRegistrationQuery();
const registration = data?.registration ?? null;
const [start] = useStartSeafarerRegistrationMutation();
+ const [cancelDraft, { isLoading: cancelling }] = useCancelSeafarerRegistrationMutation();
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
const [startError, setStartError] = useState(null);
+ const [confirmingCancel, setConfirmingCancel] = useState(false);
const started = useRef(false);
useEffect(() => {
@@ -204,7 +222,7 @@ export function SeafarerRegistrationPage() {
function validateStep(index: number): boolean {
const found: Partial> = {};
- for (const key of REQUIRED_BY_STEP[index] ?? []) {
+ for (const key of requiredForStep(index, form.nationality)) {
if (blank(form[key])) found[key] = `${SEAFARER_REGISTRATION_FIELD_LABELS[key]} is required.`;
}
if (index === 1) {
@@ -232,7 +250,7 @@ export function SeafarerRegistrationPage() {
}
if (index === 3) {
const supplied = new Set(attachments.filter((a) => a.files?.length).map((a) => a.documentKey));
- const missing = documentSlots(Boolean(form.passportNumber))
+ const missing = documentSlots(Boolean(form.passportNumber), form.nationality)
.filter((d) => d.isRequired && !supplied.has(d.key))
.map((d) => d.name);
if (missing.length) {
@@ -302,6 +320,19 @@ export function SeafarerRegistrationPage() {
}
}
+ async function handleCancel() {
+ if (!registration) return;
+ try {
+ await cancelDraft(registration.id).unwrap();
+ notifications.show({ color: 'teal', title: 'Draft discarded', message: 'Nothing was saved.' });
+ navigate('/dashboard');
+ } catch (err) {
+ notifications.show({ color: 'red', title: 'Could not discard the draft', message: extractErrorMessage(err) });
+ } finally {
+ setConfirmingCancel(false);
+ }
+ }
+
const stepProps = { form, set, errors, disabled: readOnly };
return (
@@ -319,11 +350,24 @@ export function SeafarerRegistrationPage() {
/>
- {showSummary && !readOnly && (
- } onClick={() => setViewingSummary(false)}>
- Edit details
-
- )}
+
+ {registration.status === 'DRAFT' && (
+ }
+ onClick={() => setConfirmingCancel(true)}
+ >
+ Cancel & discard draft
+
+ )}
+ {showSummary && !readOnly && (
+ } onClick={() => setViewingSummary(false)}>
+ Edit details
+
+ )}
+
{registration.status === 'APPROVED' && (
@@ -386,6 +430,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.
+
+
+
+
+
+
+
);
}
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}
/>
` 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 +642,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);
}