Files
emaui/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx

641 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Draft>(() => 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<Draft>) {
setDraft((current) => ({ ...current, ...values }));
setDirty(true);
}
function set<K extends keyof Draft>(key: K, value: Draft[K]) {
patch({ [key]: value } as Partial<Draft>);
}
// 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 (
<Stack gap="md">
<Section title={t('certReq.behavior.workflow', 'Workflow')}>
<Alert
variant="light"
color="yellow"
icon={<IconAlertTriangle size={16} />}
>
<Text size="sm">
{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.',
)}
</Text>
</Alert>
<Select
label={t('certReq.behavior.workflowProfile', 'Workflow profile')}
description={t(
'certReq.behavior.workflowProfileHint',
'REGISTRATION drops the evaluation and inspection stages.',
)}
data={[
{ value: 'STANDARD', label: t('certReq.behavior.standard', 'Standard licence course') },
{ value: 'REGISTRATION', label: t('certReq.behavior.registration', 'Registration (review only)') },
]}
value={draft.workflowProfile}
onChange={(v) => v && set('workflowProfile', v as WorkflowProfile)}
allowDeselect={false}
disabled={!canEdit}
/>
<Select
label={t('certReq.behavior.completionEffect', 'Completion effect')}
description={t(
'certReq.behavior.completionEffectHint',
'Platform action taken when an application of this type completes.',
)}
data={[
{ value: 'REGISTER_SEAFARER', label: t('certReq.behavior.registerSeafarer', 'Register the seafarer') },
{ value: 'REGISTER_VESSEL', label: t('certReq.behavior.registerVessel', 'Register the vessel') },
{ value: 'OPEN_SEAFARER_DOCUMENTS', label: t('certReq.behavior.openDocuments', 'Open seafarer documents') },
]}
value={draft.completionEffect}
onChange={(v) => set('completionEffect', (v as CompletionEffect) ?? null)}
placeholder={t('certReq.behavior.noEffect', 'No side effect')}
clearable
disabled={!canEdit}
/>
<Switch
checked={draft.requiresExamination}
onChange={(e) => 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}
/>
<Switch
checked={draft.issuesCertificate}
onChange={(e) => 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}
/>
<Select
label={t('certReq.behavior.serviceKind', 'Service kind')}
description={t(
'certReq.behavior.serviceKindHint',
'Catalogue classification only — no workflow depends on it.',
)}
data={[
{ value: 'LICENSE', label: t('certReq.behavior.license', 'Licence') },
{ value: 'REGISTRATION', label: t('certReq.behavior.registrationKind', 'Registration') },
]}
value={draft.serviceKind}
onChange={(v) => v && set('serviceKind', v as ServiceKind)}
allowDeselect={false}
disabled={!canEdit}
/>
</Section>
<Section title={t('certReq.behavior.eligibility', 'Eligibility gates')}>
<Text size="xs" c="dimmed">
{t(
'certReq.behavior.eligibilityHint',
'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.',
)}
</Text>
<NullableNumber
label={t('certReq.behavior.capitalThreshold', 'Minimum paid-up capital')}
switchLabel={t('certReq.behavior.capitalOn', 'Require a minimum paid-up capital')}
description={t(
'certReq.behavior.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.',
)}
value={draft.capitalThreshold}
onChange={(v) => set('capitalThreshold', v)}
disabled={!canEdit}
min={0}
defaultValue={1_000_000}
thousandSeparator
/>
<Switch
checked={draft.requiresSeafarerRegistration}
onChange={(e) => set('requiresSeafarerRegistration', e.currentTarget.checked)}
label={t('certReq.behavior.requiresSeafarer', 'Requires an active seafarer registration')}
description={t(
'certReq.behavior.requiresSeafarerHint',
'Also marks this type as a seafarer certificate, which is what makes it appear in the seafarer portal.',
)}
disabled={!canEdit}
/>
<Switch
checked={draft.requiresValidMedical}
onChange={(e) => set('requiresValidMedical', e.currentTarget.checked)}
label={t('certReq.behavior.requiresMedical', 'Requires a current medical certificate')}
disabled={!canEdit}
/>
<NullableNumber
label={t('certReq.behavior.minSeaTime', 'Minimum sea time (days)')}
switchLabel={t('certReq.behavior.minSeaTimeOn', 'Require verified sea time')}
value={draft.minSeaTimeDays}
onChange={(v) => set('minSeaTimeDays', v)}
disabled={!canEdit}
min={0}
/>
<Select
label={t('certReq.behavior.certificateCategory', 'Certificate category')}
description={t(
'certReq.behavior.certificateCategoryHint',
'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.',
)}
data={['COC', 'COP', 'ENDORSEMENT', 'GOC', 'NATIONAL'].map((v) => ({
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}
/>
</Section>
<Section title={t('certReq.behavior.renewal', 'Validity and renewal')}>
{/* 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`. */}
<Group align="flex-end" gap="sm" wrap="nowrap">
{expires && (
<NumberInput
label={t('certReq.behavior.validity', 'Valid for')}
description={t(
'certReq.behavior.validityHint',
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
)}
value={draft.validityDays ?? draft.validityMonths}
onChange={(v) => {
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: 13650 days, or 6240 months.
min={draft.validityDays !== null ? 1 : 6}
max={draft.validityDays !== null ? 3650 : 240}
allowNegative={false}
disabled={!canEdit}
flex={1}
/>
)}
<Select
label={
expires
? undefined
: t('certReq.behavior.validityUnit', 'Validity unit')
}
aria-label={t('certReq.behavior.validityUnit', 'Validity unit')}
data={[
{ value: 'MONTHS', label: t('certReq.behavior.unitMonths', 'Months') },
{ value: 'DAYS', label: t('certReq.behavior.unitDays', 'Days') },
...(noExpiryAvailable
? [
{
value: 'NONE',
label: t('certReq.behavior.unitNone', 'Does not expire'),
},
]
: []),
]}
value={
draft.validityDays !== null
? 'DAYS'
: draft.validityMonths > 0
? 'MONTHS'
: 'NONE'
}
onChange={(unit) => {
// Switching unit is a change of policy, not a conversion: 12
// calendar months is not 365 days, so carry no arithmetic across
// and let the administrator state the new term outright.
if (unit === 'DAYS') set('validityDays', draft.validityDays ?? 90);
else if (unit === 'MONTHS')
patch({
validityDays: null,
validityMonths:
draft.validityMonths >= 6 ? draft.validityMonths : 12,
});
// No expiry means no renewal policy: clear it here rather than
// save renewal settings that could never fire.
else
patch({
validityDays: null,
validityMonths: 0,
renewalEnabled: false,
});
}}
allowDeselect={false}
disabled={!canEdit}
w={expires ? 130 : 220}
/>
</Group>
{!expires && (
<Text size="xs" c="dimmed">
{t(
'certReq.behavior.noExpiryHint',
'What this type issues never expires — an ownership transfer, or any one-off record. There is no renewal policy to set.',
)}
</Text>
)}
{expires && (
<>
<Switch
checked={draft.renewalEnabled}
onChange={(e) => 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}
/>
{/* 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 && (
<>
<NumberInput
label={t('certReq.behavior.renewalWindow', 'Renewal opens (days before expiry)')}
value={draft.renewalWindowDays}
onChange={(v) =>
set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)
}
min={1}
max={365}
allowNegative={false}
disabled={!canEdit}
/>
<MultiSelect
label={t('certReq.behavior.reminders', 'Expiry reminders (days before)')}
description={t(
'certReq.behavior.remindersHint',
'The holder is reminded at each of these offsets.',
)}
data={REMINDER_OFFSETS}
value={draft.expiryReminderDays.map(String)}
onChange={(values) =>
set(
'expiryReminderDays',
values.map(Number).sort((a, b) => b - a),
)
}
disabled={!canEdit}
clearable
/>
</>
)}
</>
)}
</Section>
<Section title={t('certReq.behavior.applicantRules', 'Applicant rules')}>
<Switch
checked={draft.requiresOperatorMode}
onChange={(e) => 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}
/>
<Switch
checked={draft.allowMultipleOpenDrafts}
onChange={(e) => 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 ones draft.',
)}
disabled={!canEdit}
/>
<Switch
checked={draft.inspectionRequired}
onChange={(e) => 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}
/>
<Switch
checked={draft.requiresIssuanceScheduling}
onChange={(e) => 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}
/>
<NullableNumber
label={t('certReq.behavior.slaHours', 'Decision target (hours)')}
switchLabel={t('certReq.behavior.slaOn', 'Track against an SLA')}
value={draft.slaHours}
onChange={(v) => 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.',
)}
/>
<TextInput
label={t('certReq.behavior.uniqueFormKey', 'One application per answer at')}
description={t(
'certReq.behavior.uniqueFormKeyHint',
'Dotted form path, e.g. shipment.billOfLading. The field must exist in this types form, or no application can be submitted.',
)}
value={draft.uniqueFormKeyPath ?? ''}
onChange={(e) =>
set('uniqueFormKeyPath', e.currentTarget.value.trim() || null)
}
maxLength={128}
placeholder={t('certReq.behavior.noUniqueRule', 'No uniqueness rule')}
disabled={!canEdit}
/>
</Section>
<Group justify="flex-end">
<Tooltip
label={t('certReq.behavior.noPermission', 'You do not have permission to change licence configuration.')}
disabled={canEdit}
>
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={onSave}
loading={saving}
disabled={!canEdit || !dirty}
>
{t('certReq.behavior.save', 'Save configuration')}
</Button>
</Tooltip>
</Group>
</Stack>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Paper withBorder radius="md" p="md">
<Stack gap="sm">
<Text fw={600} size="sm">
{title}
</Text>
{children}
</Stack>
</Paper>
);
}
/**
* 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,
defaultValue,
thousandSeparator,
}: {
label: string;
switchLabel: string;
description?: string;
value: number | null;
onChange: (value: number | null) => void;
disabled: boolean;
min: number;
/** Seeded when the switch is turned on. Defaults to the minimum. */
defaultValue?: number;
thousandSeparator?: boolean;
}) {
return (
<Stack gap="xs">
<Switch
checked={value !== null}
onChange={(e) =>
onChange(e.currentTarget.checked ? (defaultValue ?? min ?? 1) : null)
}
label={switchLabel}
description={description}
disabled={disabled}
/>
{value !== null && (
<NumberInput
label={label}
value={value}
onChange={(v) => onChange(typeof v === 'number' ? v : value)}
min={min}
allowNegative={false}
thousandSeparator={thousandSeparator ? ',' : undefined}
decimalScale={thousandSeparator ? 2 : undefined}
disabled={disabled}
/>
)}
</Stack>
);
}