mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-01 19:23:28 +00:00
@@ -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 }} />
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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: 1–3650 days, or 6–240 months.
|
||||
min={draft.validityDays !== null ? 1 : 6}
|
||||
max={draft.validityDays !== null ? 3650 : 240}
|
||||
allowNegative={false}
|
||||
disabled={!canEdit}
|
||||
flex={1}
|
||||
/>
|
||||
<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 one’s 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 type’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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: "ሰርዝ",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user