Implement nationality-based document requirements and add cancellation feature for seafarer registration

This commit is contained in:
Nati
2026-08-26 09:14:45 +00:00
parent bd65fc0eab
commit 207994df69
6 changed files with 151 additions and 25 deletions

View File

@@ -3,6 +3,7 @@ import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } fr
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react'; import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
import { import {
SEAFARER_REGISTRATION_DOCUMENTS, SEAFARER_REGISTRATION_DOCUMENTS,
isEthiopianNationality,
uploadDocument, uploadDocument,
type Attachment, type Attachment,
} from '@ema-platform/api'; } from '@ema-platform/api';
@@ -10,22 +11,25 @@ import {
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
/** The document slots a registration asks for. */ /** 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) => ({ return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({
...d, ...d,
isRequired: d.required === 'passport' ? passportDeclared : d.required, isRequired: d.required === 'passport' ? passportDeclared : d.required === 'ethiopian' ? ethiopian : d.required,
})).filter((d) => d.required !== 'passport' || passportDeclared); })).filter((d) => (d.required !== 'passport' || passportDeclared) && (d.required !== 'ethiopian' || ethiopian));
} }
export function RegistrationDocuments({ export function RegistrationDocuments({
registrationId, registrationId,
passportDeclared, passportDeclared,
nationality,
attachments, attachments,
readOnly, readOnly,
onUploaded, onUploaded,
}: { }: {
registrationId: string; registrationId: string;
passportDeclared: boolean; passportDeclared: boolean;
nationality?: string | null;
attachments: Attachment[]; attachments: Attachment[];
readOnly?: boolean; readOnly?: boolean;
onUploaded: () => void; onUploaded: () => void;
@@ -62,7 +66,7 @@ export function RegistrationDocuments({
{error} {error}
</Alert> </Alert>
)} )}
{documentSlots(passportDeclared).map((slot) => { {documentSlots(passportDeclared, nationality).map((slot) => {
const existing = attachments.find((a) => a.documentKey === slot.key); const existing = attachments.find((a) => a.documentKey === slot.key);
const uploaded = Boolean(existing?.files?.length); const uploaded = Boolean(existing?.files?.length);
return ( return (

View File

@@ -6,6 +6,7 @@ import {
GENDER_OPTIONS, GENDER_OPTIONS,
HAIR_COLOR_OPTIONS, HAIR_COLOR_OPTIONS,
MARITAL_STATUS_OPTIONS, MARITAL_STATUS_OPTIONS,
isEthiopianNationality,
useGetActiveDepartmentsQuery, useGetActiveDepartmentsQuery,
useLocalized, useLocalized,
} from '@ema-platform/api'; } from '@ema-platform/api';
@@ -44,6 +45,7 @@ function SectionTitle({ title, description }: { title: string; description?: str
export function IdentityDetailsStep( export function IdentityDetailsStep(
p: StepProps & { account: { email?: string; phoneNumber?: string } }, p: StepProps & { account: { email?: string; phoneNumber?: string } },
) { ) {
const ethiopian = isEthiopianNationality(p.form.nationality);
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<SectionTitle title="Contact Details" /> <SectionTitle title="Contact Details" />
@@ -68,7 +70,18 @@ export function IdentityDetailsStep(
<DateField {...p} name="dateOfBirth" label="Date of Birth" required /> <DateField {...p} name="dateOfBirth" label="Date of Birth" required />
<SelectField {...p} name="maritalStatus" label="Marital Status" required options={MARITAL_STATUS_OPTIONS} /> <SelectField {...p} name="maritalStatus" label="Marital Status" required options={MARITAL_STATUS_OPTIONS} />
<NationalityField {...p} name="nationality" label="Nationality" required /> <NationalityField {...p} name="nationality" label="Nationality" required />
{ethiopian ? (
<TextField {...p} name="nationalIdNumber" label="National ID (Fayda) Number" required maxLength={64} /> <TextField {...p} name="nationalIdNumber" label="National ID (Fayda) Number" required maxLength={64} />
) : (
<TextField
{...p}
name="passportNumber"
label="Passport Number"
required
maxLength={32}
description="Required for non-Ethiopian applicants in place of a National ID."
/>
)}
</Grid> </Grid>
</Stack> </Stack>
); );
@@ -88,12 +101,16 @@ export function ApplicantDetailsStep(p: StepProps) {
const departmentOptions = const departmentOptions =
departments?.map((d) => ({ value: d.code, label: localized(d.name) })) ?? departments?.map((d) => ({ value: d.code, label: localized(d.name) })) ??
DEPARTMENT_OPTIONS; 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 ( return (
<Stack gap="lg"> <Stack gap="lg">
<SectionTitle title="Identity" /> <SectionTitle title="Identity" />
<Grid> <Grid>
<TextField {...p} name="placeOfBirth" label="Place of Birth" required maxLength={128} /> <TextField {...p} name="placeOfBirth" label="Place of Birth" required maxLength={128} />
{ethiopian && (
<TextField <TextField
{...p} {...p}
name="passportNumber" name="passportNumber"
@@ -101,6 +118,7 @@ export function ApplicantDetailsStep(p: StepProps) {
maxLength={32} maxLength={32}
description="Required later for international sea service; optional at registration." description="Required later for international sea service; optional at registration."
/> />
)}
{p.form.passportNumber && ( {p.form.passportNumber && (
<DateField {...p} name="passportExpiry" label="Passport Expiry Date" /> <DateField {...p} name="passportExpiry" label="Passport Expiry Date" />
)} )}

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
@@ -8,13 +9,14 @@ import {
Grid, Grid,
Group, Group,
Loader, Loader,
Modal,
Paper, Paper,
Stack, Stack,
Stepper, Stepper,
Text, Text,
Title, Title,
} from '@mantine/core'; } 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 { notifications } from '@mantine/notifications';
import { import {
PHYSICAL_BOUNDS, PHYSICAL_BOUNDS,
@@ -23,6 +25,8 @@ import {
SEAFARER_REGISTRATION_STATUS_LABELS, SEAFARER_REGISTRATION_STATUS_LABELS,
extractErrorMessage, extractErrorMessage,
extractValidationIssues, extractValidationIssues,
isEthiopianNationality,
useCancelSeafarerRegistrationMutation,
useGetAttachmentsQuery, useGetAttachmentsQuery,
useGetMySeafarerRegistrationQuery, useGetMySeafarerRegistrationQuery,
useSaveSeafarerRegistrationMutation, useSaveSeafarerRegistrationMutation,
@@ -48,15 +52,26 @@ const STEPS = [
{ label: 'Review', description: 'Check & submit' }, { 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[][] = [ 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'], ['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'], ['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
[], [],
['declarationAccepted'], ['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[]; const ANSWER_KEYS = Object.keys(SEAFARER_REGISTRATION_FIELD_LABELS) as AnswerKey[];
function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration { function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration {
@@ -119,15 +134,18 @@ function withProfileDefaults(
* still missing. A submitted registration opens to a read-only summary. * still missing. A submitted registration opens to a read-only summary.
*/ */
export function SeafarerRegistrationPage() { export function SeafarerRegistrationPage() {
const navigate = useNavigate();
const accountUser = useAppSelector((state) => state.auth.user); const accountUser = useAppSelector((state) => state.auth.user);
const { profile } = useCurrentProfile(); const { profile } = useCurrentProfile();
const { data, isLoading } = useGetMySeafarerRegistrationQuery(); const { data, isLoading } = useGetMySeafarerRegistrationQuery();
const registration = data?.registration ?? null; const registration = data?.registration ?? null;
const [start] = useStartSeafarerRegistrationMutation(); const [start] = useStartSeafarerRegistrationMutation();
const [cancelDraft, { isLoading: cancelling }] = useCancelSeafarerRegistrationMutation();
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation(); const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation(); const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
const [startError, setStartError] = useState<string | null>(null); const [startError, setStartError] = useState<string | null>(null);
const [confirmingCancel, setConfirmingCancel] = useState(false);
const started = useRef(false); const started = useRef(false);
useEffect(() => { useEffect(() => {
@@ -204,7 +222,7 @@ export function SeafarerRegistrationPage() {
function validateStep(index: number): boolean { function validateStep(index: number): boolean {
const found: Partial<Record<AnswerKey, string>> = {}; const found: Partial<Record<AnswerKey, string>> = {};
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 (blank(form[key])) found[key] = `${SEAFARER_REGISTRATION_FIELD_LABELS[key]} is required.`;
} }
if (index === 1) { if (index === 1) {
@@ -232,7 +250,7 @@ export function SeafarerRegistrationPage() {
} }
if (index === 3) { if (index === 3) {
const supplied = new Set(attachments.filter((a) => a.files?.length).map((a) => a.documentKey)); 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)) .filter((d) => d.isRequired && !supplied.has(d.key))
.map((d) => d.name); .map((d) => d.name);
if (missing.length) { 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 }; const stepProps = { form, set, errors, disabled: readOnly };
return ( return (
@@ -319,12 +350,25 @@ export function SeafarerRegistrationPage() {
/> />
</Group> </Group>
</div> </div>
<Group gap="xs">
{registration.status === 'DRAFT' && (
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => setConfirmingCancel(true)}
>
Cancel &amp; discard draft
</Button>
)}
{showSummary && !readOnly && ( {showSummary && !readOnly && (
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}> <Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
Edit details Edit details
</Button> </Button>
)} )}
</Group> </Group>
</Group>
{registration.status === 'APPROVED' && ( {registration.status === 'APPROVED' && (
<Alert color="teal" icon={<IconCheck size={16} />} title="Registered" mb="md"> <Alert color="teal" icon={<IconCheck size={16} />} title="Registered" mb="md">
@@ -386,6 +430,7 @@ export function SeafarerRegistrationPage() {
<RegistrationDocuments <RegistrationDocuments
registrationId={registration.id} registrationId={registration.id}
passportDeclared={Boolean(form.passportNumber)} passportDeclared={Boolean(form.passportNumber)}
nationality={form.nationality}
attachments={attachments} attachments={attachments}
readOnly={readOnly} readOnly={readOnly}
onUploaded={refetchAttachments} onUploaded={refetchAttachments}
@@ -426,6 +471,23 @@ export function SeafarerRegistrationPage() {
</Group> </Group>
</Paper> </Paper>
)} )}
<Modal opened={confirmingCancel} onClose={() => setConfirmingCancel(false)} title="Discard this draft?" centered>
<Stack>
<Text size="sm">
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.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setConfirmingCancel(false)} disabled={cancelling}>
Keep draft
</Button>
<Button color="red" loading={cancelling} onClick={handleCancel}>
Discard draft
</Button>
</Group>
</Stack>
</Modal>
</Container> </Container>
); );
} }

View File

@@ -298,6 +298,22 @@ function SeaServiceTab() {
}) })
: null; : 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 = const valid =
form.vesselName.trim().length > 1 && form.vesselName.trim().length > 1 &&
form.rank.trim().length > 1 && form.rank.trim().length > 1 &&
@@ -391,6 +407,7 @@ function SeaServiceTab() {
required required
value={form.vesselName} value={form.vesselName}
onChange={(e) => setForm({ ...form, vesselName: e.target.value })} onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
error={vesselNameError}
/> />
<TextInput <TextInput
label={t('seaRecords.seaService.fields.imoNumber')} label={t('seaRecords.seaService.fields.imoNumber')}
@@ -418,9 +435,13 @@ function SeaServiceTab() {
</Group> </Group>
<TextInput <TextInput
label={t('seaRecords.seaService.fields.rank')} label={t('seaRecords.seaService.fields.rank')}
description={t('seaRecords.seaService.fields.rankHint', {
defaultValue: 'Must be at least 2 characters, e.g. "AB" or "2nd Officer".',
})}
required required
value={form.rank} value={form.rank}
onChange={(e) => setForm({ ...form, rank: e.target.value })} onChange={(e) => setForm({ ...form, rank: e.target.value })}
error={rankError}
/> />
<Group grow> <Group grow>
<AmharicDatePicker <AmharicDatePicker

View File

@@ -36,6 +36,11 @@ export const seafarerRegistrationApi = baseApi
invalidatesTags: (_r, error) => (error ? [] : [LIST]), invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}), }),
cancelSeafarerRegistration: builder.mutation<void, string>({
query: (id) => ({ url: `/seafarer-registrations/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
}),
saveSeafarerRegistration: builder.mutation< saveSeafarerRegistration: builder.mutation<
SeafarerRegistration, SeafarerRegistration,
{ id: string; body: SaveSeafarerRegistration } { id: string; body: SaveSeafarerRegistration }
@@ -108,6 +113,7 @@ export const seafarerRegistrationApi = baseApi
export const { export const {
useGetMySeafarerRegistrationQuery, useGetMySeafarerRegistrationQuery,
useStartSeafarerRegistrationMutation, useStartSeafarerRegistrationMutation,
useCancelSeafarerRegistrationMutation,
useSaveSeafarerRegistrationMutation, useSaveSeafarerRegistrationMutation,
useSubmitSeafarerRegistrationMutation, useSubmitSeafarerRegistrationMutation,
useListSeafarerRegistrationsQuery, useListSeafarerRegistrationsQuery,

View File

@@ -64,13 +64,28 @@ export const PHYSICAL_BOUNDS = {
weightKg: { min: 30, max: 250 }, weightKg: { min: 30, max: 250 },
} as const; } 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. */ /** Upload slots, keyed as the API's submission check expects them. */
export const SEAFARER_REGISTRATION_DOCUMENTS: { export const SEAFARER_REGISTRATION_DOCUMENTS: {
key: string; key: string;
name: string; name: string;
description?: 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; accept?: string;
}[] = [ }[] = [
{ {
@@ -80,7 +95,7 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
required: true, required: true,
accept: 'image/jpeg,image/png', 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: 'passport', name: 'Passport Copy', required: 'passport' },
{ key: 'graduation', name: 'Educational Certificate', required: false }, { key: 'graduation', name: 'Educational Certificate', required: false },
{ {