Merge remote-tracking branch 'origin/dev' into fix/coc-exam-workflow-defects

# Conflicts:
#	apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx
#	libs/api/src/lib/features/licensing/licensing-api.ts
#	libs/api/src/lib/features/licensing/licensing.types.ts
This commit is contained in:
mihretue
2026-08-31 09:16:24 +00:00
101 changed files with 8172 additions and 2077 deletions

View File

@@ -0,0 +1,303 @@
import { useMemo, useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
Container,
Group,
Loader,
Select,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
} from '@mantine/core';
import { IconAlertTriangle, IconFingerprint, IconScan, IconSearch, IconX } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
extractErrorMessage,
useEnrollBiometricMutation,
useGenerateBsidMutation,
useGetBiometricEnrollmentsQuery,
useGetBiometricSimulateCapabilitiesQuery,
useListSeafarerRegistrationsQuery,
useRevokeBiometricEnrollmentMutation,
type BiometricModality,
type SeafarerRegistration,
} from '@ema-platform/api';
import { notify, PageHeader, StatusBadge } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const MODALITIES: { value: BiometricModality; label: string }[] = [
{ value: 'FINGERPRINT', label: 'Fingerprint' },
{ value: 'FACE', label: 'Face' },
];
function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middleName' | 'lastName'>): string {
return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—';
}
/**
* No scanner is wired yet (US-BIO placeholder): "Simulate Scan" stands in for
* the real vendor SDK capture, producing a random template so the rest of the
* pipeline — encrypt, store, print — is exercisable end to end. Swap the
* simulated bytes for the SDK's real template once a vendor is chosen; the
* API call shape (base64 template + format tag) does not change.
*/
function fakeTemplate(): string {
const bytes = crypto.getRandomValues(new Uint8Array(64));
return btoa(String.fromCharCode(...bytes));
}
/**
* Pick a seafarer waiting on enrolment.
*
* AWAITING_BIOMETRICS only: enrolment is the step that unblocks the review, so
* this queue is exactly the registrations held for it. An approved seafarer has
* already been through here — listing them would invite a second capture of
* someone who is finished.
*/
function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }) {
const [search, setSearch] = useState('');
const [debounced] = useDebouncedValue(search, 300);
const { data, isFetching } = useListSeafarerRegistrationsQuery({
status: 'AWAITING_BIOMETRICS',
search: debounced || undefined,
take: 10,
});
return (
<Card withBorder radius="md" p="md">
<TextInput
placeholder="Search seafarers awaiting enrolment…"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
mb="sm"
/>
{isFetching && <Loader size="sm" />}
<Table highlightOnHover fz="sm">
<Table.Tbody>
{(data?.items ?? []).map((r) => (
<Table.Tr key={r.id} onClick={() => onPick(r)} style={{ cursor: 'pointer' }}>
<Table.Td>
<Text fz="sm" fw={600}>{applicantName(r)}</Text>
{/* Not seafarerNumber: that is only issued on approval, which
is downstream of this screen, so it is always blank here. */}
<Text fz="xs" c="dimmed" ff="monospace">{r.registrationNumber}</Text>
</Table.Td>
</Table.Tr>
))}
{!isFetching && (data?.items ?? []).length === 0 && (
<Table.Tr>
<Table.Td>
<Text fz="sm" c="dimmed">No seafarer is waiting on enrolment.</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Card>
);
}
/** Backoffice counter screen: enroll a scanner capture against a profile, view what's on file, print the slip. */
export function BiometricEnrollmentPage() {
const showDate = useDateDisplayer();
const [selected, setSelected] = useState<SeafarerRegistration | null>(null);
const [modality, setModality] = useState<BiometricModality>('FINGERPRINT');
const [deviceId, setDeviceId] = useState('');
const profileId = selected?.profileId ?? '';
const { data: enrollments, isLoading } = useGetBiometricEnrollmentsQuery(profileId, { skip: !profileId });
// No vendor SDK integrated yet — "Simulate Scan" fakes a capture so the
// rest of the flow is exercisable. Reports false in production unless
// ALLOW_BIOMETRIC_SIMULATION=true, same shortcut the payment bypass uses.
const { data: capabilities } = useGetBiometricSimulateCapabilitiesQuery();
const simulateEnabled = capabilities?.simulateEnabled ?? false;
const [generateBsid, { isLoading: generatingBsid }] = useGenerateBsidMutation();
// Seeded from the seafarer registration list (which does not carry BSID
// yet) and updated locally once generated — this screen's only source of
// truth for it until the registry surfaces the profile's BSID directly.
const [bsid, setBsid] = useState<string | null>(null);
const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation();
const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation();
const hasActive = useMemo(
() => (m: BiometricModality) => (enrollments ?? []).some((e) => e.modality === m),
[enrollments],
);
async function handleEnroll() {
if (!profileId) return;
try {
await enroll({
profileId,
modality,
template: fakeTemplate(),
templateFormat: 'SIMULATED',
deviceId: deviceId || undefined,
consentAt: new Date().toISOString(),
}).unwrap();
notify.success(`${modality === 'FINGERPRINT' ? 'Fingerprint' : 'Face'} enrolled.`);
} catch (err) {
notify.error(extractErrorMessage(err, 'Enrollment failed.'));
}
}
async function handleGenerateBsid() {
if (!profileId) return;
try {
const result = await generateBsid(profileId).unwrap();
setBsid(result.bsid);
notify.success(`BSID ${result.bsid} generated.`);
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not generate BSID.'));
}
}
async function handleRevoke(id: string) {
if (!profileId) return;
try {
await revoke({ id, profileId, reason: 'Withdrawn at counter' }).unwrap();
notify.success('Enrollment revoked.');
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not revoke.'));
}
}
return (
<Container size="md" py="md">
<PageHeader
title="Biometric Enrollment"
subtitle="Capture a fingerprint or face template for a seafarer awaiting enrolment, then issue their BSID."
/>
{!selected ? (
<ProfilePicker
onPick={(r) => {
setSelected(r);
setBsid(null);
}}
/>
) : (
<Stack gap="md">
<Card withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600}>{applicantName(selected)}</Text>
<Text fz="xs" c="dimmed" ff="monospace">{selected.registrationNumber}</Text>
</div>
<Button
variant="subtle"
size="xs"
leftSection={<IconX size={14} />}
onClick={() => {
setSelected(null);
setBsid(null);
}}
>
Change seafarer
</Button>
</Group>
</Card>
<Card withBorder radius="md" p="md">
<Text fz="sm" fw={600} mb="sm">Capture</Text>
{simulateEnabled ? (
<>
<Alert color="yellow" icon={<IconAlertTriangle size={16} />} mb="sm" variant="light">
No scanner is wired yet this simulates a capture so the rest of the flow can be tested.
</Alert>
<Group align="flex-end">
<Select label="Modality" data={MODALITIES} value={modality} onChange={(v) => setModality((v as BiometricModality) ?? 'FINGERPRINT')} w={160} />
<TextInput label="Device (optional)" placeholder="scanner-01" value={deviceId} onChange={(e) => setDeviceId(e.currentTarget.value)} w={180} />
<Button leftSection={<IconScan size={16} />} onClick={handleEnroll} loading={enrolling}>
Simulate Scan &amp; Enroll
</Button>
</Group>
</>
) : (
<Alert color="gray" icon={<IconAlertTriangle size={16} />} variant="light">
No scanner is wired yet, and capture simulation is off in this environment.
</Alert>
)}
</Card>
<Card withBorder radius="md" p="md">
<Text fz="sm" fw={600} mb="sm">Biometric Subject ID (BSID)</Text>
<Text fz="xs" c="dimmed" mb="sm">
Required before this registration can be approved. Generating it is final
confirm the capture is good first.
</Text>
<Group justify="space-between">
{bsid ? (
<StatusBadge tone="success" label={`BSID ${bsid}`} />
) : (
<Badge color="gray" variant="light">Not generated</Badge>
)}
{!bsid && (
<Button
size="xs"
onClick={handleGenerateBsid}
loading={generatingBsid}
disabled={!hasActive('FINGERPRINT') && !hasActive('FACE')}
>
Generate BSID
</Button>
)}
</Group>
</Card>
<Card withBorder radius="md" p="md">
<Text fz="sm" fw={600} mb="sm">On file</Text>
{isLoading ? (
<Loader size="sm" />
) : (
<Stack gap="xs">
{MODALITIES.map((m) => (
<Group key={m.value} justify="space-between" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
<Group gap="xs">
<ThemeIcon variant="light" color={hasActive(m.value) ? 'teal' : 'gray'} size={30} radius="md">
<IconFingerprint size={15} />
</ThemeIcon>
<Text fz="sm">{m.label}</Text>
</Group>
{hasActive(m.value) ? (
<Group gap="xs">
<StatusBadge tone="success" label="Enrolled" />
<Button
size="xs"
color="red"
variant="subtle"
loading={revoking}
onClick={() => {
const row = (enrollments ?? []).find((e) => e.modality === m.value);
if (row) handleRevoke(row.id);
}}
>
Revoke
</Button>
</Group>
) : (
<Badge color="gray" variant="light">Not enrolled</Badge>
)}
</Group>
))}
{(enrollments ?? []).map((e) => (
<Text key={e.id} fz="xs" c="dimmed">
{e.modality} captured {showDate(e.enrolledAt)}{e.deviceId ? ` · ${e.deviceId}` : ''}
</Text>
))}
</Stack>
)}
</Card>
</Stack>
)}
</Container>
);
}
export default BiometricEnrollmentPage;

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

@@ -1,9 +1,9 @@
import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } from '@ema-platform/api';
import { BASE_API_URL } from '@ema-platform/api';
/** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */
export const API_BASE_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
export const API_BASE_URL = BASE_API_URL;
export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
DRAFT: 'gray',

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

@@ -198,7 +198,7 @@ function ConditionArmFields({
fz="xs"
px={6}
py={2}
bg="var(--mantine-color-gray-1)"
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: 4, cursor: 'pointer' }}
onClick={() => setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))}
title={t('certReq.condition.removeValue', 'Click to remove')}

View File

@@ -6,6 +6,7 @@ import {
Drawer,
MultiSelect,
NumberInput,
SegmentedControl,
Select,
Stack,
Text,
@@ -13,33 +14,103 @@ import {
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import type { ApplicationKind, DocumentRequirement, FormSchemaPalette } from '@ema-platform/api';
import {
useLocalized,
type ApplicationKind,
type DocumentRequirement,
type FormSchemaPalette,
type LicenseType,
} from '@ema-platform/api';
import { ConditionBuilder, type ConditionValue } from './ConditionBuilder';
import type { ConditionTarget } from '../config/schema-paths';
/**
* File types a slot may be opened to.
*
* Longer than the three a slot starts with, because what an applicant
* actually has is not always a scan: a phone photographs an ID as HEIC, a
* scanner writes multi-page TIFF, and an academic record often arrives as the
* Word file its institution issued. Widening a slot stays a deliberate choice
* — the defaults below do not change — but it no longer needs a release.
*/
const MIME_OPTIONS = [
{ value: 'application/pdf', label: 'PDF' },
{ value: 'image/jpeg', label: 'JPEG' },
{ value: 'image/png', label: 'PNG' },
{ value: 'image/webp', label: 'WebP' },
{ value: 'image/heic', label: 'HEIC (iPhone photo)' },
{ value: 'image/heif', label: 'HEIF' },
{ value: 'image/tiff', label: 'TIFF (scan)' },
{ value: 'image/bmp', label: 'BMP' },
{ value: 'application/msword', label: 'Word (.doc)' },
{
value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
label: 'Word (.docx)',
},
{ value: 'application/vnd.ms-excel', label: 'Excel (.xls)' },
{
value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
label: 'Excel (.xlsx)',
},
{ value: 'text/csv', label: 'CSV' },
{ value: 'text/plain', label: 'Plain text (.txt)' },
// Video is measured in hundreds of megabytes, not the 5 MB a slot starts
// with: raise "Max file size" on any slot that accepts one.
{ value: 'video/mp4', label: 'Video (.mp4)' },
{ value: 'video/quicktime', label: 'Video (.mov, iPhone)' },
{ value: 'video/webm', label: 'Video (.webm)' },
{ value: 'video/x-msvideo', label: 'Video (.avi)' },
{ value: 'audio/mpeg', label: 'Audio (.mp3)' },
{ value: 'audio/wav', label: 'Audio (.wav)' },
// Both, because the same .m4a is reported as audio/mp4 by Chrome and
// audio/x-m4a by Safari; picking one would reject half the recordings.
{ value: 'audio/mp4', label: 'Audio (.m4a)' },
{ value: 'audio/x-m4a', label: 'Audio (.m4a, Safari)' },
{ value: 'audio/ogg', label: 'Audio (.ogg)' },
];
/** What a new slot accepts until someone widens it. */
const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
function emptyDraft(applicationKind: ApplicationKind): DraftRequirement {
/**
* Which licences a personal document is asked for.
*
* Empty means every licence (stored as a row with no licence type); otherwise
* one row per chosen type, all sharing the key. The applicant sees one slot
* either way — the portal collapses the rows by key — and only if they have
* declared operating as one of the types.
*/
export type PersonalScope = string[];
function emptyDraft(applicationKind: ApplicationKind, personal: boolean): DraftRequirement {
return {
key: '',
name: { en: '', am: '' },
applicationKind,
mode: 'ALWAYS',
allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
// A personal document is never demanded by one application, so "always
// required" would be a promise nothing here can keep.
mode: personal ? 'OPTIONAL' : 'ALWAYS',
allowedMimeTypes: [...DEFAULT_MIME_TYPES],
maxSizeMb: 5,
requiresValidityDates: false,
allowMultiple: false,
isPersonal: personal,
maxFiles: personal ? 1 : null,
sortOrder: 0,
};
}
/** Adds/edits one document requirement slot for a licence type + application kind. */
/**
* Adds/edits one document requirement slot.
*
* Two shapes of the same row: a slot on one licence type's application form,
* and — with `personal` — a document every applicant keeps in their own vault
* whatever they apply for. The vault has no application to condition on and no
* renewal of its own, so those fields are hidden rather than left to mean
* nothing.
*/
export function DocumentRequirementEditorDrawer({
opened,
onClose,
@@ -49,19 +120,33 @@ export function DocumentRequirementEditorDrawer({
palette,
conditionTargets,
saving,
personal = false,
licenseTypes = [],
scope = [],
}: {
opened: boolean;
onClose: () => void;
/** Null = adding a new requirement. */
requirement: DocumentRequirement | null;
defaultApplicationKind: ApplicationKind;
onSave: (draft: DraftRequirement) => void;
onSave: (draft: DraftRequirement, scope: PersonalScope) => void;
palette: FormSchemaPalette | undefined;
conditionTargets: ConditionTarget[];
saving: boolean;
/** Editing a personal document — one kept in the applicant's own vault. */
personal?: boolean;
/** Licence types offered as scope; only read when `personal`. */
licenseTypes?: LicenseType[];
/** The licence types this document is already scoped to. */
scope?: PersonalScope;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<DraftRequirement>(emptyDraft(defaultApplicationKind));
const localized = useLocalized();
const [draft, setDraft] = useState<DraftRequirement>(
emptyDraft(defaultApplicationKind, personal),
);
const [scopeIds, setScopeIds] = useState<PersonalScope>(scope);
const [appliesToAll, setAppliesToAll] = useState(scope.length === 0);
const [keyError, setKeyError] = useState<string | null>(null);
const isNew = !requirement;
@@ -80,13 +165,19 @@ export function DocumentRequirementEditorDrawer({
maxSizeMb: requirement.maxSizeMb,
requiresValidityDates: requirement.requiresValidityDates,
allowMultiple: requirement.allowMultiple,
isPersonal: requirement.isPersonal ?? personal,
maxFiles: requirement.maxFiles ?? null,
sortOrder: requirement.sortOrder,
}
: emptyDraft(defaultApplicationKind),
: emptyDraft(defaultApplicationKind, personal),
);
setScopeIds(scope);
setAppliesToAll(scope.length === 0);
setKeyError(null);
}
}, [opened, requirement, defaultApplicationKind]);
// `scope` is a fresh array each render; the opened flag is what gates this.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, requirement, defaultApplicationKind, personal]);
function save() {
if (!draft.key.trim()) {
@@ -107,11 +198,22 @@ export function DocumentRequirementEditorDrawer({
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
return;
}
onSave({
...draft,
key: draft.key.trim(),
conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined,
});
if (personal && !appliesToAll && scopeIds.length === 0) {
setKeyError(t('certReq.doc.scopeRequired', 'Choose at least one licence type'));
return;
}
onSave(
{
...draft,
key: draft.key.trim(),
conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined,
isPersonal: personal,
// `allowMultiple` predates `maxFiles` and nothing reads it any more;
// kept in step so the two columns never contradict each other.
allowMultiple: draft.maxFiles !== 1,
},
appliesToAll ? [] : scopeIds,
);
}
return (
@@ -131,7 +233,13 @@ export function DocumentRequirementEditorDrawer({
error={keyError}
disabled={!isNew}
description={isNew ? t('certReq.doc.keyHelp', 'Stable slug identifying this document slot') : t('certReq.doc.keyLocked', 'Key cannot change once created')}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
// The value is read out of the event first: a functional updater
// runs after React has released the event, so `currentTarget` is
// null by the time it would be read inside one.
onChange={(e) => {
const { value } = e.currentTarget;
setDraft((d) => ({ ...d, key: value }));
}}
/>
<BilingualInput
@@ -147,32 +255,72 @@ export function DocumentRequirementEditorDrawer({
onChange={(v) => setDraft((d) => ({ ...d, description: v }))}
/>
<Select
label={t('certReq.doc.applicationKind', 'Application kind')}
data={[
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
]}
value={draft.applicationKind}
onChange={(v) => v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))}
allowDeselect={false}
disabled={!isNew}
description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined}
/>
{personal && (
<Stack gap="xs">
<Text fz="sm" fw={500}>
{t('certReq.doc.scope', 'Applies to')}
</Text>
<SegmentedControl
fullWidth
value={appliesToAll ? 'all' : 'selected'}
onChange={(v) => setAppliesToAll(v === 'all')}
data={[
{ value: 'all', label: t('certReq.doc.scopeAll', 'All licences') },
{
value: 'selected',
label: t('certReq.doc.scopeSelected', 'Selected licence types'),
},
]}
/>
{!appliesToAll && (
<MultiSelect
data={licenseTypes
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key }))}
value={scopeIds}
onChange={setScopeIds}
searchable
placeholder={t('certReq.doc.scopePlaceholder', 'Choose licence types')}
description={t(
'certReq.doc.scopeHelp',
'Only applicants who declared one of these as a mode of operation are asked for it.',
)}
/>
)}
</Stack>
)}
<Select
label={t('certReq.doc.mode', 'Mode')}
data={[
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
{ value: 'CONDITIONAL', label: t('certReq.doc.modeConditional', 'Required when condition holds') },
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
]}
value={draft.mode}
onChange={(v) => v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))}
allowDeselect={false}
/>
{!personal && (
<Select
label={t('certReq.doc.applicationKind', 'Application kind')}
data={[
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
]}
value={draft.applicationKind}
onChange={(v) => v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))}
allowDeselect={false}
disabled={!isNew}
description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined}
/>
)}
{draft.mode === 'CONDITIONAL' && (
{!personal && (
<Select
label={t('certReq.doc.mode', 'Mode')}
data={[
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
{ value: 'CONDITIONAL', label: t('certReq.doc.modeConditional', 'Required when condition holds') },
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
]}
value={draft.mode}
onChange={(v) => v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))}
allowDeselect={false}
/>
)}
{!personal && draft.mode === 'CONDITIONAL' && (
<>
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
<ConditionBuilder
@@ -187,6 +335,11 @@ export function DocumentRequirementEditorDrawer({
<MultiSelect
label={t('certReq.doc.allowedTypes', 'Allowed file types')}
description={t(
'certReq.doc.allowedTypesHelp',
'The applicant can only upload these. PDF, JPEG and PNG are selected by default.',
)}
searchable
data={MIME_OPTIONS}
value={draft.allowedMimeTypes}
onChange={(v) => setDraft((d) => ({ ...d, allowedMimeTypes: v }))}
@@ -199,16 +352,29 @@ export function DocumentRequirementEditorDrawer({
onChange={(v) => setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))}
/>
<Checkbox
label={t('certReq.doc.requiresValidity', 'Requires validity dates')}
checked={draft.requiresValidityDates}
onChange={(e) => setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))}
/>
{!personal && (
<Checkbox
label={t('certReq.doc.requiresValidity', 'Requires validity dates')}
checked={draft.requiresValidityDates}
onChange={(e) => {
const { checked } = e.currentTarget;
setDraft((d) => ({ ...d, requiresValidityDates: checked }));
}}
/>
)}
<Checkbox
label={t('certReq.doc.allowMultiple', 'Allow multiple uploads')}
checked={draft.allowMultiple}
onChange={(e) => setDraft((d) => ({ ...d, allowMultiple: e.currentTarget.checked }))}
<NumberInput
label={t('certReq.doc.maxFiles', 'Files accepted')}
description={t(
'certReq.doc.maxFilesHelp',
'Leave empty for no limit. Use 2 for a document with a front and a back.',
)}
placeholder={t('certReq.doc.maxFilesUnlimited', 'No limit')}
min={1}
value={draft.maxFiles ?? ''}
onChange={(v) =>
setDraft((d) => ({ ...d, maxFiles: typeof v === 'number' ? v : null }))
}
/>
<NumberInput

View File

@@ -50,7 +50,12 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
const requirements = useMemo(
() => (data?.items ?? []).filter((r) => r.licenseTypeId === licenseType.id),
// Personal documents can also name a licence type — they are asked for in
// the applicant's vault, not on this form, and are edited in Configuration.
() =>
(data?.items ?? []).filter(
(r) => r.licenseTypeId === licenseType.id && !r.isPersonal,
),
[data, licenseType.id],
);
const conditionTargets = collectConditionTargets(licenseType.formSchema.sections);
@@ -127,7 +132,7 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
) : (
<Stack gap="xs">
{rows.map((req) => (
<Card key={req.id} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
<Card key={req.id} withBorder radius="sm" p="xs" bg="var(--mantine-color-default-hover)">
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Group gap={6}>

View File

@@ -113,7 +113,10 @@ export function FieldEditorDrawer({
? t('certReq.field.keyHelp', 'Letters, numbers and underscores only — becomes the form data key')
: t('certReq.field.keyLocked', 'Key cannot change once created')
}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
onChange={(e) => {
const { value } = e.currentTarget;
setDraft((d) => ({ ...d, key: value }));
}}
/>
<BilingualInput
@@ -134,7 +137,10 @@ export function FieldEditorDrawer({
<Checkbox
label={t('certReq.field.required', 'Required')}
checked={Boolean(draft.required)}
onChange={(e) => setDraft((d) => ({ ...d, required: e.currentTarget.checked }))}
onChange={(e) => {
const { checked } = e.currentTarget;
setDraft((d) => ({ ...d, required: checked }));
}}
/>
<BilingualInput

View File

@@ -209,7 +209,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
<Card key={section.key} withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" mb="sm">
<Group gap="xs" wrap="nowrap">
<IconGripVertical size={16} color="var(--mantine-color-gray-5)" />
<IconGripVertical size={16} color="var(--mantine-color-dimmed)" />
<div>
<Group gap="xs">
<Text fw={700}>{localized(section.title) || section.key}</Text>
@@ -237,7 +237,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
<Stack gap="xs">
{section.fields.map((field, fIndex) => (
<Card key={field.key} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
<Card key={field.key} withBorder radius="sm" p="xs" bg="var(--mantine-color-default-hover)">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<div style={{ minWidth: 0 }}>

View File

@@ -0,0 +1,502 @@
import { useMemo, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Group,
Modal,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash, IconX } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
AdvancedTable,
ModalFooter,
useServerTable,
type AdvancedColumn,
} from '@ema-platform/ui';
import {
useCreateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useGetLicenseTypesQuery,
useGetPersonalDocumentsQuery,
useLocalized,
useUpdateDocumentRequirementMutation,
type DocumentRequirement,
type PersonalDocumentGroup as PersonalDocumentGroupDto,
} from '@ema-platform/api';
import { useRequirementActions } from '../hooks/useRequirementActions';
import {
DocumentRequirementEditorDrawer,
type PersonalScope,
} from './DocumentRequirementEditorDrawer';
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
/** Filter value for "the documents every licence asks for". */
const GLOBAL_ONLY = 'GLOBAL';
const SEARCH_DEBOUNCE_MS = 300;
/**
* Badge colours for licence types.
*
* Red is left out: it reads as a problem, and a licence type is not one.
* Everything else the theme offers is in, because the point of the colour is
* telling two licence types apart at a glance.
*/
const SCOPE_COLORS = [
'blue',
'grape',
'teal',
'orange',
'violet',
'cyan',
'pink',
'lime',
'indigo',
'green',
'yellow',
'gray',
];
/** Doubles the palette: the same hue, a visibly different badge. */
const SCOPE_VARIANTS = ['light', 'outline'] as const;
/**
* A colour per licence type, assigned by position in the catalogue.
*
* Hashing the id looked tidier and was wrong: eight buckets over sixteen
* licence types collide by the pigeonhole principle, so Vessel Registration
* and Freight Forwarder came out the same colour and the badge stopped
* carrying information. Walking the sorted catalogue instead gives every type
* a distinct colour until the palette runs out, and only then repeats a hue in
* the other variant — 24 distinct badges before any two can look alike.
*
* Sorted by `sortOrder` so the assignment is the same for every officer and
* survives a refresh; a type added later takes the next free style rather than
* reshuffling the ones already learned.
*/
function buildScopeStyles(
types: { id: string; sortOrder: number }[],
): Map<string, { color: string; variant: (typeof SCOPE_VARIANTS)[number] }> {
const styles = new Map<
string,
{ color: string; variant: (typeof SCOPE_VARIANTS)[number] }
>();
types
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.forEach((type, index) => {
styles.set(type.id, {
color: SCOPE_COLORS[index % SCOPE_COLORS.length],
variant:
SCOPE_VARIANTS[
Math.floor(index / SCOPE_COLORS.length) % SCOPE_VARIANTS.length
],
});
});
return styles;
}
/** `application/vnd.openxmlformats-…-document` is nobody's idea of a column. */
const MIME_LABELS: Record<string, string> = {
'application/pdf': 'PDF',
'application/msword': 'DOC',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
'application/vnd.ms-excel': 'XLS',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
};
function shortMime(mime: string): string {
return MIME_LABELS[mime] ?? mime.split('/')[1]?.toUpperCase() ?? mime;
}
/**
* A document as the table renders it: what the server sent, plus the row id
* `AdvancedTable` keys on and the scope read off its rows.
*
* The grouping itself belongs to the server — a page of rows would split a
* document configured for three licence types across two pages and misreport
* the scope of both halves.
*/
interface PersonalDocumentGroup extends PersonalDocumentGroupDto {
/** The key doubles as the row id; one group is one document. */
id: string;
/** Empty when the document applies to every licence. */
scope: PersonalScope;
}
/**
* Documents an applicant keeps in their own vault.
*
* Same `document_requirements` table as a licence type's upload slots, flagged
* `isPersonal`: these are not asked for on an application form but held once,
* under My Documents in the portal. A document can apply to every licence or
* only to the modes of operation an applicant has declared — a sea service
* book is worth asking a seafarer for and pointless for a freight forwarder.
*/
export function PersonalDocumentsCard() {
const { t, i18n } = useTranslation();
const localized = useLocalized();
const run = useRequirementActions();
const { data: licenseTypes } = useGetLicenseTypesQuery();
const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation();
const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation();
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
const [editing, setEditing] = useState<{ group: PersonalDocumentGroup | null } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<PersonalDocumentGroup | null>(null);
/** null = any licence, GLOBAL_ONLY = the all-licence ones, else a type id. */
const [licenseTypeFilter, setLicenseTypeFilter] = useState<string | null>(null);
const { pageIndex, setPageIndex, pageSize, setPageSize } = useServerTable({
pageSize: 10,
});
const [searchInput, setSearchInput] = useState('');
// Typing must not fire a request per keystroke; same 300ms as the queue.
const [search] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
// Every facet goes to the server: it filters and searches in SQL, groups the
// rows into documents, then pages the documents.
const { data, isFetching, refetch } = useGetPersonalDocumentsQuery({
search: search.trim() || undefined,
licenseTypeId:
licenseTypeFilter && licenseTypeFilter !== GLOBAL_ONLY
? licenseTypeFilter
: undefined,
globalOnly: licenseTypeFilter === GLOBAL_ONLY,
take: pageSize,
skip: pageIndex * pageSize,
locale: i18n.language === 'am' ? 'am' : 'en',
});
const groups = useMemo<PersonalDocumentGroup[]>(
() =>
(data?.items ?? []).map((group) => ({
...group,
id: group.key,
// A single row with no licence type means "every licence"; the two
// never coexist, because the editor writes one shape or the other.
scope: group.rows
.map((r) => r.licenseTypeId)
.filter((id): id is string => id !== null),
})),
[data],
);
/** Filters are the server's business now; an empty page is its answer. */
const isFiltered = search.trim() !== '' || licenseTypeFilter !== null;
function clearFilters() {
setSearchInput('');
setLicenseTypeFilter(null);
setPageIndex(0);
}
const scopeStyles = useMemo(
() => buildScopeStyles(licenseTypes?.items ?? []),
[licenseTypes],
);
const typeName = (id: string) => {
const found = (licenseTypes?.items ?? []).find((lt) => lt.id === id);
return found ? localized(found.name) || found.key : id;
};
const columns = useMemo<AdvancedColumn<PersonalDocumentGroup>[]>(
() => [
{
header: t('certReq.personal.columns.document', 'Document'),
label: t('certReq.personal.columns.document', 'Document'),
cell: ({ row }) => (
<div>
<Text fz="sm" fw={600}>
{localized(row.original.rows[0].name) || row.original.key}
</Text>
<Text fz="xs" c="dimmed">
{row.original.key}
</Text>
</div>
),
},
{
header: t('certReq.doc.scope', 'Applies to'),
label: t('certReq.doc.scope', 'Applies to'),
cell: ({ row }) =>
row.original.scope.length === 0 ? (
// Filled, where a licence type is outlined: "every licence" is a
// different kind of answer, not one more item in the same list.
<Badge size="sm" variant="filled" color="gray">
{t('certReq.doc.scopeAll', 'All licences')}
</Badge>
) : (
<Group gap={4}>
{row.original.scope.map((id) => {
// A type the catalogue no longer lists still needs a badge.
const style = scopeStyles.get(id) ?? { color: 'gray', variant: 'light' };
return (
<Badge key={id} size="sm" variant={style.variant} color={style.color}>
{typeName(id)}
</Badge>
);
})}
</Group>
),
},
{
header: t('certReq.doc.maxFiles', 'Files accepted'),
label: t('certReq.doc.maxFiles', 'Files accepted'),
align: 'center',
cell: ({ row }) =>
row.original.rows[0].maxFiles === null
? t('certReq.doc.maxFilesUnlimited', 'No limit')
: row.original.rows[0].maxFiles,
},
{
header: t('certReq.doc.allowedTypes', 'Allowed file types'),
label: t('certReq.doc.allowedTypes', 'Allowed file types'),
cell: ({ row }) => {
const types = row.original.rows[0].allowedMimeTypes ?? [];
return (
// Twenty-odd mime types would own the row; the full list is one
// hover away instead.
<Tooltip label={types.join(', ')} multiline w={280} disabled={types.length <= 3}>
<Text fz="xs">
{types.slice(0, 3).map(shortMime).join(', ')}
{types.length > 3
? t('certReq.personal.moreTypes', ' +{{count}} more', {
count: types.length - 3,
})
: ''}
</Text>
</Tooltip>
);
},
},
{
header: t('certReq.doc.maxSize', 'Max file size (MB)'),
label: t('certReq.doc.maxSize', 'Max file size (MB)'),
align: 'center',
cell: ({ row }) => `${row.original.rows[0].maxSizeMb} MB`,
},
{
header: '',
label: t('certReq.personal.columns.actions', 'Actions'),
align: 'right',
cell: ({ row }) => (
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon
variant="subtle"
color="blue"
onClick={() => setEditing({ group: row.original })}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => setDeleteTarget(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
),
},
],
// `typeName` and `scopeStyles` both close over the licence-type list.
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, localized, licenseTypes, scopeStyles],
);
/**
* Saves the group as the set of rows it now means.
*
* The scope is edited as a whole, so the diff is the honest way to apply it:
* rows for licence types that were added get created, rows for types that
* were dropped get deleted, and everything still in scope is updated. A
* document moved to "all licences" collapses to a single row with none.
*/
async function handleSave(draft: DraftRequirement, scope: PersonalScope) {
const existing = editing?.group?.rows ?? [];
// `null` is a licence type here too — the one meaning "every licence".
const wanted: (string | null)[] = scope.length ? scope : [null];
const ok = await run(async () => {
const stale = existing.filter((row) => !wanted.includes(row.licenseTypeId));
const kept = existing.filter((row) => wanted.includes(row.licenseTypeId));
const added = wanted.filter(
(id) => !existing.some((row) => row.licenseTypeId === id),
);
await Promise.all([
...kept.map((row) => updateRequirement({ id: row.id, ...draft }).unwrap()),
...added.map((licenseTypeId) =>
createRequirement({ ...draft, licenseTypeId }).unwrap(),
),
...stale.map((row) => deleteRequirement(row.id).unwrap()),
]);
}, editing?.group
? t('certReq.doc.updated', 'Document requirement updated')
: t('certReq.doc.created', 'Document requirement added'));
if (ok) setEditing(null);
}
async function confirmDelete() {
if (!deleteTarget) return;
const ok = await run(
() => Promise.all(deleteTarget.rows.map((row) => deleteRequirement(row.id).unwrap())),
t('certReq.doc.deleted', 'Document requirement removed'),
);
if (ok) setDeleteTarget(null);
}
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Title order={5}>{t('certReq.personal.title', 'Personal documents')}</Title>
<Text fz="sm" c="dimmed">
{t(
'certReq.personal.subtitle',
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
)}
</Text>
</div>
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={13} />}
onClick={() => setEditing({ group: null })}
style={{ flexShrink: 0 }}
>
{t('certReq.personal.add', 'Add personal document')}
</Button>
</Group>
{/* Facets, in the shape the licence-review queue uses. No date range:
a configuration row has no submission date to filter on. */}
<Paper withBorder p="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<TextInput
label={t('certReq.personal.search', 'Search')}
placeholder={t('certReq.personal.searchPlaceholder', 'Search by name or key')}
leftSection={<IconSearch size={14} />}
value={searchInput}
onChange={(e) => {
const { value } = e.currentTarget;
setSearchInput(value);
setPageIndex(0);
}}
w={240}
/>
<Select
label={t('certReq.doc.scope', 'Applies to')}
placeholder={t('certReq.personal.filterAny', 'Any licence type')}
data={[
{
value: GLOBAL_ONLY,
label: t('certReq.personal.filterGlobal', 'All-licence documents only'),
},
...(licenseTypes?.items ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key })),
]}
value={licenseTypeFilter}
onChange={(value) => {
setLicenseTypeFilter(value);
// A narrower list can be shorter than the page you were on.
setPageIndex(0);
}}
searchable
clearable
w={240}
/>
{isFiltered && (
<Button
variant="subtle"
leftSection={<IconX size={14} />}
onClick={clearFilters}
>
{t('certReq.personal.clearFilters', 'Clear')}
</Button>
)}
</Group>
</Paper>
<AdvancedTable
tableName={t('certReq.personal.title', 'Personal documents')}
columns={columns}
data={groups}
itemCount={data?.total ?? 0}
pageIndex={pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={
isFiltered
? t('certReq.personal.noMatch', 'No personal document matches those filters.')
: t('certReq.personal.empty', 'No personal documents configured yet.')
}
/>
<DocumentRequirementEditorDrawer
opened={editing !== null}
onClose={() => setEditing(null)}
requirement={editing?.group?.rows[0] ?? null}
defaultApplicationKind="NEW"
onSave={handleSave}
palette={undefined}
conditionTargets={[]}
saving={creating || updating}
personal
licenseTypes={licenseTypes?.items ?? []}
scope={editing?.group?.scope ?? []}
/>
<Modal
opened={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title={t('certReq.doc.delete', 'Delete document requirement')}
size="sm"
>
<Stack gap="md">
<Alert color="yellow" variant="light">
{t(
'certReq.personal.deleteWarning',
'The slot disappears from every applicants My Documents. Files already uploaded are kept, but nobody can reach them.',
)}
</Alert>
<Text fz="sm">
{t('certReq.doc.deleteConfirm', 'Remove "{{name}}"?', {
name: deleteTarget
? localized(deleteTarget.rows[0].name) || deleteTarget.key
: '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteTarget(null)}>
{t('certReq.cancel', 'Cancel')}
</Button>
<Button color="red" onClick={confirmDelete}>
{t('certReq.delete', 'Delete')}
</Button>
</ModalFooter>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -72,7 +72,10 @@ export function SectionEditorDrawer({
? t('certReq.section.keyHelp', 'Letters, numbers and underscores only')
: t('certReq.section.keyLocked', 'Key cannot change once created')
}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
onChange={(e) => {
const { value } = e.currentTarget;
setDraft((d) => ({ ...d, key: value }));
}}
/>
<BilingualInput
@@ -95,7 +98,10 @@ export function SectionEditorDrawer({
'Sections sharing the same group render together on one step',
)}
value={draft.group ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, group: e.currentTarget.value || undefined }))}
onChange={(e) => {
const { value } = e.currentTarget;
setDraft((d) => ({ ...d, group: value || undefined }));
}}
/>
<NumberInput

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

@@ -38,6 +38,7 @@ const SCOPES: { value: NumberFormatScope; label: string }[] = [
{ value: 'SEAFARER_NUMBER', label: 'Seafarer Number' },
{ value: 'SEAMAN_BOOK_NUMBER', label: 'Seaman Book Number' },
{ value: 'BTC_NUMBER', label: 'BTC Number' },
{ value: 'BSID', label: 'Biometric Subject ID (BSID)' },
];
const scopeLabel = (scope: NumberFormatScope) =>

View File

@@ -24,6 +24,7 @@ import {
IconHash,
IconInfoCircle,
IconAnchor,
IconId,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
@@ -35,6 +36,9 @@ import {
PageLoader,
} from "@ema-platform/ui";
import { LocationPage } from "../../../location/pages/LocationPage";
// Lives with the document-requirement editor it reuses; shown here because a
// document required for every licence is configuration, not one type's form.
import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard";
import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab";
import { RankDepartmentTab } from "./RankDepartmentTab";
@@ -406,6 +410,9 @@ export function ConfigurationPage() {
<Tabs.Tab value="ranks" leftSection={<IconAnchor size={16} />}>
{t("configuration.ranksTab", "Ranks & Departments")}
</Tabs.Tab>
<Tabs.Tab value="personalDocuments" leftSection={<IconId size={16} />}>
{t("configuration.personalDocumentsTab", "Personal Documents")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="professions" pt="md">
@@ -427,6 +434,10 @@ export function ConfigurationPage() {
<Tabs.Panel value="ranks" pt="md">
<RankDepartmentTab />
</Tabs.Panel>
<Tabs.Panel value="personalDocuments" pt="md">
<PersonalDocumentsCard />
</Tabs.Panel>
</Tabs>
</Stack>
);

View File

@@ -51,7 +51,8 @@ export interface UpdateProfessionPayload {
export type NumberFormatScope =
| 'SEAFARER_NUMBER'
| 'SEAMAN_BOOK_NUMBER'
| 'BTC_NUMBER';
| 'BTC_NUMBER'
| 'BSID';
/**
* The shape of a generated identifier — prefix, optional year, separator and

View File

@@ -28,6 +28,7 @@ export type ActionId =
| 'complete-review'
| 'approve-documents'
| 'schedule-inspection'
| 'reschedule-inspection'
| 'record-inspection'
| 'final-approve'
| 'request-adjustment'
@@ -198,6 +199,17 @@ export const ACTIONS: ActionDefinition[] = [
permissions: ['can:create:inspection'],
emphasis: 'filled',
},
{
id: 'reschedule-inspection',
tier: 'primary',
labelKey: 'review.actions.rescheduleInspection',
from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
// Either permission: the team leader who booked the visit holds CREATE,
// the inspector who has to attend holds UPDATE, and both have a reason to
// move it. The server guards the same pair.
permissions: ['can:create:inspection', 'can:update:inspection'],
emphasis: 'light',
},
{
id: 'record-inspection',
tier: 'primary',
@@ -420,6 +432,12 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
// one applies depends on whether an inspection is already booked.
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
// The mirror of the scheduling gate: there is nothing to move until a
// visit is booked, and once one is, moving it is the officer's only
// option until the day arrives.
if (action.id === 'reschedule-inspection' && !ctx.hasPendingInspection) {
return [];
}
// The transition table doesn't know which types need an inspection, so
// `availableEvents` lists approve-documents at UNDER_EVALUATION even for

View File

@@ -1,14 +1,21 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { Badge, Checkbox, Text, Tooltip } from "@mantine/core";
import { Badge, Checkbox, Group, Text, Tooltip } from "@mantine/core";
import type { TFunction } from "i18next";
import {
STATUS_COLORS,
STATUS_LABELS,
applicantOrCompanyName,
localized,
type ApplicationKind,
type LicenseApplication,
type QueueFilter,
} from "@ema-platform/api";
const KIND_COLOR: Record<ApplicationKind, string> = {
NEW: "blue",
RENEWAL: "teal",
REISSUE: "orange",
};
import type { AdvancedColumn } from "@ema-platform/ui";
import { dateDisplayer } from "@ema-platform/shared";
import { computeSla } from "../../sla";
@@ -112,9 +119,19 @@ export function licenseQueueColumns(
{
header: t("queue.typeCol", "Type"),
cell: ({ row }) => (
<Text size="sm">
{localized(row.original.licenseType?.name, locale) || "—"}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm">
{localized(row.original.licenseType?.name, locale) || "—"}
</Text>
{row.original.kind !== "NEW" && (
<Badge size="xs" variant="light" color={KIND_COLOR[row.original.kind]}>
{t(
`queue.kindValues.${row.original.kind}`,
row.original.kind === "RENEWAL" ? "Renewal" : "Replacement",
)}
</Badge>
)}
</Group>
),
},
{

View File

@@ -42,6 +42,7 @@ import {
useGetQueueCountsQuery,
useGetQueueQuery,
useLazyExportApplicationsQuery,
type ApplicationKind,
type LicenseApplication,
type LicenseStatus,
type LicenseType,
@@ -437,6 +438,7 @@ export function LicenseQueuePage() {
const hasFacets = Boolean(
urlFilter.status?.length ||
urlFilter.licenseTypeId ||
urlFilter.kind ||
urlFilter.assignee ||
urlFilter.submittedFrom ||
debouncedSearch,
@@ -589,6 +591,19 @@ export function LicenseQueuePage() {
w={220}
/>
)}
<Select
label={t("queue.kind", "Application kind")}
placeholder={t("queue.anyType", "Any")}
data={[
{ value: "NEW", label: t("queue.kindValues.NEW", "New") },
{ value: "RENEWAL", label: t("queue.kindValues.RENEWAL", "Renewal") },
{ value: "REISSUE", label: t("queue.kindValues.REISSUE", "Replacement") },
]}
value={urlFilter.kind ?? null}
onChange={(v) => setFacet({ kind: (v as ApplicationKind) ?? undefined })}
clearable
w={180}
/>
<AmharicDatePicker
label={t("queue.submittedFrom", "Submitted from")}
value={urlFilter.submittedFrom ?? ""}

View File

@@ -32,6 +32,7 @@ import {
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
IconPaperclip,
IconPencil,
IconQuestionMark,
IconX,
} from "@tabler/icons-react";
@@ -68,6 +69,7 @@ import {
useRequestAdjustmentMutation,
useResumeApplicationMutation,
useScheduleInspectionMutation,
useRescheduleInspectionMutation,
useGetCertificateUrlForOfficerMutation,
uploadDocument,
type RemarkTargetType,
@@ -225,6 +227,7 @@ export function LicenseReviewPage() {
const [finalApprove] = useFinalApproveMutation();
const [rejectApplication] = useRejectApplicationMutation();
const [scheduleInspection] = useScheduleInspectionMutation();
const [rescheduleInspection] = useRescheduleInspectionMutation();
const [recordResult] = useRecordInspectionResultMutation();
const [confirmPayment] = useConfirmPaymentMutation();
const [scheduleIssuance] = useScheduleIssuanceMutation();
@@ -265,8 +268,14 @@ export function LicenseReviewPage() {
const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">(
"MORNING",
);
/** The booking modal moves an existing visit rather than creating one. */
const [rescheduling, setRescheduling] = useState(false);
const [rescheduleReason, setRescheduleReason] = useState("");
const [issuanceOpen, setIssuanceOpen] = useState(false);
const [issuanceDate, setIssuanceDate] = useState("");
const [issuancePeriod, setIssuancePeriod] = useState<"MORNING" | "AFTERNOON">(
"MORNING",
);
const [resultOpen, setResultOpen] = useState(false);
const [findings, setFindings] = useState("");
const [findingsUploadBusy, setFindingsUploadBusy] = useState(false);
@@ -312,10 +321,14 @@ export function LicenseReviewPage() {
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
// A visit cannot have an outcome before it happens — mirror of the server's
// inspection_not_yet_due guard, compared instant-to-instant.
// inspection_not_yet_due guard. The column holds a calendar day, so the
// comparison is between day strings in the authority's timezone: parsing
// "2026-08-28" as a Date would read it as UTC midnight, i.e. 03:00 in Addis.
const inspectionNotYetDue = Boolean(
pendingInspection?.scheduledDate &&
new Date(pendingInspection.scheduledDate) > new Date(),
new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Addis_Ababa" }).format(
new Date(),
) < pendingInspection.scheduledDate.slice(0, 10),
);
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
@@ -559,12 +572,29 @@ export function LicenseReviewPage() {
}
}
/** Opens the booking modal seeded with the visit already on the books. */
function openReschedule() {
if (!pendingInspection) return;
setRescheduling(true);
setInspectionDate(pendingInspection.scheduledDate ?? "");
setInspectionTimeSlot(pendingInspection.timeSlot ?? "MORNING");
setRescheduleReason("");
setInspectionOpen(true);
}
/** Actions with their own dedicated form open that; the rest confirm. */
function handleAction(action: ResolvedAction) {
switch (action.id) {
case "schedule-inspection":
setRescheduling(false);
setInspectionDate("");
setInspectionTimeSlot("MORNING");
setRescheduleReason("");
setInspectionOpen(true);
return;
case "reschedule-inspection":
openReschedule();
return;
case "schedule-issuance":
setIssuanceOpen(true);
return;
@@ -848,6 +878,11 @@ export function LicenseReviewPage() {
<Badge color={STATUS_COLORS[status]} variant="light">
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
</Badge>
{data.issuedLicenseStatus === "SUPERSEDED" && (
<Badge color="gray" variant="light">
{t("review.certificateSuperseded", "Certificate superseded")}
</Badge>
)}
{app.adjustmentRound > 0 && (
<Badge color="orange" variant="light" size="sm">
{t("review.round", {
@@ -1210,18 +1245,6 @@ export function LicenseReviewPage() {
</Tabs.Panel>
<Tabs.Panel value="inspection">
{status === "INSPECTION_FAILED" && (
<Alert
mb="md"
color="red"
icon={<IconAlertTriangle size={16} />}
>
{t(
"review.inspectionFailedBlocked",
"Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.",
)}
</Alert>
)}
<Paper withBorder p="md">
{inspections.length === 0 ? (
<Text size="sm" c="dimmed">
@@ -1254,21 +1277,48 @@ export function LicenseReviewPage() {
</Text>
)}
</div>
<Badge
variant="light"
color={
inspection.result === "FAILED" ? "red" : "teal"
}
>
{inspection.result === "PASSED"
? t("review.passed", "Passed")
: inspection.result === "FAILED"
? t("review.failed", "Failed")
: t(
`review.inspectionStatus.${inspection.status}`,
inspection.status,
<Group gap="xs">
{inspection.status === "SCHEDULED" &&
inspection.id === pendingInspection?.id &&
can([
"can:create:inspection",
"can:update:inspection",
]) && (
<Tooltip
label={t(
"review.actions.rescheduleInspection",
"Reschedule inspection",
)}
</Badge>
>
<ActionIcon
variant="subtle"
size="sm"
aria-label={t(
"review.actions.rescheduleInspection",
"Reschedule inspection",
)}
onClick={openReschedule}
>
<IconPencil size={16} />
</ActionIcon>
</Tooltip>
)}
<Badge
variant="light"
color={
inspection.result === "FAILED" ? "red" : "teal"
}
>
{inspection.result === "PASSED"
? t("review.passed", "Passed")
: inspection.result === "FAILED"
? t("review.failed", "Failed")
: t(
`review.inspectionStatus.${inspection.status}`,
inspection.status,
)}
</Badge>
</Group>
</Group>
))}
</Stack>
@@ -1277,6 +1327,18 @@ export function LicenseReviewPage() {
</Tabs.Panel>
</Tabs>
{/* Page-level, not inside the inspection tab: a license type
configured without an inspection detail section must still show
why approval is blocked if it ever lands here. */}
{status === "INSPECTION_FAILED" && (
<Alert mt="md" color="red" icon={<IconAlertTriangle size={16} />}>
{t(
"review.inspectionFailedBlocked",
"Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.",
)}
</Alert>
)}
{status === "PAYMENT_PENDING" && (
<Alert
mt="md"
@@ -1335,7 +1397,11 @@ export function LicenseReviewPage() {
<Modal
opened={inspectionOpen}
onClose={() => setInspectionOpen(false)}
title={t("review.actions.scheduleInspection", "Schedule inspection")}
title={
rescheduling
? t("review.actions.rescheduleInspection", "Reschedule inspection")
: t("review.actions.scheduleInspection", "Schedule inspection")
}
>
<Stack>
<AmharicDatePicker
@@ -1351,36 +1417,72 @@ export function LicenseReviewPage() {
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
]}
/>
{rescheduling && (
<Textarea
label={t("review.rescheduleReason", "Why is it moving?")}
description={t(
"review.rescheduleReasonHint",
"Kept in the audit trail and sent to the applicant.",
)}
value={rescheduleReason}
onChange={(e) => setRescheduleReason(e.currentTarget.value)}
autosize
minRows={2}
/>
)}
<ModalFooter>
{/* Mantine strips pointer events from a disabled control, so the
tooltip wraps a span — same trick as DecisionBar's ActionButton;
a disabled button must still say why. */}
<Tooltip
label={t("review.pickDate", "Pick a date first")}
disabled={Boolean(inspectionDate)}
>
<span>
<button type="button" hidden aria-hidden />
</span>
</Tooltip>
<span style={{ display: "inline-flex" }}>
<ActionIcon
variant="filled"
size="lg"
disabled={!inspectionDate}
aria-label={t("review.schedule", "Schedule")}
aria-label={
rescheduling
? t("review.reschedule", "Reschedule")
: t("review.schedule", "Schedule")
}
onClick={() =>
run(
async () => {
await scheduleInspection({
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
}).unwrap();
if (rescheduling) {
// Guarded by the action's own gate, which only offers
// rescheduling while a booking exists.
if (!pendingInspection) return;
await rescheduleInspection({
inspectionId: pendingInspection.id,
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
...(rescheduleReason.trim()
? { reason: rescheduleReason.trim() }
: {}),
}).unwrap();
} else {
await scheduleInspection({
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
}).unwrap();
}
setInspectionOpen(false);
},
t("review.done.scheduled", "Inspection scheduled"),
rescheduling
? t("review.done.rescheduled", "Inspection rescheduled")
: t("review.done.scheduled", "Inspection scheduled"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
</span>
</Tooltip>
</ModalFooter>
</Stack>
</Modal>
@@ -1396,35 +1498,46 @@ export function LicenseReviewPage() {
value={issuanceDate}
onChange={setIssuanceDate}
/>
<SegmentedControl
value={issuancePeriod}
onChange={(value) => setIssuancePeriod(value as "MORNING" | "AFTERNOON")}
data={[
{ value: "MORNING", label: t("review.morning", "Morning") },
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
]}
/>
<ModalFooter>
{/* Span-wrapped like DecisionBar's ActionButton — Mantine strips
pointer events from a disabled control, and a disabled button
must still say why. */}
<Tooltip
label={t("review.pickDate", "Pick a date and time first")}
label={t("review.pickDate", "Pick a date first")}
disabled={Boolean(issuanceDate)}
>
<span>
<button type="button" hidden aria-hidden />
<span style={{ display: "inline-flex" }}>
<ActionIcon
variant="filled"
size="lg"
disabled={!issuanceDate}
aria-label={t("review.schedule", "Schedule")}
onClick={() =>
run(
async () => {
await scheduleIssuance({
id,
scheduledDate: issuanceDate,
scheduledPeriod: issuancePeriod,
}).unwrap();
setIssuanceOpen(false);
},
t("review.done.scheduleIssuance", "Pickup scheduled"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
</span>
</Tooltip>
<ActionIcon
variant="filled"
size="lg"
disabled={!issuanceDate}
aria-label={t("review.schedule", "Schedule")}
onClick={() =>
run(
async () => {
await scheduleIssuance({
id,
scheduledDate: issuanceDate,
}).unwrap();
setIssuanceOpen(false);
},
t("review.done.scheduleIssuance", "Pickup scheduled"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
</ModalFooter>
</Stack>
</Modal>

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

@@ -0,0 +1,71 @@
import { Button, Group } from '@mantine/core';
import { IconCheck, IconUserCheck, IconX } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { PickupAppointment } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
export function pickupDeskActionsColumn(
t: TFunction,
handlers: {
onCheckIn: (appointment: PickupAppointment) => void;
onIssue: (appointment: PickupAppointment) => void;
onNoShow: (appointment: PickupAppointment) => void;
},
loadingId: string | null,
): AdvancedColumn<PickupAppointment> {
return {
header: '',
size: 260,
align: 'right',
cell: ({ row }) => {
const appointment = row.original;
const loading = loadingId === appointment.id;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
{appointment.status === 'SCHEDULED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_PICKUP_DESK]} hideOnly>
<Button
size="xs"
variant="light"
loading={loading}
leftSection={<IconUserCheck size={14} />}
onClick={() => handlers.onCheckIn(appointment)}
>
{t('pickupDesk.checkIn', 'Check in')}
</Button>
</RequirePermission>
)}
{(appointment.status === 'SCHEDULED' || appointment.status === 'CHECKED_IN') && (
<>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.ISSUE_CERTIFICATE]} hideOnly>
<Button
size="xs"
variant="filled"
color="teal"
loading={loading}
leftSection={<IconCheck size={14} />}
onClick={() => handlers.onIssue(appointment)}
>
{t('pickupDesk.issue', 'Issue')}
</Button>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_PICKUP_DESK]} hideOnly>
<Button
size="xs"
variant="subtle"
color="red"
loading={loading}
leftSection={<IconX size={14} />}
onClick={() => handlers.onNoShow(appointment)}
>
{t('pickupDesk.noShow', 'No-show')}
</Button>
</RequirePermission>
</>
)}
</Group>
);
},
};
}

View File

@@ -0,0 +1,51 @@
import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { PickupAppointment, PickupOffice } from '@ema-platform/api';
const STATUS_COLOR: Record<PickupAppointment['status'], string> = {
SCHEDULED: 'cyan',
CHECKED_IN: 'yellow',
ISSUED: 'green',
NO_SHOW: 'red',
RESCHEDULED: 'gray',
CANCELLED: 'gray',
};
export function pickupDeskColumns(
t: TFunction,
officesById: Map<string, PickupOffice>,
): AdvancedColumn<PickupAppointment>[] {
return [
{
header: t('pickupDesk.columns.time', 'Time'),
cell: ({ row }) => (
<Text size="sm" fw={600} ff="monospace">
{row.original.slotStartTime}
</Text>
),
},
{
header: t('pickupDesk.columns.appointment', 'Appointment'),
cell: ({ row }) => (
<Text size="sm" ff="monospace">
{row.original.appointmentNumber}
</Text>
),
},
{
header: t('pickupDesk.columns.office', 'Office'),
cell: ({ row }) => (
<Text size="sm">{officesById.get(row.original.officeId)?.name ?? '—'}</Text>
),
},
{
header: t('pickupDesk.columns.status', 'Status'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
{row.original.status.replace('_', ' ')}
</Badge>
),
},
];
}

View File

@@ -0,0 +1,150 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, Select, Stack, ThemeIcon } from '@mantine/core';
import { IconCalendarEvent } from '@tabler/icons-react';
import { AmharicDatePicker, AdvancedTable, PageHeader, notify, useServerTable } from '@ema-platform/ui';
import {
extractErrorMessage,
useCheckInPickupMutation,
useGetPickupOfficesQuery,
useGetPickupWorklistQuery,
useIssueCertificateMutation,
useMarkPickupIssuedMutation,
useMarkPickupNoShowMutation,
type PickupAppointment,
} from '@ema-platform/api';
import { pickupDeskActionsColumn } from './actions';
import { pickupDeskColumns } from './columns';
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* The pickup officer's worklist for one day (spec §43): who is booked, when,
* and where they are in the visit. Check-in and no-show are pickup-desk
* concerns; Issue calls the existing certificate-issuance endpoint and then
* marks the appointment issued, so the two stay in the same state a
* `SCHEDULED` application has always moved through.
*/
export function PickupDeskPage() {
const { t } = useTranslation();
const [date, setDate] = useState(todayIso());
const [officeId, setOfficeId] = useState<string | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null);
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const { data: offices } = useGetPickupOfficesQuery();
const {
data: appointments,
isFetching,
refetch,
} = useGetPickupWorklistQuery({ date, officeId: officeId ?? undefined });
const [checkIn] = useCheckInPickupMutation();
const [markIssued] = useMarkPickupIssuedMutation();
const [markNoShow] = useMarkPickupNoShowMutation();
const [issueCertificate] = useIssueCertificateMutation();
const officesById = useMemo(
() => new Map((offices ?? []).map((o) => [o.id, o])),
[offices],
);
const officeOptions = useMemo(
() => (offices ?? []).map((o) => ({ value: o.id, label: o.name })),
[offices],
);
const rows = [...(appointments ?? [])].sort((a, b) =>
a.slotStartTime.localeCompare(b.slotStartTime),
);
const page = paginate(rows);
async function withLoading(id: string, action: () => Promise<unknown>) {
setLoadingId(id);
try {
await action();
} catch (err) {
notify.error(extractErrorMessage(err), t('pickupDesk.actionFailed', 'Action failed'));
} finally {
setLoadingId(null);
}
}
async function handleCheckIn(appointment: PickupAppointment) {
await withLoading(appointment.id, () => checkIn(appointment.id).unwrap());
}
async function handleIssue(appointment: PickupAppointment) {
await withLoading(appointment.id, async () => {
// Renders and stores the certificate — the same action a raw
// schedule-only application reaches from the review page.
await issueCertificate(appointment.applicationId).unwrap();
await markIssued(appointment.id).unwrap();
notify.success(t('pickupDesk.issued', 'Document issued'));
});
}
async function handleNoShow(appointment: PickupAppointment) {
await withLoading(appointment.id, () => markNoShow(appointment.id).unwrap());
}
const columns = [
...pickupDeskColumns(t, officesById),
pickupDeskActionsColumn(
t,
{ onCheckIn: handleCheckIn, onIssue: handleIssue, onNoShow: handleNoShow },
loadingId,
),
];
return (
<Stack gap="lg">
<PageHeader
title={t('pickupDesk.title', 'Pickup Desk')}
subtitle={t(
'pickupDesk.subtitle',
"Today's and upcoming document pickup appointments.",
)}
noMargin
action={
<ThemeIcon size="xl" radius="md" variant="light">
<IconCalendarEvent size={22} />
</ThemeIcon>
}
/>
<Group gap="sm">
<AmharicDatePicker
label={t('pickupDesk.date', 'Date')}
value={date}
onChange={setDate}
/>
<Select
label={t('pickupDesk.office', 'Office')}
placeholder={t('pickupDesk.allOffices', 'All offices')}
data={officeOptions}
value={officeId}
onChange={setOfficeId}
clearable
/>
</Group>
<AdvancedTable
tableName="pickup-desk-appointments"
columns={columns}
data={page.rows}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('pickupDesk.empty', 'No appointments for this day.')}
/>
</Stack>
);
}
export default PickupDeskPage;

View File

@@ -0,0 +1,309 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Group,
Modal,
MultiSelect,
NumberInput,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
} from '@mantine/core';
import { IconBuildingWarehouse, IconPlus } from '@tabler/icons-react';
import { AdvancedTable, ModalFooter, PageHeader, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import {
extractErrorMessage,
useCreatePickupOfficeMutation,
useGetPickupOfficesQuery,
useUpdatePickupOfficeMutation,
type PickupOffice,
} from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const WEEKDAYS = [
{ value: '0', label: 'Sun' },
{ value: '1', label: 'Mon' },
{ value: '2', label: 'Tue' },
{ value: '3', label: 'Wed' },
{ value: '4', label: 'Thu' },
{ value: '5', label: 'Fri' },
{ value: '6', label: 'Sat' },
];
type OfficeDraft = {
name: string;
address: string;
workingDays: string[];
startTime: string;
endTime: string;
slotDurationMinutes: number;
maxApplicantsPerSlot: number;
rescheduleMinNoticeHours: number;
isActive: boolean;
};
const EMPTY_DRAFT: OfficeDraft = {
name: '',
address: '',
workingDays: ['1', '2', '3', '4', '5'],
startTime: '08:30',
endTime: '17:00',
slotDurationMinutes: 30,
maxApplicantsPerSlot: 10,
rescheduleMinNoticeHours: 24,
isActive: true,
};
function toDraft(office: PickupOffice): OfficeDraft {
return {
name: office.name,
address: office.address ?? '',
workingDays: office.workingDays.map(String),
startTime: office.startTime,
endTime: office.endTime,
slotDurationMinutes: office.slotDurationMinutes,
maxApplicantsPerSlot: office.maxApplicantsPerSlot,
rescheduleMinNoticeHours: office.rescheduleMinNoticeHours,
isActive: office.isActive,
};
}
/**
* Office/location, working hours, slot capacity and reschedule cutoff — the
* configuration `PickupService.availableSlots` computes real slots from
* (spec §20). Holiday management lives here too, one office at a time,
* rather than a separate page — a holiday has no meaning without an office.
*/
export function PickupOfficesPage() {
const { t } = useTranslation();
const { data: offices, isFetching, refetch } = useGetPickupOfficesQuery();
const [createOffice, { isLoading: creating }] = useCreatePickupOfficeMutation();
const [updateOffice, { isLoading: updating }] = useUpdatePickupOfficeMutation();
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const [editing, setEditing] = useState<PickupOffice | null>(null);
const [creatingNew, setCreatingNew] = useState(false);
const [draft, setDraft] = useState<OfficeDraft>(EMPTY_DRAFT);
const page = paginate(offices ?? []);
function openEdit(office: PickupOffice) {
setEditing(office);
setDraft(toDraft(office));
}
function openCreate() {
setCreatingNew(true);
setDraft(EMPTY_DRAFT);
}
function close() {
setEditing(null);
setCreatingNew(false);
}
async function save() {
const body = {
name: draft.name,
address: draft.address || undefined,
workingDays: draft.workingDays.map(Number),
startTime: draft.startTime,
endTime: draft.endTime,
slotDurationMinutes: draft.slotDurationMinutes,
maxApplicantsPerSlot: draft.maxApplicantsPerSlot,
rescheduleMinNoticeHours: draft.rescheduleMinNoticeHours,
isActive: draft.isActive,
};
try {
if (editing) {
await updateOffice({ id: editing.id, ...body }).unwrap();
} else {
await createOffice(body).unwrap();
}
notify.success(t('pickupOffices.saved', 'Office saved'));
close();
} catch (err) {
notify.error(extractErrorMessage(err), t('pickupOffices.saveFailed', 'Could not save'));
}
}
const columns: AdvancedColumn<PickupOffice>[] = [
{
header: t('pickupOffices.columns.name', 'Office'),
cell: ({ row }) => (
<>
<Text size="sm" fw={600}>
{row.original.name}
</Text>
<Text size="xs" c="dimmed">
{row.original.address ?? '—'}
</Text>
</>
),
},
{
header: t('pickupOffices.columns.hours', 'Working hours'),
cell: ({ row }) => (
<Text size="sm">
{row.original.startTime}{row.original.endTime}
</Text>
),
},
{
header: t('pickupOffices.columns.capacity', 'Capacity / slot'),
cell: ({ row }) => (
<Text size="sm">
{row.original.maxApplicantsPerSlot} · {row.original.slotDurationMinutes}min
</Text>
),
},
{
header: t('pickupOffices.columns.status', 'Status'),
cell: ({ row }) => (
<Badge size="sm" color={row.original.isActive ? 'teal' : 'gray'} variant="light">
{row.original.isActive
? t('pickupOffices.active', 'Active')
: t('pickupOffices.inactive', 'Inactive')}
</Badge>
),
},
{
header: '',
align: 'right',
cell: ({ row }) => (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CONFIGURE_PICKUP_OFFICES]} hideOnly>
<Button size="xs" variant="light" onClick={() => openEdit(row.original)}>
{t('pickupOffices.edit', 'Edit')}
</Button>
</RequirePermission>
),
},
];
return (
<Stack gap="lg">
<PageHeader
title={t('pickupOffices.title', 'Pickup Offices')}
subtitle={t(
'pickupOffices.subtitle',
'Where applicants collect printed documents, and how many can be booked into each slot.',
)}
noMargin
action={
<Group gap="sm">
<ThemeIcon size="xl" radius="md" variant="light">
<IconBuildingWarehouse size={22} />
</ThemeIcon>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CONFIGURE_PICKUP_OFFICES]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
{t('pickupOffices.new', 'New office')}
</Button>
</RequirePermission>
</Group>
}
/>
<AdvancedTable
tableName="pickup-offices"
columns={columns}
data={page.rows}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
/>
<Modal
opened={Boolean(editing) || creatingNew}
onClose={close}
title={editing ? t('pickupOffices.editTitle', 'Edit office') : t('pickupOffices.new', 'New office')}
>
<Stack>
<TextInput
label={t('pickupOffices.form.name', 'Name')}
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.currentTarget.value }))}
withAsterisk
/>
<TextInput
label={t('pickupOffices.form.address', 'Address')}
value={draft.address}
onChange={(e) => setDraft((d) => ({ ...d, address: e.currentTarget.value }))}
/>
<MultiSelect
label={t('pickupOffices.form.workingDays', 'Working days')}
data={WEEKDAYS}
value={draft.workingDays}
onChange={(v) => setDraft((d) => ({ ...d, workingDays: v }))}
/>
<Group grow>
<TextInput
label={t('pickupOffices.form.startTime', 'Start time')}
placeholder="08:30"
value={draft.startTime}
onChange={(e) => setDraft((d) => ({ ...d, startTime: e.currentTarget.value }))}
/>
<TextInput
label={t('pickupOffices.form.endTime', 'End time')}
placeholder="17:00"
value={draft.endTime}
onChange={(e) => setDraft((d) => ({ ...d, endTime: e.currentTarget.value }))}
/>
</Group>
<Group grow>
<NumberInput
label={t('pickupOffices.form.slotDuration', 'Slot length (min)')}
min={5}
value={draft.slotDurationMinutes}
onChange={(v) =>
setDraft((d) => ({ ...d, slotDurationMinutes: Number(v) || d.slotDurationMinutes }))
}
/>
<NumberInput
label={t('pickupOffices.form.capacity', 'Max per slot')}
min={1}
value={draft.maxApplicantsPerSlot}
onChange={(v) =>
setDraft((d) => ({ ...d, maxApplicantsPerSlot: Number(v) || d.maxApplicantsPerSlot }))
}
/>
</Group>
<NumberInput
label={t('pickupOffices.form.rescheduleCutoff', 'Reschedule minimum notice (hours)')}
min={0}
value={draft.rescheduleMinNoticeHours}
onChange={(v) =>
setDraft((d) => ({
...d,
rescheduleMinNoticeHours: Number(v) || d.rescheduleMinNoticeHours,
}))
}
/>
<Switch
label={t('pickupOffices.form.active', 'Active')}
checked={draft.isActive}
onChange={(e) => setDraft((d) => ({ ...d, isActive: e.currentTarget.checked }))}
/>
<ModalFooter>
<Button variant="default" onClick={close}>
{t('common.cancel', 'Cancel')}
</Button>
<Button loading={creating || updating} disabled={!draft.name.trim()} onClick={save}>
{t('common.save', 'Save')}
</Button>
</ModalFooter>
</Stack>
</Modal>
</Stack>
);
}
export default PickupOfficesPage;

View File

@@ -0,0 +1,42 @@
import { baseApi } from '@ema-platform/api';
/**
* The officer's own signing signature — drawn onto certificates they approve
* (`{{signatureImage}}`), as distinct from the seafarer's specimen signature
* that the portal manages.
*
* Scoped to the caller: the API resolves the employee record from the token,
* so no employee id is passed and nobody can upload on another's behalf.
*/
const signatureApi = baseApi
.enhanceEndpoints({ addTagTypes: ['MyEmployeeSignature'] as const })
.injectEndpoints({
endpoints: (builder) => ({
getMyEmployeeSignature: builder.query<{ url: string | null }, void>({
query: () => ({ url: '/employee-signatures/me' }),
providesTags: ['MyEmployeeSignature'],
}),
uploadMyEmployeeSignature: builder.mutation<{ id: string }, File>({
query: (file) => {
const body = new FormData();
body.append('file', file);
// No Content-Type header: fetch sets it with the multipart boundary.
return { url: '/employee-signatures/me', method: 'POST', body };
},
invalidatesTags: (_r, error) => (error ? [] : ['MyEmployeeSignature']),
}),
deleteMyEmployeeSignature: builder.mutation<{ removed: boolean }, void>({
query: () => ({ url: '/employee-signatures/me', method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : ['MyEmployeeSignature']),
}),
}),
overrideExisting: false,
});
export const {
useGetMyEmployeeSignatureQuery,
useUploadMyEmployeeSignatureMutation,
useDeleteMyEmployeeSignatureMutation,
} = signatureApi;

View File

@@ -0,0 +1,26 @@
import { SignaturePad } from '@ema-platform/ui';
import {
useDeleteMyEmployeeSignatureMutation,
useGetMyEmployeeSignatureQuery,
useUploadMyEmployeeSignatureMutation,
} from '../api/signature-api';
/** The signature drawn onto certificates this officer approves. */
export function MySignaturePad() {
const { data, isLoading } = useGetMyEmployeeSignatureQuery();
const [upload, { isLoading: isUploading }] =
useUploadMyEmployeeSignatureMutation();
const [remove, { isLoading: isDeleting }] =
useDeleteMyEmployeeSignatureMutation();
return (
<SignaturePad
currentUrl={data?.url ?? null}
isLoading={isLoading}
isUploading={isUploading}
isDeleting={isDeleting}
onUpload={(file) => upload(file).unwrap()}
onDelete={() => remove().unwrap()}
/>
);
}

View File

@@ -32,6 +32,7 @@ import {
IconMail,
IconMoon,
IconSettings,
IconSignature,
IconShieldLock,
IconSun,
IconUser,
@@ -43,12 +44,18 @@ import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { ActiveSessions, setUser } from '@ema-platform/auth';
import {
ActiveSessions,
LICENSE_PERMISSIONS,
setUser,
usePermissions,
} from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setLayoutMode } from '../../../store/preferences.slice';
import type { LayoutMode } from '../../../store/preferences.slice';
import { MySignaturePad } from '../components/MySignaturePad';
import classes from './ProfilePage.module.css';
function getInitials(name: string, fallback: string) {
@@ -77,6 +84,10 @@ export function ProfilePage() {
const { colorScheme, setColorScheme } = useMantineColorScheme();
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const { handleError } = useErrorHandler();
// Only officers who approve applications ever sign a certificate, so nobody
// else is asked for a signature they would never use.
const { can } = usePermissions();
const canSign = can([LICENSE_PERMISSIONS.APPROVE_APPLICATION]);
const [updateTrigger] = useApiMutation<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
@@ -318,6 +329,11 @@ export function ProfilePage() {
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
{t('profile.tabs.profile')}
</Tabs.Tab>
{canSign && (
<Tabs.Tab value="signature" leftSection={<IconSignature size={18} />}>
{t('profile.tabs.signature')}
</Tabs.Tab>
)}
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
</Tabs.Tab>
@@ -405,6 +421,15 @@ export function ProfilePage() {
</Paper>
</Tabs.Panel>
{/* ---- Signature (drawn onto certificates this officer approves) ---- */}
{canSign && (
<Tabs.Panel value="signature" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<MySignaturePad />
</Paper>
</Tabs.Panel>
)}
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Stack gap="lg">

View File

@@ -5,10 +5,13 @@ import { IconSearch } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
useListSeafarerDocumentsQuery,
type SeafarerDocumentKind,
type SeafarerDocumentRequestKind,
type SeafarerDocumentRow,
type SeafarerDocumentStatus,
} from '@ema-platform/api';
@@ -22,6 +25,10 @@ const STATUS_FILTERS = (
['PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'SCHEDULED', 'ISSUED', 'REJECTED'] as SeafarerDocumentStatus[]
).map((value) => ({ value, label: SEAFARER_DOCUMENT_STATUS_LABELS[value] }));
const REQUEST_KIND_FILTERS = (
['NEW', 'RENEWAL', 'REPLACEMENT'] as SeafarerDocumentRequestKind[]
).map((value) => ({ value, label: SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[value] }));
/**
* The Seaman Book queue and the BTC queue — one page, keyed by kind. Requests
* appear here once the seafarer registration that opened them is approved.
@@ -30,6 +37,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
const navigate = useNavigate();
const showDate = useDateDisplayer();
const [status, setStatus] = useState<SeafarerDocumentStatus | null>(null);
const [requestKind, setRequestKind] = useState<SeafarerDocumentRequestKind | null>(null);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
const [page, setPage] = useState(0);
@@ -38,6 +46,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
const { data, isLoading, isFetching, refetch } = useListSeafarerDocumentsQuery({
kind,
status: status ?? undefined,
requestKind: requestKind ?? undefined,
search: debouncedSearch || undefined,
take: pageSize,
skip: page * pageSize,
@@ -75,6 +84,15 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
</div>
),
},
{
header: 'Type',
accessorKey: 'requestKind',
cell: ({ row }) => (
<Badge size="sm" variant="outline" color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[row.original.requestKind]}>
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[row.original.requestKind]}
</Badge>
),
},
{
header: 'Fee',
accessorKey: 'feeAmount',
@@ -148,6 +166,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
clearable
w={200}
/>
<Select
placeholder="All types"
data={REQUEST_KIND_FILTERS}
value={requestKind}
onChange={(v) => {
setRequestKind(v as SeafarerDocumentRequestKind | null);
setPage(0);
}}
clearable
w={160}
/>
</>
}
itemCount={data?.total ?? 0}

View File

@@ -4,6 +4,8 @@ import {Alert, Avatar, Badge, Button, Center, Container, Divider, Group, Loader,
import { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
extractErrorMessage,
@@ -133,6 +135,11 @@ export function SeafarerDocumentReviewPage() {
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]}>
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
</Badge>
{document.requestKind !== 'NEW' && (
<Badge size="sm" variant="outline" color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[document.requestKind]}>
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]}
</Badge>
)}
{document.documentNumber && (
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
{document.documentNumber}

View File

@@ -26,7 +26,7 @@ export function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middl
return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—';
}
/** Submitted seafarer registrations, oldest first — click a row to review it. */
/** Submitted seafarer registrations, newest first — click a row to review it. */
export function SeafarerRegistrationQueuePage() {
const navigate = useNavigate();
const showDate = useDateDisplayer();
@@ -43,6 +43,8 @@ export function SeafarerRegistrationQueuePage() {
const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({
status: status ?? undefined,
search: debouncedSearch || undefined,
sortBy: 'submittedAt',
sortDir: 'DESC',
take: pageSize,
skip: page * pageSize,
});

View File

@@ -63,8 +63,13 @@ export function SeafarerRegistrationReviewPage() {
}
const { registration, attachments } = data;
// Decided straight off the queue — no claim step.
const canDecide = registration.status === 'SUBMITTED';
// Decided straight off the queue — no claim step. AWAITING_BIOMETRICS is
// deliberately excluded: approval is blocked on a BSID that only exists once
// the applicant has been enrolled, so the decision is not the reviewer's to
// take yet. SUBMITTED stays decidable for files that predate the gate.
const awaitingBiometrics = registration.status === 'AWAITING_BIOMETRICS';
const canDecide =
registration.status === 'UNDER_REVIEW' || registration.status === 'SUBMITTED';
const busy = approving || rejecting || requesting;
async function run(action: () => Promise<unknown>, done: string) {
@@ -141,6 +146,18 @@ export function SeafarerRegistrationReviewPage() {
}
/>
{awaitingBiometrics && (
<Alert
color="blue"
icon={<IconAlertTriangle size={16} />}
title="Awaiting biometric enrolment"
mb="md"
>
This registration cannot be decided yet. The applicant has to be
enrolled at a counter and issued a BSID first the registration moves
to Under Review automatically once that happens.
</Alert>
)}
{registration.status === 'RESUBMIT_REQUIRED' && (
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
{registration.reviewRemark}

View File

@@ -184,7 +184,7 @@ export function SeafarerRegistryPage() {
</SimpleGrid>
{/* Search + filters */}
<Paper withBorder radius="lg" p="xl">
<Paper withBorder radius="lg" p={{ base: 'md', sm: 'xl' }}>
<Group mb="sm" gap="sm" justify="space-between">
<TextInput
placeholder="Search by name, seafarer ID, or seaman book…"
@@ -215,6 +215,7 @@ export function SeafarerRegistryPage() {
<Text fz="xs" c="dimmed" mb="sm">{filtered.length} seafarer(s) found</Text>
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
@@ -278,6 +279,7 @@ export function SeafarerRegistryPage() {
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Paper>
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />

View File

@@ -1,10 +1,14 @@
import Cookies from 'js-cookie';
import { useCallback, useEffect, useRef } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useNavigate } from 'react-router-dom';
import { UserManagementApp } from '@tria-plc/iamui';
import type { DesignConfig, UserManagementSessionOptions } from '@tria-plc/iamui';
import '@tria-plc/iamui/style.css';
import Cookies from "js-cookie";
import { useCallback, useEffect, useRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { useNavigate } from "react-router-dom";
import { UserManagementApp } from "@tria-plc/iamui";
import { BASE_API_URL } from "@ema-platform/api";
import type {
DesignConfig,
UserManagementSessionOptions,
} from "@tria-plc/iamui";
import "@tria-plc/iamui/style.css";
const UM_OVERRIDES = `
.um-theme-light {
@@ -58,73 +62,73 @@ const UM_OVERRIDES = `
const UM_CONFIG: DesignConfig = {
brand: {
appName: 'Ethiopian Maritime Licence',
logoUrl: '/assets/emaLogo.jpg',
appName: "Ethiopian Maritime Licence",
logoUrl: "/assets/emaLogo.jpg",
},
colors: {
primary: '#2563eb',
sidebar: '#ffffff',
background: '#f8fafc',
foreground: '#1e293b',
border: '#e2e8f0',
mutedForeground: '#94a3b8',
card: '#ffffff',
primary: "#2563eb",
sidebar: "#ffffff",
background: "#f8fafc",
foreground: "#1e293b",
border: "#e2e8f0",
mutedForeground: "#94a3b8",
card: "#ffffff",
},
typography: {
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
},
layout: {
userManagementView: 'classic',
sidebarBrandLabel: 'Ethiopian Maritime Authority',
sidebarBrandSublabel: 'User Management',
sidebarBackground: '#ffffff',
sidebarColor: '#1e293b',
sidebarMutedColor: '#94a3b8',
sidebarActiveBackground: '#eff6ff',
sidebarActiveColor: '#2563eb',
sidebarHoverBackground: '#f8fafc',
sidebarBorder: '#e2e8f0',
sidebarWidth: '280px',
sidebarCollapsedWidth: '80px',
modalAccentColor: '#2563eb',
modalHeaderBackground: '#f8fafc',
modalHeaderEditBackground: '#eff6ff',
modalIconBackground: '#eff6ff',
modalIconColor: '#2563eb',
modalTitleColor: '#1e293b',
modalFocusColor: '#2563eb',
modalSurface: '#ffffff',
userManagementView: "classic",
sidebarBrandLabel: "Ethiopian Maritime Authority",
sidebarBrandSublabel: "User Management",
sidebarBackground: "#ffffff",
sidebarColor: "#1e293b",
sidebarMutedColor: "#94a3b8",
sidebarActiveBackground: "#eff6ff",
sidebarActiveColor: "#2563eb",
sidebarHoverBackground: "#f8fafc",
sidebarBorder: "#e2e8f0",
sidebarWidth: "280px",
sidebarCollapsedWidth: "80px",
modalAccentColor: "#2563eb",
modalHeaderBackground: "#f8fafc",
modalHeaderEditBackground: "#eff6ff",
modalIconBackground: "#eff6ff",
modalIconColor: "#2563eb",
modalTitleColor: "#1e293b",
modalFocusColor: "#2563eb",
modalSurface: "#ffffff",
},
};
const UM_RUNTIME = {
basename: '/um',
basename: "/um",
// Keep the embedded IAM module on the same API as the backoffice client.
// When VITE_BASE_API_URL is absent locally, passing undefined makes iamui
// fall back to its remote development server, where the local JWT is
// rejected and the module redirects to its login page.
apiUrl: import.meta.env.VITE_BASE_API_URL ?? 'http://localhost:3000/api',
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "http://localhost:3000/api",
};
const buttonStyle: React.CSSProperties = {
position: 'fixed',
position: "fixed",
top: 12,
left: 12,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
gap: 6,
padding: '8px 16px',
border: '1px solid #e2e8f0',
padding: "8px 16px",
border: "1px solid #e2e8f0",
borderRadius: 8,
background: '#ffffff',
color: '#2563eb',
background: "#ffffff",
color: "#2563eb",
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
transition: 'all 150ms ease',
cursor: "pointer",
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
transition: "all 150ms ease",
};
export default function UserManagementPage() {
@@ -133,29 +137,31 @@ export default function UserManagementPage() {
const navigate = useNavigate();
const handleReturn = useCallback(() => {
navigate('/dashboard');
navigate("/dashboard");
}, [navigate]);
useEffect(() => {
if (!containerRef.current) return;
const style = document.createElement('style');
const style = document.createElement("style");
style.textContent = UM_OVERRIDES;
document.head.appendChild(style);
const token = Cookies.get('ema-backoffice-auth-token') ?? '';
const refreshToken = Cookies.get('ema-backoffice-refresh-token');
const token = Cookies.get("ema-backoffice-auth-token") ?? "";
const refreshToken = Cookies.get("ema-backoffice-refresh-token");
const session: UserManagementSessionOptions = {
initialSession: token
? { token, refreshToken, rememberMe: true }
: null,
initialSession: token ? { token, refreshToken, rememberMe: true } : null,
enableEmbeddedAuthBridge: false,
};
rootRef.current = createRoot(containerRef.current);
rootRef.current.render(
<UserManagementApp config={UM_CONFIG} runtime={UM_RUNTIME} session={session} />,
<UserManagementApp
config={UM_CONFIG}
runtime={UM_RUNTIME}
session={session}
/>,
);
return () => {
@@ -173,21 +179,28 @@ export default function UserManagementPage() {
onClick={handleReturn}
style={buttonStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f8fafc';
e.currentTarget.style.boxShadow = '0 1px 6px rgba(0,0,0,0.12)';
e.currentTarget.style.background = "#f8fafc";
e.currentTarget.style.boxShadow = "0 1px 6px rgba(0,0,0,0.12)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ffffff';
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
e.currentTarget.style.background = "#ffffff";
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0,0,0,0.08)";
}}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round">
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
Return to EMA
</button>
<div ref={containerRef} style={{ position: 'fixed', inset: 0 }} />
<div ref={containerRef} style={{ position: "fixed", inset: 0 }} />
</>
);
}

View File

@@ -88,14 +88,19 @@ export const am: Translations = {
btcQueue: "የBTC ወረፋ",
cocQueue: "የCoC ወረፋ",
copQueue: "የCoP ወረፋ",
endorsementCocQueue: "የCoC እውቅና ወረፋ",
endorsementGocQueue: "የGOC እውቅና ወረፋ",
endorsementQueue: "የማስተያየት ወረፋ",
vesselRegistrations: "የመርከብ ምዝገባ",
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
seafarerRegistry: "የመርከበኞች መዝገብ",
biometricEnrollment: "ባዮሜትሪክ ምዝገባ",
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
applications: "ማመልከቻዎች",
paymentConfig: "የክፍያ ውቅረት",
pickupDesk: "የመረከቢያ ዴስክ",
pickupOffices: "የመረከቢያ ቢሮዎች",
analytics: "ትንታኔ",
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
medicalVerification: "የሕክምና ማረጋገጫ",
@@ -469,9 +474,31 @@ export const am: Translations = {
unverified: "ያልተረጋገጠ",
tabs: {
profile: "መገለጫ",
signature: "ፊርማ",
security: "ደህንነት",
preferences: "ምርጫዎች",
},
signature: {
title: "የመፈረሚያ ፊርማ",
description: "እርስዎ በሚያጸድቋቸው ሰነዶች ላይ ይታተማል። አንድ ጊዜ ይሳሉ ወይም ምስል ይጫኑ።",
reissueNotice:
"ፊርማዎን መቀየር አስቀድሞ የተሰጠን ሰነድ አይለውጥም — ከአሁን በኋላ በሚፈርሙዋቸው ላይ ብቻ ይሠራል።",
current: "የተመዘገበ ፊርማ",
currentAlt: "የተቀመጠ ፊርማዎ",
none: "እስካሁን የተመዘገበ ፊርማ የለም። የሚያጸድቋቸው ሰነዶች ያለ ፊርማ ይሰጣሉ።",
modeDraw: "ይሳሉ",
modeUpload: "ይጫኑ",
save: "ፊርማ አስቀምጥ",
clear: "አጽዳ",
choose: "ምስል ይምረጡ",
fileHint: "PNG ወይም JPEG፣ እስከ 2 ሜባ።",
remove: "አስወግድ",
saved: "ፊርማ ተቀምጧል።",
removed: "ፊርማ ተወግዷል።",
badType: "PNG እና JPEG ምስሎች ብቻ ይፈቀዳሉ።",
tooLarge: "ምስሉ ከ2 ሜባ ይበልጣል።",
drawFailed: "ሥዕሉን ማንበብ አልተቻለም። እባክዎ እንደገና ይሞክሩ።",
},
personalHint: "በኦፊሴላዊ ኢማ ሰነዶች ላይ እንደሚታየው ስምዎ።",
languageTitle: "ቋንቋ",
languageHint: "በአስተዳደር ፓነል ውስጥ የሚጠቀሙትን ቋንቋ ይምረጡ።",
@@ -849,6 +876,7 @@ export const am: Translations = {
configuration: {
title: "ውቅረት",
personalDocumentsTab: "የግል ሰነዶች",
departments: "ክፍሎች",
professions: "ሙያዎች",
departmentsList: "ክፍሎች",
@@ -893,11 +921,18 @@ export const am: Translations = {
type: "ዓይነት",
anyType: "ማንኛውም",
typeCol: "ዓይነት",
kind: "የማመልከቻ ዓይነት",
kindValues: {
NEW: "አዲስ",
RENEWAL: "እድሳት",
REISSUE: "ምትክ",
},
statusCol: "ሁኔታ",
statusValues: {
DRAFT: "ረቂቅ",
SUBMITTED: "ቀርቧል",
UNDER_REVIEW: "በግምገማ ላይ",
AWAITING_BIOMETRICS: "ባዮሜትሪክ በመጠባበቅ ላይ",
UNDER_EVALUATION: "በምዘና ላይ",
RESUBMIT_REQUIRED: "እንደገና ማቅረብ ያስፈልጋል",
INSPECTION_PENDING: "ምርመራ በመጠባበቅ ላይ",
@@ -967,6 +1002,7 @@ export const am: Translations = {
},
review: {
certificateSuperseded: "ሰርተፍኬቱ ተተክቷል",
summary: "ማጠቃለያ",
officer: "ሹም",
supervisor: "የበላይ ኃላፊ",
@@ -1017,6 +1053,9 @@ export const am: Translations = {
morning: "ጠዋት",
afternoon: "ከሰዓት በኋላ",
schedule: "ያዝ",
reschedule: "አዛውር",
rescheduleReason: "ለምን ይዛወራል?",
rescheduleReasonHint: "በኦዲት መዝገብ ውስጥ ተይዞ ለአመልካቹ ይላካል።",
pickDate: "መጀመሪያ ቀን ይምረጡ",
passed: "አልፏል",
failed: "ወድቋል",
@@ -1053,6 +1092,7 @@ export const am: Translations = {
completeReview: "ግምገማ አጠናቅቅ",
approveDocuments: "ሰነዶችን አጽድቅ",
scheduleInspection: "ምርመራ ያዝ",
rescheduleInspection: "ምርመራ አዛውር",
recordInspection: "የምርመራ ውጤት መዝግብ",
finalApprove: "አጽድቅ እና ስጥ",
requestAdjustment: "ማስተካከያ ጠይቅ",
@@ -1203,6 +1243,7 @@ export const am: Translations = {
assign: "እንደገና ተመድቧል",
assignReviewer: "ግምገማ ተመድቧል",
scheduled: "ምርመራ ተይዟል",
rescheduled: "ምርመራ ተዛውሯል",
inspectionPassed: "ምርመራ አልፏል",
inspectionFailed: "ምርመራ ወድቋል",
},
@@ -1225,12 +1266,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: "የስሪት ስም",
@@ -1273,6 +1316,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: "ለውጦችን አስቀምጥ",
@@ -1372,13 +1495,46 @@ export const am: Translations = {
modeOptional: "አማራጭ ስቀላ",
conditionRequired: "ሁኔታዊ መስፈርት ሁኔታ ያስፈልገዋል",
allowedTypes: "የተፈቀዱ የፋይል ዓይነቶች",
allowedTypesHelp:
"አመልካቹ እነዚህን ብቻ መስቀል ይችላል። በነባሪ PDF፣ JPEG እና PNG ተመርጠዋል።",
maxSize: "ከፍተኛ የፋይል መጠን (MB)",
requiresValidity: "የቀን ገደብ ያስፈልጋል",
allowMultiple: "ብዙ ስቀላዎችን ፍቀድ",
multiple: "ብዙ",
scope: "የሚመለከተው",
scopeAll: "ሁሉም ፈቃዶች",
scopeSelected: "የተመረጡ የፈቃድ አይነቶች",
scopePlaceholder: "የፈቃድ አይነቶችን ይምረጡ",
scopeHelp: "ከእነዚህ አንዱን የሥራ ዘርፍ አድርገው ያሳወቁ አመልካቾች ብቻ ይጠየቃሉ።",
scopeRequired: "ቢያንስ አንድ የፈቃድ አይነት ይምረጡ",
maxFiles: "የሚፈቀዱ ፋይሎች",
maxFilesHelp: "ገደብ ከሌለ ባዶ ይተዉት። ፊትና ጀርባ ላለው ሰነድ 2 ይጠቀሙ።",
maxFilesUnlimited: "ገደብ የለም",
sortOrder: "የቅደም ተከተል ቁጥር",
when: "መቼ",
},
personal: {
title: "የግል ሰነዶች",
subtitle:
"አመልካቹ በእያንዳንዱ ማመልከቻ ላይ ከመስቀል ይልቅ በ\u2018ሰነዶቼ\u2019 ውስጥ አንድ ጊዜ የሚያስቀምጣቸው የማንነትና የትምህርት ሰነዶች። ለተወሰኑ የፈቃድ አይነቶች ከወሰኑት፣ እነዚያን የሥራ ዘርፍ ያሳወቁ አመልካቾች ብቻ ይጠየቃሉ።",
add: "የግል ሰነድ ጨምር",
empty: "እስካሁን የተዋቀረ የግል ሰነድ የለም።",
search: "ፍለጋ",
searchPlaceholder: "በስም ወይም በቁልፍ ይፈልጉ",
moreTypes: " +{{count}} ተጨማሪ",
columns: {
document: "ሰነድ",
actions: "ድርጊቶች",
},
filterAny: "ማንኛውም የፈቃድ አይነት",
filterGlobal: "ለሁሉም ፈቃዶች የሚሆኑ ብቻ",
noMatch: "በእነዚህ ማጣሪያዎች የሚመጣጠን የግል ሰነድ የለም።",
clearFilters: "አጽዳ",
fileCount_one: "{{count}} ፋይል",
fileCount_other: "{{count}} ፋይሎች",
deleteWarning:
"ማስገቢያው ከሁሉም አመልካቾች \u2018ሰነዶቼ\u2019 ውስጥ ይጠፋል። ቀደም ብለው የተሰቀሉ ፋይሎች ይቀመጣሉ፣ ነገር ግን ማንም ሊደርስባቸው አይችልም።",
},
},
seafarerRegistry: {
@@ -1483,6 +1639,15 @@ export const am: Translations = {
newFeeLabel: "የአዲስ ማመልከቻ ክፍያ",
sameRateLabel: "እድሳትን በተመሳሳይ ተመን ያስከፍሉ",
sameRateDescription: "የተለየ የእድሳት ክፍያ ለማዘጋጀት ያጥፉ።",
examinedNotice:
"ይህ የምስክር ወረቀት በፈተና የሚገኝ ስለሆነ በሦስት ደረጃዎች ይከፈላል። ከላይ ካሉት ክፍያዎች በተለየ እነዚህ በሚጸድቁበት ጊዜ አይቀዘቅዙም — ለውጡ ቀደም ብለው በሂደት ላይ ላሉ ተፈታኞችም ይሠራል። ተፈታኞች ሊከፍሉት በመጠባበቅ ላይ እያሉ አንዱን ማጥፋት ተቀባይነት አያገኝም።",
eligibilityFeeLabel: "የብቁነት ምዘና ክፍያ",
eligibilityFeeHint:
"ኃላፊው ማመልከቻውን ከመገምገሙ በፊት፣ በሚቀርብበት ጊዜ የሚከፈል። ክፍያ ከሌለ ባዶ ይተውት።",
examinationFeeLabel: "የፈተና ክፍያ",
examinationFeeHint: "ብቁነቱ ሲጸድቅ የሚከፈል፣ እንዲሁም ለድጋሚ ፈተና እንደገና።",
certificateFeeLabel: "የምስክር ወረቀት ክፍያ",
certificateFeeHint: "ካለፉ በኋላ፣ የምስክር ወረቀቱ ከመሰጠቱ በፊት የሚከፈል።",
renewalFeeLabel: "የእድሳት ክፍያ",
currencyLabel: "ገንዘብ",
cancel: "ሰርዝ",

View File

@@ -88,13 +88,18 @@ export const en = {
postWaiverQueue: 'Post-Waiver Queue',
cocQueue: 'CoC Queue',
copQueue: 'CoP Queue',
endorsementCocQueue: 'CoC Endorsement Queue',
endorsementGocQueue: 'GOC Endorsement Queue',
endorsementQueue: 'Endorsement Queue',
vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Ownership Transfer',
seafarerRegistry: 'Seafarer Registry',
biometricEnrollment: 'Biometric Enrollment',
seafarerRegistrationQueue: 'Seafarer Registration Queue',
applications: 'Applications',
paymentConfig: 'Payment Config',
pickupDesk: 'Pickup Desk',
pickupOffices: 'Pickup Offices',
analytics: 'Analytics',
seaServiceVerification: 'Sea Service Verification',
medicalVerification: 'Medical Verification',
@@ -468,9 +473,32 @@ export const en = {
unverified: 'Unverified',
tabs: {
profile: 'Profile',
signature: 'Signature',
security: 'Security',
preferences: 'Preferences',
},
signature: {
title: 'Signing signature',
description:
'Drawn onto the certificates you approve. Draw it once or upload an image.',
reissueNotice:
'Changing your signature does not alter a certificate already issued — it applies to whatever you sign from now on.',
current: 'Signature on file',
currentAlt: 'Your stored signature',
none: 'No signature on file yet. Certificates you approve will be issued without one.',
modeDraw: 'Draw',
modeUpload: 'Upload',
save: 'Save signature',
clear: 'Clear',
choose: 'Choose image',
fileHint: 'PNG or JPEG, up to 2 MB.',
remove: 'Remove',
saved: 'Signature saved.',
removed: 'Signature removed.',
badType: 'Only PNG and JPEG images are accepted.',
tooLarge: 'That image is larger than 2 MB.',
drawFailed: 'Could not read the drawing. Please try again.',
},
personalHint: 'Your name as it appears on official EMA documents.',
languageTitle: 'Language',
languageHint: 'Choose the language used across the admin panel.',
@@ -853,6 +881,7 @@ export const en = {
configuration: {
title: 'Configuration',
personalDocumentsTab: 'Personal Documents',
departments: 'Departments',
professions: 'Professions',
departmentsList: 'Departments',
@@ -900,11 +929,18 @@ export const en = {
type: 'Type',
anyType: 'Any',
typeCol: 'Type',
kind: 'Application kind',
kindValues: {
NEW: 'New',
RENEWAL: 'Renewal',
REISSUE: 'Replacement',
},
statusCol: 'Status',
statusValues: {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
@@ -976,6 +1012,7 @@ export const en = {
},
review: {
certificateSuperseded: 'Certificate superseded',
summary: 'Summary',
officer: 'Officer',
supervisor: 'Supervisor',
@@ -1026,6 +1063,9 @@ export const en = {
morning: 'Morning',
afternoon: 'Afternoon',
schedule: 'Schedule',
reschedule: 'Reschedule',
rescheduleReason: 'Why is it moving?',
rescheduleReasonHint: 'Kept in the audit trail and sent to the applicant.',
pickDate: 'Pick a date first',
passed: 'Passed',
failed: 'Failed',
@@ -1062,6 +1102,7 @@ export const en = {
completeReview: 'Complete review',
approveDocuments: 'Approve documents',
scheduleInspection: 'Schedule inspection',
rescheduleInspection: 'Reschedule inspection',
recordInspection: 'Record inspection result',
finalApprove: 'Approve & issue',
requestAdjustment: 'Request adjustment',
@@ -1209,6 +1250,7 @@ export const en = {
assign: 'Reassigned',
assignReviewer: 'Review assigned',
scheduled: 'Inspection scheduled',
rescheduled: 'Inspection rescheduled',
inspectionPassed: 'Inspection passed',
inspectionFailed: 'Inspection failed',
},
@@ -1231,12 +1273,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',
@@ -1278,6 +1322,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',
@@ -1377,13 +1501,47 @@ export const en = {
modeOptional: 'Optional upload',
conditionRequired: 'A conditional requirement needs a condition',
allowedTypes: 'Allowed file types',
allowedTypesHelp:
'The applicant can only upload these. PDF, JPEG and PNG are selected by default.',
maxSize: 'Max file size (MB)',
requiresValidity: 'Requires validity dates',
allowMultiple: 'Allow multiple uploads',
multiple: 'multiple',
scope: 'Applies to',
scopeAll: 'All licences',
scopeSelected: 'Selected licence types',
scopePlaceholder: 'Choose licence types',
scopeHelp:
'Only applicants who declared one of these as a mode of operation are asked for it.',
scopeRequired: 'Choose at least one licence type',
maxFiles: 'Files accepted',
maxFilesHelp: 'Leave empty for no limit. Use 2 for a document with a front and a back.',
maxFilesUnlimited: 'No limit',
sortOrder: 'Sort order',
when: 'when',
},
personal: {
title: 'Personal documents',
subtitle:
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
add: 'Add personal document',
empty: 'No personal documents configured yet.',
search: 'Search',
searchPlaceholder: 'Search by name or key',
moreTypes: ' +{{count}} more',
columns: {
document: 'Document',
actions: 'Actions',
},
filterAny: 'Any licence type',
filterGlobal: 'All-licence documents only',
noMatch: 'No personal document matches those filters.',
clearFilters: 'Clear',
fileCount_one: '{{count}} file',
fileCount_other: '{{count}} files',
deleteWarning:
'The slot disappears from every applicant\u2019s My Documents. Files already uploaded are kept, but nobody can reach them.',
},
},
seafarerRegistry: {
@@ -1488,6 +1646,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

@@ -2,12 +2,15 @@ import {
IconAnchor,
IconArrowsExchange,
IconBook2,
IconBuildingWarehouse,
IconCalendarEvent,
IconChartBar,
IconClipboardList,
IconClipboardText,
IconCreditCard,
IconFileDescription,
IconFilePlus,
IconFingerprint,
IconGauge,
IconGavel,
IconHeart,
@@ -25,9 +28,9 @@ import {
IconTruck,
IconUsers,
IconUserShield,
} from '@tabler/icons-react';
import type { NavSection } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS as P } from '@ema-platform/auth';
} from "@tabler/icons-react";
import type { NavSection } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS as P } from "@ema-platform/auth";
/**
* Every licence-type queue and its review workspace share one gate: the
@@ -35,6 +38,18 @@ import { LICENSE_PERMISSIONS as P } from '@ema-platform/auth';
*/
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
/**
* Seafarer queues are reachable by registry staff and application reviewers
* alike, so both permission families gate them as any-of.
*/
const SEAFARER_QUEUE = [P.VIEW_SEAFARER_REGISTRY, ...APPLICATION_QUEUE];
const BIOMETRIC_ENROLLMENT = [
P.ENROLL_BIOMETRICS,
P.VIEW_BIOMETRICS,
P.VIEW_SEAFARER_REGISTRY,
];
/**
* The backoffice information architecture.
*
@@ -50,127 +65,294 @@ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
*/
export const NAV_SECTIONS: NavSection[] = [
{
items: [{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard }],
items: [
{ to: "/dashboard", label: "nav.dashboard", icon: IconLayoutDashboard },
],
},
{
label: 'nav.groupLicensing',
label: "nav.groupLicensing",
items: [
{
to: '/licence-review',
label: 'nav.allApplications',
to: "/licence-review",
label: "nav.allApplications",
icon: IconListCheck,
permissions: APPLICATION_QUEUE,
},
{
// A disclosure, not a destination — each child deep-links the grid to
// one type, which is a facet of the same workspace.
label: 'nav.byType',
label: "nav.byType",
icon: IconTruck,
permissions: APPLICATION_QUEUE,
children: [
{ to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/SHIPPING_AGENT', label: 'nav.typeShippingAgent', icon: IconShip, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor, permissions: APPLICATION_QUEUE },
{
to: "/licence-review/type/FREIGHT_FORWARDER",
label: "nav.typeFreightForwarder",
icon: IconTruck,
permissions: APPLICATION_QUEUE,
},
{
to: "/licence-review/type/SHIPPING_AGENT",
label: "nav.typeShippingAgent",
icon: IconShip,
permissions: APPLICATION_QUEUE,
},
{
to: "/licence-review/type/COMBINED_SA_FF",
label: "nav.typeCombined",
icon: IconFileDescription,
permissions: APPLICATION_QUEUE,
},
{
to: "/licence-review/type/JOINT_INVESTOR",
label: "nav.typeJointInvestment",
icon: IconUsers,
permissions: APPLICATION_QUEUE,
},
{
to: "/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR",
label: "nav.typeMto",
icon: IconAnchor,
permissions: APPLICATION_QUEUE,
},
],
},
{
to: '/licence-register',
label: 'nav.licenceRegister',
to: "/licence-register",
label: "nav.licenceRegister",
icon: IconListCheck,
permissions: [P.VIEW_LICENSES],
},
{ to: '/licence-review/type/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
{
to: "/licence-review/type/PRE_WAIVER",
label: "nav.preWaiverQueue",
icon: IconShieldOff,
permissions: APPLICATION_QUEUE,
},
{
to: "/licence-review/type/POST_WAIVER",
label: "nav.postWaiverQueue",
icon: IconShieldOff,
permissions: APPLICATION_QUEUE,
},
{
// Every figure on it is derived from the licence application queue.
to: '/logistics-head-dashboard',
label: 'nav.logisticsHeadDashboard',
to: "/logistics-head-dashboard",
label: "nav.logisticsHeadDashboard",
icon: IconGauge,
permissions: APPLICATION_QUEUE,
},
],
},
{
label: 'nav.groupSeafarer',
label: "nav.groupSeafarer",
items: [
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] },
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/ENDORSEMENT_SEAFARER', label: 'nav.endorsementQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
],
},
{
label: 'nav.groupVessels',
items: [
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
],
},
{
label: 'nav.groupExaminations',
items: [
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark, permissions: [P.APPROVE_QUESTION, P.AUTHOR_QUESTION] },
{
to: '/exams',
label: 'nav.exams',
to: "/seafarer-registry",
label: "nav.seafarerRegistry",
icon: IconUsers,
permissions: [P.VIEW_SEAFARER_REGISTRY],
},
{
to: "/biometric-enrollment",
label: "nav.biometricEnrollment",
icon: IconFingerprint,
permissions: BIOMETRIC_ENROLLMENT,
},
{
to: "/seafarer-registrations",
label: "nav.seafarerRegistrationQueue",
icon: IconId,
permissions: SEAFARER_QUEUE,
},
{
to: "/licence-review/type/CERTIFICATE_OF_COMPETENCY",
label: "nav.cocQueue",
icon: IconShieldCheck,
permissions: SEAFARER_QUEUE,
},
{
to: "/licence-review/type/CERTIFICATE_OF_PROFICIENCY",
label: "nav.copQueue",
icon: IconShieldCheck,
permissions: SEAFARER_QUEUE,
},
{
to: "/seaman-book-queue",
label: "nav.seamanBookQueue",
icon: IconBook2,
permissions: SEAFARER_QUEUE,
},
{
to: "/btc-queue",
label: "nav.btcQueue",
icon: IconShieldCheck,
permissions: SEAFARER_QUEUE,
},
{
to: "/licence-review/type/ENDORSEMENT_COC",
label: "nav.endorsementCocQueue",
icon: IconRubberStamp,
permissions: [P.VIEW_SEAFARER_REGISTRY],
},
{
to: "/licence-review/type/ENDORSEMENT_GOC",
label: "nav.endorsementGocQueue",
icon: IconRubberStamp,
permissions: [P.VIEW_SEAFARER_REGISTRY],
},
{
to: "/licence-review/type/ENDORSEMENT_SEAFARER",
label: "nav.endorsementQueue",
icon: IconRubberStamp,
permissions: APPLICATION_QUEUE,
},
{
to: "/sea-service-verification",
label: "nav.seaServiceVerification",
icon: IconAnchor,
permissions: [P.VERIFY_SEAFARER_RECORDS],
},
{
to: "/medical-verification",
label: "nav.medicalVerification",
icon: IconHeart,
permissions: [P.VERIFY_SEAFARER_RECORDS],
},
],
},
{
label: "nav.groupVessels",
items: [
{
to: "/vessel-registration-report",
label: "nav.vesselRegistrationReport",
icon: IconChartBar,
permissions: [P.VIEW_VESSEL_REGISTRY],
},
{
to: "/vessel-registration-queue",
label: "nav.vesselRegistrationQueue",
icon: IconAnchor,
permissions: [P.VIEW_VESSEL_REGISTRY],
},
{
to: "/licence-review/type/VESSEL_REGISTRATION",
label: "nav.vesselRegistrationApplicationQueue",
icon: IconAnchor,
permissions: [P.VIEW_VESSEL_REGISTRY],
},
{
to: "/licence-review/type/VESSEL_OWNERSHIP_TRANSFER",
label: "nav.ownershipTransferQueue",
icon: IconArrowsExchange,
permissions: [P.VIEW_VESSEL_REGISTRY],
},
{
to: "/vessel-registration-queue/new",
label: "nav.vesselFormBuilder",
icon: IconFilePlus,
soon: true,
permissions: [P.VIEW_VESSEL_REGISTRY],
},
],
},
{
label: "nav.groupExaminations",
items: [
{
to: "/questions",
label: "nav.questions",
icon: IconQuestionMark,
permissions: [P.APPROVE_QUESTION, P.AUTHOR_QUESTION],
},
{
to: "/exams",
label: "nav.exams",
icon: IconClipboardList,
permissions: [P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT],
permissions: [
P.MANAGE_EXAMS,
P.RECORD_EXAM_ATTENDANCE,
P.MANAGE_EXAM_INCIDENTS,
P.PUBLISH_EXAM_RESULT,
],
},
{
to: '/exam-results',
label: 'nav.examResults',
to: "/exam-results",
label: "nav.examResults",
icon: IconReport,
permissions: [P.RECORD_EXAM_RESULT, P.MODERATE_EXAM_RESULT, P.APPROVE_EXAM_RESULT, P.PUBLISH_EXAM_RESULT],
permissions: [
P.RECORD_EXAM_RESULT,
P.MODERATE_EXAM_RESULT,
P.APPROVE_EXAM_RESULT,
P.PUBLISH_EXAM_RESULT,
],
},
{
to: "/exam-appeals",
label: "nav.examAppeals",
icon: IconGavel,
permissions: [P.DECIDE_EXAM_APPEAL],
},
{ to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel, permissions: [P.DECIDE_EXAM_APPEAL] },
],
},
{
label: 'nav.groupShared',
label: "nav.groupShared",
items: [
{
to: '/certificate-designer',
label: 'nav.certificateDesigner',
to: "/certificate-designer",
label: "nav.certificateDesigner",
icon: IconRosetteDiscountCheck,
permissions: [P.VIEW_TEMPLATES],
},
{
to: '/certificate-requirements',
label: 'nav.certificateRequirements',
to: "/certificate-requirements",
label: "nav.certificateRequirements",
icon: IconClipboardText,
permissions: [P.VIEW_LICENSE_TYPES],
},
{
to: '/payment-config',
label: 'nav.paymentConfig',
to: "/payment-config",
label: "nav.paymentConfig",
icon: IconCreditCard,
permissions: [P.VIEW_PAYMENTS],
},
{
to: '/pickup-desk',
label: 'nav.pickupDesk',
icon: IconCalendarEvent,
permissions: [P.MANAGE_PICKUP_DESK],
},
{
to: '/pickup-offices',
label: 'nav.pickupOffices',
icon: IconBuildingWarehouse,
permissions: [P.CONFIGURE_PICKUP_OFFICES],
},
],
},
{
label: 'nav.groupAdministration',
label: "nav.groupAdministration",
items: [
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
{
to: "/um/user-management/dashboard",
label: "nav.userManagement",
icon: IconUserShield,
},
{
// Professions, locations and certifications have no dedicated keys;
// the config-view keys are the closest published contract.
to: '/configuration',
label: 'nav.configuration',
to: "/configuration",
label: "nav.configuration",
icon: IconSettings,
permissions: [P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES],
},
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
{
to: "/analytics",
label: "nav.analytics",
icon: IconChartBar,
soon: true,
},
],
},
// `/profile` deliberately absent: it is a property of the signed-in user,

View File

@@ -28,6 +28,8 @@ import {
SeaServiceVerificationPage,
} from '../features/medical-verification/pages/MedicalVerificationPage';
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
import { PickupDeskPage } from '../features/pickup/pages/PickupDeskPage';
import { PickupOfficesPage } from '../features/pickup/pages/PickupOfficesPage';
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
import { SeafarerRegistrationReviewPage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage';
@@ -48,10 +50,17 @@ import { LicenseReviewPage } from '../features/license-review/pages/LicenseRevie
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage';
import { BiometricEnrollmentPage } from '../features/biometric-enrollment/pages/BiometricEnrollmentPage';
/** Any-of gate shared by every licence-type queue and its review workspace. */
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
const BIOMETRIC_ENROLLMENT = [
P.ENROLL_BIOMETRICS,
P.VIEW_BIOMETRICS,
P.VIEW_SEAFARER_REGISTRY,
];
/** Route gate: same keys as the route's nav item in nav-config.ts. */
const guard = (anyOf: string[], element: ReactNode) => (
<RequirePermission anyOf={anyOf}>{element}</RequirePermission>
@@ -99,7 +108,10 @@ const router = createBrowserRouter([
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
{ path: 'pickup-desk', element: guard([P.MANAGE_PICKUP_DESK], <PickupDeskPage />) },
{ path: 'pickup-offices', element: guard([P.CONFIGURE_PICKUP_OFFICES], <PickupOfficesPage />) },
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
{ path: 'biometric-enrollment', element: guard(BIOMETRIC_ENROLLMENT, <BiometricEnrollmentPage />) },
// Seafarer registration is not a licence: own queue, own review.
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },

View File

@@ -42,14 +42,14 @@ export default defineConfig({
emptyOutDir: true,
reportCompressedSize: true,
},
// Unit tests for the pure helpers behind a screen (formatters, URL state).
// Component tests are deliberately not set up: nothing here renders React,
// so no jsdom environment or setup file is needed.
// test: {
// watch: false,
// globals: true,
// environment: 'node',
// include: ['src/**/*.spec.ts'],
// reporters: ['default'],
// },
// Unit tests for the pure helpers behind a screen (formatters, URL state,
// queue views). Component tests are deliberately not set up: nothing here
// renders React, so no jsdom environment or setup file is needed.
test: {
watch: false,
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
reporters: ['default'],
},
});