mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
496 lines
19 KiB
TypeScript
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 & 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;
|