feat: add configuration UI for license behavior and multi-stage examination fees

This commit is contained in:
estifanos
2026-08-29 06:34:33 +00:00
parent af3fad018a
commit 2e9083211f
8 changed files with 764 additions and 1 deletions

View File

@@ -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<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 set<K extends keyof Draft>(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 (
<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}
/>
<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. Tightening a gate can stop someone mid-way from submitting a draft they have already started.',
)}
</Text>
<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', 'Renewal')}>
<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.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,
}: {
label: string;
switchLabel: string;
description?: string;
value: number | null;
onChange: (value: number | null) => void;
disabled: boolean;
min: number;
}) {
return (
<Stack gap="xs">
<Switch
checked={value !== null}
onChange={(e) => onChange(e.currentTarget.checked ? 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}
disabled={disabled}
/>
)}
</Stack>
);
}

View File

@@ -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() {
<Tabs.Tab value="documents" leftSection={<IconFiles size={15} />}>
{t('certReq.tabDocuments', 'Document requirements')}
</Tabs.Tab>
<Tabs.Tab value="behavior" leftSection={<IconAdjustments size={15} />}>
{t('certReq.tabBehavior', 'Behaviour')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="schema" pt="md">
@@ -87,6 +97,10 @@ export function CertificateRequirementsPage() {
<Tabs.Panel value="documents" pt="md">
<DocumentRequirementsTab licenseType={selectedType} />
</Tabs.Panel>
<Tabs.Panel value="behavior" pt="md">
<BehaviorTab licenseType={selectedType} />
</Tabs.Panel>
</Tabs>
)}
</Stack>

View File

@@ -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<number | ''>('');
const [examinationFee, setExaminationFee] = useState<number | ''>('');
const [certificateFee, setCertificateFee] = useState<number | ''>('');
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 && (
<>
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={16} />}
>
<Text size="sm">
{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.',
)}
</Text>
</Alert>
<NumberInput
label={t('paymentConfig.modal.eligibilityFeeLabel', 'Eligibility assessment fee')}
description={t(
'paymentConfig.modal.eligibilityFeeHint',
'Due on submission, before an officer reviews the application. Leave empty for no charge.',
)}
value={eligibilityFee}
onChange={(v) => setEligibilityFee(v === '' ? '' : Number(v))}
min={0}
max={9_999_999_999}
thousandSeparator=","
allowNegative={false}
decimalScale={2}
/>
<NumberInput
label={t('paymentConfig.modal.examinationFeeLabel', 'Examination fee')}
description={t(
'paymentConfig.modal.examinationFeeHint',
'Due once eligibility is approved, and again for a retake.',
)}
value={examinationFee}
onChange={(v) => setExaminationFee(v === '' ? '' : Number(v))}
min={0}
max={9_999_999_999}
thousandSeparator=","
allowNegative={false}
decimalScale={2}
/>
<NumberInput
label={t('paymentConfig.modal.certificateFeeLabel', 'Certificate fee')}
description={t(
'paymentConfig.modal.certificateFeeHint',
'Due after a pass, before the certificate is issued.',
)}
value={certificateFee}
onChange={(v) => setCertificateFee(v === '' ? '' : Number(v))}
min={0}
max={9_999_999_999}
thousandSeparator=","
allowNegative={false}
decimalScale={2}
/>
</>
)}
<ModalFooter mt="xs">
<Button variant="default" onClick={onClose} disabled={isLoading}>
{t('paymentConfig.modal.cancel', 'Cancel')}

View File

@@ -1271,6 +1271,69 @@ export const am: Translations = {
loadFailed: "የፈቃድ ዓይነቶችን መጫን አልተቻለም",
tabSchema: "የቅጽ ቅንብር",
tabDocuments: "የሰነድ መስፈርቶች",
// TRANSLATION NEEDED: English text below so the Behaviour tab type-checks
// and stays readable. A translator should replace these with Amharic.
tabBehavior: 'Behaviour',
behavior: {
workflow: 'Workflow',
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.',
workflowProfile: 'Workflow profile',
workflowProfileHint: 'REGISTRATION drops the evaluation and inspection stages.',
standard: 'Standard licence course',
registration: 'Registration (review only)',
completionEffect: 'Completion effect',
completionEffectHint:
'Platform action taken when an application of this type completes.',
registerSeafarer: 'Register the seafarer',
registerVessel: 'Register the vessel',
openDocuments: 'Open seafarer documents',
noEffect: 'No side effect',
requiresExamination: 'Requires an examination',
requiresExaminationHint:
'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.',
serviceKind: 'Service kind',
serviceKindHint: 'Catalogue classification only — no workflow depends on it.',
license: 'Licence',
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.',
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.',
requiresMedical: 'Requires a current medical certificate',
minSeaTime: 'Minimum sea time (days)',
minSeaTimeOn: 'Require verified sea time',
certificateCategory: 'Certificate category',
certificateCategoryHint:
'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.',
notACertificate: 'Not a certificate',
renewal: 'Renewal',
renewalWindow: 'Renewal opens (days before expiry)',
reminders: 'Expiry reminders (days before)',
remindersHint: 'The holder is reminded at each of these offsets.',
applicantRules: 'Applicant rules',
requiresOperatorMode: 'Applicant must declare this operating mode',
requiresOperatorModeHint:
'Turn off for person-centric registrations any signed-in applicant may start.',
allowMultipleDrafts: 'Allow several open drafts at once',
allowMultipleDraftsHint:
'On for per-asset registrations \u2014 registering a second vessel must not resume the first one\u2019s draft.',
requiresScheduling: 'Schedule a pickup date before issuing',
requiresSchedulingHint: 'For documents printed once and handed over in person.',
slaHours: 'Decision target (hours)',
slaOn: 'Track against an SLA',
slaHint:
'Drives the Age/SLA column and the Overdue view. Applies to applications already submitted as well as new ones.',
uniqueFormKey: 'One application per answer at',
uniqueFormKeyHint:
'Dotted form path, e.g. shipment.billOfLading. The field must exist in this type\u2019s form, or no application can be submitted.',
noUniqueRule: 'No uniqueness rule',
save: 'Save configuration',
saved: 'Configuration saved.',
noPermission: 'You do not have permission to change licence configuration.',
},
cancel: "ይቅር",
delete: "ሰርዝ",
saveChanges: "ለውጦችን አስቀምጥ",
@@ -1514,6 +1577,16 @@ export const am: Translations = {
newFeeLabel: "የአዲስ ማመልከቻ ክፍያ",
sameRateLabel: "እድሳትን በተመሳሳይ ተመን ያስከፍሉ",
sameRateDescription: "የተለየ የእድሳት ክፍያ ለማዘጋጀት ያጥፉ።",
// TRANSLATION NEEDED: English until a translator supplies Amharic.
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.',
eligibilityFeeLabel: 'Eligibility assessment fee',
eligibilityFeeHint:
'Due on submission, before an officer reviews the application. Leave empty for no charge.',
examinationFeeLabel: 'Examination fee',
examinationFeeHint: 'Due once eligibility is approved, and again for a retake.',
certificateFeeLabel: 'Certificate fee',
certificateFeeHint: 'Due after a pass, before the certificate is issued.',
renewalFeeLabel: "የእድሳት ክፍያ",
currencyLabel: "ገንዘብ",
cancel: "ሰርዝ",

View File

@@ -1276,6 +1276,67 @@ export const en = {
loadFailed: 'Could not load licence types',
tabSchema: 'Form schema',
tabDocuments: 'Document requirements',
tabBehavior: 'Behaviour',
behavior: {
workflow: 'Workflow',
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.',
workflowProfile: 'Workflow profile',
workflowProfileHint: 'REGISTRATION drops the evaluation and inspection stages.',
standard: 'Standard licence course',
registration: 'Registration (review only)',
completionEffect: 'Completion effect',
completionEffectHint:
'Platform action taken when an application of this type completes.',
registerSeafarer: 'Register the seafarer',
registerVessel: 'Register the vessel',
openDocuments: 'Open seafarer documents',
noEffect: 'No side effect',
requiresExamination: 'Requires an examination',
requiresExaminationHint:
'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.',
serviceKind: 'Service kind',
serviceKindHint: 'Catalogue classification only — no workflow depends on it.',
license: 'Licence',
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.',
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.',
requiresMedical: 'Requires a current medical certificate',
minSeaTime: 'Minimum sea time (days)',
minSeaTimeOn: 'Require verified sea time',
certificateCategory: 'Certificate category',
certificateCategoryHint:
'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.',
notACertificate: 'Not a certificate',
renewal: 'Renewal',
renewalWindow: 'Renewal opens (days before expiry)',
reminders: 'Expiry reminders (days before)',
remindersHint: 'The holder is reminded at each of these offsets.',
applicantRules: 'Applicant rules',
requiresOperatorMode: 'Applicant must declare this operating mode',
requiresOperatorModeHint:
'Turn off for person-centric registrations any signed-in applicant may start.',
allowMultipleDrafts: 'Allow several open drafts at once',
allowMultipleDraftsHint:
'On for per-asset registrations — registering a second vessel must not resume the first one\u2019s draft.',
requiresScheduling: 'Schedule a pickup date before issuing',
requiresSchedulingHint: 'For documents printed once and handed over in person.',
slaHours: 'Decision target (hours)',
slaOn: 'Track against an SLA',
slaHint:
'Drives the Age/SLA column and the Overdue view. Applies to applications already submitted as well as new ones.',
uniqueFormKey: 'One application per answer at',
uniqueFormKeyHint:
'Dotted form path, e.g. shipment.billOfLading. The field must exist in this type\u2019s form, or no application can be submitted.',
noUniqueRule: 'No uniqueness rule',
save: 'Save configuration',
saved: 'Configuration saved.',
noPermission: 'You do not have permission to change licence configuration.',
},
cancel: 'Cancel',
delete: 'Delete',
saveChanges: 'Save changes',
@@ -1520,6 +1581,15 @@ export const en = {
newFeeLabel: 'New application fee',
sameRateLabel: 'Charge renewal at the same rate',
sameRateDescription: 'Turn off to set a separate renewal fee.',
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.',
eligibilityFeeLabel: 'Eligibility assessment fee',
eligibilityFeeHint:
'Due on submission, before an officer reviews the application. Leave empty for no charge.',
examinationFeeLabel: 'Examination fee',
examinationFeeHint: 'Due once eligibility is approved, and again for a retake.',
certificateFeeLabel: 'Certificate fee',
certificateFeeHint: 'Due after a pass, before the certificate is issued.',
renewalFeeLabel: 'Renewal fee',
currencyLabel: 'Currency',
cancel: 'Cancel',

View File

@@ -21,6 +21,8 @@ import type {
LicenseTypeRequirements,
OperatorType,
AssignableOfficer,
CertificateCategory,
CompletionEffect,
DocumentDecision,
DocumentReview,
EligibleExam,
@@ -37,12 +39,14 @@ import type {
RemarkTargetType,
SavedQueueView,
SchemaIssue,
ServiceKind,
TemplateFieldPlacement,
TemplateLogoPlacement,
TemplatePageOptions,
TemplateVariable,
PersonalDocumentFilter,
PersonalDocumentGroup,
WorkflowProfile,
} from './licensing.types';
/**
@@ -182,6 +186,12 @@ export const licensingApi = baseApi
feeNewApplication?: number | null;
feeRenewal?: number | null;
feeCurrency?: string;
// The examined-certificate stages. Unlike the two above, these are
// read live off the licence type rather than snapshotted, so the
// server refuses to clear one a candidate is currently waiting on.
feeEligibility?: number | null;
feeExamination?: number | null;
feeCertificate?: number | null;
}
>({
query: ({ id, ...body }) => ({
@@ -193,6 +203,47 @@ export const licensingApi = baseApi
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}),
/**
* How a licence type behaves: its workflow, eligibility gates, renewal
* policy and applicant rules — everything that used to be settable only
* by editing a seed file.
*
* Three of these (`workflowProfile`, `completionEffect`,
* `requiresExamination`) decide the course an application runs, and are
* read live rather than snapshotted. The server answers 409
* `license_type_in_use` when changing one would strand applications that
* have not yet had their approval decision.
*/
updateLicenseBehavior: builder.mutation<
LicenseType,
{
id: string;
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;
}
>({
query: ({ id, ...body }) => ({
url: `/license-types/${id}/behavior`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}),
/** Validity is edited beside the certificate design, not with the fees. */
updateLicenseValidity: builder.mutation<
LicenseType,
@@ -1240,6 +1291,7 @@ export const {
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
useUpdateLicenseBehaviorMutation,
useUpdateFormSchemaMutation,
useValidateFormSchemaMutation,
useGetFormSchemaPaletteQuery,

View File

@@ -400,6 +400,15 @@ const ERROR_MESSAGES: Record<string, string> = {
inspection_not_passed:
'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
license_type_inactive: 'This licence type is not currently accepting applications.',
// Licence-type configuration guards. Each of these refuses a change that
// would leave applications already in progress unable to move.
license_type_in_use:
'Applications of this type are already in progress, and this setting decides the course they run. Wait until those have been decided, or change something else.',
stage_fee_in_use:
'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.',
form_schema_missing_protected_paths:
'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.',
};
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {

View File

@@ -216,11 +216,39 @@ export interface LicenseType {
// --------------------------------------------------- examined certificates
/** Approval establishes eligibility; the certificate is earned by exam. */
requiresExamination?: boolean;
/** Assessment fee, due on submission before any officer review. */
feeEligibility?: string | number | null;
/** Fee per sitting. Falls back to `feeNewApplication` when null. */
feeExamination?: string | number | null;
/** Fee to issue after a pass. Falls back to `feeNewApplication` when null. */
feeCertificate?: string | number | null;
// ------------------------------------------------------- behaviour config
// Editable from the backoffice Behavior tab. Optional because the API has
// only recently begun returning them and older mocks/fixtures omit them.
/** Licence or registration. Catalogue metadata; no behaviour hangs off it. */
serviceKind?: ServiceKind;
/** Platform side effect fired when an application of this type completes. */
completionEffect?: CompletionEffect | null;
/** Only ACTIVE registered seafarers may apply. */
requiresSeafarerRegistration?: boolean;
/** Submission requires a current, unexpired medical certificate. */
requiresValidMedical?: boolean;
/** Minimum VERIFIED sea time in days at submission. Null means no floor. */
minSeaTimeDays?: number | null;
/** Whether several open drafts may exist at once, for per-asset registrations. */
allowMultipleOpenDrafts?: boolean;
/** Days before expiry that renewal opens. */
renewalWindowDays?: number;
/** Days before expiry to remind the holder, most distant first. */
expiryReminderDays?: number[];
/**
* Dotted `formData` path whose answer may appear on only one live
* application of this type. Null means no such rule.
*/
uniqueFormKeyPath?: string | null;
// ---------------------------------------------------------- STCW mapping
certificateCategory?: CertificateCategory | null;
stcwControlled?: boolean;
@@ -579,6 +607,15 @@ export type QueueSortField =
/** Review → evaluation → (inspection) → approval, or the short registration course. */
export type WorkflowProfile = "STANDARD" | "REGISTRATION";
/** A permission to operate, or a registration granting a status and a number. */
export type ServiceKind = "LICENSE" | "REGISTRATION";
/** Platform side effect fired when an application reaches COMPLETED. */
export type CompletionEffect =
| "REGISTER_SEAFARER"
| "REGISTER_VESSEL"
| "OPEN_SEAFARER_DOCUMENTS";
/** Row counts behind the queue's saved-view tabs. */
export interface QueueCounts {
unassigned: number;