diff --git a/apps/backoffice/src/app/features/configuration/api/configuration-api.ts b/apps/backoffice/src/app/features/configuration/api/configuration-api.ts index 93a728f1e..29f58f9c1 100644 --- a/apps/backoffice/src/app/features/configuration/api/configuration-api.ts +++ b/apps/backoffice/src/app/features/configuration/api/configuration-api.ts @@ -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({ + 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 + >({ + 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; diff --git a/apps/backoffice/src/app/features/configuration/components/NumberFormatTab.tsx b/apps/backoffice/src/app/features/configuration/components/NumberFormatTab.tsx new file mode 100644 index 000000000..62ee9460e --- /dev/null +++ b/apps/backoffice/src/app/features/configuration/components/NumberFormatTab.tsx @@ -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(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 && ( + + )} + + + ); + })} + +
+
+ )} + + +
+ +