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), // 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, 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 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, ...rest, ...(expires ? { validityMonths, validityDays } : {}), ...(expires && draft.renewalEnabled ? { renewalWindowDays, expiryReminderDays } : {}), }).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. The third unit is no term at all, which the server stores as `validityMonths: 0`. */} {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} /> )}