From 32ad5124f16a1a8c91ba3bfe555b2deba1cc967e Mon Sep 17 00:00:00 2001 From: estifanos Date: Tue, 1 Sep 2026 05:57:54 +0000 Subject: [PATCH] feat: add non-expiring license support to backoffice and portal UI with updated i18n and selective form patching --- .../components/DesignerToolbar.tsx | 8 ++-- .../components/BehaviorTab.tsx | 48 +++++++++++++++++-- apps/backoffice/src/app/i18n/locales/am.ts | 1 + apps/backoffice/src/app/i18n/locales/en.ts | 1 + .../licensing/components/LicenseCatalogue.tsx | 10 +++- apps/portal/src/app/i18n/locales/am.ts | 3 ++ apps/portal/src/app/i18n/locales/en.ts | 3 ++ 7 files changed, 66 insertions(+), 8 deletions(-) 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 891acfc15..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, @@ -128,9 +132,38 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { // 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); @@ -334,7 +367,14 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { 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') }, + ...(noExpiryAvailable + ? [ + { + value: 'NONE', + label: t('certReq.behavior.unitNone', 'Does not expire'), + }, + ] + : []), ]} value={ draft.validityDays !== null diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 543417370..5b6e36fbe 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -1253,6 +1253,7 @@ export const am: Translations = { validityMonths_other: "{{count}} ወራት", validityDays_one: "{{count}} ቀን", validityDays_other: "{{count}} ቀናት", + validityNone: "ጊዜው አያልፍም", validityEditedOn: "— በምስክር ወረቀት መስፈርቶች → ባህሪ ውስጥ ይዘጋጃል", newVersion: "አዲስ ስሪት", versions: "ስሪቶች", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index a3aed8a28..efe91b783 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -1260,6 +1260,7 @@ export const en = { validityMonths_other: '{{count}} months', validityDays_one: '{{count}} day', validityDays_other: '{{count}} days', + validityNone: 'Does not expire', validityEditedOn: '— set on Certificate Requirements → Behaviour', newVersion: 'New version', versions: 'Versions', diff --git a/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx b/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx index c948e5e58..189eb8f00 100644 --- a/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx +++ b/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx @@ -322,8 +322,16 @@ function LicenseTypeCard({ )} {type.issuesCertificate ? ( + // A term in days wins over the months column, and a type with + // neither issues something that simply does not expire — a + // transfer, or any one-off record. Reading `validityMonths` + // alone badged those "0 months". - {t('licensing.catalogue.validityBadge', { months: type.validityMonths })} + {type.validityDays + ? t('licensing.catalogue.validityDaysBadge', { count: type.validityDays }) + : type.validityMonths + ? t('licensing.catalogue.validityBadge', { months: type.validityMonths }) + : t('licensing.catalogue.noExpiryBadge')} ) : ( diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index fdc789666..a52fe60b6 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -884,6 +884,9 @@ export const am: Translations = { capitalTooltip: 'በባንክ ደብዳቤ መረጋገጥ ያለበት ዝቅተኛ ካፒታል', capitalBadge: 'ካፒታል {{amount}}', validityBadge: '{{months}} ወራት', + validityDaysBadge_one: '{{count}} ቀን', + validityDaysBadge_other: '{{count}} ቀናት', + noExpiryBadge: 'ጊዜው አያልፍም', evaluationTooltip: 'በምስክር ወረቀት ፈንታ በባለሥልጣኑ ውሳኔ የሚጠናቀቅ', evaluationOnly: 'ግምገማ ብቻ', startApplication: 'ማመልከቻ ጀምር', diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 3e52108bb..14ba1720c 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -886,6 +886,9 @@ export const en = { capitalTooltip: 'Minimum capital that must be evidenced by a bank letter', capitalBadge: 'Capital {{amount}}', validityBadge: '{{months}} months', + validityDaysBadge_one: '{{count}} day', + validityDaysBadge_other: '{{count}} days', + noExpiryBadge: 'No expiry', evaluationTooltip: 'Concludes with an EMA decision rather than a certificate', evaluationOnly: 'Evaluation only', startApplication: 'Start application',