mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-04 11:23:42 +00:00
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:
@@ -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 & 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;
|
||||
@@ -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 }} />
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
useGetRanksQuery,
|
||||
useGetTemplateVariablesQuery,
|
||||
usePublishLicenseTemplateMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useUpdateLicenseTemplateMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui';
|
||||
@@ -88,7 +87,6 @@ export function CertificateDesignerPage() {
|
||||
const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation();
|
||||
const [archiveTemplate] = useArchiveLicenseTemplateMutation();
|
||||
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
|
||||
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
|
||||
|
||||
const draft = useTemplateDraft(templates);
|
||||
const run = useDesignerActions();
|
||||
@@ -96,7 +94,6 @@ export function CertificateDesignerPage() {
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [validityMonths, setValidityMonths] = useState<number>(12);
|
||||
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
|
||||
|
||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||
@@ -127,10 +124,6 @@ export function CertificateDesignerPage() {
|
||||
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
|
||||
}, [licenseTypes, typeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
||||
}, [selectedType]);
|
||||
|
||||
// Switching licence type leaves a stale rank selected from the previous
|
||||
// type's ladder — reset to the type's default design.
|
||||
useEffect(() => {
|
||||
@@ -169,17 +162,9 @@ export function CertificateDesignerPage() {
|
||||
setRankId(value);
|
||||
draft.setSelectedId(null);
|
||||
}}
|
||||
validityMonths={validityMonths}
|
||||
onValidityChange={setValidityMonths}
|
||||
currentValidityMonths={selectedType?.validityMonths}
|
||||
validityMonths={selectedType?.validityMonths ?? 12}
|
||||
validityDays={selectedType?.validityDays ?? null}
|
||||
canEdit={canEdit}
|
||||
savingValidity={savingValidity}
|
||||
onSaveValidity={() =>
|
||||
run(
|
||||
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
|
||||
t('designer.validitySaved', 'Validity updated'),
|
||||
)
|
||||
}
|
||||
onNewVersion={startNewVersion}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LICENSE_PERMISSIONS, usePermissions } from '@ema-platform/auth';
|
||||
import {
|
||||
useUpdateLicenseBehaviorMutation,
|
||||
type CertificateCategory,
|
||||
type CompletionEffect,
|
||||
type LicenseType,
|
||||
type ServiceKind,
|
||||
type WorkflowProfile,
|
||||
} from '@ema-platform/api';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
|
||||
/** The shape the form edits — every field the behaviour endpoint accepts. */
|
||||
interface Draft {
|
||||
workflowProfile: WorkflowProfile;
|
||||
serviceKind: ServiceKind;
|
||||
completionEffect: CompletionEffect | null;
|
||||
certificateCategory: CertificateCategory | null;
|
||||
requiresExamination: boolean;
|
||||
inspectionRequired: boolean;
|
||||
issuesCertificate: boolean;
|
||||
renewalEnabled: boolean;
|
||||
requiresSeafarerRegistration: boolean;
|
||||
requiresValidMedical: boolean;
|
||||
minSeaTimeDays: number | null;
|
||||
capitalThreshold: number | null;
|
||||
validityMonths: number;
|
||||
validityDays: number | null;
|
||||
renewalWindowDays: number;
|
||||
expiryReminderDays: number[];
|
||||
requiresOperatorMode: boolean;
|
||||
allowMultipleOpenDrafts: boolean;
|
||||
requiresIssuanceScheduling: boolean;
|
||||
uniqueFormKeyPath: string | null;
|
||||
slaHours: number | null;
|
||||
}
|
||||
|
||||
/** Offsets EMA reminds on. Fixed rather than free-form — these are policy, not arithmetic. */
|
||||
const REMINDER_OFFSETS = ['90', '60', '30', '14', '7'];
|
||||
|
||||
function toDraft(licenseType: LicenseType): Draft {
|
||||
return {
|
||||
workflowProfile: licenseType.workflowProfile ?? 'STANDARD',
|
||||
serviceKind: licenseType.serviceKind ?? 'LICENSE',
|
||||
completionEffect: licenseType.completionEffect ?? null,
|
||||
certificateCategory: licenseType.certificateCategory ?? null,
|
||||
requiresExamination: licenseType.requiresExamination ?? false,
|
||||
inspectionRequired: licenseType.inspectionRequired ?? true,
|
||||
issuesCertificate: licenseType.issuesCertificate ?? true,
|
||||
renewalEnabled: licenseType.renewalEnabled ?? true,
|
||||
requiresSeafarerRegistration:
|
||||
licenseType.requiresSeafarerRegistration ?? false,
|
||||
requiresValidMedical: licenseType.requiresValidMedical ?? false,
|
||||
minSeaTimeDays: licenseType.minSeaTimeDays ?? null,
|
||||
capitalThreshold:
|
||||
licenseType.capitalThreshold == null
|
||||
? null
|
||||
: Number(licenseType.capitalThreshold),
|
||||
validityMonths: licenseType.validityMonths ?? 12,
|
||||
validityDays: licenseType.validityDays ?? null,
|
||||
renewalWindowDays: licenseType.renewalWindowDays ?? 60,
|
||||
expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7],
|
||||
requiresOperatorMode: licenseType.requiresOperatorMode ?? true,
|
||||
allowMultipleOpenDrafts: licenseType.allowMultipleOpenDrafts ?? false,
|
||||
requiresIssuanceScheduling: licenseType.requiresIssuanceScheduling ?? false,
|
||||
uniqueFormKeyPath: licenseType.uniqueFormKeyPath ?? null,
|
||||
slaHours: licenseType.slaHours ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How one licence type behaves: the course it runs, who may apply, when it
|
||||
* renews and what rules the applicant meets.
|
||||
*
|
||||
* These were seed-only until now — changing an SLA target or a renewal window
|
||||
* meant editing a file and redeploying. They are read live off the licence
|
||||
* type rather than snapshotted onto applications, so a change here applies to
|
||||
* files already in the queue as well as new ones. For the three settings that
|
||||
* decide an application's course the server refuses the change outright while
|
||||
* anything is still awaiting a decision, rather than stranding it.
|
||||
*/
|
||||
export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
const { t } = useTranslation();
|
||||
const run = useRequirementActions();
|
||||
const { can } = usePermissions();
|
||||
const canEdit = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]);
|
||||
|
||||
const [save, { isLoading: saving }] = useUpdateLicenseBehaviorMutation();
|
||||
const [draft, setDraft] = useState<Draft>(() => toDraft(licenseType));
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// Keyed on the id alone, like FormSchemaTab: this tab's own save invalidates
|
||||
// the licence-type list, and the refetch that follows must not overwrite an
|
||||
// edit the administrator is still working on.
|
||||
useEffect(() => {
|
||||
setDraft(toDraft(licenseType));
|
||||
setDirty(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [licenseType.id]);
|
||||
|
||||
function set<K extends keyof Draft>(key: K, value: Draft[K]) {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
const ok = await run(
|
||||
() => save({ id: licenseType.id, ...draft }).unwrap(),
|
||||
t('certReq.behavior.saved', 'Configuration saved.'),
|
||||
);
|
||||
if (ok) setDirty(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Section title={t('certReq.behavior.workflow', 'Workflow')}>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="yellow"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'certReq.behavior.workflowWarning',
|
||||
'These three settings decide the course an application runs. They cannot be changed while applications of this type are still awaiting a decision — saving will be refused until those are decided.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Select
|
||||
label={t('certReq.behavior.workflowProfile', 'Workflow profile')}
|
||||
description={t(
|
||||
'certReq.behavior.workflowProfileHint',
|
||||
'REGISTRATION drops the evaluation and inspection stages.',
|
||||
)}
|
||||
data={[
|
||||
{ value: 'STANDARD', label: t('certReq.behavior.standard', 'Standard licence course') },
|
||||
{ value: 'REGISTRATION', label: t('certReq.behavior.registration', 'Registration (review only)') },
|
||||
]}
|
||||
value={draft.workflowProfile}
|
||||
onChange={(v) => v && set('workflowProfile', v as WorkflowProfile)}
|
||||
allowDeselect={false}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('certReq.behavior.completionEffect', 'Completion effect')}
|
||||
description={t(
|
||||
'certReq.behavior.completionEffectHint',
|
||||
'Platform action taken when an application of this type completes.',
|
||||
)}
|
||||
data={[
|
||||
{ value: 'REGISTER_SEAFARER', label: t('certReq.behavior.registerSeafarer', 'Register the seafarer') },
|
||||
{ value: 'REGISTER_VESSEL', label: t('certReq.behavior.registerVessel', 'Register the vessel') },
|
||||
{ value: 'OPEN_SEAFARER_DOCUMENTS', label: t('certReq.behavior.openDocuments', 'Open seafarer documents') },
|
||||
]}
|
||||
value={draft.completionEffect}
|
||||
onChange={(v) => set('completionEffect', (v as CompletionEffect) ?? null)}
|
||||
placeholder={t('certReq.behavior.noEffect', 'No side effect')}
|
||||
clearable
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.requiresExamination}
|
||||
onChange={(e) => set('requiresExamination', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.requiresExamination', 'Requires an examination')}
|
||||
description={t(
|
||||
'certReq.behavior.requiresExaminationHint',
|
||||
'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.issuesCertificate}
|
||||
onChange={(e) => set('issuesCertificate', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.issuesCertificate', 'Issues a certificate')}
|
||||
description={t(
|
||||
'certReq.behavior.issuesCertificateHint',
|
||||
'Turn off for a type that ends with an EMA decision and never reaches a payment stage. Turning it back on requires a new-application fee to be set, or approved applicants would be asked for a fee that does not exist.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('certReq.behavior.serviceKind', 'Service kind')}
|
||||
description={t(
|
||||
'certReq.behavior.serviceKindHint',
|
||||
'Catalogue classification only — no workflow depends on it.',
|
||||
)}
|
||||
data={[
|
||||
{ value: 'LICENSE', label: t('certReq.behavior.license', 'Licence') },
|
||||
{ value: 'REGISTRATION', label: t('certReq.behavior.registrationKind', 'Registration') },
|
||||
]}
|
||||
value={draft.serviceKind}
|
||||
onChange={(v) => v && set('serviceKind', v as ServiceKind)}
|
||||
allowDeselect={false}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title={t('certReq.behavior.eligibility', 'Eligibility gates')}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'certReq.behavior.eligibilityHint',
|
||||
'Checked when an applicant submits, and the capital requirement again when an officer approves. Tightening a gate can stop someone mid-way from submitting a draft they have already started.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<NullableNumber
|
||||
label={t('certReq.behavior.capitalThreshold', 'Minimum paid-up capital')}
|
||||
switchLabel={t('certReq.behavior.capitalOn', 'Require a minimum paid-up capital')}
|
||||
description={t(
|
||||
'certReq.behavior.capitalHint',
|
||||
'An officer cannot approve until they have verified capital at or above this. Applies to applications already in the queue, not just new ones.',
|
||||
)}
|
||||
value={draft.capitalThreshold}
|
||||
onChange={(v) => set('capitalThreshold', v)}
|
||||
disabled={!canEdit}
|
||||
min={0}
|
||||
defaultValue={1_000_000}
|
||||
thousandSeparator
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.requiresSeafarerRegistration}
|
||||
onChange={(e) => set('requiresSeafarerRegistration', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.requiresSeafarer', 'Requires an active seafarer registration')}
|
||||
description={t(
|
||||
'certReq.behavior.requiresSeafarerHint',
|
||||
'Also marks this type as a seafarer certificate, which is what makes it appear in the seafarer portal.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.requiresValidMedical}
|
||||
onChange={(e) => set('requiresValidMedical', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.requiresMedical', 'Requires a current medical certificate')}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<NullableNumber
|
||||
label={t('certReq.behavior.minSeaTime', 'Minimum sea time (days)')}
|
||||
switchLabel={t('certReq.behavior.minSeaTimeOn', 'Require verified sea time')}
|
||||
value={draft.minSeaTimeDays}
|
||||
onChange={(v) => set('minSeaTimeDays', v)}
|
||||
disabled={!canEdit}
|
||||
min={0}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('certReq.behavior.certificateCategory', 'Certificate category')}
|
||||
description={t(
|
||||
'certReq.behavior.certificateCategoryHint',
|
||||
'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.',
|
||||
)}
|
||||
data={['COC', 'COP', 'ENDORSEMENT', 'GOC', 'NATIONAL'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
value={draft.certificateCategory}
|
||||
onChange={(v) => set('certificateCategory', (v as CertificateCategory) ?? null)}
|
||||
placeholder={t('certReq.behavior.notACertificate', 'Not a certificate')}
|
||||
clearable
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title={t('certReq.behavior.renewal', 'Validity and renewal')}>
|
||||
{/* Amount + unit rather than a years box that silently divides by 12:
|
||||
the seed says `validityMonths: 12` and this now says "12 Months",
|
||||
so the two read the same. Months advance the calendar (issued on
|
||||
the 31st, expires on the 31st); days are for terms shorter than a
|
||||
month can express. */}
|
||||
<Group align="flex-end" gap="sm" wrap="nowrap">
|
||||
<NumberInput
|
||||
label={t('certReq.behavior.validity', 'Valid for')}
|
||||
description={t(
|
||||
'certReq.behavior.validityHint',
|
||||
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
|
||||
)}
|
||||
value={draft.validityDays ?? draft.validityMonths}
|
||||
onChange={(v) => {
|
||||
const next = typeof v === 'number' ? v : 0;
|
||||
if (!next) return;
|
||||
if (draft.validityDays !== null) set('validityDays', next);
|
||||
else set('validityMonths', next);
|
||||
}}
|
||||
// Matches the server's ranges, so the box cannot offer a value the
|
||||
// save would reject: 1–3650 days, or 6–240 months.
|
||||
min={draft.validityDays !== null ? 1 : 6}
|
||||
max={draft.validityDays !== null ? 3650 : 240}
|
||||
allowNegative={false}
|
||||
disabled={!canEdit}
|
||||
flex={1}
|
||||
/>
|
||||
<Select
|
||||
aria-label={t('certReq.behavior.validityUnit', 'Validity unit')}
|
||||
data={[
|
||||
{ value: 'MONTHS', label: t('certReq.behavior.unitMonths', 'Months') },
|
||||
{ value: 'DAYS', label: t('certReq.behavior.unitDays', 'Days') },
|
||||
]}
|
||||
value={draft.validityDays !== null ? 'DAYS' : 'MONTHS'}
|
||||
onChange={(unit) => {
|
||||
// Switching unit is a change of policy, not a conversion: 12
|
||||
// calendar months is not 365 days, so carry no arithmetic across
|
||||
// and let the administrator state the new term outright.
|
||||
if (unit === 'DAYS') set('validityDays', draft.validityDays ?? 90);
|
||||
else set('validityDays', null);
|
||||
}}
|
||||
allowDeselect={false}
|
||||
disabled={!canEdit}
|
||||
w={130}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Switch
|
||||
checked={draft.renewalEnabled}
|
||||
onChange={(e) => set('renewalEnabled', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.renewalEnabled', 'Holders may renew this licence')}
|
||||
description={t(
|
||||
'certReq.behavior.renewalEnabledHint',
|
||||
'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
{/* The window and the reminders are both measured against an expiry a
|
||||
non-renewing licence never reaches, so they are hidden rather than
|
||||
shown as settings that quietly do nothing. */}
|
||||
{draft.renewalEnabled && (
|
||||
<>
|
||||
<NumberInput
|
||||
label={t('certReq.behavior.renewalWindow', 'Renewal opens (days before expiry)')}
|
||||
value={draft.renewalWindowDays}
|
||||
onChange={(v) =>
|
||||
set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)
|
||||
}
|
||||
min={1}
|
||||
max={365}
|
||||
allowNegative={false}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label={t('certReq.behavior.reminders', 'Expiry reminders (days before)')}
|
||||
description={t(
|
||||
'certReq.behavior.remindersHint',
|
||||
'The holder is reminded at each of these offsets.',
|
||||
)}
|
||||
data={REMINDER_OFFSETS}
|
||||
value={draft.expiryReminderDays.map(String)}
|
||||
onChange={(values) =>
|
||||
set(
|
||||
'expiryReminderDays',
|
||||
values.map(Number).sort((a, b) => b - a),
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
clearable
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t('certReq.behavior.applicantRules', 'Applicant rules')}>
|
||||
<Switch
|
||||
checked={draft.requiresOperatorMode}
|
||||
onChange={(e) => set('requiresOperatorMode', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.requiresOperatorMode', 'Applicant must declare this operating mode')}
|
||||
description={t(
|
||||
'certReq.behavior.requiresOperatorModeHint',
|
||||
'Turn off for person-centric registrations any signed-in applicant may start.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.allowMultipleOpenDrafts}
|
||||
onChange={(e) => set('allowMultipleOpenDrafts', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.allowMultipleDrafts', 'Allow several open drafts at once')}
|
||||
description={t(
|
||||
'certReq.behavior.allowMultipleDraftsHint',
|
||||
'On for per-asset registrations — registering a second vessel must not resume the first one’s draft.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.inspectionRequired}
|
||||
onChange={(e) => set('inspectionRequired', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.inspectionRequired', 'Requires a physical inspection')}
|
||||
description={t(
|
||||
'certReq.behavior.inspectionRequiredHint',
|
||||
'An officer cannot approve until an inspection has been recorded and passed. Safe to change with applications in progress — an officer can still assign an inspector to a file already under evaluation.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={draft.requiresIssuanceScheduling}
|
||||
onChange={(e) => set('requiresIssuanceScheduling', e.currentTarget.checked)}
|
||||
label={t('certReq.behavior.requiresScheduling', 'Schedule a pickup date before issuing')}
|
||||
description={t(
|
||||
'certReq.behavior.requiresSchedulingHint',
|
||||
'For documents printed once and handed over in person.',
|
||||
)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<NullableNumber
|
||||
label={t('certReq.behavior.slaHours', 'Decision target (hours)')}
|
||||
switchLabel={t('certReq.behavior.slaOn', 'Track against an SLA')}
|
||||
value={draft.slaHours}
|
||||
onChange={(v) => set('slaHours', v)}
|
||||
disabled={!canEdit}
|
||||
min={1}
|
||||
description={t(
|
||||
'certReq.behavior.slaHint',
|
||||
'Drives the Age/SLA column and the Overdue view. Applies to applications already submitted as well as new ones.',
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t('certReq.behavior.uniqueFormKey', 'One application per answer at')}
|
||||
description={t(
|
||||
'certReq.behavior.uniqueFormKeyHint',
|
||||
'Dotted form path, e.g. shipment.billOfLading. The field must exist in this type’s form, or no application can be submitted.',
|
||||
)}
|
||||
value={draft.uniqueFormKeyPath ?? ''}
|
||||
onChange={(e) =>
|
||||
set('uniqueFormKeyPath', e.currentTarget.value.trim() || null)
|
||||
}
|
||||
maxLength={128}
|
||||
placeholder={t('certReq.behavior.noUniqueRule', 'No uniqueness rule')}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Tooltip
|
||||
label={t('certReq.behavior.noPermission', 'You do not have permission to change licence configuration.')}
|
||||
disabled={canEdit}
|
||||
>
|
||||
<Button
|
||||
leftSection={<IconDeviceFloppy size={16} />}
|
||||
onClick={onSave}
|
||||
loading={saving}
|
||||
disabled={!canEdit || !dirty}
|
||||
>
|
||||
{t('certReq.behavior.save', 'Save configuration')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A number that may be switched off entirely.
|
||||
*
|
||||
* Null is a real configuration state here — "not tracked against an SLA", "no
|
||||
* sea-time floor" — and is not the same as an empty box, so the choice gets its
|
||||
* own switch rather than being inferred from a blank field.
|
||||
*/
|
||||
function NullableNumber({
|
||||
label,
|
||||
switchLabel,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
min,
|
||||
defaultValue,
|
||||
thousandSeparator,
|
||||
}: {
|
||||
label: string;
|
||||
switchLabel: string;
|
||||
description?: string;
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
disabled: boolean;
|
||||
min: number;
|
||||
/** Seeded when the switch is turned on. Defaults to the minimum. */
|
||||
defaultValue?: number;
|
||||
thousandSeparator?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Switch
|
||||
checked={value !== null}
|
||||
onChange={(e) =>
|
||||
onChange(e.currentTarget.checked ? (defaultValue ?? min ?? 1) : null)
|
||||
}
|
||||
label={switchLabel}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value !== null && (
|
||||
<NumberInput
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(v) => onChange(typeof v === 'number' ? v : value)}
|
||||
min={min}
|
||||
allowNegative={false}
|
||||
thousandSeparator={thousandSeparator ? ',' : undefined}
|
||||
decimalScale={thousandSeparator ? 2 : undefined}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 applicant’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 ?? ""}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: "ሰርዝ",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />) },
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user