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(null); const { data: formats = [], isLoading } = useGetNumberFormatsQuery(); const [createFormat, { isLoading: creating }] = useCreateNumberFormatMutation(); const [updateFormat] = useUpdateNumberFormatMutation(); const [previewFormat] = usePreviewNumberFormatMutation(); const form = useForm({ 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 (
); } return (
{t('numberFormat.title', 'Number Formats')} {t( 'numberFormat.subtitle', 'The shape of generated seafarer, seaman book and certificate numbers.', )}
} 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.', )} {formats.length === 0 ? ( {t( 'numberFormat.empty', 'No formats configured. Numbers use the built-in default until one is added.', )} ) : ( {t('numberFormat.scope', 'Identifier')} {t('numberFormat.example', 'Example')} {t('numberFormat.sequence', 'Sequence')} {t('numberFormat.status', 'Status')} {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 ( {scopeLabel(format.scope)} {parts.join(format.separator)} {format.sequenceLength} digits {format.isActive ? t('numberFormat.active', 'Active') : t('numberFormat.superseded', 'Superseded')} {format.isActive && ( )} ); })}
)}