feat: add support for license validity defined in days alongside months

This commit is contained in:
estifanos
2026-08-29 08:41:08 +00:00
parent 53c0ec98af
commit e25a786139
7 changed files with 82 additions and 25 deletions

View File

@@ -14,6 +14,8 @@ interface Props {
onRankChange: (id: string | null) => void;
/** Shown for context only; edited on Certificate Requirements → Behaviour. */
validityMonths: number;
/** Non-null when the type is configured in days rather than months. */
validityDays?: number | null;
canEdit: boolean;
onNewVersion: () => void;
}
@@ -27,6 +29,7 @@ export function DesignerToolbar({
rankId,
onRankChange,
validityMonths,
validityDays,
canEdit,
onNewVersion,
}: Props) {
@@ -75,11 +78,15 @@ export function DesignerToolbar({
{typeId && (
<Stack gap={2}>
<Text size="xs" c="dimmed" fw={500}>
{t('designer.validityYears', 'Valid for (years)')}
{t('designer.validity', 'Valid for')}
</Text>
<Group gap={6} align="baseline">
<Text size="sm" fw={600}>
{Number((validityMonths / 12).toFixed(2))}
{validityDays != null
? t('designer.validityDays', '{{count}} days', { count: validityDays })
: t('designer.validityMonths', '{{count}} months', {
count: validityMonths,
})}
</Text>
<Text size="xs" c="dimmed">
{t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')}

View File

@@ -163,6 +163,7 @@ export function CertificateDesignerPage() {
draft.setSelectedId(null);
}}
validityMonths={selectedType?.validityMonths ?? 12}
validityDays={selectedType?.validityDays ?? null}
canEdit={canEdit}
onNewVersion={startNewVersion}
/>

View File

@@ -38,6 +38,7 @@ interface Draft {
minSeaTimeDays: number | null;
capitalThreshold: number | null;
validityMonths: number;
validityDays: number | null;
renewalWindowDays: number;
expiryReminderDays: number[];
requiresOperatorMode: boolean;
@@ -66,6 +67,7 @@ function toDraft(licenseType: LicenseType): Draft {
? null
: Number(licenseType.capitalThreshold),
validityMonths: licenseType.validityMonths ?? 12,
validityDays: licenseType.validityDays ?? null,
renewalWindowDays: licenseType.renewalWindowDays ?? 60,
expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7],
requiresOperatorMode: licenseType.requiresOperatorMode ?? true,
@@ -266,25 +268,52 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
</Section>
<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}
/>
{/* Amount + unit rather than a years box that silently divides by 12:
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. */}
<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}
/>
<Select
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') },
]}
value={draft.validityDays !== null ? 'DAYS' : 'MONTHS'}
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);
}}
allowDeselect={false}
disabled={!canEdit}
w={130}
/>
</Group>
<NumberInput
label={t('certReq.behavior.renewalWindow', 'Renewal opens (days before expiry)')}

View File

@@ -1225,7 +1225,11 @@ export const am: Translations = {
title: "የምስክር ወረቀት ንድፍ",
subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ።",
licenceType: "የፈቃድ ዓይነት",
validityYears: "የሚቆይበት (ዓመታት)",
validity: "የሚቆይበት",
validityMonths_one: "{{count}} ወር",
validityMonths_other: "{{count}} ወራት",
validityDays_one: "{{count}} ቀን",
validityDays_other: "{{count}} ቀናት",
validityEditedOn: "— በምስክር ወረቀት መስፈርቶች → ባህሪ ውስጥ ይዘጋጃል",
newVersion: "አዲስ ስሪት",
versions: "ስሪቶች",
@@ -1299,7 +1303,10 @@ export const am: Translations = {
capitalOn: "አነስተኛ የተከፈለ ካፒታል ይጠየቅ",
capitalHint:
"ኃላፊው ከዚህ እኩል ወይም በላይ የሆነ ካፒታል እስኪያረጋግጥ ድረስ ማጽደቅ አይችልም። ለአዲሶቹ ብቻ ሳይሆን ቀደም ብለው በወረፋ ላይ ላሉ ማመልከቻዎችም ይሠራል።",
validityYears: "የሚቆይበት (ዓመታት)",
validity: "የሚቆይበት",
validityUnit: "የሚቆይበት መለኪያ",
unitMonths: "ወራት",
unitDays: "ቀናት",
validityHint:
"ፈቃድ ሲሰጥ ተግባራዊ ይሆናል። ቀደም ብለው የተሰጡ ፈቃዶች የተሰጣቸውን የማብቂያ ቀን ይይዛሉ።",
requiresSeafarer: "የጸና የመርከበኛ ምዝገባ ያስፈልገዋል",

View File

@@ -1231,7 +1231,11 @@ export const en = {
title: 'Certificate designer',
subtitle: 'Design the certificate issued to licence holders.',
licenceType: 'Licence type',
validityYears: 'Valid for (years)',
validity: 'Valid for',
validityMonths_one: '{{count}} month',
validityMonths_other: '{{count}} months',
validityDays_one: '{{count}} day',
validityDays_other: '{{count}} days',
validityEditedOn: '— set on Certificate Requirements → Behaviour',
newVersion: 'New version',
versions: 'Versions',
@@ -1304,7 +1308,10 @@ export const en = {
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)',
validity: 'Valid for',
validityUnit: 'Validity unit',
unitMonths: 'Months',
unitDays: 'Days',
validityHint:
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
requiresSeafarer: 'Requires an active seafarer registration',

View File

@@ -227,6 +227,7 @@ export const licensingApi = baseApi
requiresValidMedical?: boolean;
minSeaTimeDays?: number | null;
validityMonths?: number;
validityDays?: number | null;
capitalThreshold?: number | null;
renewalWindowDays?: number;
expiryReminderDays?: number[];

View File

@@ -187,6 +187,11 @@ export interface LicenseType {
feeCurrency: string;
capitalThreshold: string | number | null;
validityMonths: number;
/**
* A term in days instead of months, for licences shorter than a month can
* express. Wins over `validityMonths` when set; null keeps calendar months.
*/
validityDays?: number | null;
/**
* Target turnaround in hours. Null means this type is not tracked against
* an SLA, which the grid renders as "—" rather than as instantly overdue.