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)[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(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({}); const [errors, setErrors] = useState>>({}); const [issues, setIssues] = useState([]); 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 ( } title="Seafarer Registration"> {profile?.seafarerNumber ? `You are already registered as a seafarer (${profile.seafarerNumber}).` : startError} ); } if (isLoading || !registration) { return (
); } 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> = {}; 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 { 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 (
Seafarer Registration {registration.registrationNumber}
{registration.status === 'DRAFT' && ( )} {showSummary && !readOnly && ( )}
{registration.status === 'APPROVED' && ( } title="Registered" mb="md"> You are a registered seafarer. Your seafarer number is {registration.seafarerNumber}. You can now apply for a certificate endorsement from the Endorsement Seafarer page. )} {registration.status === 'REJECTED' && ( } title="Registration rejected" mb="md"> {registration.rejectionReason} )} {isAdjusting && ( } title="Corrections requested" mb="md"> {registration.reviewRemark} )} {registration.status === 'SUBMITTED' && ( } 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. )} {issues.length > 0 && ( } title="Still missing" mb="md"> {issues.map((issue, i) => ( • {issue.message} ))} )} {showSummary && ( )} {!showSummary && ( {STEPS.map((step) => ( ))} {active === 0 && ( )} {active === 1 && } {active === 2 && } {active === 3 && ( )} {active === 4 && ( Declaration Review )} {active < STEPS.length - 1 ? ( ) : ( )} )} 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.
); } export default SeafarerRegistrationPage;