mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 15:25:47 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user