Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-18 09:56:16 +03:00
8 changed files with 964 additions and 315 deletions

View File

@@ -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<NumberFormatConfig[], void>({
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<NumberFormatPayload>
>({
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;

View File

@@ -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>
);
}

View File

@@ -21,6 +21,7 @@ import {
IconBriefcase,
IconMap,
IconCertificate,
IconHash,
IconInfoCircle,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -34,6 +35,7 @@ import {
} from "@ema-platform/ui";
import { LocationPage } from "../../../location/pages/LocationPage";
import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab";
import {
useGetOrganizationsQuery,
useGetProfessionsQuery,
@@ -395,6 +397,9 @@ export function ConfigurationPage() {
>
{t("certification.title")}
</Tabs.Tab>
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
{t("numberFormat.title", "Number Formats")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="professions" pt="md">
@@ -408,6 +413,10 @@ export function ConfigurationPage() {
<Tabs.Panel value="certifications" pt="md">
<CertificationPage />
</Tabs.Panel>
<Tabs.Panel value="numberFormats" pt="md">
<NumberFormatTab />
</Tabs.Panel>
</Tabs>
</Stack>
);

View File

@@ -46,3 +46,41 @@ export interface UpdateProfessionPayload {
description?: NamePair;
isActive?: boolean;
}
/** Identifiers whose rendered shape is authored in the backoffice. */
export type NumberFormatScope =
| 'SEAFARER_NUMBER'
| 'SEAMAN_BOOK_NUMBER'
| 'BTC_NUMBER';
/**
* The shape of a generated identifier — prefix, optional year, separator and
* zero-padded counter, e.g. SEA-2026-000001.
*
* Only the shape. The counter itself is server-side and atomic, so nothing
* here can cause two people to be issued the same number.
*/
export interface NumberFormatConfig {
id: string;
scope: NumberFormatScope;
prefix: string;
includeYear: boolean;
separator: string;
sequenceLength: number;
startingNumber: number;
activeFrom: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface NumberFormatPayload {
scope: NumberFormatScope;
prefix: string;
includeYear?: boolean;
separator?: string;
sequenceLength?: number;
startingNumber?: number;
activeFrom?: string;
isActive?: boolean;
}

View File

@@ -0,0 +1,88 @@
import { Divider, Paper, Stack, Table, Text, Title } from "@mantine/core";
import {
conditionHolds,
type Attachment,
type FormSectionConfig,
type LicenseTypeRequirements,
} from "@ema-platform/api";
import { DocumentSlots } from "./DocumentSlots";
interface Props {
/** Every form section (not just the "review" group) in wizard-step order. */
sections: FormSectionConfig[];
formData: Record<string, Record<string, unknown>>;
localized: (value: { en?: string; am?: string } | undefined) => string;
config: LicenseTypeRequirements;
attachments: Attachment[];
applicationId: string;
}
/**
* Read-only "what was filed" view for a submitted application: every
* answered section as a labelled table, then the uploaded documents.
*
* Shown instead of the wizard once there is nothing left to step through —
* the stepper is for filling a form in, not for re-reading one that is
* already someone else's decision to make.
*/
export function ApplicationSummary({
sections,
formData,
localized,
config,
attachments,
applicationId,
}: Props) {
return (
<Paper withBorder p="lg" radius="md">
<Stack gap="lg">
{sections.map((section) => (
<div key={section.key}>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
{localized(section.title)}
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
{(section.fields ?? [])
.filter((f) => conditionHolds(f.showWhen, formData))
.map((field) => (
<Table.Tr key={field.key}>
<Table.Td w="45%">
<Text size="xs" c="dimmed">
{localized(field.label)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{String(formData[section.key]?.[field.key] ?? "—")}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
))}
<div>
<Divider mb="md" />
<Title order={5} mb="sm">
Documents
</Title>
<DocumentSlots
requirements={config.documentRequirements}
attachments={attachments}
formData={formData}
ownerType="APPLICATION"
ownerId={applicationId}
readOnly
onUploaded={() => {
// Read-only here — nothing to react to, but DocumentSlots
// requires the callback.
}}
/>
</div>
</Stack>
</Paper>
);
}

View File

@@ -152,13 +152,19 @@ export const router = createBrowserRouter([
},
// Seafarer
// The standalone wizard is gone — registration is the config-driven
// licensing flow like every other licence type, gated by
// RequireSeafarerProfile + RequirePermission the same way
// /licensing/:typeCode/apply already is.
//
// Registration runs through the shared application wizard like every
// other service. It used to have a page of its own that wrote the
// profile directly and created no application at all — which meant a
// "submitted" registration was never reviewed, never approved and never
// numbered, because there was nothing for an officer to open. The
// wizard, the review queue and the approval side effects all already
// existed; only the route was pointed away from them.
{
path: "/seafarer-registration",
element: <Navigate to="/licensing/SEAFARER_REGISTRATION/apply" replace />,
element: (
<Navigate to="/licensing/SEAFARER_REGISTRATION/apply" replace />
),
},
{
path: "/seafarer/records",

View File

@@ -1 +1 @@
export const tagTypes = ["ProfessionApi"];
export const tagTypes = ["ProfessionApi", "NumberFormatApi"];