Merge branch 'createNewLicenseType' of https://github.com/Tria-plc/emaui into createNewLicenseType

This commit is contained in:
Estifo77
2026-09-04 09:43:46 +03:00
29 changed files with 504 additions and 192 deletions

View File

@@ -135,6 +135,21 @@ export function BlockPropertiesPanel({
/>
</Group>
<NumberInput
label={t('designer.blockPage', 'Page')}
description={t('designer.blockPageHint', 'Which sheet of the document this block prints on')}
value={(block.page ?? 0) + 1}
onChange={(value) => {
const index = Math.max(0, Math.min(63, Math.floor(Number(value) || 1) - 1));
// Omitted on the first page so a single-page design stays as it was.
const { page: _page, ...rest } = block;
onChange(index > 0 ? { ...rest, page: index } : rest);
}}
min={1}
max={64}
disabled={disabled}
/>
<Group gap="xs" grow>
<NumberInput
label={t('designer.blockX', 'X (%)')}

View File

@@ -84,9 +84,14 @@ export function DesignerToolbar({
<Text size="sm" fw={600}>
{validityDays != null
? t('designer.validityDays', '{{count}} days', { count: validityDays })
: t('designer.validityMonths', '{{count}} months', {
count: validityMonths,
})}
: validityMonths
? t('designer.validityMonths', '{{count}} months', {
count: validityMonths,
})
: // A zero-month term is not honoured at issuance — the
// renderer falls back to twelve months — so say what
// will actually be printed rather than "does not expire".
t('designer.validityNone', 'No term set — issues for 12 months')}
</Text>
<Text size="xs" c="dimmed">
{t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')}

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Paper, Text } from '@mantine/core';
import { IconPhoto } from '@tabler/icons-react';
import { Box, Button, Group, Paper, Text } from '@mantine/core';
import { IconPhoto, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
import { pageIndexOf } from '../config/layout-compiler';
interface Props {
backgroundUrl: string;
@@ -16,6 +17,11 @@ interface Props {
onSelect: (id: string | null) => void;
onChange: (placements: TemplateFieldPlacement[]) => void;
disabled: boolean;
/** Zero-based page being shown; only that page's blocks are drawn. */
page: number;
pageCount: number;
onPageChange: (page: number) => void;
onAddPage: () => void;
}
/** A4 aspect ratio, the fallback for a version with no custom page size. */
@@ -71,9 +77,14 @@ export function TemplateCanvas({
onSelect,
onChange,
disabled,
page,
pageCount,
onPageChange,
onAddPage,
}: Props) {
const { t } = useTranslation();
const pageRef = useRef<HTMLDivElement>(null);
const visible = placements.filter((block) => pageIndexOf(block) === page);
const [drag, setDrag] = useState<DragState | null>(null);
// Held in a ref as well: the pointer handlers are bound to the window for the
@@ -214,6 +225,31 @@ export function TemplateCanvas({
)}
</Text>
{/* One tab per sheet. A single-page certificate shows only "Page 1" and
the add button; a booklet (the Seaman Book) switches between its
pages here, each positioning its own blocks. */}
<Group gap={6} mb="xs">
{Array.from({ length: pageCount }, (_, index) => (
<Button
key={index}
size="compact-xs"
variant={index === page ? 'filled' : 'default'}
onClick={() => onPageChange(index)}
>
{t('designer.pageTab', 'Page {{n}}', { n: index + 1 })}
</Button>
))}
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconPlus size={12} />}
disabled={disabled}
onClick={onAddPage}
>
{t('designer.addPage', 'Add page')}
</Button>
</Group>
<Box
ref={pageRef}
onPointerDown={() => onSelect(null)}
@@ -256,7 +292,7 @@ export function TemplateCanvas({
/>
)}
{placements.map((block) => {
{visible.map((block) => {
const isSelected = block.id === selectedId;
const isImage = block.type === 'image';
return (

View File

@@ -51,7 +51,7 @@ export function TemplateEditor({
<Text size="xs">
{t(
'designer.publishedLocked',
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
'This version is live and cannot be edited — certificates are issued from it. Create a new version to make changes.',
)}
</Text>
</Paper>

View File

@@ -102,7 +102,16 @@ export function compileLayoutToHbs(input: {
? ` <img class="ema-background" src="${escapeHtml(input.backgroundUrl)}" alt="" />\n`
: '';
const blocks = input.fieldPlacements.map(blockHtml).join('');
const logo = logoHtml(input.logoUrl, input.logoPlacement);
// Same grouping as the server: one page box per page index, each carrying
// the background and logo, so a booklet previews as the sheets it prints.
const body = groupByPage(input.fieldPlacements)
.map(
(blocks) =>
` <div class="ema-page">\n${background}${logo}${blocks.map(blockHtml).join('')} </div>\n`,
)
.join('');
return `<!doctype html>
<html>
@@ -113,6 +122,10 @@ export function compileLayoutToHbs(input: {
html, body { margin: 0; padding: 0; height: 100%; }
body { font-family: "Helvetica Neue", Arial, sans-serif; }
.ema-page { position: relative; width: 100%; height: 100vh; overflow: hidden; }
/* Every page but the last starts a new sheet. Applied to the element
rather than between them so a single-page design prints one sheet
with no trailing blank. */
.ema-page + .ema-page { page-break-before: always; break-before: page; }
.ema-background {
position: absolute; inset: 0;
width: 100%; height: 100%;
@@ -122,9 +135,28 @@ export function compileLayoutToHbs(input: {
</style>
</head>
<body>
<div class="ema-page">
${background}${logoHtml(input.logoUrl, input.logoPlacement)}${blocks} </div>
</body>
${body} </body>
</html>
`;
}
/** Upper bound on pages, matching the server, so a bad value cannot explode the preview. */
const MAX_PAGE_INDEX = 63;
/** A block's page, defaulting to the first and refusing a nonsense value. */
export function pageIndexOf(block: Pick<TemplateFieldPlacement, 'page'>): number {
const page = block.page;
if (typeof page !== 'number' || !Number.isFinite(page) || page < 0) return 0;
return Math.min(Math.floor(page), MAX_PAGE_INDEX);
}
/**
* Blocks split into pages, in page order, with no gaps — an empty page in
* the middle of a booklet is a deliberate blank verso and is kept.
*/
export function groupByPage(placements: TemplateFieldPlacement[]): TemplateFieldPlacement[][] {
const highest = placements.reduce((max, block) => Math.max(max, pageIndexOf(block)), 0);
const pages: TemplateFieldPlacement[][] = Array.from({ length: highest + 1 }, () => []);
for (const block of placements) pages[pageIndexOf(block)].push(block);
return pages;
}

View File

@@ -4,6 +4,7 @@ import type {
TemplateFieldPlacement,
TemplateLogoPlacement,
} from '@ema-platform/api';
import { pageIndexOf } from '../config/layout-compiler';
/** Stable ids for new blocks; `crypto.randomUUID` is not in every test env. */
function blockId(): string {
@@ -32,6 +33,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
const [logoPlacement, setLogoPlacement] = useState<TemplateLogoPlacement>({});
const [placements, setPlacements] = useState<TemplateFieldPlacement[]>([]);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
// The page the canvas is showing. Pages exist only through the blocks on
// them, so a freshly added page is held here until its first block lands.
const [currentPage, setCurrentPage] = useState(0);
const [extraPages, setExtraPages] = useState(0);
const editorRef = useRef<HTMLTextAreaElement>(null);
const selected = useMemo(
@@ -61,8 +66,19 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
setLogoPlacement(selected.logoPlacement ?? {});
setPlacements(selected.fieldPlacements ?? []);
setSelectedBlockId(null);
setCurrentPage(0);
setExtraPages(0);
}, [selected]);
/** Pages the design spans: the highest block page, plus any added and still empty. */
const pageCount =
placements.reduce((max, block) => Math.max(max, pageIndexOf(block)), 0) + 1 + extraPages;
const addPage = useCallback(() => {
setExtraPages((n) => n + 1);
setCurrentPage(pageCount);
}, [pageCount]);
const isPublished = selected?.status === 'PUBLISHED';
// Compared as JSON because both are plain data the server round-trips; a
@@ -101,12 +117,16 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
*/
const addBlock = useCallback(
(variable: string | null, text?: string, kind: 'text' | 'image' = 'text') => {
// Placed on the page being viewed; `page` is omitted on the first so a
// single-page design stays byte-for-byte what it was before booklets.
const page = currentPage > 0 ? { page: currentPage } : {};
const block: TemplateFieldPlacement =
kind === 'image'
? {
id: blockId(),
variable,
type: 'image',
...page,
xPct: 10,
yPct: 10,
widthPct: 15,
@@ -116,6 +136,7 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
variable,
text,
type: 'text',
...page,
xPct: 10,
yPct: 10,
widthPct: 30,
@@ -128,7 +149,7 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
setPlacements((prev) => [...prev, block]);
setSelectedBlockId(block.id);
},
[],
[currentPage],
);
const updateBlock = useCallback((next: TemplateFieldPlacement) => {
@@ -185,6 +206,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
setSelectedBlockId,
selectedBlock,
usesCanvas,
currentPage,
setCurrentPage,
pageCount,
addPage,
addBlock,
updateBlock,
deleteBlock,

View File

@@ -4,6 +4,7 @@ import {
Button,
Container,
Group,
Modal,
Paper,
Stack,
Tabs,
@@ -23,7 +24,6 @@ import {
useArchiveLicenseTemplateMutation,
useCreateLicenseTemplateMutation,
useDeleteLicenseTemplateMutation,
useGetBuiltInTemplateQuery,
useGetLicenseTemplatesQuery,
useGetLicenseTypesQuery,
useGetRanksQuery,
@@ -31,7 +31,7 @@ import {
usePublishLicenseTemplateMutation,
useUpdateLicenseTemplateMutation,
} from '@ema-platform/api';
import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui';
import { EmptyState, ErrorState, ModalFooter, PageHeader, PdfPreviewModal } from '@ema-platform/ui';
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
import { DesignerToolbar } from '../components/DesignerToolbar';
@@ -62,6 +62,9 @@ export function CertificateDesignerPage() {
const { can } = usePermissions();
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
// Its own permission on the server, so the button follows it rather than
// showing to an editor who would only be refused on click.
const canCreate = can([PERMISSIONS.CREATE_TEMPLATE]);
const canPublish = can([PERMISSIONS.PUBLISH_TEMPLATE]);
const { data: licenseTypes } = useGetLicenseTypesQuery();
@@ -79,8 +82,10 @@ export function CertificateDesignerPage() {
// default both come back, so the version list is scoped to whichever the
// toolbar has selected.
const templates = allTemplates.filter((tpl) => (tpl.rankId ?? null) === rankId);
const { data: variables = [] } = useGetTemplateVariablesQuery();
const { data: builtIn } = useGetBuiltInTemplateQuery();
// Per type, so the palette also lists this form's own answers.
const { data: variables = [] } = useGetTemplateVariablesQuery(
typeId ? { licenseTypeId: typeId } : undefined,
);
const [createTemplate, { isLoading: creating }] = useCreateLicenseTemplateMutation();
const [updateTemplate, { isLoading: saving }] = useUpdateLicenseTemplateMutation();
@@ -94,25 +99,23 @@ export function CertificateDesignerPage() {
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
// A rank ladder only exists for CoC/CoP — every other licence type designs
// one certificate for everyone who holds it. Keyed on `key`, not
// `certificateCategory`: that STCW-mapping column is unset on the seeded
// CoC/CoP rows (it's authored later, per StcwMappingPanel), while `key` is
// the stable identity CertificateEligibilityService itself branches on.
// CoC/CoP are each a single LicenseType spanning every department's ladder
// (the applicant's own department, not the type, decides which ladder they
// climb), so the picker offers every rank in the ladder across all
// departments rather than one department's.
// one certificate for everyone who holds it. Keyed on the configured
// `certificateCategory`, the same column the server's rank resolution reads,
// so a type an administrator marks as a CoC/CoP on the Behaviour tab gets a
// rank picker without a code change. CoC/CoP are each a single LicenseType
// spanning every department's ladder (the applicant's own department, not
// the type, decides which ladder they climb), so the picker offers every
// rank across all departments rather than one department's.
const rankCategory: 'COC' | 'COP' | null =
selectedType?.key === 'CERTIFICATE_OF_COMPETENCY'
? 'COC'
: selectedType?.key === 'CERTIFICATE_OF_PROFICIENCY'
? 'COP'
: null;
selectedType?.certificateCategory === 'COC' || selectedType?.certificateCategory === 'COP'
? selectedType.certificateCategory
: null;
const isRankScoped = rankCategory !== null;
const { data: allRanks } = useGetRanksQuery(undefined, { skip: !isRankScoped });
const ranks = (allRanks?.items ?? [])
@@ -164,7 +167,7 @@ export function CertificateDesignerPage() {
}}
validityMonths={selectedType?.validityMonths ?? 12}
validityDays={selectedType?.validityDays ?? null}
canEdit={canEdit}
canEdit={canCreate}
onNewVersion={startNewVersion}
/>
@@ -183,7 +186,7 @@ export function CertificateDesignerPage() {
'Certificates currently use the built-in layout. Create a version to take control of it.',
)}
action={
canEdit
canCreate
? { label: t('designer.newVersion', 'New version'), onClick: startNewVersion }
: undefined
}
@@ -228,10 +231,10 @@ export function CertificateDesignerPage() {
<Text size="sm">
{t(
'designer.liveDesignBody',
'Certificates have been issued from this version, so it cannot be changed. Create a new version to edit — it starts as a copy of this one, and only replaces it when you publish.',
'Certificates are issued from this version, so it cannot be changed. Create a new version to edit — it starts as a copy of this one, and only replaces it when you publish.',
)}
</Text>
{canEdit && (
{canCreate && (
<Button
size="xs"
leftSection={<IconPlus size={14} />}
@@ -274,6 +277,10 @@ export function CertificateDesignerPage() {
onSelect={draft.setSelectedBlockId}
onChange={draft.setPlacements}
disabled={editingLocked}
page={draft.currentPage}
pageCount={draft.pageCount}
onPageChange={draft.setCurrentPage}
onAddPage={draft.addPage}
/>
<BlockPropertiesPanel
block={draft.selectedBlock}
@@ -356,8 +363,10 @@ export function CertificateDesignerPage() {
? { width: draft.pageWidth, height: draft.pageHeight }
: undefined,
),
backgroundUrl: draft.backgroundUrl || undefined,
logoUrl: draft.logoUrl || undefined,
// Null, not undefined: undefined means "leave as stored",
// which is what made Remove look like it worked until reload.
backgroundUrl: draft.backgroundUrl.trim() || null,
logoUrl: draft.logoUrl.trim() || null,
logoPlacement: draft.logoPlacement,
fieldPlacements: draft.placements,
}).unwrap(),
@@ -376,12 +385,7 @@ export function CertificateDesignerPage() {
t('designer.archived', 'Design withdrawn'),
)
}
onDelete={() =>
run(
() => deleteTemplate(draft.selected!.id).unwrap(),
t('designer.deleted', 'Draft deleted'),
)
}
onDelete={() => setDeleteOpen(true)}
/>
</Stack>
@@ -404,11 +408,15 @@ export function CertificateDesignerPage() {
onClose={() => setNewOpen(false)}
onCreate={() =>
run(async () => {
// No source is sent on purpose: the server seeds the draft from
// the live design for this rank, then the type's default, then
// the built-in layout — and it copies that design's blocks and
// artwork along with the HTML, which sending a source here would
// have split apart.
const created = await createTemplate({
licenseTypeId: typeId as string,
rankId,
name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap();
draft.setSelectedId(created.id);
setNewOpen(false);
@@ -416,6 +424,39 @@ export function CertificateDesignerPage() {
}
/>
<Modal
opened={deleteOpen}
onClose={() => setDeleteOpen(false)}
title={t('designer.delete', 'Delete draft')}
size="sm"
>
<Stack gap="md">
<Text size="sm">
{t('designer.deleteConfirm', 'Delete "{{name}}" (v{{version}})? This cannot be undone.', {
name: draft.selected?.name ?? '',
version: draft.selected?.version ?? '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteOpen(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
color="red"
onClick={async () => {
setDeleteOpen(false);
await run(
() => deleteTemplate(draft.selected!.id).unwrap(),
t('designer.deleted', 'Draft deleted'),
);
}}
>
{t('designer.delete', 'Delete draft')}
</Button>
</ModalFooter>
</Stack>
</Modal>
<PdfPreviewModal
opened={Boolean(previewUrl)}
onClose={closePreview}

View File

@@ -21,7 +21,6 @@ import {
type CertificateCategory,
type CompletionEffect,
type LicenseType,
type ServiceKind,
type WorkflowProfile,
} from '@ema-platform/api';
import { useRequirementActions } from '../hooks/useRequirementActions';
@@ -29,7 +28,6 @@ import { useRequirementActions } from '../hooks/useRequirementActions';
/** The shape the form edits — every field the behaviour endpoint accepts. */
interface Draft {
workflowProfile: WorkflowProfile;
serviceKind: ServiceKind;
completionEffect: CompletionEffect | null;
certificateCategory: CertificateCategory | null;
requiresExamination: boolean;
@@ -57,7 +55,6 @@ const REMINDER_OFFSETS = ['90', '60', '30', '14', '7'];
function toDraft(licenseType: LicenseType): Draft {
return {
workflowProfile: licenseType.workflowProfile ?? 'STANDARD',
serviceKind: licenseType.serviceKind ?? 'LICENSE',
completionEffect: licenseType.completionEffect ?? null,
certificateCategory: licenseType.certificateCategory ?? null,
requiresExamination: licenseType.requiresExamination ?? false,
@@ -72,9 +69,13 @@ function toDraft(licenseType: LicenseType): Draft {
licenseType.capitalThreshold == null
? null
: Number(licenseType.capitalThreshold),
// Zero is a real stored state for `validityMonths` (a type that issues
// nothing with an expiry) and is kept. Zero in the other two is not a
// policy anyone set — it is an unfilled column — and seeding a box with a
// value below its own floor only produces a save the server rejects.
validityMonths: licenseType.validityMonths ?? 12,
validityDays: licenseType.validityDays ?? null,
renewalWindowDays: licenseType.renewalWindowDays ?? 60,
validityDays: licenseType.validityDays || null,
renewalWindowDays: licenseType.renewalWindowDays || 60,
expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7],
requiresOperatorMode: licenseType.requiresOperatorMode ?? true,
allowMultipleOpenDrafts: licenseType.allowMultipleOpenDrafts ?? false,
@@ -114,14 +115,62 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [licenseType.id]);
function set<K extends keyof Draft>(key: K, value: Draft[K]) {
setDraft((current) => ({ ...current, [key]: value }));
function patch(values: Partial<Draft>) {
setDraft((current) => ({ ...current, ...values }));
setDirty(true);
}
function set<K extends keyof Draft>(key: K, value: Draft[K]) {
patch({ [key]: value } as Partial<Draft>);
}
// Nothing this type issues carries an expiry date — a transfer, or any
// one-off record. Renewal, its window and its reminders are all measured
// against an expiry that never arrives, so none of them are asked for.
const expires = draft.validityDays !== null || draft.validityMonths > 0;
// The server's floor for a stated term is 6 months, so no-expiry is a state
// this form can hold and edit around but cannot switch a type into. Offered
// only where it is already what the type is, rather than as an option whose
// save would be refused.
const noExpiryAvailable =
(licenseType.validityMonths ?? 12) === 0 && !licenseType.validityDays;
async function onSave() {
// Only the settings this form actually asked for. The endpoint patches, so
// an omitted field keeps its stored value — and the fields hidden above are
// hidden precisely because the type has no such policy, which the server
// stores as a zero its own validators then refuse (`validityMonths` has a
// floor of 6, `renewalWindowDays` of 1). Echoing those back is what made
// saving an ownership transfer fail outright.
const {
validityMonths,
validityDays,
renewalWindowDays,
expiryReminderDays,
...rest
} = draft;
// A type switched from "does not expire" straight to a term in days still
// carries `validityMonths: 0`, which the server's 6-month floor refuses.
// Days win over months at issuance, so the zero is simply left alone.
const term = expires
? {
validityDays,
...(validityMonths > 0 ? { validityMonths } : {}),
}
: {};
const ok = await run(
() => save({ id: licenseType.id, ...draft }).unwrap(),
() =>
save({
id: licenseType.id,
...rest,
...term,
...(expires && draft.renewalEnabled
? { renewalWindowDays, expiryReminderDays }
: {}),
}).unwrap(),
t('certReq.behavior.saved', 'Configuration saved.'),
);
if (ok) setDirty(false);
@@ -199,21 +248,10 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
disabled={!canEdit}
/>
<Select
label={t('certReq.behavior.serviceKind', 'Service kind')}
description={t(
'certReq.behavior.serviceKindHint',
'Catalogue classification only — no workflow depends on it.',
)}
data={[
{ value: 'LICENSE', label: t('certReq.behavior.license', 'Licence') },
{ value: 'REGISTRATION', label: t('certReq.behavior.registrationKind', 'Registration') },
]}
value={draft.serviceKind}
onChange={(v) => v && set('serviceKind', v as ServiceKind)}
allowDeselect={false}
disabled={!canEdit}
/>
{/* `serviceKind` is deliberately not offered here: nothing in the
backend or either portal reads it, so a control for it would be
a setting that changes nothing. Bring it back once something
branches on it. */}
</Section>
<Section title={t('certReq.behavior.eligibility', 'Eligibility gates')}>
@@ -289,94 +327,142 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
the seed says `validityMonths: 12` and this now says "12 Months",
so the two read the same. Months advance the calendar (issued on
the 31st, expires on the 31st); days are for terms shorter than a
month can express. */}
month can express. The third unit is no term at all, which the
server stores as `validityMonths: 0`. */}
<Group align="flex-end" gap="sm" wrap="nowrap">
<NumberInput
label={t('certReq.behavior.validity', 'Valid for')}
description={t(
'certReq.behavior.validityHint',
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
)}
value={draft.validityDays ?? draft.validityMonths}
onChange={(v) => {
const next = typeof v === 'number' ? v : 0;
if (!next) return;
if (draft.validityDays !== null) set('validityDays', next);
else set('validityMonths', next);
}}
// Matches the server's ranges, so the box cannot offer a value the
// save would reject: 13650 days, or 6240 months.
min={draft.validityDays !== null ? 1 : 6}
max={draft.validityDays !== null ? 3650 : 240}
allowNegative={false}
disabled={!canEdit}
flex={1}
/>
{expires && (
<NumberInput
label={t('certReq.behavior.validity', 'Valid for')}
description={t(
'certReq.behavior.validityHint',
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
)}
value={draft.validityDays ?? draft.validityMonths}
onChange={(v) => {
const next = typeof v === 'number' ? v : 0;
if (!next) return;
if (draft.validityDays !== null) set('validityDays', next);
else set('validityMonths', next);
}}
// Matches the server's ranges, so the box cannot offer a value the
// save would reject: 13650 days, or 6240 months.
min={draft.validityDays !== null ? 1 : 6}
max={draft.validityDays !== null ? 3650 : 240}
allowNegative={false}
disabled={!canEdit}
flex={1}
/>
)}
<Select
label={
expires
? undefined
: t('certReq.behavior.validityUnit', 'Validity unit')
}
aria-label={t('certReq.behavior.validityUnit', 'Validity unit')}
data={[
{ value: 'MONTHS', label: t('certReq.behavior.unitMonths', 'Months') },
{ value: 'DAYS', label: t('certReq.behavior.unitDays', 'Days') },
...(noExpiryAvailable
? [
{
value: 'NONE',
label: t('certReq.behavior.unitNone', 'Does not expire'),
},
]
: []),
]}
value={draft.validityDays !== null ? 'DAYS' : 'MONTHS'}
value={
draft.validityDays !== null
? 'DAYS'
: draft.validityMonths > 0
? 'MONTHS'
: 'NONE'
}
onChange={(unit) => {
// Switching unit is a change of policy, not a conversion: 12
// calendar months is not 365 days, so carry no arithmetic across
// and let the administrator state the new term outright.
if (unit === 'DAYS') set('validityDays', draft.validityDays ?? 90);
else set('validityDays', null);
else if (unit === 'MONTHS')
patch({
validityDays: null,
validityMonths:
draft.validityMonths >= 6 ? draft.validityMonths : 12,
});
// No expiry means no renewal policy: clear it here rather than
// save renewal settings that could never fire.
else
patch({
validityDays: null,
validityMonths: 0,
renewalEnabled: false,
});
}}
allowDeselect={false}
disabled={!canEdit}
w={130}
w={expires ? 130 : 220}
/>
</Group>
<Switch
checked={draft.renewalEnabled}
onChange={(e) => set('renewalEnabled', e.currentTarget.checked)}
label={t('certReq.behavior.renewalEnabled', 'Holders may renew this licence')}
description={t(
'certReq.behavior.renewalEnabledHint',
'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.',
)}
disabled={!canEdit}
/>
{!expires && (
<Text size="xs" c="dimmed">
{t(
'certReq.behavior.noExpiryHint',
'What this type issues never expires — an ownership transfer, or any one-off record. There is no renewal policy to set.',
)}
</Text>
)}
{/* The window and the reminders are both measured against an expiry a
non-renewing licence never reaches, so they are hidden rather than
shown as settings that quietly do nothing. */}
{draft.renewalEnabled && (
{expires && (
<>
<NumberInput
label={t('certReq.behavior.renewalWindow', 'Renewal opens (days before expiry)')}
value={draft.renewalWindowDays}
onChange={(v) =>
set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)
}
min={1}
max={365}
allowNegative={false}
<Switch
checked={draft.renewalEnabled}
onChange={(e) => set('renewalEnabled', e.currentTarget.checked)}
label={t('certReq.behavior.renewalEnabled', 'Holders may renew this licence')}
description={t(
'certReq.behavior.renewalEnabledHint',
'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.',
)}
disabled={!canEdit}
/>
<MultiSelect
label={t('certReq.behavior.reminders', 'Expiry reminders (days before)')}
description={t(
'certReq.behavior.remindersHint',
'The holder is reminded at each of these offsets.',
)}
data={REMINDER_OFFSETS}
value={draft.expiryReminderDays.map(String)}
onChange={(values) =>
set(
'expiryReminderDays',
values.map(Number).sort((a, b) => b - a),
)
}
disabled={!canEdit}
clearable
/>
{/* The window and the reminders are both measured against an expiry
a non-renewing licence never reaches, so they are hidden rather
than shown as settings that quietly do nothing. */}
{draft.renewalEnabled && (
<>
<NumberInput
label={t('certReq.behavior.renewalWindow', 'Renewal opens (days before expiry)')}
value={draft.renewalWindowDays}
onChange={(v) =>
set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)
}
min={1}
max={365}
allowNegative={false}
disabled={!canEdit}
/>
<MultiSelect
label={t('certReq.behavior.reminders', 'Expiry reminders (days before)')}
description={t(
'certReq.behavior.remindersHint',
'The holder is reminded at each of these offsets.',
)}
data={REMINDER_OFFSETS}
value={draft.expiryReminderDays.map(String)}
onChange={(values) =>
set(
'expiryReminderDays',
values.map(Number).sort((a, b) => b - a),
)
}
disabled={!canEdit}
clearable
/>
</>
)}
</>
)}
</Section>

View File

@@ -322,6 +322,7 @@ export function DocumentRequirementEditorDrawer({
data={[
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
{ value: 'REISSUE', label: t('certReq.doc.kindReissue', 'Damaged / reissue') },
]}
value={draft.applicationKind}
onChange={(v) => v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))}

View File

@@ -20,7 +20,14 @@ import { useRequirementActions } from '../hooks/useRequirementActions';
import { describeCondition } from './ConditionBuilder';
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
/** Every kind the server validates a document set for, so each can be configured. */
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL', 'REISSUE'];
const KIND_LABEL: Record<ApplicationKind, [string, string]> = {
NEW: ['certReq.doc.kindNew', 'New application'],
RENEWAL: ['certReq.doc.kindRenewal', 'Renewal'],
REISSUE: ['certReq.doc.kindReissue', 'Damaged / reissue'],
};
const MODE_COLOR: Record<DocumentRequirement['mode'], string> = {
ALWAYS: 'blue',
@@ -112,9 +119,7 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
return (
<Card key={kind} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Title order={5}>
{kind === 'NEW' ? t('certReq.doc.kindNew', 'New application') : t('certReq.doc.kindRenewal', 'Renewal')}
</Title>
<Title order={5}>{t(...KIND_LABEL[kind])}</Title>
<Button
size="xs"
variant="light"