refactor: move license validity management to behavior tab and add capital threshold requirement

This commit is contained in:
estifanos
2026-08-29 08:22:09 +00:00
parent 92390ed573
commit 53c0ec98af
6 changed files with 102 additions and 73 deletions

View File

@@ -1,4 +1,4 @@
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
import { Button, Group, Select, Stack, Text } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api';
@@ -12,12 +12,9 @@ interface Props {
ranks: Rank[];
rankId: string | null;
onRankChange: (id: string | null) => void;
/** Shown for context only; edited on Certificate Requirements → Behaviour. */
validityMonths: number;
onValidityChange: (months: number) => void;
currentValidityMonths?: number | null;
canEdit: boolean;
savingValidity: boolean;
onSaveValidity: () => void;
onNewVersion: () => void;
}
@@ -30,11 +27,7 @@ export function DesignerToolbar({
rankId,
onRankChange,
validityMonths,
onValidityChange,
currentValidityMonths,
canEdit,
savingValidity,
onSaveValidity,
onNewVersion,
}: Props) {
const { t } = useTranslation();
@@ -73,38 +66,27 @@ export function DesignerToolbar({
/>
)}
{/* Validity lives beside the design because it is the other half of
what a certificate promises. */}
<NumberInput
label={t('designer.validityYears', 'Valid for (years)')}
description={t('designer.validityHint', 'Applied when a licence is issued')}
value={Number((validityMonths / 12).toFixed(2))}
onChange={(value) => onValidityChange(Math.round(Number(value || 0) * 12))}
min={0.5}
max={20}
step={0.5}
decimalScale={1}
w={190}
disabled={!canEdit}
/>
<Tooltip
label={
canEdit
? t('designer.saveValidity', 'Save validity')
: t('designer.noPermission', 'You do not have permission')
}
>
<span>
<Button
variant="light"
loading={savingValidity}
disabled={!canEdit || !typeId || validityMonths === currentValidityMonths}
onClick={onSaveValidity}
>
{t('designer.saveValidity', 'Save validity')}
</Button>
</span>
</Tooltip>
{/* Read-only here. Validity is one policy decision with the renewal
window and the expiry reminders, so it is edited in one place —
Certificate Requirements → Behaviour — rather than from two screens
behind two different permissions. Still shown, because a designer
laying out a certificate that prints an expiry needs to see the term
it promises. */}
{typeId && (
<Stack gap={2}>
<Text size="xs" c="dimmed" fw={500}>
{t('designer.validityYears', 'Valid for (years)')}
</Text>
<Group gap={6} align="baseline">
<Text size="sm" fw={600}>
{Number((validityMonths / 12).toFixed(2))}
</Text>
<Text size="xs" c="dimmed">
{t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')}
</Text>
</Group>
</Stack>
)}
<div style={{ flex: 1 }} />

View File

@@ -29,7 +29,6 @@ import {
useGetRanksQuery,
useGetTemplateVariablesQuery,
usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation,
useUpdateLicenseTemplateMutation,
} from '@ema-platform/api';
import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui';
@@ -88,7 +87,6 @@ export function CertificateDesignerPage() {
const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation();
const [archiveTemplate] = useArchiveLicenseTemplateMutation();
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
const draft = useTemplateDraft(templates);
const run = useDesignerActions();
@@ -96,7 +94,6 @@ export function CertificateDesignerPage() {
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
const [validityMonths, setValidityMonths] = useState<number>(12);
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
@@ -127,10 +124,6 @@ export function CertificateDesignerPage() {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
}, [licenseTypes, typeId]);
useEffect(() => {
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
}, [selectedType]);
// Switching licence type leaves a stale rank selected from the previous
// type's ladder — reset to the type's default design.
useEffect(() => {
@@ -169,17 +162,8 @@ export function CertificateDesignerPage() {
setRankId(value);
draft.setSelectedId(null);
}}
validityMonths={validityMonths}
onValidityChange={setValidityMonths}
currentValidityMonths={selectedType?.validityMonths}
validityMonths={selectedType?.validityMonths ?? 12}
canEdit={canEdit}
savingValidity={savingValidity}
onSaveValidity={() =>
run(
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
t('designer.validitySaved', 'Validity updated'),
)
}
onNewVersion={startNewVersion}
/>

View File

@@ -36,6 +36,8 @@ interface Draft {
requiresSeafarerRegistration: boolean;
requiresValidMedical: boolean;
minSeaTimeDays: number | null;
capitalThreshold: number | null;
validityMonths: number;
renewalWindowDays: number;
expiryReminderDays: number[];
requiresOperatorMode: boolean;
@@ -59,6 +61,11 @@ function toDraft(licenseType: LicenseType): Draft {
licenseType.requiresSeafarerRegistration ?? false,
requiresValidMedical: licenseType.requiresValidMedical ?? false,
minSeaTimeDays: licenseType.minSeaTimeDays ?? null,
capitalThreshold:
licenseType.capitalThreshold == null
? null
: Number(licenseType.capitalThreshold),
validityMonths: licenseType.validityMonths ?? 12,
renewalWindowDays: licenseType.renewalWindowDays ?? 60,
expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7],
requiresOperatorMode: licenseType.requiresOperatorMode ?? true,
@@ -194,10 +201,25 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
<Text size="xs" c="dimmed">
{t(
'certReq.behavior.eligibilityHint',
'Checked when an applicant submits. Tightening a gate can stop someone mid-way from submitting a draft they have already started.',
'Checked when an applicant submits, and the capital requirement again when an officer approves. Tightening a gate can stop someone mid-way from submitting a draft they have already started.',
)}
</Text>
<NullableNumber
label={t('certReq.behavior.capitalThreshold', 'Minimum paid-up capital')}
switchLabel={t('certReq.behavior.capitalOn', 'Require a minimum paid-up capital')}
description={t(
'certReq.behavior.capitalHint',
'An officer cannot approve until they have verified capital at or above this. Applies to applications already in the queue, not just new ones.',
)}
value={draft.capitalThreshold}
onChange={(v) => set('capitalThreshold', v)}
disabled={!canEdit}
min={0}
defaultValue={1_000_000}
thousandSeparator
/>
<Switch
checked={draft.requiresSeafarerRegistration}
onChange={(e) => set('requiresSeafarerRegistration', e.currentTarget.checked)}
@@ -243,7 +265,27 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
/>
</Section>
<Section title={t('certReq.behavior.renewal', 'Renewal')}>
<Section title={t('certReq.behavior.renewal', 'Validity and renewal')}>
{/* Stored in months, edited in years: a licence term is a number of
years to everyone who works with one. Half-years stay expressible. */}
<NumberInput
label={t('certReq.behavior.validityYears', 'Valid for (years)')}
description={t(
'certReq.behavior.validityHint',
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
)}
value={Number((draft.validityMonths / 12).toFixed(2))}
onChange={(v) =>
set('validityMonths', Math.round(Number(v || 0) * 12) || draft.validityMonths)
}
min={0.5}
max={20}
step={0.5}
decimalScale={1}
allowNegative={false}
disabled={!canEdit}
/>
<NumberInput
label={t('certReq.behavior.renewalWindow', 'Renewal opens (days before expiry)')}
value={draft.renewalWindowDays}
@@ -389,6 +431,8 @@ function NullableNumber({
onChange,
disabled,
min,
defaultValue,
thousandSeparator,
}: {
label: string;
switchLabel: string;
@@ -397,12 +441,17 @@ function NullableNumber({
onChange: (value: number | null) => void;
disabled: boolean;
min: number;
/** Seeded when the switch is turned on. Defaults to the minimum. */
defaultValue?: number;
thousandSeparator?: boolean;
}) {
return (
<Stack gap="xs">
<Switch
checked={value !== null}
onChange={(e) => onChange(e.currentTarget.checked ? min || 1 : null)}
onChange={(e) =>
onChange(e.currentTarget.checked ? (defaultValue ?? min ?? 1) : null)
}
label={switchLabel}
description={description}
disabled={disabled}
@@ -414,6 +463,8 @@ function NullableNumber({
onChange={(v) => onChange(typeof v === 'number' ? v : value)}
min={min}
allowNegative={false}
thousandSeparator={thousandSeparator ? ',' : undefined}
decimalScale={thousandSeparator ? 2 : undefined}
disabled={disabled}
/>
)}