Files
emaui/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx
2026-08-26 11:52:13 +00:00

496 lines
19 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Center,
Container,
Divider,
Grid,
Group,
Loader,
Modal,
Paper,
Stack,
Stepper,
Text,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil, IconTrash } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import {
PHYSICAL_BOUNDS,
SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS,
extractErrorMessage,
extractValidationIssues,
isEthiopianNationality,
useCancelSeafarerRegistrationMutation,
useGetAttachmentsQuery,
useGetMySeafarerRegistrationQuery,
useSaveSeafarerRegistrationMutation,
useStartSeafarerRegistrationMutation,
useSubmitSeafarerRegistrationMutation,
type SaveSeafarerRegistration,
type SeafarerRegistration,
type ValidationIssue,
} from '@ema-platform/api';
import { splitPersonName, StatusBadge } 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 = [
{ label: 'Identity', description: 'Who you are' },
{ label: 'Details', description: 'Address & physical' },
{ label: 'Contact & Medical', description: 'Emergency & fitness' },
{ label: 'Documents', description: 'Upload evidence' },
{ label: 'Review', description: 'Check & submit' },
];
/**
* 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'],
['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 {
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 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<string | null>(null);
const [confirmingCancel, setConfirmingCancel] = useState(false);
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 readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(registration.status);
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 requiredForStep(index, form.nationality)) {
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 missingKeys = Object.keys(found) as AnswerKey[];
if (missingKeys.length) {
// Name the fields rather than counting them. "Complete 3 required fields"
// sends the applicant hunting up a step they have already scrolled past;
// the labels are what let them go straight to it.
const names = missingKeys.map((k) => SEAFARER_REGISTRATION_FIELD_LABELS[k]);
notifications.show({
color: 'red',
title: missingKeys.length > 1 ? 'Some details are missing' : 'One detail is missing',
message: `${names.join(', ')}.`,
});
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), form.nationality)
.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),
});
}
}
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 (
<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>
<StatusBadge
tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
/>
</Group>
</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 && (
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
Edit details
</Button>
)}
</Group>
</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>.
You can now apply for a certificate endorsement from the Endorsement Seafarer page.
</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>
)}
{registration.status === 'SUBMITTED' && (
<Alert color="blue" icon={<IconInfoCircle size={16} />} title="Submitted" mb="md">
Your registration is with the Authority for review. You will be notified of the outcome, or
asked for corrections if anything is missing.
</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((step) => (
<Stepper.Step key={step.label} label={step.label} description={step.description} />
))}
</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)}
nationality={form.nationality}
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 loading={saving} onClick={() => goToStep(active + 1)}>
Continue
</Button>
) : (
<Button color="teal" loading={saving || submitting} disabled={readOnly} onClick={handleSubmit}>
{isAdjusting ? 'Resubmit corrections' : 'Submit registration'}
</Button>
)}
</Group>
</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>
);
}
export default SeafarerRegistrationPage;