diff --git a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx index 4ab644bc6..c34a1e368 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx @@ -84,9 +84,11 @@ export function DesignerToolbar({ {validityDays != null ? t('designer.validityDays', '{{count}} days', { count: validityDays }) - : t('designer.validityMonths', '{{count}} months', { - count: validityMonths, - })} + : validityMonths + ? t('designer.validityMonths', '{{count}} months', { + count: validityMonths, + }) + : t('designer.validityNone', 'Does not expire')} {t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx index cab0bf4c4..d1606b697 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -72,9 +72,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 +118,52 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [licenseType.id]); - function set(key: K, value: Draft[K]) { - setDraft((current) => ({ ...current, [key]: value })); + function patch(values: Partial) { + setDraft((current) => ({ ...current, ...values })); setDirty(true); } + function set(key: K, value: Draft[K]) { + patch({ [key]: value } as Partial); + } + + // 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; + const ok = await run( - () => save({ id: licenseType.id, ...draft }).unwrap(), + () => + save({ + id: licenseType.id, + ...rest, + ...(expires ? { validityMonths, validityDays } : {}), + ...(expires && draft.renewalEnabled + ? { renewalWindowDays, expiryReminderDays } + : {}), + }).unwrap(), t('certReq.behavior.saved', 'Configuration saved.'), ); if (ok) setDirty(false); @@ -289,94 +331,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`. */} - { - 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: 1–3650 days, or 6–240 months. - min={draft.validityDays !== null ? 1 : 6} - max={draft.validityDays !== null ? 3650 : 240} - allowNegative={false} - disabled={!canEdit} - flex={1} - /> + {expires && ( + { + 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: 1–3650 days, or 6–240 months. + min={draft.validityDays !== null ? 1 : 6} + max={draft.validityDays !== null ? 3650 : 240} + allowNegative={false} + disabled={!canEdit} + flex={1} + /> + )}