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

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