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 5c19085fa..4ab644bc6 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx @@ -1,4 +1,4 @@ -import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core'; +import { Button, Group, Select, Stack, Text } from '@mantine/core'; import { IconPlus } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api'; @@ -12,12 +12,11 @@ interface Props { ranks: Rank[]; rankId: string | null; onRankChange: (id: string | null) => void; + /** Shown for context only; edited on Certificate Requirements → Behaviour. */ validityMonths: number; - onValidityChange: (months: number) => void; - currentValidityMonths?: number | null; + /** Non-null when the type is configured in days rather than months. */ + validityDays?: number | null; canEdit: boolean; - savingValidity: boolean; - onSaveValidity: () => void; onNewVersion: () => void; } @@ -30,11 +29,8 @@ export function DesignerToolbar({ rankId, onRankChange, validityMonths, - onValidityChange, - currentValidityMonths, + validityDays, canEdit, - savingValidity, - onSaveValidity, onNewVersion, }: Props) { const { t } = useTranslation(); @@ -73,38 +69,31 @@ export function DesignerToolbar({ /> )} - {/* Validity lives beside the design because it is the other half of - what a certificate promises. */} - onValidityChange(Math.round(Number(value || 0) * 12))} - min={0.5} - max={20} - step={0.5} - decimalScale={1} - w={190} - disabled={!canEdit} - /> - - - - - + {/* 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.validity', 'Valid for')} + + + + {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 f9401f3e4..b46d68689 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,9 @@ export function CertificateDesignerPage() { setRankId(value); draft.setSelectedId(null); }} - validityMonths={validityMonths} - onValidityChange={setValidityMonths} - currentValidityMonths={selectedType?.validityMonths} + validityMonths={selectedType?.validityMonths ?? 12} + validityDays={selectedType?.validityDays ?? null} 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 new file mode 100644 index 000000000..cab0bf4c4 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -0,0 +1,550 @@ +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; + inspectionRequired: boolean; + issuesCertificate: boolean; + renewalEnabled: boolean; + requiresSeafarerRegistration: boolean; + requiresValidMedical: boolean; + minSeaTimeDays: number | null; + capitalThreshold: number | null; + validityMonths: number; + validityDays: 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, + inspectionRequired: licenseType.inspectionRequired ?? true, + issuesCertificate: licenseType.issuesCertificate ?? true, + renewalEnabled: licenseType.renewalEnabled ?? true, + requiresSeafarerRegistration: + licenseType.requiresSeafarerRegistration ?? false, + requiresValidMedical: licenseType.requiresValidMedical ?? false, + minSeaTimeDays: licenseType.minSeaTimeDays ?? null, + capitalThreshold: + licenseType.capitalThreshold == null + ? 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, + 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} + /> + + set('issuesCertificate', e.currentTarget.checked)} + label={t('certReq.behavior.issuesCertificate', 'Issues a certificate')} + description={t( + 'certReq.behavior.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.', + )} + 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} + /> +
+ +
+ {/* 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} + /> +