From 2e9083211fa519ac9c1bcc8a1cb6679774a6dd8a Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 29 Aug 2026 06:34:33 +0000 Subject: [PATCH 1/5] feat: add configuration UI for license behavior and multi-stage examination fees --- .../components/BehaviorTab.tsx | 422 ++++++++++++++++++ .../pages/CertificateRequirementsPage.tsx | 16 +- .../pages/PaymentConfigPage/index.tsx | 86 ++++ apps/backoffice/src/app/i18n/locales/am.ts | 73 +++ apps/backoffice/src/app/i18n/locales/en.ts | 70 +++ .../lib/features/licensing/licensing-api.ts | 52 +++ .../features/licensing/licensing.helpers.ts | 9 + .../lib/features/licensing/licensing.types.ts | 37 ++ 8 files changed, 764 insertions(+), 1 deletion(-) create mode 100644 apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx new file mode 100644 index 000000000..28f348ab1 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -0,0 +1,422 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + Button, + Group, + MultiSelect, + NumberInput, + Paper, + Select, + Stack, + Switch, + Text, + TextInput, + Tooltip, +} from '@mantine/core'; +import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { LICENSE_PERMISSIONS, usePermissions } from '@ema-platform/auth'; +import { + useUpdateLicenseBehaviorMutation, + type CertificateCategory, + type CompletionEffect, + type LicenseType, + type ServiceKind, + type WorkflowProfile, +} from '@ema-platform/api'; +import { useRequirementActions } from '../hooks/useRequirementActions'; + +/** The shape the form edits — every field the behaviour endpoint accepts. */ +interface Draft { + workflowProfile: WorkflowProfile; + serviceKind: ServiceKind; + completionEffect: CompletionEffect | null; + certificateCategory: CertificateCategory | null; + requiresExamination: boolean; + requiresSeafarerRegistration: boolean; + requiresValidMedical: boolean; + minSeaTimeDays: number | null; + renewalWindowDays: number; + expiryReminderDays: number[]; + requiresOperatorMode: boolean; + allowMultipleOpenDrafts: boolean; + requiresIssuanceScheduling: boolean; + uniqueFormKeyPath: string | null; + slaHours: number | null; +} + +/** Offsets EMA reminds on. Fixed rather than free-form — these are policy, not arithmetic. */ +const REMINDER_OFFSETS = ['90', '60', '30', '14', '7']; + +function toDraft(licenseType: LicenseType): Draft { + return { + workflowProfile: licenseType.workflowProfile ?? 'STANDARD', + serviceKind: licenseType.serviceKind ?? 'LICENSE', + completionEffect: licenseType.completionEffect ?? null, + certificateCategory: licenseType.certificateCategory ?? null, + requiresExamination: licenseType.requiresExamination ?? false, + requiresSeafarerRegistration: + licenseType.requiresSeafarerRegistration ?? false, + requiresValidMedical: licenseType.requiresValidMedical ?? false, + minSeaTimeDays: licenseType.minSeaTimeDays ?? null, + renewalWindowDays: licenseType.renewalWindowDays ?? 60, + expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7], + requiresOperatorMode: licenseType.requiresOperatorMode ?? true, + allowMultipleOpenDrafts: licenseType.allowMultipleOpenDrafts ?? false, + requiresIssuanceScheduling: licenseType.requiresIssuanceScheduling ?? false, + uniqueFormKeyPath: licenseType.uniqueFormKeyPath ?? null, + slaHours: licenseType.slaHours ?? null, + }; +} + +/** + * How one licence type behaves: the course it runs, who may apply, when it + * renews and what rules the applicant meets. + * + * These were seed-only until now — changing an SLA target or a renewal window + * meant editing a file and redeploying. They are read live off the licence + * type rather than snapshotted onto applications, so a change here applies to + * files already in the queue as well as new ones. For the three settings that + * decide an application's course the server refuses the change outright while + * anything is still awaiting a decision, rather than stranding it. + */ +export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { + const { t } = useTranslation(); + const run = useRequirementActions(); + const { can } = usePermissions(); + const canEdit = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]); + + const [save, { isLoading: saving }] = useUpdateLicenseBehaviorMutation(); + const [draft, setDraft] = useState(() => toDraft(licenseType)); + const [dirty, setDirty] = useState(false); + + // Keyed on the id alone, like FormSchemaTab: this tab's own save invalidates + // the licence-type list, and the refetch that follows must not overwrite an + // edit the administrator is still working on. + useEffect(() => { + setDraft(toDraft(licenseType)); + setDirty(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [licenseType.id]); + + function set(key: K, value: Draft[K]) { + setDraft((current) => ({ ...current, [key]: value })); + setDirty(true); + } + + async function onSave() { + const ok = await run( + () => save({ id: licenseType.id, ...draft }).unwrap(), + t('certReq.behavior.saved', 'Configuration saved.'), + ); + if (ok) setDirty(false); + } + + return ( + +
+ } + > + + {t( + 'certReq.behavior.workflowWarning', + 'These three settings decide the course an application runs. They cannot be changed while applications of this type are still awaiting a decision — saving will be refused until those are decided.', + )} + + + + set('completionEffect', (v as CompletionEffect) ?? null)} + placeholder={t('certReq.behavior.noEffect', 'No side effect')} + clearable + disabled={!canEdit} + /> + + set('requiresExamination', e.currentTarget.checked)} + label={t('certReq.behavior.requiresExamination', 'Requires an examination')} + description={t( + 'certReq.behavior.requiresExaminationHint', + 'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.', + )} + disabled={!canEdit} + /> + + ({ + value: v, + label: v, + }))} + value={draft.certificateCategory} + onChange={(v) => set('certificateCategory', (v as CertificateCategory) ?? null)} + placeholder={t('certReq.behavior.notACertificate', 'Not a certificate')} + clearable + disabled={!canEdit} + /> +
+ +
+ set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)} + min={1} + max={365} + allowNegative={false} + disabled={!canEdit} + /> + + + set( + 'expiryReminderDays', + values.map(Number).sort((a, b) => b - a), + ) + } + disabled={!canEdit} + clearable + /> +
+ +
+ set('requiresOperatorMode', e.currentTarget.checked)} + label={t('certReq.behavior.requiresOperatorMode', 'Applicant must declare this operating mode')} + description={t( + 'certReq.behavior.requiresOperatorModeHint', + 'Turn off for person-centric registrations any signed-in applicant may start.', + )} + disabled={!canEdit} + /> + + set('allowMultipleOpenDrafts', e.currentTarget.checked)} + label={t('certReq.behavior.allowMultipleDrafts', 'Allow several open drafts at once')} + description={t( + 'certReq.behavior.allowMultipleDraftsHint', + 'On for per-asset registrations — registering a second vessel must not resume the first one’s draft.', + )} + disabled={!canEdit} + /> + + set('requiresIssuanceScheduling', e.currentTarget.checked)} + label={t('certReq.behavior.requiresScheduling', 'Schedule a pickup date before issuing')} + description={t( + 'certReq.behavior.requiresSchedulingHint', + 'For documents printed once and handed over in person.', + )} + disabled={!canEdit} + /> + + set('slaHours', v)} + disabled={!canEdit} + min={1} + description={t( + 'certReq.behavior.slaHint', + 'Drives the Age/SLA column and the Overdue view. Applies to applications already submitted as well as new ones.', + )} + /> + + + set('uniqueFormKeyPath', e.currentTarget.value.trim() || null) + } + maxLength={128} + placeholder={t('certReq.behavior.noUniqueRule', 'No uniqueness rule')} + disabled={!canEdit} + /> +
+ + + + + + +
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + + {title} + + {children} + + + ); +} + +/** + * A number that may be switched off entirely. + * + * Null is a real configuration state here — "not tracked against an SLA", "no + * sea-time floor" — and is not the same as an empty box, so the choice gets its + * own switch rather than being inferred from a blank field. + */ +function NullableNumber({ + label, + switchLabel, + description, + value, + onChange, + disabled, + min, +}: { + label: string; + switchLabel: string; + description?: string; + value: number | null; + onChange: (value: number | null) => void; + disabled: boolean; + min: number; +}) { + return ( + + onChange(e.currentTarget.checked ? min || 1 : null)} + label={switchLabel} + description={description} + disabled={disabled} + /> + {value !== null && ( + onChange(typeof v === 'number' ? v : value)} + min={min} + allowNegative={false} + disabled={disabled} + /> + )} + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx index 86352bb97..f6ccf9c36 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx @@ -1,9 +1,16 @@ import { useEffect, useState } from 'react'; import { Container, Select, Stack, Tabs } from '@mantine/core'; -import { IconAlertCircle, IconFileText, IconFiles, IconSettings } from '@tabler/icons-react'; +import { + IconAdjustments, + IconAlertCircle, + IconFileText, + IconFiles, + IconSettings, +} from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { ErrorState, PageHeader, PageLoader } from '@ema-platform/ui'; import { extractErrorMessage, useGetLicenseTypesQuery, useLocalized } from '@ema-platform/api'; +import { BehaviorTab } from '../components/BehaviorTab'; import { DocumentRequirementsTab } from '../components/DocumentRequirementsTab'; import { FormSchemaTab } from '../components/FormSchemaTab'; @@ -78,6 +85,9 @@ export function CertificateRequirementsPage() { }> {t('certReq.tabDocuments', 'Document requirements')} + }> + {t('certReq.tabBehavior', 'Behaviour')} + @@ -87,6 +97,10 @@ export function CertificateRequirementsPage() { + + + + )} diff --git a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx index adaca4a96..03ce1673e 100644 --- a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx +++ b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx @@ -179,9 +179,25 @@ function FeeEditModal({ // are real configuration states, not blank fields. const [chargeable, setChargeable] = useState(true); const [sameAsNew, setSameAsNew] = useState(true); + // The examined-certificate stages. Held the same way — null means "this stage + // is not charged", which is different from a blank box. + const [eligibilityFee, setEligibilityFee] = useState(''); + const [examinationFee, setExaminationFee] = useState(''); + const [certificateFee, setCertificateFee] = useState(''); + + const examined = licenseType?.requiresExamination ?? false; useEffect(() => { if (!licenseType) return; + setEligibilityFee( + licenseType.feeEligibility == null ? '' : Number(licenseType.feeEligibility), + ); + setExaminationFee( + licenseType.feeExamination == null ? '' : Number(licenseType.feeExamination), + ); + setCertificateFee( + licenseType.feeCertificate == null ? '' : Number(licenseType.feeCertificate), + ); setChargeable(licenseType.feeNewApplication !== null); setNewFee( licenseType.feeNewApplication === null @@ -221,6 +237,14 @@ function FeeEditModal({ feeNewApplication: chargeable ? Number(newFee) : null, feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null, feeCurrency: currency.trim() || 'ETB', + // Only sent for examined types; otherwise the columns stay untouched. + ...(examined + ? { + feeEligibility: eligibilityFee === '' ? null : Number(eligibilityFee), + feeExamination: examinationFee === '' ? null : Number(examinationFee), + feeCertificate: certificateFee === '' ? null : Number(certificateFee), + } + : {}), }).unwrap(); notify.success( t('paymentConfig.modal.updated', { @@ -325,6 +349,68 @@ function FeeEditModal({ )} + {examined && ( + <> + } + > + + {t( + 'paymentConfig.modal.examinedNotice', + 'This certificate is earned by examination, so it is charged in three stages. Unlike the fees above, these are not fixed at approval — a change applies to candidates already part-way through. Clearing one while candidates are waiting to pay it will be refused.', + )} + + + + setEligibilityFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + setExaminationFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + setCertificateFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + )} + - - + {/* 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 && ( + + + {t('designer.validityYears', 'Valid for (years)')} + + + + {Number((validityMonths / 12).toFixed(2))} + + + {t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')} + + + + )}
diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx index f9401f3e4..8f5597f49 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -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(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} /> 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 28f348ab1..6acfd90b4 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -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 }) { {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.', )} + set('capitalThreshold', v)} + disabled={!canEdit} + min={0} + defaultValue={1_000_000} + thousandSeparator + /> + set('requiresSeafarerRegistration', e.currentTarget.checked)} @@ -243,7 +265,27 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { /> -
+
+ {/* Stored in months, edited in years: a licence term is a number of + years to everyone who works with one. Half-years stay expressible. */} + + set('validityMonths', Math.round(Number(v || 0) * 12) || draft.validityMonths) + } + min={0.5} + max={20} + step={0.5} + decimalScale={1} + allowNegative={false} + disabled={!canEdit} + /> + void; disabled: boolean; min: number; + /** Seeded when the switch is turned on. Defaults to the minimum. */ + defaultValue?: number; + thousandSeparator?: boolean; }) { return ( 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} /> )} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 3c8eb5a9f..d83b5d451 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -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: "ባለቤቱ በእያንዳንዱ በእነዚህ ጊዜያት ይታሰባል።", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 40e501bdc..8279081ec 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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.', diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index e4ffc9770..e1a56677a 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -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; From e25a786139f843caee1ff0e3cb5232f8e7c31691 Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 29 Aug 2026 08:41:08 +0000 Subject: [PATCH 4/5] feat: add support for license validity defined in days alongside months --- .../components/DesignerToolbar.tsx | 11 ++- .../pages/CertificateDesignerPage.tsx | 1 + .../components/BehaviorTab.tsx | 67 +++++++++++++------ apps/backoffice/src/app/i18n/locales/am.ts | 11 ++- apps/backoffice/src/app/i18n/locales/en.ts | 11 ++- .../lib/features/licensing/licensing-api.ts | 1 + .../lib/features/licensing/licensing.types.ts | 5 ++ 7 files changed, 82 insertions(+), 25 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 3a8296005..4ab644bc6 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx @@ -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 && ( - {t('designer.validityYears', 'Valid for (years)')} + {t('designer.validity', 'Valid for')} - {Number((validityMonths / 12).toFixed(2))} + {validityDays != null + ? t('designer.validityDays', '{{count}} days', { count: validityDays }) + : t('designer.validityMonths', '{{count}} months', { + count: validityMonths, + })} {t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')} diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx index 8f5597f49..b46d68689 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -163,6 +163,7 @@ export function CertificateDesignerPage() { draft.setSelectedId(null); }} validityMonths={selectedType?.validityMonths ?? 12} + validityDays={selectedType?.validityDays ?? null} canEdit={canEdit} onNewVersion={startNewVersion} /> 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 6acfd90b4..d756d1d38 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -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 }) {
- {/* Stored in months, edited in years: a licence term is a number of - years to everyone who works with one. Half-years stay expressible. */} - - 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. */} + + { + 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} + /> + - set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)} - min={1} - max={365} - allowNegative={false} + 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} /> - - 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 && ( + <> + + set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays) + } + min={1} + max={365} + allowNegative={false} + disabled={!canEdit} + /> + + + set( + 'expiryReminderDays', + values.map(Number).sort((a, b) => b - a), + ) + } + disabled={!canEdit} + clearable + /> + + )}
@@ -367,6 +404,17 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { disabled={!canEdit} /> + set('inspectionRequired', e.currentTarget.checked)} + label={t('certReq.behavior.inspectionRequired', 'Requires a physical inspection')} + description={t( + 'certReq.behavior.inspectionRequiredHint', + 'An officer cannot approve until an inspection has been recorded and passed. Safe to change with applications in progress — an officer can still assign an inspector to a file already under evaluation.', + )} + disabled={!canEdit} + /> + set('requiresIssuanceScheduling', e.currentTarget.checked)} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index f3b3abfac..b5e96ace4 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -1292,6 +1292,15 @@ export const am: Translations = { requiresExamination: "ፈተና ያስፈልገዋል", requiresExaminationHint: "ማመልከቻውን በብቁነት፣ ከዚያ በፈተና፣ ከዚያ በምስክር ወረቀት ሂደት ያሳልፋል። ሦስቱን የደረጃ ክፍያዎች በክፍያ ቅንብር ገጽ ላይ ያስቀምጡ።", + issuesCertificate: "የምስክር ወረቀት ይሰጣል", + issuesCertificateHint: + "በኢማ ውሳኔ ተጠናቆ ወደ ክፍያ ደረጃ ለማይደርስ ዓይነት ያጥፉ። እንደገና ማብራት የአዲስ ማመልከቻ ክፍያ እንዲዘጋጅ ይጠይቃል፤ አለበለዚያ የጸደቁ አመልካቾች የሌለ ክፍያ እንዲከፍሉ ይጠየቃሉ።", + inspectionRequired: "አካላዊ ምርመራ ያስፈልገዋል", + inspectionRequiredHint: + "ምርመራ ተመዝግቦ እስኪያልፍ ድረስ ኃላፊው ማጽደቅ አይችልም። ማመልከቻዎች በሂደት ላይ እያሉ መቀየር አስተማማኝ ነው — ኃላፊው በግምገማ ላይ ላለ ማመልከቻ አሁንም መርማሪ መመደብ ይችላል።", + renewalEnabled: "ባለቤቶች ይህንን ፈቃድ ማደስ ይችላሉ", + renewalEnabledHint: + "አንድ ጊዜ ብቻ ለሚሰጡ ያጥፉ — ጊዜው ለማያልፍ ምዝገባ፣ ወይም ለአንድ ጭነት ለተጻፈ ነፃ ፈቃድ።", serviceKind: "የአገልግሎት ዓይነት", serviceKindHint: "ለዝርዝር ምድብ ብቻ — ምንም የሥራ ሂደት በእሱ ላይ አይመሠረትም።", license: "ፈቃድ", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index c09503dbf..585e965ad 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -1297,6 +1297,15 @@ export const en = { requiresExamination: 'Requires an examination', requiresExaminationHint: 'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.', + issuesCertificate: 'Issues a certificate', + issuesCertificateHint: + 'Turn off for a type that ends with an EMA decision and never reaches a payment stage. Turning it back on requires a new-application fee to be set, or approved applicants would be asked for a fee that does not exist.', + inspectionRequired: 'Requires a physical inspection', + inspectionRequiredHint: + 'An officer cannot approve until an inspection has been recorded and passed. Safe to change with applications in progress — an officer can still assign an inspector to a file already under evaluation.', + renewalEnabled: 'Holders may renew this licence', + renewalEnabledHint: + 'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.', serviceKind: 'Service kind', serviceKindHint: 'Catalogue classification only — no workflow depends on it.', license: 'Licence', diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index f0dcf3534..a15e2a45a 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -223,6 +223,9 @@ export const licensingApi = baseApi completionEffect?: CompletionEffect | null; certificateCategory?: CertificateCategory | null; requiresExamination?: boolean; + inspectionRequired?: boolean; + issuesCertificate?: boolean; + renewalEnabled?: boolean; requiresSeafarerRegistration?: boolean; requiresValidMedical?: boolean; minSeaTimeDays?: number | null;