From c7bd69eecbe86d08898e488823a7373a7ee7c8c4 Mon Sep 17 00:00:00 2001 From: Nati Date: Mon, 17 Aug 2026 14:48:26 +0000 Subject: [PATCH] fix(seafarer): route registration through the application wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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) --- .../configuration/api/configuration-api.ts | 51 ++ .../components/NumberFormatTab.tsx | 288 ++++++++ .../pages/ConfigurationPage/index.tsx | 9 + .../configuration/types/configuration.ts | 38 ++ .../pages/SeafarerRegistrationPage.tsx | 642 ------------------ apps/portal/src/app/router.tsx | 17 +- libs/api/src/lib/base-api/tagTypes.ts | 2 +- 7 files changed, 396 insertions(+), 651 deletions(-) create mode 100644 apps/backoffice/src/app/features/configuration/components/NumberFormatTab.tsx delete mode 100644 apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx 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 && ( + + )} + + + ); + })} + +
+
+ )} + + +
+ + - - setPlaceOfBirth(e.currentTarget.value)} /> - - - - - - setNationalIdNumber(e.currentTarget.value)} /> - setPassportNumber(e.currentTarget.value)} /> - setPassportExpiry(e.currentTarget.value)} /> - - - }> - A unique Seafarer ID will be automatically generated upon approval of this registration. - - - )} - - {/* ── Step 2: Contact Details ─────────────────────────────────── */} - {active === 1 && ( - - - - setMobile(e.currentTarget.value)} /> - setEmail(e.currentTarget.value)} /> - - - - - - -