mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const tagTypes = ["ProfessionApi"];
|
||||
export const tagTypes = ["ProfessionApi", "NumberFormatApi"];
|
||||
|
||||
Reference in New Issue
Block a user