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}
/>
)}

View File

@@ -1223,12 +1223,10 @@ export const am: Translations = {
designer: {
title: "የምስክር ወረቀት ንድፍ",
subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ፣ የሚቆይበትንም ጊዜ ያዘጋጁ።",
subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ።",
licenceType: "የፈቃድ ዓይነት",
validityYears: "የሚቆይበት (ዓመታት)",
validityHint: "ፈቃድ ሲሰጥ ተግባራዊ ይሆናል",
saveValidity: "የሚቆይበትን ጊዜ አስቀምጥ",
validitySaved: "የሚቆይበት ጊዜ ተዘምኗል",
validityEditedOn: "— በምስክር ወረቀት መስፈርቶች → ባህሪ ውስጥ ይዘጋጃል",
newVersion: "አዲስ ስሪት",
versions: "ስሪቶች",
name: "የስሪት ስም",
@@ -1296,7 +1294,14 @@ export const am: Translations = {
registrationKind: "ምዝገባ",
eligibility: "የብቁነት መስፈርቶች",
eligibilityHint:
"አመልካቹ ሲያስገባ ይመረመራሉ። መስፈርትን ማጥበቅ ቀደም ብሎ ረቂቅ የጀመረን ሰው ከማስገባት ሊያግደው ይችላል።",
"አመልካቹ ሲያስገባ ይመረመራሉ፤ የካፒታል መስፈርቱ ደግሞ ኃላፊው ሲያጸድቅ እንደገና ይመረመራል። መስፈርትን ማጥበቅ ቀደም ብሎ ረቂቅ የጀመረን ሰው ከማስገባት ሊያግደው ይችላል።",
capitalThreshold: "አነስተኛ የተከፈለ ካፒታል",
capitalOn: "አነስተኛ የተከፈለ ካፒታል ይጠየቅ",
capitalHint:
"ኃላፊው ከዚህ እኩል ወይም በላይ የሆነ ካፒታል እስኪያረጋግጥ ድረስ ማጽደቅ አይችልም። ለአዲሶቹ ብቻ ሳይሆን ቀደም ብለው በወረፋ ላይ ላሉ ማመልከቻዎችም ይሠራል።",
validityYears: "የሚቆይበት (ዓመታት)",
validityHint:
"ፈቃድ ሲሰጥ ተግባራዊ ይሆናል። ቀደም ብለው የተሰጡ ፈቃዶች የተሰጣቸውን የማብቂያ ቀን ይይዛሉ።",
requiresSeafarer: "የጸና የመርከበኛ ምዝገባ ያስፈልገዋል",
requiresSeafarerHint:
"ይህንን ዓይነት እንደ የመርከበኛ የምስክር ወረቀት ያመለክታል፤ በመርከበኛው ፖርታል ላይ እንዲታይ የሚያደርገው ይኸው ነው።",
@@ -1307,7 +1312,7 @@ export const am: Translations = {
certificateCategoryHint:
"ይህ የሚሰጠው የሰነድ ዓይነት። ማረጋገጫዎች በመርከበኛው ፖርታል ላይ ለብቻቸው ይመደባሉ።",
notACertificate: "የምስክር ወረቀት አይደለም",
renewal: "እድሳት",
renewal: "የሚቆይበት ጊዜና እድሳት",
renewalWindow: "እድሳት የሚከፈትበት (ጊዜው ከማብቃቱ በፊት ያሉ ቀናት)",
reminders: "የማብቂያ አስታዋሾች (ቀደም ብለው ያሉ ቀናት)",
remindersHint: "ባለቤቱ በእያንዳንዱ በእነዚህ ጊዜያት ይታሰባል።",

View File

@@ -1229,12 +1229,10 @@ export const en = {
designer: {
title: 'Certificate designer',
subtitle: 'Design the certificate issued to licence holders, and set how long it stays valid.',
subtitle: 'Design the certificate issued to licence holders.',
licenceType: 'Licence type',
validityYears: 'Valid for (years)',
validityHint: 'Applied when a licence is issued',
saveValidity: 'Save validity',
validitySaved: 'Validity updated',
validityEditedOn: '— set on Certificate Requirements → Behaviour',
newVersion: 'New version',
versions: 'Versions',
name: 'Version name',
@@ -1301,7 +1299,14 @@ export const en = {
registrationKind: 'Registration',
eligibility: 'Eligibility gates',
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.',
capitalThreshold: 'Minimum paid-up capital',
capitalOn: 'Require a minimum paid-up capital',
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.',
validityYears: 'Valid for (years)',
validityHint:
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
requiresSeafarer: 'Requires an active seafarer registration',
requiresSeafarerHint:
'Also marks this type as a seafarer certificate, which is what makes it appear in the seafarer portal.',
@@ -1312,7 +1317,7 @@ export const en = {
certificateCategoryHint:
'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.',
notACertificate: 'Not a certificate',
renewal: 'Renewal',
renewal: 'Validity and renewal',
renewalWindow: 'Renewal opens (days before expiry)',
reminders: 'Expiry reminders (days before)',
remindersHint: 'The holder is reminded at each of these offsets.',

View File

@@ -226,6 +226,8 @@ export const licensingApi = baseApi
requiresSeafarerRegistration?: boolean;
requiresValidMedical?: boolean;
minSeaTimeDays?: number | null;
validityMonths?: number;
capitalThreshold?: number | null;
renewalWindowDays?: number;
expiryReminderDays?: number[];
requiresOperatorMode?: boolean;