feat: add seafarer registration feature with multi-step form

- Implemented SeafarerRegistrationPage component with five-step registration process.
- Added API endpoints for seafarer registration including start, save, and submit functionalities.
- Created necessary types and constants for seafarer registration.
- Updated router to include new registration paths and permissions.
- Integrated profile defaults to pre-fill registration fields where applicable.
- Added validation and error handling for registration steps.
- Enhanced document upload functionality specific to seafarer registration.
This commit is contained in:
Nati
2026-08-20 07:14:57 +00:00
parent 688b111b6a
commit 818964a694
24 changed files with 2147 additions and 445 deletions

View File

@@ -7,14 +7,12 @@ import { OperationsFormContent } from '../../profile/components/OperationsFormCo
* "I own a vessel" or "I am a seafarer" came here to register, so they are
* taken straight to that form instead of a dashboard that only links to it.
*
* Seafarer used to detour via `/profile` because the wizard refused to open
* without a complete one. It no longer does — the Identity Details step
* collects those answers itself (`RequireSeafarerProfile`) — so the detour was
* only an extra screen between a new signup and the thing they came for.
* Seafarer wins when both are ticked; the other form is one nav click away.
* Seafarer goes to its own registration page, whose Identity Details step
* collects the profile answers itself — no detour via `/profile`. Seafarer
* wins when both are ticked; the other form is one nav click away.
*/
const NEXT_STEP: Record<string, string> = {
SEAFARER_REGISTRATION: '/licensing/SEAFARER_REGISTRATION/apply',
SEAFARER_REGISTRATION: '/seafarer-registration',
VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply',
};

View File

@@ -1,15 +1,11 @@
import { Center, Loader } from "@mantine/core";
import { Navigate, useParams } from "react-router-dom";
import { useCurrentProfile, type ProfileRequirement } from "@ema-platform/auth";
import { useGetMyApplicationsQuery } from "@ema-platform/api";
import type { ProfileRequirement } from "@ema-platform/auth";
/**
* The identity the seafarer wizard needs before it can produce a registration.
* The identity a seafarer registration is built from.
*
* No longer a gate on opening the wizard: the Identity Details step collects
* these itself, so an applicant with an empty profile starts in registration
* rather than being sent to `/profile` to prepare for it. Kept because
* `ProfilePage` still reads it to show what a seafarer registration will need.
* Not a gate: the registration form (`/seafarer-registration`) collects these
* itself and prefills from the profile where it can. `ProfilePage` reads it
* to show what a registration will need.
*/
export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
fields: [
@@ -29,78 +25,3 @@ export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
reason:
"Seafarer registration is built from your profile — these details fill it in for you.",
};
const REGISTRATION_TYPE_KEY = "SEAFARER_REGISTRATION";
/**
* Opens the existing registration summary when the applicant already holds a
* seafarer number, avoiding an attempt to create a duplicate registration.
*
* It deliberately does *not* gate on profile completeness any more. Selecting
* Seafarer Registration now opens the wizard, and the Identity Details step
* collects name, gender, DOB, marital status, nationality and national ID
* itself — an empty profile is a thing the wizard fills, not a reason to be
* sent away from it. Those answers reach the profile when a reviewer approves
* the registration (`CompletionEffectService.registerSeafarer`).
*
* Wraps `/seafarer-registration` directly and `/licensing/:typeCode/apply`
* when `typeCode` is the seafarer type — the latter is the shared wizard route
* every licence type renders through, so without it a deep link skips this.
*/
export function RequireSeafarerProfile({
children,
}: {
children: React.ReactNode;
}) {
const { typeCode } = useParams();
const { isLoading, error, profile } = useCurrentProfile();
// Shared wizard route — only the seafarer type is checked here.
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
const registered = Boolean(profile?.seafarerNumber);
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery(undefined, {
skip: !gated || !registered,
});
if (!gated) return <>{children}</>;
if (isLoading) {
return (
<Center h={200}>
<Loader />
</Center>
);
}
// The number is permanent and the server refuses a second registration.
// Keep the registration tab useful by opening the completed application in
// its read-only summary instead.
if (registered) {
if (loadingApplications) {
return (
<Center h={200}>
<Loader />
</Center>
);
}
const registration = applications?.items.find(
(application) => application.licenseType?.key === REGISTRATION_TYPE_KEY,
);
if (registration) {
return (
<Navigate
to={`/licensing/${REGISTRATION_TYPE_KEY}/applications/${registration.id}`}
replace
/>
);
}
}
// A failed lookup must not lock anyone out — an unreadable profile says
// nothing about whether this applicant is already registered, and the
// server refuses a duplicate registration regardless.
if (error) return <>{children}</>;
return <>{children}</>;
}

View File

@@ -0,0 +1,140 @@
import { useRef, useState } from 'react';
import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } from '@mantine/core';
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
import {
SEAFARER_REGISTRATION_DOCUMENTS,
uploadDocument,
type Attachment,
} from '@ema-platform/api';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
/** The document slots a registration asks for. */
export function documentSlots(passportDeclared: boolean) {
return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({
...d,
isRequired: d.required === 'passport' ? passportDeclared : d.required,
})).filter((d) => d.required !== 'passport' || passportDeclared);
}
export function RegistrationDocuments({
registrationId,
passportDeclared,
attachments,
readOnly,
onUploaded,
}: {
registrationId: string;
passportDeclared: boolean;
attachments: Attachment[];
readOnly?: boolean;
onUploaded: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const resetRefs = useRef<Record<string, () => void>>({});
async function handle(documentKey: string, file: File | null) {
if (!file) return;
if (file.size > MAX_FILE_SIZE_BYTES) {
setError(`File exceeds 5MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB).`);
resetRefs.current[documentKey]?.();
return;
}
setBusy(documentKey);
setError(null);
const result = await uploadDocument({
ownerType: 'SEAFARER_REGISTRATION',
ownerId: registrationId,
documentKey,
file,
});
setBusy(null);
resetRefs.current[documentKey]?.();
if (result.ok) onUploaded();
else setError(result.error);
}
return (
<Stack gap="sm">
{error && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{error}
</Alert>
)}
{documentSlots(passportDeclared).map((slot) => {
const existing = attachments.find((a) => a.documentKey === slot.key);
const uploaded = Boolean(existing?.files?.length);
return (
<Card
key={slot.key}
withBorder
padding="md"
style={{
borderColor: uploaded ? 'var(--mantine-color-teal-4)' : undefined,
borderStyle: uploaded ? 'solid' : 'dashed',
}}
>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<div style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={600} size="sm">
{slot.name}
</Text>
{!slot.isRequired && (
<Badge size="xs" variant="light" color="gray">
optional
</Badge>
)}
{uploaded && (
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
uploaded
</Badge>
)}
</Group>
{slot.description && (
<Text size="xs" c="dimmed" mt={2}>
{slot.description}
</Text>
)}
{existing?.files?.[0] && (
<Text size="xs" c="dimmed" truncate mt={2}>
{existing.files[0].originalName} · {(existing.files[0].sizeBytes / 1024).toFixed(0)} KB
</Text>
)}
</div>
<Group gap="xs" wrap="nowrap">
{existing?.files?.[0]?.url && (
<Button size="xs" variant="subtle" component="a" href={existing.files[0].url} target="_blank">
View
</Button>
)}
{!readOnly && (
<FileButton
resetRef={(r) => {
if (r) resetRefs.current[slot.key] = r;
}}
onChange={(file) => handle(slot.key, file)}
accept={slot.accept}
>
{(props) => (
<Button
{...props}
size="xs"
variant={uploaded ? 'light' : 'filled'}
leftSection={busy === slot.key ? <Loader size={12} /> : <IconFileUpload size={14} />}
disabled={busy === slot.key}
>
{uploaded ? 'Replace' : 'Upload'}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
</Card>
);
})}
</Stack>
);
}

View File

@@ -0,0 +1,87 @@
import { Divider, Stack, Table, Text } from '@mantine/core';
import {
SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_SECTIONS,
displaySeafarerAnswer,
type Attachment,
type SaveSeafarerRegistration,
} from '@ema-platform/api';
import { documentSlots } from './RegistrationDocuments';
/** Read-only view of every answer and upload, grouped as the wizard asked them. */
export function RegistrationSummary({
answers,
attachments,
}: {
answers: SaveSeafarerRegistration;
attachments?: Attachment[];
}) {
return (
<Stack gap="md">
{SEAFARER_REGISTRATION_SECTIONS.map((section) => (
<div key={section.key}>
<Text fw={600} size="sm" mb={4}>
{section.title}
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
{section.fields
.filter((f) => f !== 'passportExpiry' || answers.passportNumber)
.map((field) => (
<Table.Tr key={field}>
<Table.Td w="45%">
<Text size="xs" c="dimmed">
{SEAFARER_REGISTRATION_FIELD_LABELS[field]}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{displaySeafarerAnswer(field, answers[field])}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
))}
{attachments && (
<>
<Divider />
<Text fw={600} size="sm" mb={4}>
Documents
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
{documentSlots(Boolean(answers.passportNumber)).map((slot) => {
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];
return (
<Table.Tr key={slot.key}>
<Table.Td w="45%">
<Text size="xs" c="dimmed">
{slot.name}
</Text>
</Table.Td>
<Table.Td>
{file ? (
file.url ? (
<a href={file.url} target="_blank" rel="noreferrer">
{file.originalName}
</a>
) : (
<Text size="sm">{file.originalName}</Text>
)
) : (
<Text size="sm" c={slot.isRequired ? 'red' : 'dimmed'}>
{slot.isRequired ? 'Missing' : '—'}
</Text>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</>
)}
</Stack>
);
}

View File

@@ -0,0 +1,143 @@
import { Checkbox, Grid, Input, NumberInput, Select, TextInput } from '@mantine/core';
import type { SaveSeafarerRegistration } from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect, getCountryCode, getCountryName } from '@ema-platform/ui';
import { LocationPicker } from '../../location/components/LocationPicker';
export type AnswerKey = keyof SaveSeafarerRegistration;
/** What every step receives: the answers, a setter, the errors, and whether it is locked. */
export interface StepProps {
form: SaveSeafarerRegistration;
set: (key: AnswerKey, value: unknown) => void;
errors: Partial<Record<AnswerKey, string>>;
disabled?: boolean;
}
interface FieldProps extends StepProps {
name: AnswerKey;
label: string;
required?: boolean;
description?: string;
maxLength?: number;
span?: number;
}
const common = (p: FieldProps) => ({
label: p.label,
description: p.description,
withAsterisk: p.required,
error: p.errors[p.name],
disabled: p.disabled,
});
const Col = ({ span = 6, children }: { span?: number; children: React.ReactNode }) => (
<Grid.Col span={{ base: 12, md: span }}>{children}</Grid.Col>
);
export function TextField(p: FieldProps) {
return (
<Col span={p.span}>
<TextInput
{...common(p)}
maxLength={p.maxLength}
value={(p.form[p.name] as string) ?? ''}
onChange={(e) => p.set(p.name, e.currentTarget.value)}
/>
</Col>
);
}
export function SelectField(p: FieldProps & { options: { value: string; label: string }[] }) {
return (
<Col span={p.span}>
<Select
{...common(p)}
data={p.options}
value={(p.form[p.name] as string) ?? null}
onChange={(v) => p.set(p.name, v)}
clearable={!p.required}
/>
</Col>
);
}
export function DateField(p: FieldProps) {
return (
<Col span={p.span}>
<AmharicDatePicker
label={p.label}
error={p.errors[p.name]}
disabled={p.disabled}
required={p.required}
dateFormat="date"
value={(p.form[p.name] as string) ?? ''}
onChange={(v) => p.set(p.name, v)}
/>
</Col>
);
}
export function NumberField(p: FieldProps) {
return (
<Col span={p.span}>
<NumberInput
{...common(p)}
// Not clamped: validation reports an out-of-range figure instead of
// Mantine quietly rewriting what the applicant typed.
value={(p.form[p.name] as number) ?? ''}
onChange={(v) => p.set(p.name, v === '' ? null : Number(v))}
/>
</Col>
);
}
/** Stored as the full country name, like the profile; picked by alpha-2 code. */
export function NationalityField(p: FieldProps) {
const stored = (p.form[p.name] as string) ?? '';
return (
<Col span={p.span}>
<CountrySelect
{...common(p)}
required={p.required}
demonym
value={getCountryCode(stored) ?? (stored || null)}
onChange={(code) => p.set(p.name, code ? getCountryName(code) || code : null)}
/>
</Col>
);
}
export function LocationField(p: FieldProps) {
return (
<Col span={p.span}>
<Input.Wrapper
label={p.label}
description={p.description}
withAsterisk={p.required}
error={p.errors[p.name]}
>
<LocationPicker
value={(p.form[p.name] as string) ?? undefined}
onChange={(id) => p.set(p.name, id)}
required={p.required}
maxDepth={3}
disabled={p.disabled}
/>
</Input.Wrapper>
</Col>
);
}
export function CheckboxField(p: FieldProps) {
return (
<Col span={12}>
<Checkbox
label={p.label}
error={p.errors[p.name]}
disabled={p.disabled}
checked={Boolean(p.form[p.name])}
onChange={(e) => p.set(p.name, e.currentTarget.checked)}
/>
</Col>
);
}

View File

@@ -0,0 +1,173 @@
import { Divider, Grid, Stack, Text, TextInput } from '@mantine/core';
import {
BLOOD_TYPE_OPTIONS,
DEPARTMENT_OPTIONS,
EYE_COLOR_OPTIONS,
GENDER_OPTIONS,
HAIR_COLOR_OPTIONS,
MARITAL_STATUS_OPTIONS,
} from '@ema-platform/api';
import {
DateField,
LocationField,
NationalityField,
NumberField,
SelectField,
TextField,
type StepProps,
} from './fields';
function SectionTitle({ title, description }: { title: string; description?: string }) {
return (
<div>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">
{title}
</Text>
{description && (
<Text size="sm" c="dimmed" mt={2}>
{description}
</Text>
)}
</div>
);
}
/**
* Step 1 — Identity Details.
*
* Email and phone are account credentials: shown, never edited here. The
* rest is prefilled from the profile and editable — what is entered becomes
* the registered identity once a reviewer approves.
*/
export function IdentityDetailsStep(
p: StepProps & { account: { email?: string; phoneNumber?: string } },
) {
return (
<Stack gap="lg">
<SectionTitle title="Contact Details" />
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput label="Account Email" value={p.account.email ?? ''} disabled />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput label="Account Phone Number" value={p.account.phoneNumber ?? ''} disabled />
</Grid.Col>
</Grid>
<Divider />
<SectionTitle
title="Identity Details"
description="Prefilled from your profile where we have it. Check each field and correct anything that is wrong — what you enter here becomes your registered identity."
/>
<Grid>
<TextField {...p} name="firstName" label="First Name" required maxLength={128} />
<TextField {...p} name="middleName" label="Middle Name" maxLength={128} />
<TextField {...p} name="lastName" label="Last Name" required maxLength={128} />
<SelectField {...p} name="gender" label="Gender" required options={GENDER_OPTIONS} />
<DateField {...p} name="dateOfBirth" label="Date of Birth" required />
<SelectField {...p} name="maritalStatus" label="Marital Status" required options={MARITAL_STATUS_OPTIONS} />
<NationalityField {...p} name="nationality" label="Nationality" required />
<TextField {...p} name="nationalIdNumber" label="National ID (Fayda) Number" required maxLength={64} />
</Grid>
</Stack>
);
}
/** Step 2 — Identity, Address, Physical Characteristics, Medical Certificate. */
export function ApplicantDetailsStep(p: StepProps) {
return (
<Stack gap="lg">
<SectionTitle title="Identity" />
<Grid>
<TextField {...p} name="placeOfBirth" label="Place of Birth" required maxLength={128} />
<TextField
{...p}
name="passportNumber"
label="Passport Number"
maxLength={32}
description="Required later for international sea service; optional at registration."
/>
{p.form.passportNumber && (
<DateField {...p} name="passportExpiry" label="Passport Expiry Date" />
)}
<SelectField
{...p}
name="department"
label="Department"
required
options={DEPARTMENT_OPTIONS}
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
/>
</Grid>
<Divider />
<SectionTitle title="Address" />
<Grid>
<LocationField
{...p}
name="locationId"
label="Location"
required
description="City / sub-city selected from the location picker."
/>
<TextField {...p} name="permanentAddress" label="Permanent Address" maxLength={255} />
<TextField
{...p}
name="currentAddress"
label="Current Address"
maxLength={255}
description="Where you currently live, if different from the permanent address."
/>
</Grid>
<Divider />
<SectionTitle
title="Physical Characteristics"
description="Identifying details printed in your Seaman Book."
/>
<Grid>
<SelectField {...p} name="hairColor" label="Hair Colour" required options={HAIR_COLOR_OPTIONS} />
<SelectField {...p} name="eyeColor" label="Eye Colour" required options={EYE_COLOR_OPTIONS} />
<NumberField {...p} name="heightCm" label="Height (cm)" required description="In centimetres, e.g. 172.5" />
<NumberField {...p} name="weightKg" label="Weight (kg)" required description="In kilograms, e.g. 68.0" />
<SelectField
{...p}
name="bloodType"
label="Blood Type"
options={BLOOD_TYPE_OPTIONS}
description="Optional. Select Unknown if you have not been tested."
/>
</Grid>
<Divider />
<SectionTitle
title="Medical Certificate"
description="Details from your STCW medical fitness certificate. The expiry date is calculated from the issue date."
/>
<Grid>
<TextField {...p} name="medicalCertificateNumber" label="Certificate Number" required maxLength={64} />
<TextField {...p} name="medicalIssuerName" label="Issuing Clinic or Practitioner" required maxLength={255} />
<DateField
{...p}
name="medicalIssueDate"
label="Issue Date"
required
description="Cannot be a future date. Validity is calculated from this: two years, or one year if you are under 18."
/>
</Grid>
</Stack>
);
}
/** Step 3 — Emergency Contact. */
export function EmergencyContactStep(p: StepProps) {
return (
<Stack gap="lg">
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
<Grid>
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
</Grid>
</Stack>
);
}

View File

@@ -0,0 +1,425 @@
import { useEffect, useRef, useState } from 'react';
import {
Alert,
Badge,
Button,
Center,
Container,
Divider,
Grid,
Group,
Loader,
Paper,
Stack,
Stepper,
Text,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import {
PHYSICAL_BOUNDS,
SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_STATUS_COLORS,
SEAFARER_REGISTRATION_STATUS_LABELS,
extractErrorMessage,
extractValidationIssues,
useGetAttachmentsQuery,
useGetMySeafarerRegistrationQuery,
useSaveSeafarerRegistrationMutation,
useStartSeafarerRegistrationMutation,
useSubmitSeafarerRegistrationMutation,
type SaveSeafarerRegistration,
type SeafarerRegistration,
type ValidationIssue,
} from '@ema-platform/api';
import { splitPersonName } from '@ema-platform/ui';
import { useCurrentProfile, type CurrentProfile } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks';
import { CheckboxField, type AnswerKey } from '../components/fields';
import { ApplicantDetailsStep, EmergencyContactStep, IdentityDetailsStep } from '../components/steps';
import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments';
import { RegistrationSummary } from '../components/RegistrationSummary';
const STEPS = ['Identity Details', 'Applicant Details', 'Emergency Contact', 'Documents', 'Review'];
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
const REQUIRED_BY_STEP: AnswerKey[][] = [
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
[
'placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg',
'medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate',
],
[],
[],
['declarationAccepted'],
];
const ANSWER_KEYS = Object.keys(SEAFARER_REGISTRATION_FIELD_LABELS) as AnswerKey[];
function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration {
return Object.fromEntries(ANSWER_KEYS.map((k) => [k, registration[k]])) as SaveSeafarerRegistration;
}
function blank(value: unknown): boolean {
return value === null || value === undefined || value === '' || value === false;
}
/**
* Fills blank answers from the profile.
*
* The API prefills a draft when it is opened, but an applicant who declared
* "seafarer" at onboarding lands here before their profile has an address —
* so whatever they fill in on /profile afterwards would never reach a draft
* already open. Blank-only: an answer the applicant typed or the server saved
* is left alone.
*/
function withProfileDefaults(
answers: SaveSeafarerRegistration,
profile: CurrentProfile | undefined,
accountName: string | undefined,
): SaveSeafarerRegistration {
if (!profile) return answers;
const a = profile.address;
const parts = accountName ? splitPersonName(accountName) : null;
const defaults: SaveSeafarerRegistration = {
firstName: profile.firstName || parts?.firstName || null,
middleName: profile.middleName || parts?.middleName || null,
lastName: profile.lastName || parts?.lastName || null,
gender: (profile.gender as SaveSeafarerRegistration['gender']) || null,
dateOfBirth: profile.dob ? profile.dob.slice(0, 10) : null,
maritalStatus: (profile.maritalStatus as SaveSeafarerRegistration['maritalStatus']) || null,
placeOfBirth: profile.pob || null,
nationality: a?.nationality || null,
nationalIdNumber: a?.idType === 'NID' ? a.idNumber || null : null,
passportNumber: a?.passportNumber || null,
passportExpiry: a?.passportExpiry || null,
permanentAddress: a?.streetAddress || null,
currentAddress: a?.currentAddress || null,
emergencyContactName: a?.emergencyContactName || null,
emergencyContactPhone: a?.emergencyContactPhone || null,
emergencyContactRelationship: a?.emergencyContactRelation || null,
department: profile.seafarerDepartment || null,
};
const next = { ...answers };
for (const [key, value] of Object.entries(defaults) as [AnswerKey, unknown][]) {
if (blank(next[key]) && !blank(value)) (next as Record<string, unknown>)[key] = value;
}
return next;
}
/**
* Seafarer registration — a fixed five-step form, not a configured wizard.
*
* A draft is opened on first visit so uploads have an owner and nothing is
* lost if the browser closes mid-way. Each "Continue" validates the step and
* saves it; Submit saves everything and asks the API, which names anything
* still missing. A submitted registration opens to a read-only summary.
*/
export function SeafarerRegistrationPage() {
const accountUser = useAppSelector((state) => state.auth.user);
const { profile } = useCurrentProfile();
const { data, isLoading } = useGetMySeafarerRegistrationQuery();
const registration = data?.registration ?? null;
const [start] = useStartSeafarerRegistrationMutation();
const [save] = useSaveSeafarerRegistrationMutation();
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
const [startError, setStartError] = useState<string | null>(null);
const started = useRef(false);
useEffect(() => {
if (isLoading || registration || started.current) return;
started.current = true;
start()
.unwrap()
.catch((err) => setStartError(extractErrorMessage(err)));
}, [isLoading, registration, start]);
const { data: attachments = [], refetch: refetchAttachments } = useGetAttachmentsQuery(
{ ownerType: 'SEAFARER_REGISTRATION', ownerId: registration?.id ?? '' },
{ skip: !registration },
);
const [active, setActive] = useState(0);
const [viewingSummary, setViewingSummary] = useState(true);
const [form, setForm] = useState<SaveSeafarerRegistration>({});
const [errors, setErrors] = useState<Partial<Record<AnswerKey, string>>>({});
const [issues, setIssues] = useState<ValidationIssue[]>([]);
const accountName = accountUser?.name?.en ?? profile?.user?.name?.en;
const isDraft = registration?.status === 'DRAFT';
// Seed local edits from the server copy when the registration (or its
// round) changes — not on every refetch, which would wipe typing in progress.
useEffect(() => {
if (!registration) return;
const answers = answersOf(registration);
setForm(isDraft ? withProfileDefaults(answers, profile, accountName) : answers);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [registration?.id, registration?.status]);
// The profile can arrive after the draft did; fill what is still blank.
useEffect(() => {
if (!isDraft || !profile) return;
setForm((prev) => withProfileDefaults(prev, profile, accountName));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile?.id, profile?.address?.id, isDraft]);
if (startError) {
return (
<Container size="md" py="xl">
<Alert color={profile?.seafarerNumber ? 'teal' : 'red'} icon={<IconInfoCircle size={16} />} title="Seafarer Registration">
{profile?.seafarerNumber
? `You are already registered as a seafarer (${profile.seafarerNumber}).`
: startError}
</Alert>
</Container>
);
}
if (isLoading || !registration) {
return (
<Center h={400}>
<Loader />
</Center>
);
}
const isAdjusting = registration.status === 'RESUBMIT_REQUIRED';
const editableWhileSubmitted = registration.status === 'SUBMITTED' && !registration.assignedOfficerId;
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(registration.status) && !editableWhileSubmitted;
const showSummary = registration.status !== 'DRAFT' && viewingSummary;
function set(key: AnswerKey, value: unknown) {
setForm((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => {
if (!prev[key]) return prev;
const next = { ...prev };
delete next[key];
return next;
});
}
function validateStep(index: number): boolean {
const found: Partial<Record<AnswerKey, string>> = {};
for (const key of REQUIRED_BY_STEP[index] ?? []) {
if (blank(form[key])) found[key] = `${SEAFARER_REGISTRATION_FIELD_LABELS[key]} is required.`;
}
if (index === 1) {
const { heightCm, weightKg } = PHYSICAL_BOUNDS;
if (typeof form.heightCm === 'number' && (form.heightCm < heightCm.min || form.heightCm > heightCm.max)) {
found.heightCm = `Enter a height between ${heightCm.min} and ${heightCm.max} cm.`;
}
if (typeof form.weightKg === 'number' && (form.weightKg < weightKg.min || form.weightKg > weightKg.max)) {
found.weightKg = `Enter a weight between ${weightKg.min} and ${weightKg.max} kg.`;
}
}
setErrors(found);
const count = Object.keys(found).length;
if (count) {
notifications.show({
color: 'red',
title: 'Incomplete',
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
});
return false;
}
if (index === 3) {
const supplied = new Set(attachments.filter((a) => a.files?.length).map((a) => a.documentKey));
const missing = documentSlots(Boolean(form.passportNumber))
.filter((d) => d.isRequired && !supplied.has(d.key))
.map((d) => d.name);
if (missing.length) {
notifications.show({
color: 'red',
title: 'Documents missing',
message: `Upload: ${missing.join(', ')}.`,
});
return false;
}
}
return true;
}
async function saveAnswers(): Promise<boolean> {
if (readOnly || !registration) return true;
try {
await save({ id: registration.id, body: form }).unwrap();
return true;
} catch (err) {
notifications.show({ color: 'red', title: 'Could not save', message: extractErrorMessage(err) });
return false;
}
}
async function goToStep(target: number) {
if (target <= active) {
setActive(target);
return;
}
// Going forward validates every step passed over, so a jump cannot skip a
// required field; the walk stops on the first step that fails.
for (let step = active; step < target; step++) {
if (!readOnly && !validateStep(step)) {
setActive(step);
return;
}
}
if (!(await saveAnswers())) return;
setErrors({});
setActive(target);
}
async function handleSubmit() {
if (!registration) return;
setIssues([]);
if (!readOnly && !validateStep(4)) return;
if (!(await saveAnswers())) return;
try {
await submit(registration.id).unwrap();
notifications.show({
color: 'teal',
title: isAdjusting ? 'Resubmitted' : 'Registration submitted',
message: isAdjusting
? 'Your corrections were sent back to the reviewing officer.'
: 'You will be notified as it progresses.',
});
setViewingSummary(true);
} catch (err) {
const found = extractValidationIssues(err);
setIssues(found);
notifications.show({
color: 'red',
title: 'Registration incomplete',
message: found.length ? `${found.length} item(s) still need attention.` : extractErrorMessage(err),
});
}
}
const stepProps = { form, set, errors, disabled: readOnly };
return (
<Container size="lg" py="md">
<Group justify="space-between" mb="xs" align="flex-start">
<div>
<Title order={3}>Seafarer Registration</Title>
<Group gap="xs" mt={4}>
<Text size="sm" c="dimmed">
{registration.registrationNumber}
</Text>
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
</Badge>
</Group>
</div>
{showSummary && !readOnly && (
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
Edit details
</Button>
)}
</Group>
{registration.status === 'APPROVED' && (
<Alert color="teal" icon={<IconCheck size={16} />} title="Registered" mb="md">
You are a registered seafarer. Your seafarer number is <b>{registration.seafarerNumber}</b>.
Your Seaman Book and Basic Training Certificate applications have been opened for you.
</Alert>
)}
{registration.status === 'REJECTED' && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Registration rejected" mb="md">
{registration.rejectionReason}
</Alert>
)}
{isAdjusting && (
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Corrections requested" mb="md">
{registration.reviewRemark}
</Alert>
)}
{showSummary && editableWhileSubmitted && (
<Alert color="blue" icon={<IconInfoCircle size={16} />} title="Submitted — still correctable" mb="md">
Your registration is in the queue. You can still change any detail until a reviewing officer
picks it up; after that, corrections happen only if they ask for them.
</Alert>
)}
{issues.length > 0 && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Still missing" mb="md">
<Stack gap={2}>
{issues.map((issue, i) => (
<Text size="sm" key={i}>
{issue.message}
</Text>
))}
</Stack>
</Alert>
)}
{showSummary && (
<Paper withBorder p="lg" radius="md">
<RegistrationSummary answers={answersOf(registration)} attachments={attachments} />
</Paper>
)}
{!showSummary && (
<Paper withBorder p="lg" radius="md">
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
{STEPS.map((label) => (
<Stepper.Step key={label} label={label} />
))}
</Stepper>
{active === 0 && (
<IdentityDetailsStep
{...stepProps}
account={{ email: accountUser?.email, phoneNumber: accountUser?.phoneNumber }}
/>
)}
{active === 1 && <ApplicantDetailsStep {...stepProps} />}
{active === 2 && <EmergencyContactStep {...stepProps} />}
{active === 3 && (
<RegistrationDocuments
registrationId={registration.id}
passportDeclared={Boolean(form.passportNumber)}
attachments={attachments}
readOnly={readOnly}
onUploaded={refetchAttachments}
/>
)}
{active === 4 && (
<Stack>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">
Declaration
</Text>
<Grid>
<CheckboxField
{...stepProps}
name="declarationAccepted"
label="I declare that the information provided is complete and accurate."
required
/>
</Grid>
<Divider my="md" />
<Title order={5}>Review</Title>
<RegistrationSummary answers={form} attachments={attachments} />
</Stack>
)}
<Group justify="space-between" mt="xl">
<Button variant="default" onClick={() => setActive((s) => Math.max(0, s - 1))} disabled={active === 0}>
Back
</Button>
{active < STEPS.length - 1 ? (
<Button onClick={() => goToStep(active + 1)}>Continue</Button>
) : (
<Button color="teal" loading={submitting} disabled={readOnly} onClick={handleSubmit}>
{isAdjusting ? 'Resubmit corrections' : 'Submit registration'}
</Button>
)}
</Group>
</Paper>
)}
</Container>
);
}
export default SeafarerRegistrationPage;

View File

@@ -23,11 +23,11 @@ const L = LICENSE_PERMISSIONS;
// Portal feature pages
import { DashboardPage } from "./features/dashboard/pages/DashboardPage";
import { RequireOperations } from "./features/onboarding/components/RequireOperations";
import { RequireSeafarerProfile } from "./features/profile/components/RequireSeafarerProfile";
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
import { ProfilePage } from "./features/profile/pages/ProfilePage";
import { SupportPage } from "./features/support/pages/SupportPage";
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
import { ExamsPage } from "./features/exams/pages/ExamsPage";
// Phase 1 pages
@@ -140,14 +140,21 @@ export const router = createBrowserRouter([
path: "/licensing/SEAMAN_BOOK/applications/:applicationId",
element: <Navigate to="/seaman-book" replace />,
},
// Seafarer registration is not a licence: it has its own page and API.
{
path: "/licensing/SEAFARER_REGISTRATION/apply",
element: <Navigate to="/seafarer-registration" replace />,
},
{
path: "/licensing/SEAFARER_REGISTRATION/applications/:applicationId",
element: <Navigate to="/seafarer-registration" replace />,
},
{
path: "/licensing/:typeCode/apply",
element: (
<RequireSeafarerProfile>
<RequirePermission anyOf={[L.CREATE_APPLICATION]}>
<LicenseApplicationPage />
</RequirePermission>
</RequireSeafarerProfile>
<RequirePermission anyOf={[L.CREATE_APPLICATION]}>
<LicenseApplicationPage />
</RequirePermission>
),
},
{
@@ -171,17 +178,15 @@ export const router = createBrowserRouter([
// Seafarer
//
// Registration runs through the shared application wizard like every
// other service. It used to have a page of its own that wrote the
// profile directly and created no application at all — which meant a
// "submitted" registration was never reviewed, never approved and never
// numbered, because there was nothing for an officer to open. The
// wizard, the review queue and the approval side effects all already
// existed; only the route was pointed away from them.
// Registration is its own five-step form over its own endpoints —
// not a configured licence type. A draft opens on first visit; once
// submitted the page shows the registration's status and answers.
{
path: "/seafarer-registration",
element: (
<Navigate to="/licensing/SEAFARER_REGISTRATION/apply" replace />
<RequirePermission anyOf={[P.APPLY_SEAFARER_REGISTRATION]}>
<SeafarerRegistrationPage />
</RequirePermission>
),
},
{