mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-02 00:03:27 +00:00
290 lines
9.6 KiB
TypeScript
290 lines
9.6 KiB
TypeScript
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' },
|
|
{ value: 'BSID', label: 'Biometric Subject ID (BSID)' },
|
|
];
|
|
|
|
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>
|
|
);
|
|
}
|