mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -3,6 +3,7 @@ import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } fr
|
||||
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||
isEthiopianNationality,
|
||||
uploadDocument,
|
||||
type Attachment,
|
||||
} from '@ema-platform/api';
|
||||
@@ -10,22 +11,25 @@ import {
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** 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) => ({
|
||||
...d,
|
||||
isRequired: d.required === 'passport' ? passportDeclared : d.required,
|
||||
})).filter((d) => d.required !== 'passport' || passportDeclared);
|
||||
isRequired: d.required === 'passport' ? passportDeclared : d.required === 'ethiopian' ? ethiopian : d.required,
|
||||
})).filter((d) => (d.required !== 'passport' || passportDeclared) && (d.required !== 'ethiopian' || ethiopian));
|
||||
}
|
||||
|
||||
export function RegistrationDocuments({
|
||||
registrationId,
|
||||
passportDeclared,
|
||||
nationality,
|
||||
attachments,
|
||||
readOnly,
|
||||
onUploaded,
|
||||
}: {
|
||||
registrationId: string;
|
||||
passportDeclared: boolean;
|
||||
nationality?: string | null;
|
||||
attachments: Attachment[];
|
||||
readOnly?: boolean;
|
||||
onUploaded: () => void;
|
||||
@@ -62,7 +66,7 @@ export function RegistrationDocuments({
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{documentSlots(passportDeclared).map((slot) => {
|
||||
{documentSlots(passportDeclared, nationality).map((slot) => {
|
||||
const existing = attachments.find((a) => a.documentKey === slot.key);
|
||||
const uploaded = Boolean(existing?.files?.length);
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GENDER_OPTIONS,
|
||||
HAIR_COLOR_OPTIONS,
|
||||
MARITAL_STATUS_OPTIONS,
|
||||
isEthiopianNationality,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useLocalized,
|
||||
} from '@ema-platform/api';
|
||||
@@ -44,6 +45,7 @@ function SectionTitle({ title, description }: { title: string; description?: str
|
||||
export function IdentityDetailsStep(
|
||||
p: StepProps & { account: { email?: string; phoneNumber?: string } },
|
||||
) {
|
||||
const ethiopian = isEthiopianNationality(p.form.nationality);
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Contact Details" />
|
||||
@@ -68,7 +70,18 @@ export function IdentityDetailsStep(
|
||||
<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} />
|
||||
{ethiopian ? (
|
||||
<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>
|
||||
</Stack>
|
||||
);
|
||||
@@ -88,19 +101,24 @@ export function ApplicantDetailsStep(p: StepProps) {
|
||||
const departmentOptions =
|
||||
departments?.map((d) => ({ value: d.code, label: localized(d.name) })) ??
|
||||
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 (
|
||||
<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."
|
||||
/>
|
||||
{ethiopian && (
|
||||
<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" />
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -8,13 +9,14 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Title,
|
||||
} 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 {
|
||||
PHYSICAL_BOUNDS,
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
extractValidationIssues,
|
||||
isEthiopianNationality,
|
||||
useCancelSeafarerRegistrationMutation,
|
||||
useGetAttachmentsQuery,
|
||||
useGetMySeafarerRegistrationQuery,
|
||||
useSaveSeafarerRegistrationMutation,
|
||||
@@ -48,15 +52,26 @@ const STEPS = [
|
||||
{ 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[][] = [
|
||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
|
||||
['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 {
|
||||
@@ -119,15 +134,18 @@ function withProfileDefaults(
|
||||
* 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(() => {
|
||||
@@ -204,7 +222,7 @@ export function SeafarerRegistrationPage() {
|
||||
|
||||
function validateStep(index: number): boolean {
|
||||
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 (index === 1) {
|
||||
@@ -232,7 +250,7 @@ export function SeafarerRegistrationPage() {
|
||||
}
|
||||
if (index === 3) {
|
||||
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))
|
||||
.map((d) => d.name);
|
||||
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 };
|
||||
|
||||
return (
|
||||
@@ -319,11 +350,24 @@ export function SeafarerRegistrationPage() {
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
{showSummary && !readOnly && (
|
||||
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
|
||||
Edit details
|
||||
</Button>
|
||||
)}
|
||||
<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' && (
|
||||
@@ -386,6 +430,7 @@ export function SeafarerRegistrationPage() {
|
||||
<RegistrationDocuments
|
||||
registrationId={registration.id}
|
||||
passportDeclared={Boolean(form.passportNumber)}
|
||||
nationality={form.nationality}
|
||||
attachments={attachments}
|
||||
readOnly={readOnly}
|
||||
onUploaded={refetchAttachments}
|
||||
@@ -426,6 +471,23 @@ export function SeafarerRegistrationPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,6 +298,22 @@ function SeaServiceTab() {
|
||||
})
|
||||
: 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 =
|
||||
form.vesselName.trim().length > 1 &&
|
||||
form.rank.trim().length > 1 &&
|
||||
@@ -391,6 +407,7 @@ function SeaServiceTab() {
|
||||
required
|
||||
value={form.vesselName}
|
||||
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
|
||||
error={vesselNameError}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('seaRecords.seaService.fields.imoNumber')}
|
||||
@@ -418,9 +435,13 @@ function SeaServiceTab() {
|
||||
</Group>
|
||||
<TextInput
|
||||
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
|
||||
value={form.rank}
|
||||
onChange={(e) => setForm({ ...form, rank: e.target.value })}
|
||||
error={rankError}
|
||||
/>
|
||||
<Group grow>
|
||||
<AmharicDatePicker
|
||||
|
||||
Reference in New Issue
Block a user