feat: add non-expiring license option to behavior configuration with conditional UI updates

This commit is contained in:
estifanos
2026-09-01 05:47:05 +00:00
parent 6f141baca9
commit 3859e7b976
3 changed files with 123 additions and 67 deletions

View File

@@ -114,11 +114,20 @@ 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;
async function onSave() {
const ok = await run(
() => save({ id: licenseType.id, ...draft }).unwrap(),
@@ -289,94 +298,135 @@ 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') },
{ 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

@@ -1339,8 +1339,11 @@ export const am: Translations = {
validityUnit: "የሚቆይበት መለኪያ",
unitMonths: "ወራት",
unitDays: "ቀናት",
unitNone: "ጊዜው አያልፍም",
validityHint:
"ፈቃድ ሲሰጥ ተግባራዊ ይሆናል። ቀደም ብለው የተሰጡ ፈቃዶች የተሰጣቸውን የማብቂያ ቀን ይይዛሉ።",
noExpiryHint:
"ይህ ዓይነት የሚሰጠው ሰነድ ጊዜው አያልፍም — የባለቤትነት ዝውውር ወይም አንድ ጊዜ ብቻ የሚሰጥ መዝገብ። የሚቀመጥ የዕድሳት መመሪያ የለም።",
requiresSeafarer: "የጸና የመርከበኛ ምዝገባ ያስፈልገዋል",
requiresSeafarerHint:
"ይህንን ዓይነት እንደ የመርከበኛ የምስክር ወረቀት ያመለክታል፤ በመርከበኛው ፖርታል ላይ እንዲታይ የሚያደርገው ይኸው ነው።",

View File

@@ -1345,8 +1345,11 @@ export const en = {
validityUnit: 'Validity unit',
unitMonths: 'Months',
unitDays: 'Days',
unitNone: 'Does not expire',
validityHint:
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
noExpiryHint:
'What this type issues never expires — an ownership transfer, or any one-off record. There is no renewal policy to set.',
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.',