diff --git a/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx b/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx
index 8afdde637..e1fb715e3 100644
--- a/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx
+++ b/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx
@@ -3,6 +3,7 @@ import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } fr
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
import {
SEAFARER_REGISTRATION_DOCUMENTS,
+ isEthiopianNationality,
uploadDocument,
type Attachment,
} from '@ema-platform/api';
@@ -10,22 +11,25 @@ import {
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
/** The document slots a registration asks for. */
-export function documentSlots(passportDeclared: boolean) {
+export function documentSlots(passportDeclared: boolean, nationality?: string | null) {
+ const ethiopian = isEthiopianNationality(nationality);
return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({
...d,
- isRequired: d.required === 'passport' ? passportDeclared : d.required,
- })).filter((d) => d.required !== 'passport' || passportDeclared);
+ isRequired: d.required === 'passport' ? passportDeclared : d.required === 'ethiopian' ? ethiopian : d.required,
+ })).filter((d) => (d.required !== 'passport' || passportDeclared) && (d.required !== 'ethiopian' || ethiopian));
}
export function RegistrationDocuments({
registrationId,
passportDeclared,
+ nationality,
attachments,
readOnly,
onUploaded,
}: {
registrationId: string;
passportDeclared: boolean;
+ nationality?: string | null;
attachments: Attachment[];
readOnly?: boolean;
onUploaded: () => void;
@@ -62,7 +66,7 @@ export function RegistrationDocuments({
{error}
)}
- {documentSlots(passportDeclared).map((slot) => {
+ {documentSlots(passportDeclared, nationality).map((slot) => {
const existing = attachments.find((a) => a.documentKey === slot.key);
const uploaded = Boolean(existing?.files?.length);
return (
diff --git a/apps/portal/src/app/features/seafarer-registration/components/steps.tsx b/apps/portal/src/app/features/seafarer-registration/components/steps.tsx
index bccc92480..1ca06da9a 100644
--- a/apps/portal/src/app/features/seafarer-registration/components/steps.tsx
+++ b/apps/portal/src/app/features/seafarer-registration/components/steps.tsx
@@ -6,6 +6,7 @@ import {
GENDER_OPTIONS,
HAIR_COLOR_OPTIONS,
MARITAL_STATUS_OPTIONS,
+ isEthiopianNationality,
useGetActiveDepartmentsQuery,
useLocalized,
} from '@ema-platform/api';
@@ -44,6 +45,7 @@ function SectionTitle({ title, description }: { title: string; description?: str
export function IdentityDetailsStep(
p: StepProps & { account: { email?: string; phoneNumber?: string } },
) {
+ const ethiopian = isEthiopianNationality(p.form.nationality);
return (
@@ -68,7 +70,18 @@ export function IdentityDetailsStep(
-
+ {ethiopian ? (
+
+ ) : (
+
+ )}
);
@@ -88,19 +101,24 @@ export function ApplicantDetailsStep(p: StepProps) {
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 && (
)}
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}
/>
(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 1112e2bdf..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 },
{