mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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:
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user