From 207994df698e0413e2ab2ab3ad8d8040dd7d419d Mon Sep 17 00:00:00 2001 From: Nati Date: Wed, 26 Aug 2026 09:14:45 +0000 Subject: [PATCH] Implement nationality-based document requirements and add cancellation feature for seafarer registration --- .../components/RegistrationDocuments.tsx | 12 ++- .../components/steps.tsx | 34 ++++++-- .../pages/SeafarerRegistrationPage.tsx | 82 ++++++++++++++++--- .../seafarer/pages/SeaRecords/index.tsx | 21 +++++ .../seafarer-registration-api.ts | 6 ++ .../seafarer-registration.constants.ts | 21 ++++- 6 files changed, 151 insertions(+), 25 deletions(-) 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 && ( - - )} + + {registration.status === 'DRAFT' && ( + + )} + {showSummary && !readOnly && ( + + )} + {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 }, {