mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
fix(seafarer): route registration through the application wizard
/seafarer-registration had a page of its own that wrote the profile directly and created no application at all. A "submitted" registration was therefore never reviewed, never approved and never numbered — there was nothing for an officer to open. The shared wizard, the review queue and the approval side effects all already existed; only the route pointed away from them. The page is deleted rather than repaired. It was a second wizard maintained alongside the config-driven one, and the seed already describes every step it was hand-rolling — including the physical characteristics and medical details added this release, which it would not have known about. /seafarer-registration now redirects, so navigation, deep links and the profile gate keep working. Also adds Configuration -> Number Formats, for the shape of generated seafarer, seaman book and BTC numbers. The sample is rendered by the server rather than formatted here, so the preview cannot drift from what an approval will actually generate, and asking for one never consumes a number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import type {
|
||||
ListResponse,
|
||||
CreateProfessionPayload,
|
||||
UpdateProfessionPayload,
|
||||
NumberFormatConfig,
|
||||
NumberFormatPayload,
|
||||
} from "../types/configuration";
|
||||
|
||||
const configurationApi = baseApi.injectEndpoints({
|
||||
@@ -36,6 +38,51 @@ const configurationApi = baseApi.injectEndpoints({
|
||||
query: (id) => ({ url: `/professions/${id}`, method: "DELETE" }),
|
||||
invalidatesTags: ["Api"],
|
||||
}),
|
||||
|
||||
// Number formats — the shape of generated seafarer, seaman book and BTC
|
||||
// identifiers. The counter behind each stays server-side; only the
|
||||
// rendering is configurable here.
|
||||
getNumberFormats: builder.query<NumberFormatConfig[], void>({
|
||||
query: () => "/number-format-configs",
|
||||
providesTags: ["Api", "NumberFormatApi"],
|
||||
}),
|
||||
createNumberFormat: builder.mutation<
|
||||
NumberFormatConfig,
|
||||
NumberFormatPayload
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: "/number-format-configs",
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ["Api", "NumberFormatApi"],
|
||||
}),
|
||||
updateNumberFormat: builder.mutation<
|
||||
NumberFormatConfig,
|
||||
{ id: string } & Partial<NumberFormatPayload>
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/number-format-configs/${id}`,
|
||||
method: "PATCH",
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ["Api", "NumberFormatApi"],
|
||||
}),
|
||||
/**
|
||||
* Server-rendered sample. Asked of the server rather than formatted in the
|
||||
* browser so the preview cannot drift from what approval will actually
|
||||
* generate — the two would be the same rule written twice.
|
||||
*/
|
||||
previewNumberFormat: builder.mutation<
|
||||
{ sample: string },
|
||||
NumberFormatPayload
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: "/number-format-configs/preview",
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: true,
|
||||
});
|
||||
@@ -46,4 +93,8 @@ export const {
|
||||
useCreateProfessionMutation,
|
||||
useUpdateProfessionMutation,
|
||||
useDeleteProfessionMutation,
|
||||
useGetNumberFormatsQuery,
|
||||
useCreateNumberFormatMutation,
|
||||
useUpdateNumberFormatMutation,
|
||||
usePreviewNumberFormatMutation,
|
||||
} = configurationApi;
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Code,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import {
|
||||
useCreateNumberFormatMutation,
|
||||
useGetNumberFormatsQuery,
|
||||
usePreviewNumberFormatMutation,
|
||||
useUpdateNumberFormatMutation,
|
||||
} from '../api/configuration-api';
|
||||
import type {
|
||||
NumberFormatPayload,
|
||||
NumberFormatScope,
|
||||
} from '../types/configuration';
|
||||
|
||||
const SCOPES: { value: NumberFormatScope; label: string }[] = [
|
||||
{ value: 'SEAFARER_NUMBER', label: 'Seafarer Number' },
|
||||
{ value: 'SEAMAN_BOOK_NUMBER', label: 'Seaman Book Number' },
|
||||
{ value: 'BTC_NUMBER', label: 'BTC Number' },
|
||||
];
|
||||
|
||||
const scopeLabel = (scope: NumberFormatScope) =>
|
||||
SCOPES.find((s) => s.value === scope)?.label ?? scope;
|
||||
|
||||
/**
|
||||
* Backoffice control over the shape of generated identifiers.
|
||||
*
|
||||
* Authoring a format supersedes the one it replaces rather than editing it:
|
||||
* numbers already issued were produced by a specific format, and the register
|
||||
* has to stay explicable. Superseded rows therefore stay listed.
|
||||
*/
|
||||
export function NumberFormatTab() {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [sample, setSample] = useState<string | null>(null);
|
||||
|
||||
const { data: formats = [], isLoading } = useGetNumberFormatsQuery();
|
||||
const [createFormat, { isLoading: creating }] = useCreateNumberFormatMutation();
|
||||
const [updateFormat] = useUpdateNumberFormatMutation();
|
||||
const [previewFormat] = usePreviewNumberFormatMutation();
|
||||
|
||||
const form = useForm<NumberFormatPayload>({
|
||||
initialValues: {
|
||||
scope: 'SEAFARER_NUMBER',
|
||||
prefix: 'SEA',
|
||||
includeYear: true,
|
||||
separator: '-',
|
||||
sequenceLength: 6,
|
||||
startingNumber: 1,
|
||||
isActive: true,
|
||||
},
|
||||
validate: {
|
||||
prefix: (value) => (value.trim() ? null : t('numberFormat.prefixRequired', 'Prefix is required')),
|
||||
sequenceLength: (value) =>
|
||||
value && value >= 1 && value <= 12
|
||||
? null
|
||||
: t('numberFormat.lengthRange', 'Length must be between 1 and 12'),
|
||||
startingNumber: (value) =>
|
||||
value && value >= 1
|
||||
? null
|
||||
: t('numberFormat.startPositive', 'Starting number must be at least 1'),
|
||||
},
|
||||
});
|
||||
|
||||
// The sample comes from the server so it cannot drift from what an approval
|
||||
// will actually generate. Debounced because it follows every keystroke.
|
||||
const { values } = form;
|
||||
useEffect(() => {
|
||||
if (!values.prefix?.trim()) {
|
||||
setSample(null);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
previewFormat(values)
|
||||
.unwrap()
|
||||
.then((result) => setSample(result.sample))
|
||||
// A preview that fails is not worth interrupting authoring for; the
|
||||
// create call reports properly if the format is genuinely invalid.
|
||||
.catch(() => setSample(null));
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [values, previewFormat]);
|
||||
|
||||
const submit = form.onSubmit(async (payload) => {
|
||||
try {
|
||||
await createFormat(payload).unwrap();
|
||||
notify.success(
|
||||
t('numberFormat.created', 'Number format saved. It applies to numbers issued from now on.'),
|
||||
);
|
||||
close();
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
});
|
||||
|
||||
const retire = async (id: string) => {
|
||||
try {
|
||||
await updateFormat({ id, isActive: false }).unwrap();
|
||||
notify.success(t('numberFormat.retired', 'Format retired.'));
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Title order={4}>{t('numberFormat.title', 'Number Formats')}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t(
|
||||
'numberFormat.subtitle',
|
||||
'The shape of generated seafarer, seaman book and certificate numbers.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={open} size="sm">
|
||||
{t('numberFormat.add', 'New Format')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t(
|
||||
'numberFormat.notice',
|
||||
'Changing a format never alters numbers already issued. A new format applies only to numbers generated after it becomes active.',
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{formats.length === 0 ? (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text fz="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
'numberFormat.empty',
|
||||
'No formats configured. Numbers use the built-in default until one is added.',
|
||||
)}
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('numberFormat.scope', 'Identifier')}</Table.Th>
|
||||
<Table.Th>{t('numberFormat.example', 'Example')}</Table.Th>
|
||||
<Table.Th>{t('numberFormat.sequence', 'Sequence')}</Table.Th>
|
||||
<Table.Th>{t('numberFormat.status', 'Status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{formats.map((format) => {
|
||||
const parts = [format.prefix];
|
||||
if (format.includeYear) parts.push(String(new Date().getFullYear()));
|
||||
parts.push(String(format.startingNumber).padStart(format.sequenceLength, '0'));
|
||||
return (
|
||||
<Table.Tr key={format.id}>
|
||||
<Table.Td>{scopeLabel(format.scope)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Code>{parts.join(format.separator)}</Code>
|
||||
</Table.Td>
|
||||
<Table.Td>{format.sequenceLength} digits</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={format.isActive ? 'green' : 'gray'} variant="light">
|
||||
{format.isActive
|
||||
? t('numberFormat.active', 'Active')
|
||||
: t('numberFormat.superseded', 'Superseded')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{format.isActive && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
onClick={() => retire(format.id)}
|
||||
>
|
||||
{t('numberFormat.retire', 'Retire')}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={t('numberFormat.add', 'New Format')}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t('numberFormat.scope', 'Identifier')}
|
||||
data={SCOPES}
|
||||
allowDeselect={false}
|
||||
{...form.getInputProps('scope')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('numberFormat.prefix', 'Prefix')}
|
||||
placeholder="SEA"
|
||||
maxLength={12}
|
||||
{...form.getInputProps('prefix')}
|
||||
/>
|
||||
<Switch
|
||||
label={t('numberFormat.includeYear', 'Include the issuing year')}
|
||||
{...form.getInputProps('includeYear', { type: 'checkbox' })}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('numberFormat.separator', 'Separator')}
|
||||
maxLength={4}
|
||||
{...form.getInputProps('separator')}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('numberFormat.sequenceLength', 'Sequence length')}
|
||||
description={t('numberFormat.sequenceHelp', 'Zero-padded width, e.g. 6 gives 000001.')}
|
||||
min={1}
|
||||
max={12}
|
||||
{...form.getInputProps('sequenceLength')}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('numberFormat.startingNumber', 'Starting number')}
|
||||
description={t(
|
||||
'numberFormat.startHelp',
|
||||
'Where the counter begins. Raising it later does not renumber anything already issued.',
|
||||
)}
|
||||
min={1}
|
||||
{...form.getInputProps('startingNumber')}
|
||||
/>
|
||||
|
||||
{sample && (
|
||||
<Alert color="gray" variant="light">
|
||||
<Group gap="xs">
|
||||
<Text fz="sm">{t('numberFormat.next', 'Next number will look like')}</Text>
|
||||
<Code>{sample}</Code>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={close} size="sm">
|
||||
{t('configuration.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" loading={creating} size="sm">
|
||||
{t('numberFormat.save', 'Save Format')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IconBriefcase,
|
||||
IconMap,
|
||||
IconCertificate,
|
||||
IconHash,
|
||||
IconInfoCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
} from "@ema-platform/ui";
|
||||
import { LocationPage } from "../../../location/pages/LocationPage";
|
||||
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
||||
import { NumberFormatTab } from "../../components/NumberFormatTab";
|
||||
import {
|
||||
useGetOrganizationsQuery,
|
||||
useGetProfessionsQuery,
|
||||
@@ -398,6 +400,9 @@ export function ConfigurationPage() {
|
||||
>
|
||||
{t("certification.title")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
|
||||
{t("numberFormat.title", "Number Formats")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="professions" pt="md">
|
||||
@@ -411,6 +416,10 @@ export function ConfigurationPage() {
|
||||
<Tabs.Panel value="certifications" pt="md">
|
||||
<CertificationPage />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="numberFormats" pt="md">
|
||||
<NumberFormatTab />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -46,3 +46,41 @@ export interface UpdateProfessionPayload {
|
||||
description?: NamePair;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/** Identifiers whose rendered shape is authored in the backoffice. */
|
||||
export type NumberFormatScope =
|
||||
| 'SEAFARER_NUMBER'
|
||||
| 'SEAMAN_BOOK_NUMBER'
|
||||
| 'BTC_NUMBER';
|
||||
|
||||
/**
|
||||
* The shape of a generated identifier — prefix, optional year, separator and
|
||||
* zero-padded counter, e.g. SEA-2026-000001.
|
||||
*
|
||||
* Only the shape. The counter itself is server-side and atomic, so nothing
|
||||
* here can cause two people to be issued the same number.
|
||||
*/
|
||||
export interface NumberFormatConfig {
|
||||
id: string;
|
||||
scope: NumberFormatScope;
|
||||
prefix: string;
|
||||
includeYear: boolean;
|
||||
separator: string;
|
||||
sequenceLength: number;
|
||||
startingNumber: number;
|
||||
activeFrom: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface NumberFormatPayload {
|
||||
scope: NumberFormatScope;
|
||||
prefix: string;
|
||||
includeYear?: boolean;
|
||||
separator?: string;
|
||||
sequenceLength?: number;
|
||||
startingNumber?: number;
|
||||
activeFrom?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,642 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCurrentProfile, useUpdateMyProfileMutation } from '@ema-platform/auth';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
import { useSaveMyAddressMutation } from '../../profile/api/address-api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAddressBook,
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconSchool,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { BilingualInput } from '../../../components/BilingualInput';
|
||||
import type { BilingualValue } from '../../../components/BilingualInput';
|
||||
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const NATIONALITIES = [
|
||||
'Ethiopian', 'Eritrean', 'Djiboutian', 'Kenyan', 'Somali', 'Sudanese', 'Other',
|
||||
];
|
||||
const MARITAL_STATUSES = ['Single', 'Married', 'Divorced', 'Widowed'];
|
||||
const GENDERS = ['Male', 'Female'];
|
||||
const RELATIONSHIPS = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other'];
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Personal Information' },
|
||||
{ label: 'Contact Details' },
|
||||
{ label: 'Documents Upload' },
|
||||
{ label: 'Review & Submit' },
|
||||
];
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
}
|
||||
|
||||
const DOC_SLOTS: DocSlot[] = [
|
||||
{ key: 'nationalId', label: 'National ID (Front & Back)', description: 'Both sides of your national identity card', required: true, icon: IconId },
|
||||
{ key: 'passport', label: 'Passport Copy', description: 'Bio-data page of valid passport', required: false, icon: IconFileDescription },
|
||||
{ key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification', required: false, icon: IconSchool },
|
||||
{ key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5cm', required: true, icon: IconCamera },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap">
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(40),
|
||||
height: rem(40),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDone ? (
|
||||
<IconCheck size={18} color="white" stroke={2.5} />
|
||||
) : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
|
||||
{i + 1}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
fz="xs"
|
||||
fw={isCurrent ? 700 : 400}
|
||||
c={isCurrent ? 'blue.7' : 'dimmed'}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: rem(2),
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section heading
|
||||
// ---------------------------------------------------------------------------
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Review row
|
||||
// ---------------------------------------------------------------------------
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document upload card
|
||||
// ---------------------------------------------------------------------------
|
||||
function DocCard({
|
||||
slot,
|
||||
file,
|
||||
onFile,
|
||||
}: {
|
||||
slot: DocSlot;
|
||||
file: File | null;
|
||||
onFile: (f: File | null) => void;
|
||||
}) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
const SlotIcon = slot.icon;
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file
|
||||
? 'var(--mantine-color-teal-5)'
|
||||
: 'var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: rem(44),
|
||||
height: rem(44),
|
||||
borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">
|
||||
{slot.label}
|
||||
{slot.required && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => { onFile(null); resetRef.current?.(); }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="xs" variant="default" {...props}>
|
||||
Choose File
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeafarerRegistrationPage() {
|
||||
const navigate = useNavigate();
|
||||
// Every signed-in user already has a profile (`/profiles/me` provisions
|
||||
// one), so registering fills that row in rather than creating a second —
|
||||
// POST /profiles trips the unique user_id constraint.
|
||||
const { profileId, profile } = useCurrentProfile();
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [updateProfile] = useUpdateMyProfileMutation();
|
||||
const [saveAddress] = useSaveMyAddressMutation();
|
||||
|
||||
// Step 1 — Personal Information
|
||||
const [firstName, setFirstName] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [middleName, setMiddleName] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [lastName, setLastName] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [gender, setGender] = useState<string | null>(null);
|
||||
const [dob, setDob] = useState<Date | null>(null);
|
||||
const [placeOfBirth, setPlaceOfBirth] = useState('');
|
||||
const [nationality, setNationality] = useState<string | null>('Ethiopian');
|
||||
const [maritalStatus, setMaritalStatus] = useState<string | null>(null);
|
||||
const [nationalIdNumber, setNationalIdNumber] = useState('');
|
||||
const [passportNumber, setPassportNumber] = useState('');
|
||||
const [passportExpiry, setPassportExpiry] = useState('');
|
||||
|
||||
// Step 2 — Contact Details
|
||||
const [mobile, setMobile] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [locationId, setLocationId] = useState<string | null>(null);
|
||||
const [permanentAddress, setPermanentAddress] = useState('');
|
||||
const [currentAddress, setCurrentAddress] = useState('');
|
||||
const [emergencyName, setEmergencyName] = useState('');
|
||||
const [emergencyRel, setEmergencyRel] = useState<string | null>(null);
|
||||
const [emergencyPhone, setEmergencyPhone] = useState('');
|
||||
|
||||
// Step 3 — Documents
|
||||
const [files, setFiles] = useState<Record<string, File | null>>({
|
||||
nationalId: null, passport: null, graduation: null, photo: null,
|
||||
});
|
||||
|
||||
const setFile = (key: string) => (f: File | null) =>
|
||||
setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
// Signup already asked for name, email and phone — start from those (and
|
||||
// whatever is on the profile) instead of making the applicant retype them.
|
||||
// Only blank fields are filled, so nothing typed here is overwritten.
|
||||
useEffect(() => {
|
||||
const [enFirst = '', ...enRest] = (user?.name.en ?? '').trim().split(/\s+/);
|
||||
const [amFirst = '', ...amRest] = (user?.name.am ?? '').trim().split(/\s+/);
|
||||
const orEmpty = (v?: string | null) => v ?? '';
|
||||
// Profile stores MALE / SINGLE; the selects here list Male / Single.
|
||||
const title = (v: string) => v.charAt(0) + v.slice(1).toLowerCase();
|
||||
setFirstName((c) => c.en ? c : { en: profile?.firstName || enFirst, am: amFirst });
|
||||
setMiddleName((c) => c.en ? c : { en: profile?.middleName || enRest.slice(0, -1).join(' '), am: amRest.slice(0, -1).join(' ') });
|
||||
setLastName((c) => c.en ? c : { en: profile?.lastName || orEmpty(enRest.at(-1)), am: orEmpty(amRest.at(-1)) });
|
||||
if (profile?.gender) setGender((c) => c ?? title(profile.gender));
|
||||
if (profile?.dob) setDob((c) => c ?? new Date(profile.dob));
|
||||
if (profile?.pob) setPlaceOfBirth((c) => c || profile.pob);
|
||||
if (profile?.maritalStatus) setMaritalStatus((c) => c ?? title(profile.maritalStatus));
|
||||
setEmail((c) => c || profile?.address?.email || user?.email || '');
|
||||
setMobile((c) => c || profile?.address?.primaryPhoneNumber || user?.phoneNumber || '');
|
||||
if (profile?.address?.idNumber) setNationalIdNumber((c) => c || profile.address.idNumber);
|
||||
if (profile?.address?.nationality) setNationality((c) => c ?? profile.address.nationality);
|
||||
if (profile?.address?.streetAddress) setPermanentAddress((c) => c || orEmpty(profile.address.streetAddress));
|
||||
if (profile?.address?.emergencyContactName) setEmergencyName((c) => c || orEmpty(profile.address.emergencyContactName));
|
||||
if (profile?.address?.emergencyContactPhone) setEmergencyPhone((c) => c || orEmpty(profile.address.emergencyContactPhone));
|
||||
if (profile?.address?.emergencyContactRelation) setEmergencyRel((c) => c ?? profile.address.emergencyContactRelation);
|
||||
}, [user, profile]);
|
||||
|
||||
// Registration writes the profile as SEAFARER with personal details, so a
|
||||
// profile in that state is a submitted registration: show it read-only
|
||||
// instead of an empty wizard. "Edit" reopens the wizard on the same data.
|
||||
const registered = profile?.type === 'SEAFARER' && !!profile.firstName && !!profile.dob;
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return !!firstName.en.trim() && !!lastName.en.trim() && !!gender && !!dob && !!placeOfBirth && !!nationality && !!nationalIdNumber.trim();
|
||||
if (active === 1) return !!mobile.trim() && !!email.trim() && !!locationId;
|
||||
if (active === 2) return !!files.nationalId && !!files.photo;
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!profileId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await updateProfile({
|
||||
id: profileId,
|
||||
body: {
|
||||
type: 'SEAFARER',
|
||||
firstName: firstName.en,
|
||||
middleName: middleName.en || undefined,
|
||||
lastName: lastName.en,
|
||||
gender: gender?.toUpperCase() ?? 'MALE',
|
||||
dob: dob?.toISOString().split('T')[0] ?? '',
|
||||
pob: placeOfBirth || undefined,
|
||||
maritalStatus: maritalStatus?.toUpperCase() ?? 'SINGLE',
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
await saveAddress({
|
||||
profileId,
|
||||
body: {
|
||||
idType: 'NID',
|
||||
idNumber: nationalIdNumber,
|
||||
nationality: nationality ?? 'Ethiopian',
|
||||
primaryPhoneNumber: mobile,
|
||||
email: email || undefined,
|
||||
website: null,
|
||||
streetAddress: permanentAddress || undefined,
|
||||
emergencyContactName: emergencyName || undefined,
|
||||
emergencyContactPhone: emergencyPhone || undefined,
|
||||
emergencyContactRelation: emergencyRel || undefined,
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
notify.success(`Registration submitted! Profile ID: ${profileId.slice(0, 8).toUpperCase()}`);
|
||||
setEditing(false);
|
||||
setActive(0);
|
||||
setCompleted([]);
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const review = (
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Personal Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="First Name" value={`${firstName.en}${firstName.am ? ` / ${firstName.am}` : ''}`} />
|
||||
<ReviewRow label="Middle Name" value={`${middleName.en}${middleName.am ? ` / ${middleName.am}` : ''}`} />
|
||||
<ReviewRow label="Last Name" value={`${lastName.en}${lastName.am ? ` / ${lastName.am}` : ''}`} />
|
||||
<ReviewRow label="Gender" value={gender ?? ''} />
|
||||
<ReviewRow label="Date of Birth" value={`${dob?.toLocaleDateString('en-US') ?? ''}${dob ? ` (${toEthiopicDateLabel(dob)})` : ''}`} />
|
||||
<ReviewRow label="Place of Birth" value={placeOfBirth} />
|
||||
<ReviewRow label="Nationality" value={nationality ?? ''} />
|
||||
<ReviewRow label="Marital Status" value={maritalStatus ?? ''} />
|
||||
<ReviewRow label="National ID No." value={nationalIdNumber} />
|
||||
<ReviewRow label="Passport No." value={passportNumber} />
|
||||
<ReviewRow label="Passport Expiry" value={passportExpiry} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Contact Details</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Mobile" value={mobile} />
|
||||
<ReviewRow label="Email" value={email} />
|
||||
<ReviewRow label="Location" value={locationId ?? ''} />
|
||||
<ReviewRow label="Permanent Address" value={permanentAddress} />
|
||||
<ReviewRow label="Current Address" value={currentAddress} />
|
||||
</SimpleGrid>
|
||||
{emergencyName && (
|
||||
<>
|
||||
<Divider mt="md" mb="sm" />
|
||||
<Text fw={600} fz="sm" mb="sm">Emergency Contact</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Name" value={emergencyName} />
|
||||
<ReviewRow label="Relationship" value={emergencyRel ?? ''} />
|
||||
<ReviewRow label="Phone" value={emergencyPhone} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap="xs" align="center">
|
||||
{files[slot.key] ? (
|
||||
<IconCircleCheck size={18} color="var(--mantine-color-teal-6)" />
|
||||
) : (
|
||||
<Box style={{ width: 18, height: 18, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)', flexShrink: 0 }} />
|
||||
)}
|
||||
<div>
|
||||
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||
{slot.label}
|
||||
{slot.required && !files[slot.key] && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
{files[slot.key] && (
|
||||
<Text fz="xs" c="dimmed" truncate maw={160}>{files[slot.key]!.name}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const stepLabel = STEPS[active]?.label ?? '';
|
||||
const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck];
|
||||
const StepIcon = stepIcons[active];
|
||||
|
||||
if (registered && !editing) {
|
||||
const status = profile.seafarerStatus ?? 'PENDING';
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Seafarer Registration</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{profile.seafarerNumber
|
||||
? `Seafarer ID ${profile.seafarerNumber}`
|
||||
: 'Submitted — a Seafarer ID is issued once EMA approves your registration.'}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
<Badge variant="light" color={status === 'ACTIVE' ? 'teal' : status === 'PENDING' ? 'yellow' : 'red'}>
|
||||
{status}
|
||||
</Badge>
|
||||
<Button variant="default" onClick={() => setEditing(true)}>Edit details</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
{review}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<Title order={3}>New Seafarer Registration</Title>
|
||||
<Text fz="sm" c="dimmed">Register a new seafarer profile — Step {active + 1} of {STEPS.length}</Text>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
{/* Card */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
{/* Card header */}
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="xs">
|
||||
<StepIcon size={20} stroke={1.6} />
|
||||
<Text fw={700} fz="lg">{stepLabel}</Text>
|
||||
</Group>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* ── Step 1: Personal Information ───────────────────────────── */}
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Identity Details" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<BilingualInput label="First Name" required value={firstName} onChange={setFirstName} />
|
||||
<BilingualInput label="Middle Name" value={middleName} onChange={setMiddleName} />
|
||||
<BilingualInput label="Last Name" required value={lastName} onChange={setLastName} />
|
||||
<Select label="Gender" placeholder="Select" required data={GENDERS} value={gender} onChange={setGender} />
|
||||
<AmharicDatePicker label="Date of Birth" required value={dob} onChange={setDob} />
|
||||
<TextInput label="Place of Birth" placeholder="City, Region" required value={placeOfBirth} onChange={(e) => setPlaceOfBirth(e.currentTarget.value)} />
|
||||
<Select label="Nationality" required data={NATIONALITIES} value={nationality} onChange={setNationality} searchable />
|
||||
<Select label="Marital Status" placeholder="Select" data={MARITAL_STATUSES} value={maritalStatus} onChange={setMaritalStatus} />
|
||||
</SimpleGrid>
|
||||
|
||||
<SectionHead title="Identity Documents" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="National ID Number" placeholder="ET-000000" required value={nationalIdNumber} onChange={(e) => setNationalIdNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Passport Number" placeholder="EP000000" value={passportNumber} onChange={(e) => setPassportNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Passport Expiry Date" type="date" value={passportExpiry} onChange={(e) => setPassportExpiry(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
A unique Seafarer ID will be automatically generated upon approval of this registration.
|
||||
</Alert>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Contact Details ─────────────────────────────────── */}
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Contact Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Mobile Number" placeholder="+251 9XX XXX XXX" required value={mobile} onChange={(e) => setMobile(e.currentTarget.value)} />
|
||||
<TextInput label="Email Address" placeholder="email@example.com" type="email" required value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
|
||||
<SectionHead title="Location" />
|
||||
<LocationPicker
|
||||
value={locationId ?? undefined}
|
||||
onChange={setLocationId}
|
||||
required
|
||||
/>
|
||||
|
||||
<SectionHead title="Address" />
|
||||
<Textarea label="Permanent Address" placeholder="Full permanent address" autosize minRows={2} value={permanentAddress} onChange={(e) => setPermanentAddress(e.currentTarget.value)} />
|
||||
<Textarea
|
||||
label={<><Text span fz="sm" fw={500}>Current Address</Text><Text span fz="xs" c="dimmed" ml={6}>(If different from permanent)</Text></>}
|
||||
placeholder="Full current address"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={currentAddress}
|
||||
onChange={(e) => setCurrentAddress(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<SectionHead title="Emergency Contact" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Contact Name" placeholder="Full name" value={emergencyName} onChange={(e) => setEmergencyName(e.currentTarget.value)} />
|
||||
<Select label="Relationship" placeholder="Select" data={RELATIONSHIPS} value={emergencyRel} onChange={setEmergencyRel} />
|
||||
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" value={emergencyPhone} onChange={(e) => setEmergencyPhone(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Documents Upload ────────────────────────────────── */}
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
|
||||
Please upload clear, readable copies of all required documents. Accepted formats: PDF, JPG, PNG (max 5MB each). Items marked with * are mandatory.
|
||||
</Alert>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<DocCard
|
||||
key={slot.key}
|
||||
slot={slot}
|
||||
file={files[slot.key]}
|
||||
onFile={setFile(slot.key)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0">
|
||||
<Text fw={600} fz="sm" mb="sm">Upload Progress</Text>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap={6} align="center">
|
||||
{files[slot.key] ? (
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
) : (
|
||||
<Box style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)' }} />
|
||||
)}
|
||||
<Text fz="xs" c={files[slot.key] ? 'teal.7' : 'dimmed'} fw={files[slot.key] ? 600 : 400}>
|
||||
{slot.key === 'nationalId' ? 'National ID' : slot.key === 'passport' ? 'Passport' : slot.key === 'graduation' ? 'Certificate' : 'Photo'}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Review & Submit ─────────────────────────────────── */}
|
||||
{active === 3 && review}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" onClick={() => navigate('/applications')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
{active > 0 && (
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={prev}>
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={next}
|
||||
disabled={!canNext()}
|
||||
>
|
||||
Next Step
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="blue"
|
||||
leftSection={<IconCircleCheck size={16} />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
>
|
||||
Submit Registration
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import { RequireSeafarerProfile } from "./features/profile/components/RequireSea
|
||||
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
|
||||
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||
import { SeafarerRegistrationPage } from "./features/seafarer/pages/SeafarerRegistrationPage";
|
||||
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
|
||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
|
||||
@@ -153,16 +152,18 @@ 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.
|
||||
{
|
||||
path: "/seafarer-registration",
|
||||
// No profile gate: this wizard asks for the personal, identity and
|
||||
// contact details itself across its four steps, so sending the
|
||||
// applicant to /profile first made the approved form unreachable —
|
||||
// they were bounced out before ever seeing it.
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.APPLY_SEAFARER_REGISTRATION]}>
|
||||
<SeafarerRegistrationPage />
|
||||
</RequirePermission>
|
||||
<Navigate to="/licensing/SEAFARER_REGISTRATION/apply" replace />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user