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:
Nati
2026-08-17 14:48:26 +00:00
parent 13c4e09f88
commit c7bd69eecb
7 changed files with 396 additions and 651 deletions

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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;
}