From 2e9083211fa519ac9c1bcc8a1cb6679774a6dd8a Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 29 Aug 2026 06:34:33 +0000 Subject: [PATCH] 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} + /> + + )} +