Merge pull request #47 from Tria-plc/estif-branch-1

Estif branch 1
This commit is contained in:
Nati Nigussie
2026-08-31 10:30:03 +03:00
committed by GitHub
10 changed files with 984 additions and 69 deletions

View File

@@ -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. */}
<NumberInput
label={t('designer.validityYears', 'Valid for (years)')}
description={t('designer.validityHint', 'Applied when a licence is issued')}
value={Number((validityMonths / 12).toFixed(2))}
onChange={(value) => onValidityChange(Math.round(Number(value || 0) * 12))}
min={0.5}
max={20}
step={0.5}
decimalScale={1}
w={190}
disabled={!canEdit}
/>
<Tooltip
label={
canEdit
? t('designer.saveValidity', 'Save validity')
: t('designer.noPermission', 'You do not have permission')
}
>
<span>
<Button
variant="light"
loading={savingValidity}
disabled={!canEdit || !typeId || validityMonths === currentValidityMonths}
onClick={onSaveValidity}
>
{t('designer.saveValidity', 'Save validity')}
</Button>
</span>
</Tooltip>
{/* 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 && (
<Stack gap={2}>
<Text size="xs" c="dimmed" fw={500}>
{t('designer.validity', 'Valid for')}
</Text>
<Group gap={6} align="baseline">
<Text size="sm" fw={600}>
{validityDays != null
? t('designer.validityDays', '{{count}} days', { count: validityDays })
: t('designer.validityMonths', '{{count}} months', {
count: validityMonths,
})}
</Text>
<Text size="xs" c="dimmed">
{t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')}
</Text>
</Group>
</Stack>
)}
<div style={{ flex: 1 }} />

View File

@@ -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<number>(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}
/>

View File

@@ -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<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}
/>
<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. */}
<Group align="flex-end" gap="sm" wrap="nowrap">
<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
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') },
]}
value={draft.validityDays !== null ? 'DAYS' : 'MONTHS'}
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 set('validityDays', null);
}}
allowDeselect={false}
disabled={!canEdit}
w={130}
/>
</Group>
<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>
);
}

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

@@ -1245,12 +1245,14 @@ export const am: Translations = {
designer: {
title: "የምስክር ወረቀት ንድፍ",
subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ፣ የሚቆይበትንም ጊዜ ያዘጋጁ።",
subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ።",
licenceType: "የፈቃድ ዓይነት",
validityYears: "የሚቆይበት (ዓመታት)",
validityHint: "ፈቃድ ሲሰጥ ተግባራዊ ይሆናል",
saveValidity: "የሚቆይበትን ጊዜ አስቀምጥ",
validitySaved: "የሚቆይበት ጊዜ ተዘምኗል",
validity: "የሚቆይበት",
validityMonths_one: "{{count}} ወር",
validityMonths_other: "{{count}} ወራት",
validityDays_one: "{{count}} ቀን",
validityDays_other: "{{count}} ቀናት",
validityEditedOn: "— በምስክር ወረቀት መስፈርቶች → ባህሪ ውስጥ ይዘጋጃል",
newVersion: "አዲስ ስሪት",
versions: "ስሪቶች",
name: "የስሪት ስም",
@@ -1293,6 +1295,86 @@ export const am: Translations = {
loadFailed: "የፈቃድ ዓይነቶችን መጫን አልተቻለም",
tabSchema: "የቅጽ ቅንብር",
tabDocuments: "የሰነድ መስፈርቶች",
tabBehavior: "ባህሪ",
behavior: {
workflow: "የሥራ ሂደት",
workflowWarning:
"እነዚህ ሦስት ቅንብሮች ማመልከቻው የሚከተለውን ሂደት ይወስናሉ። የዚህ ዓይነት ማመልከቻዎች ውሳኔ በመጠባበቅ ላይ እያሉ ሊቀየሩ አይችሉም — እነሱ ውሳኔ እስኪያገኙ ድረስ ማስቀመጡ ተቀባይነት አያገኝም።",
workflowProfile: "የሥራ ሂደት ዓይነት",
workflowProfileHint: "ምዝገባ የግምገማና የምርመራ ደረጃዎችን ያስቀራል።",
standard: "መደበኛ የፈቃድ ሂደት",
registration: "ምዝገባ (ግምገማ ብቻ)",
completionEffect: "የማጠናቀቂያ ውጤት",
completionEffectHint:
"የዚህ ዓይነት ማመልከቻ ሲጠናቀቅ ሥርዓቱ የሚወስደው እርምጃ።",
registerSeafarer: "መርከበኛውን መዝግብ",
registerVessel: "መርከቧን መዝግብ",
openDocuments: "የመርከበኛ ሰነዶችን ክፈት",
noEffect: "ምንም ውጤት የለም",
requiresExamination: "ፈተና ያስፈልገዋል",
requiresExaminationHint:
"ማመልከቻውን በብቁነት፣ ከዚያ በፈተና፣ ከዚያ በምስክር ወረቀት ሂደት ያሳልፋል። ሦስቱን የደረጃ ክፍያዎች በክፍያ ቅንብር ገጽ ላይ ያስቀምጡ።",
issuesCertificate: "የምስክር ወረቀት ይሰጣል",
issuesCertificateHint:
"በኢማ ውሳኔ ተጠናቆ ወደ ክፍያ ደረጃ ለማይደርስ ዓይነት ያጥፉ። እንደገና ማብራት የአዲስ ማመልከቻ ክፍያ እንዲዘጋጅ ይጠይቃል፤ አለበለዚያ የጸደቁ አመልካቾች የሌለ ክፍያ እንዲከፍሉ ይጠየቃሉ።",
inspectionRequired: "አካላዊ ምርመራ ያስፈልገዋል",
inspectionRequiredHint:
"ምርመራ ተመዝግቦ እስኪያልፍ ድረስ ኃላፊው ማጽደቅ አይችልም። ማመልከቻዎች በሂደት ላይ እያሉ መቀየር አስተማማኝ ነው — ኃላፊው በግምገማ ላይ ላለ ማመልከቻ አሁንም መርማሪ መመደብ ይችላል።",
renewalEnabled: "ባለቤቶች ይህንን ፈቃድ ማደስ ይችላሉ",
renewalEnabledHint:
"አንድ ጊዜ ብቻ ለሚሰጡ ያጥፉ — ጊዜው ለማያልፍ ምዝገባ፣ ወይም ለአንድ ጭነት ለተጻፈ ነፃ ፈቃድ።",
serviceKind: "የአገልግሎት ዓይነት",
serviceKindHint: "ለዝርዝር ምድብ ብቻ — ምንም የሥራ ሂደት በእሱ ላይ አይመሠረትም።",
license: "ፈቃድ",
registrationKind: "ምዝገባ",
eligibility: "የብቁነት መስፈርቶች",
eligibilityHint:
"አመልካቹ ሲያስገባ ይመረመራሉ፤ የካፒታል መስፈርቱ ደግሞ ኃላፊው ሲያጸድቅ እንደገና ይመረመራል። መስፈርትን ማጥበቅ ቀደም ብሎ ረቂቅ የጀመረን ሰው ከማስገባት ሊያግደው ይችላል።",
capitalThreshold: "አነስተኛ የተከፈለ ካፒታል",
capitalOn: "አነስተኛ የተከፈለ ካፒታል ይጠየቅ",
capitalHint:
"ኃላፊው ከዚህ እኩል ወይም በላይ የሆነ ካፒታል እስኪያረጋግጥ ድረስ ማጽደቅ አይችልም። ለአዲሶቹ ብቻ ሳይሆን ቀደም ብለው በወረፋ ላይ ላሉ ማመልከቻዎችም ይሠራል።",
validity: "የሚቆይበት",
validityUnit: "የሚቆይበት መለኪያ",
unitMonths: "ወራት",
unitDays: "ቀናት",
validityHint:
"ፈቃድ ሲሰጥ ተግባራዊ ይሆናል። ቀደም ብለው የተሰጡ ፈቃዶች የተሰጣቸውን የማብቂያ ቀን ይይዛሉ።",
requiresSeafarer: "የጸና የመርከበኛ ምዝገባ ያስፈልገዋል",
requiresSeafarerHint:
"ይህንን ዓይነት እንደ የመርከበኛ የምስክር ወረቀት ያመለክታል፤ በመርከበኛው ፖርታል ላይ እንዲታይ የሚያደርገው ይኸው ነው።",
requiresMedical: "የጸና የሕክምና ማረጋገጫ ያስፈልገዋል",
minSeaTime: "አነስተኛ የባህር አገልግሎት (ቀናት)",
minSeaTimeOn: "የተረጋገጠ የባህር አገልግሎት ይጠየቅ",
certificateCategory: "የምስክር ወረቀት ምድብ",
certificateCategoryHint:
"ይህ የሚሰጠው የሰነድ ዓይነት። ማረጋገጫዎች በመርከበኛው ፖርታል ላይ ለብቻቸው ይመደባሉ።",
notACertificate: "የምስክር ወረቀት አይደለም",
renewal: "የሚቆይበት ጊዜና እድሳት",
renewalWindow: "እድሳት የሚከፈትበት (ጊዜው ከማብቃቱ በፊት ያሉ ቀናት)",
reminders: "የማብቂያ አስታዋሾች (ቀደም ብለው ያሉ ቀናት)",
remindersHint: "ባለቤቱ በእያንዳንዱ በእነዚህ ጊዜያት ይታሰባል።",
applicantRules: "የአመልካች ደንቦች",
requiresOperatorMode: "አመልካቹ ይህንን የሥራ ዘርፍ ማሳወቅ አለበት",
requiresOperatorModeHint:
"ማንኛውም የገባ አመልካች ሊጀምራቸው ለሚችሉ በሰው ላይ ለሚያተኩሩ ምዝገባዎች ያጥፉ።",
allowMultipleDrafts: "በአንድ ጊዜ ብዙ ክፍት ረቂቆችን ፍቀድ",
allowMultipleDraftsHint:
"ለእያንዳንዱ ንብረት ለሚደረጉ ምዝገባዎች ያብሩ — ሁለተኛ መርከብ መመዝገብ የመጀመሪያዋን ረቂቅ መቀጠል የለበትም።",
requiresScheduling: "ከመስጠት በፊት የመረከቢያ ቀን ይያዝ",
requiresSchedulingHint: "አንድ ጊዜ ታትመው በአካል ለሚሰጡ ሰነዶች።",
slaHours: "የውሳኔ ጊዜ ግብ (ሰዓት)",
slaOn: "በጊዜ ገደብ ይከታተል",
slaHint:
"የዕድሜ/የጊዜ ገደብ አምድንና የዘገዩ እይታን ይመራል። ቀደም ብለው ለቀረቡ ማመልከቻዎችም እንደ አዲሶቹ ይሠራል።",
uniqueFormKey: "በዚህ መልስ አንድ ማመልከቻ ብቻ",
uniqueFormKeyHint:
"በነጥብ የተለየ የቅጽ መንገድ፣ ለምሳሌ shipment.billOfLading። መስኩ በዚህ ዓይነት ቅጽ ውስጥ መኖር አለበት፤ አለበለዚያ ምንም ማመልከቻ ማስገባት አይቻልም።",
noUniqueRule: "የልዩነት ደንብ የለም",
save: "ቅንብሩን አስቀምጥ",
saved: "ቅንብሩ ተቀምጧል።",
noPermission: "የፈቃድ ቅንብርን ለመቀየር ፈቃድ የለዎትም።",
},
cancel: "ይቅር",
delete: "ሰርዝ",
saveChanges: "ለውጦችን አስቀምጥ",
@@ -1536,6 +1618,15 @@ export const am: Translations = {
newFeeLabel: "የአዲስ ማመልከቻ ክፍያ",
sameRateLabel: "እድሳትን በተመሳሳይ ተመን ያስከፍሉ",
sameRateDescription: "የተለየ የእድሳት ክፍያ ለማዘጋጀት ያጥፉ።",
examinedNotice:
"ይህ የምስክር ወረቀት በፈተና የሚገኝ ስለሆነ በሦስት ደረጃዎች ይከፈላል። ከላይ ካሉት ክፍያዎች በተለየ እነዚህ በሚጸድቁበት ጊዜ አይቀዘቅዙም — ለውጡ ቀደም ብለው በሂደት ላይ ላሉ ተፈታኞችም ይሠራል። ተፈታኞች ሊከፍሉት በመጠባበቅ ላይ እያሉ አንዱን ማጥፋት ተቀባይነት አያገኝም።",
eligibilityFeeLabel: "የብቁነት ምዘና ክፍያ",
eligibilityFeeHint:
"ኃላፊው ማመልከቻውን ከመገምገሙ በፊት፣ በሚቀርብበት ጊዜ የሚከፈል። ክፍያ ከሌለ ባዶ ይተውት።",
examinationFeeLabel: "የፈተና ክፍያ",
examinationFeeHint: "ብቁነቱ ሲጸድቅ የሚከፈል፣ እንዲሁም ለድጋሚ ፈተና እንደገና።",
certificateFeeLabel: "የምስክር ወረቀት ክፍያ",
certificateFeeHint: "ካለፉ በኋላ፣ የምስክር ወረቀቱ ከመሰጠቱ በፊት የሚከፈል።",
renewalFeeLabel: "የእድሳት ክፍያ",
currencyLabel: "ገንዘብ",
cancel: "ሰርዝ",

View File

@@ -1252,12 +1252,14 @@ export const en = {
designer: {
title: 'Certificate designer',
subtitle: 'Design the certificate issued to licence holders, and set how long it stays valid.',
subtitle: 'Design the certificate issued to licence holders.',
licenceType: 'Licence type',
validityYears: 'Valid for (years)',
validityHint: 'Applied when a licence is issued',
saveValidity: 'Save validity',
validitySaved: 'Validity updated',
validity: 'Valid for',
validityMonths_one: '{{count}} month',
validityMonths_other: '{{count}} months',
validityDays_one: '{{count}} day',
validityDays_other: '{{count}} days',
validityEditedOn: '— set on Certificate Requirements → Behaviour',
newVersion: 'New version',
versions: 'Versions',
name: 'Version name',
@@ -1299,6 +1301,86 @@ 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.',
issuesCertificate: 'Issues a certificate',
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.',
inspectionRequired: 'Requires a physical inspection',
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.',
renewalEnabled: 'Holders may renew this licence',
renewalEnabledHint:
'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.',
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, 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.',
capitalThreshold: 'Minimum paid-up capital',
capitalOn: 'Require a minimum paid-up capital',
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.',
validity: 'Valid for',
validityUnit: 'Validity unit',
unitMonths: 'Months',
unitDays: 'Days',
validityHint:
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
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: 'Validity and 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',
@@ -1543,6 +1625,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,53 @@ 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;
inspectionRequired?: boolean;
issuesCertificate?: boolean;
renewalEnabled?: boolean;
requiresSeafarerRegistration?: boolean;
requiresValidMedical?: boolean;
minSeaTimeDays?: number | null;
validityMonths?: number;
validityDays?: number | null;
capitalThreshold?: 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,
@@ -1245,6 +1302,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

@@ -187,6 +187,11 @@ export interface LicenseType {
feeCurrency: string;
capitalThreshold: string | number | null;
validityMonths: number;
/**
* A term in days instead of months, for licences shorter than a month can
* express. Wins over `validityMonths` when set; null keeps calendar months.
*/
validityDays?: number | null;
/**
* Target turnaround in hours. Null means this type is not tracked against
* an SLA, which the grid renders as "—" rather than as instantly overdue.
@@ -216,11 +221,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 +612,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;