mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 22:25:43 +00:00
@@ -0,0 +1,322 @@
|
|||||||
|
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, IconPrinter, IconScan, IconSearch, IconX } from '@tabler/icons-react';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import {
|
||||||
|
extractErrorMessage,
|
||||||
|
openAuthedDocument,
|
||||||
|
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 registered seafarer to enroll — approved registrations carry a profileId. */
|
||||||
|
function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debounced] = useDebouncedValue(search, 300);
|
||||||
|
const { data, isFetching } = useListSeafarerRegistrationsQuery({
|
||||||
|
status: 'APPROVED',
|
||||||
|
search: debounced || undefined,
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search seafarer by name, ID or registration number…"
|
||||||
|
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>
|
||||||
|
<Text fz="xs" c="dimmed" ff="monospace">{r.seafarerNumber}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
{!isFetching && (data?.items ?? []).length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" c="dimmed">No registered seafarer matches.</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 [printing, setPrinting] = useState(false);
|
||||||
|
|
||||||
|
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.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrint() {
|
||||||
|
if (!profileId) return;
|
||||||
|
setPrinting(true);
|
||||||
|
try {
|
||||||
|
await openAuthedDocument(
|
||||||
|
`/biometric-enrollments/profile/${profileId}/certificate`,
|
||||||
|
`biometric-enrollment-${profileId}.pdf`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(extractErrorMessage(err, 'Could not open the certificate.'));
|
||||||
|
} finally {
|
||||||
|
setPrinting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" py="md">
|
||||||
|
<PageHeader
|
||||||
|
title="Biometric Enrollment"
|
||||||
|
subtitle="Capture a fingerprint or face template for a registered seafarer, and print the enrollment slip."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!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.seafarerNumber}</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">
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Text fz="sm" fw={600}>On file</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconPrinter size={14} />}
|
||||||
|
onClick={handlePrint}
|
||||||
|
loading={printing}
|
||||||
|
>
|
||||||
|
Print certificate
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
{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;
|
||||||
@@ -198,7 +198,7 @@ function ConditionArmFields({
|
|||||||
fz="xs"
|
fz="xs"
|
||||||
px={6}
|
px={6}
|
||||||
py={2}
|
py={2}
|
||||||
bg="var(--mantine-color-gray-1)"
|
bg="var(--mantine-color-default-hover)"
|
||||||
style={{ borderRadius: 4, cursor: 'pointer' }}
|
style={{ borderRadius: 4, cursor: 'pointer' }}
|
||||||
onClick={() => setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))}
|
onClick={() => setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))}
|
||||||
title={t('certReq.condition.removeValue', 'Click to remove')}
|
title={t('certReq.condition.removeValue', 'Click to remove')}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
|||||||
) : (
|
) : (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{rows.map((req) => (
|
{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">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
|
|||||||
<Card key={section.key} withBorder radius="md" p="md">
|
<Card key={section.key} withBorder radius="md" p="md">
|
||||||
<Group justify="space-between" align="flex-start" mb="sm">
|
<Group justify="space-between" align="flex-start" mb="sm">
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<IconGripVertical size={16} color="var(--mantine-color-gray-5)" />
|
<IconGripVertical size={16} color="var(--mantine-color-dimmed)" />
|
||||||
<div>
|
<div>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text fw={700}>{localized(section.title) || section.key}</Text>
|
<Text fw={700}>{localized(section.title) || section.key}</Text>
|
||||||
@@ -237,7 +237,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
|
|||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{section.fields.map((field, fIndex) => (
|
{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 justify="space-between" wrap="nowrap">
|
||||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type ActionId =
|
|||||||
| 'complete-review'
|
| 'complete-review'
|
||||||
| 'approve-documents'
|
| 'approve-documents'
|
||||||
| 'schedule-inspection'
|
| 'schedule-inspection'
|
||||||
|
| 'reschedule-inspection'
|
||||||
| 'record-inspection'
|
| 'record-inspection'
|
||||||
| 'final-approve'
|
| 'final-approve'
|
||||||
| 'request-adjustment'
|
| 'request-adjustment'
|
||||||
@@ -200,6 +201,17 @@ export const ACTIONS: ActionDefinition[] = [
|
|||||||
permissions: ['can:create:inspection'],
|
permissions: ['can:create:inspection'],
|
||||||
emphasis: 'filled',
|
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',
|
id: 'record-inspection',
|
||||||
tier: 'primary',
|
tier: 'primary',
|
||||||
@@ -441,6 +453,12 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
|||||||
// one applies depends on whether an inspection is already booked.
|
// one applies depends on whether an inspection is already booked.
|
||||||
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
|
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
|
||||||
if (action.id === 'record-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
|
// The transition table doesn't know which types need an inspection, so
|
||||||
// `availableEvents` lists approve-documents at UNDER_EVALUATION even for
|
// `availableEvents` lists approve-documents at UNDER_EVALUATION even for
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
IconLayoutSidebarRightCollapse,
|
IconLayoutSidebarRightCollapse,
|
||||||
IconLayoutSidebarRightExpand,
|
IconLayoutSidebarRightExpand,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
|
IconPencil,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -70,6 +71,7 @@ import {
|
|||||||
useRequestAdjustmentMutation,
|
useRequestAdjustmentMutation,
|
||||||
useResumeApplicationMutation,
|
useResumeApplicationMutation,
|
||||||
useScheduleInspectionMutation,
|
useScheduleInspectionMutation,
|
||||||
|
useRescheduleInspectionMutation,
|
||||||
useGetCertificateUrlForOfficerMutation,
|
useGetCertificateUrlForOfficerMutation,
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
type RemarkTargetType,
|
type RemarkTargetType,
|
||||||
@@ -218,6 +220,7 @@ export function LicenseReviewPage() {
|
|||||||
const [finalApprove] = useFinalApproveMutation();
|
const [finalApprove] = useFinalApproveMutation();
|
||||||
const [rejectApplication] = useRejectApplicationMutation();
|
const [rejectApplication] = useRejectApplicationMutation();
|
||||||
const [scheduleInspection] = useScheduleInspectionMutation();
|
const [scheduleInspection] = useScheduleInspectionMutation();
|
||||||
|
const [rescheduleInspection] = useRescheduleInspectionMutation();
|
||||||
const [recordResult] = useRecordInspectionResultMutation();
|
const [recordResult] = useRecordInspectionResultMutation();
|
||||||
const [confirmPayment] = useConfirmPaymentMutation();
|
const [confirmPayment] = useConfirmPaymentMutation();
|
||||||
const [scheduleIssuance] = useScheduleIssuanceMutation();
|
const [scheduleIssuance] = useScheduleIssuanceMutation();
|
||||||
@@ -261,6 +264,9 @@ export function LicenseReviewPage() {
|
|||||||
const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">(
|
const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">(
|
||||||
"MORNING",
|
"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 [issuanceOpen, setIssuanceOpen] = useState(false);
|
||||||
const [issuanceDate, setIssuanceDate] = useState("");
|
const [issuanceDate, setIssuanceDate] = useState("");
|
||||||
const [resultOpen, setResultOpen] = useState(false);
|
const [resultOpen, setResultOpen] = useState(false);
|
||||||
@@ -311,10 +317,14 @@ export function LicenseReviewPage() {
|
|||||||
|
|
||||||
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
|
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
|
||||||
// A visit cannot have an outcome before it happens — mirror of the server's
|
// 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(
|
const inspectionNotYetDue = Boolean(
|
||||||
pendingInspection?.scheduledDate &&
|
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 } =
|
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
|
||||||
@@ -558,12 +568,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. */
|
/** Actions with their own dedicated form open that; the rest confirm. */
|
||||||
function handleAction(action: ResolvedAction) {
|
function handleAction(action: ResolvedAction) {
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
case "schedule-inspection":
|
case "schedule-inspection":
|
||||||
|
setRescheduling(false);
|
||||||
|
setInspectionDate("");
|
||||||
|
setInspectionTimeSlot("MORNING");
|
||||||
|
setRescheduleReason("");
|
||||||
setInspectionOpen(true);
|
setInspectionOpen(true);
|
||||||
return;
|
return;
|
||||||
|
case "reschedule-inspection":
|
||||||
|
openReschedule();
|
||||||
|
return;
|
||||||
case "schedule-issuance":
|
case "schedule-issuance":
|
||||||
setIssuanceOpen(true);
|
setIssuanceOpen(true);
|
||||||
return;
|
return;
|
||||||
@@ -1214,18 +1241,6 @@ export function LicenseReviewPage() {
|
|||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="inspection">
|
<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">
|
<Paper withBorder p="md">
|
||||||
{inspections.length === 0 ? (
|
{inspections.length === 0 ? (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
@@ -1258,21 +1273,48 @@ export function LicenseReviewPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Group gap="xs">
|
||||||
variant="light"
|
{inspection.status === "SCHEDULED" &&
|
||||||
color={
|
inspection.id === pendingInspection?.id &&
|
||||||
inspection.result === "FAILED" ? "red" : "teal"
|
can([
|
||||||
}
|
"can:create:inspection",
|
||||||
>
|
"can:update:inspection",
|
||||||
{inspection.result === "PASSED"
|
]) && (
|
||||||
? t("review.passed", "Passed")
|
<Tooltip
|
||||||
: inspection.result === "FAILED"
|
label={t(
|
||||||
? t("review.failed", "Failed")
|
"review.actions.rescheduleInspection",
|
||||||
: t(
|
"Reschedule inspection",
|
||||||
`review.inspectionStatus.${inspection.status}`,
|
|
||||||
inspection.status,
|
|
||||||
)}
|
)}
|
||||||
</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>
|
</Group>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1281,6 +1323,18 @@ export function LicenseReviewPage() {
|
|||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</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" && (
|
{status === "PAYMENT_PENDING" && (
|
||||||
<Alert
|
<Alert
|
||||||
mt="md"
|
mt="md"
|
||||||
@@ -1430,7 +1484,11 @@ export function LicenseReviewPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
opened={inspectionOpen}
|
opened={inspectionOpen}
|
||||||
onClose={() => setInspectionOpen(false)}
|
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>
|
<Stack>
|
||||||
<AmharicDatePicker
|
<AmharicDatePicker
|
||||||
@@ -1446,36 +1504,72 @@ export function LicenseReviewPage() {
|
|||||||
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
|
{ 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>
|
<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
|
<Tooltip
|
||||||
label={t("review.pickDate", "Pick a date first")}
|
label={t("review.pickDate", "Pick a date first")}
|
||||||
disabled={Boolean(inspectionDate)}
|
disabled={Boolean(inspectionDate)}
|
||||||
>
|
>
|
||||||
<span>
|
<span style={{ display: "inline-flex" }}>
|
||||||
<button type="button" hidden aria-hidden />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={!inspectionDate}
|
disabled={!inspectionDate}
|
||||||
aria-label={t("review.schedule", "Schedule")}
|
aria-label={
|
||||||
|
rescheduling
|
||||||
|
? t("review.reschedule", "Reschedule")
|
||||||
|
: t("review.schedule", "Schedule")
|
||||||
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
run(
|
run(
|
||||||
async () => {
|
async () => {
|
||||||
await scheduleInspection({
|
if (rescheduling) {
|
||||||
applicationId: id,
|
// Guarded by the action's own gate, which only offers
|
||||||
scheduledDate: inspectionDate,
|
// rescheduling while a booking exists.
|
||||||
timeSlot: inspectionTimeSlot,
|
if (!pendingInspection) return;
|
||||||
}).unwrap();
|
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);
|
setInspectionOpen(false);
|
||||||
},
|
},
|
||||||
t("review.done.scheduled", "Inspection scheduled"),
|
rescheduling
|
||||||
|
? t("review.done.rescheduled", "Inspection rescheduled")
|
||||||
|
: t("review.done.scheduled", "Inspection scheduled"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconCheck size={18} />
|
<IconCheck size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -1492,34 +1586,36 @@ export function LicenseReviewPage() {
|
|||||||
onChange={setIssuanceDate}
|
onChange={setIssuanceDate}
|
||||||
/>
|
/>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
{/* Span-wrapped like DecisionBar's ActionButton — Mantine strips
|
||||||
|
pointer events from a disabled control, and a disabled button
|
||||||
|
must still say why. */}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={t("review.pickDate", "Pick a date and time first")}
|
label={t("review.pickDate", "Pick a date first")}
|
||||||
disabled={Boolean(issuanceDate)}
|
disabled={Boolean(issuanceDate)}
|
||||||
>
|
>
|
||||||
<span>
|
<span style={{ display: "inline-flex" }}>
|
||||||
<button type="button" hidden aria-hidden />
|
<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>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</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>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middl
|
|||||||
return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—';
|
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() {
|
export function SeafarerRegistrationQueuePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
@@ -43,6 +43,8 @@ export function SeafarerRegistrationQueuePage() {
|
|||||||
const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({
|
const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({
|
||||||
status: status ?? undefined,
|
status: status ?? undefined,
|
||||||
search: debouncedSearch || undefined,
|
search: debouncedSearch || undefined,
|
||||||
|
sortBy: 'submittedAt',
|
||||||
|
sortDir: 'DESC',
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
skip: page * pageSize,
|
skip: page * pageSize,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export function SeafarerRegistryPage() {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
{/* Search + filters */}
|
{/* 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">
|
<Group mb="sm" gap="sm" justify="space-between">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search by name, seafarer ID, or seaman book…"
|
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>
|
<Text fz="xs" c="dimmed" mb="sm">{filtered.length} seafarer(s) found</Text>
|
||||||
|
|
||||||
|
<Table.ScrollContainer minWidth={1100}>
|
||||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -278,6 +279,7 @@ export function SeafarerRegistryPage() {
|
|||||||
)}
|
)}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />
|
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export const am: Translations = {
|
|||||||
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
||||||
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
|
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
|
||||||
seafarerRegistry: "የመርከበኞች መዝገብ",
|
seafarerRegistry: "የመርከበኞች መዝገብ",
|
||||||
|
biometricEnrollment: "ባዮሜትሪክ ምዝገባ",
|
||||||
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
|
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
|
||||||
applications: "ማመልከቻዎች",
|
applications: "ማመልከቻዎች",
|
||||||
paymentConfig: "የክፍያ ውቅረት",
|
paymentConfig: "የክፍያ ውቅረት",
|
||||||
@@ -874,6 +875,7 @@ export const am: Translations = {
|
|||||||
DRAFT: "ረቂቅ",
|
DRAFT: "ረቂቅ",
|
||||||
SUBMITTED: "ቀርቧል",
|
SUBMITTED: "ቀርቧል",
|
||||||
UNDER_REVIEW: "በግምገማ ላይ",
|
UNDER_REVIEW: "በግምገማ ላይ",
|
||||||
|
AWAITING_BIOMETRICS: "ባዮሜትሪክ በመጠባበቅ ላይ",
|
||||||
UNDER_EVALUATION: "በምዘና ላይ",
|
UNDER_EVALUATION: "በምዘና ላይ",
|
||||||
RESUBMIT_REQUIRED: "እንደገና ማቅረብ ያስፈልጋል",
|
RESUBMIT_REQUIRED: "እንደገና ማቅረብ ያስፈልጋል",
|
||||||
INSPECTION_PENDING: "ምርመራ በመጠባበቅ ላይ",
|
INSPECTION_PENDING: "ምርመራ በመጠባበቅ ላይ",
|
||||||
@@ -993,6 +995,9 @@ export const am: Translations = {
|
|||||||
morning: "ጠዋት",
|
morning: "ጠዋት",
|
||||||
afternoon: "ከሰዓት በኋላ",
|
afternoon: "ከሰዓት በኋላ",
|
||||||
schedule: "ያዝ",
|
schedule: "ያዝ",
|
||||||
|
reschedule: "አዛውር",
|
||||||
|
rescheduleReason: "ለምን ይዛወራል?",
|
||||||
|
rescheduleReasonHint: "በኦዲት መዝገብ ውስጥ ተይዞ ለአመልካቹ ይላካል።",
|
||||||
pickDate: "መጀመሪያ ቀን ይምረጡ",
|
pickDate: "መጀመሪያ ቀን ይምረጡ",
|
||||||
passed: "አልፏል",
|
passed: "አልፏል",
|
||||||
failed: "ወድቋል",
|
failed: "ወድቋል",
|
||||||
@@ -1029,6 +1034,7 @@ export const am: Translations = {
|
|||||||
completeReview: "ግምገማ አጠናቅቅ",
|
completeReview: "ግምገማ አጠናቅቅ",
|
||||||
approveDocuments: "ሰነዶችን አጽድቅ",
|
approveDocuments: "ሰነዶችን አጽድቅ",
|
||||||
scheduleInspection: "ምርመራ ያዝ",
|
scheduleInspection: "ምርመራ ያዝ",
|
||||||
|
rescheduleInspection: "ምርመራ አዛውር",
|
||||||
recordInspection: "የምርመራ ውጤት መዝግብ",
|
recordInspection: "የምርመራ ውጤት መዝግብ",
|
||||||
finalApprove: "አጽድቅ እና ስጥ",
|
finalApprove: "አጽድቅ እና ስጥ",
|
||||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||||
@@ -1182,6 +1188,7 @@ export const am: Translations = {
|
|||||||
assign: "እንደገና ተመድቧል",
|
assign: "እንደገና ተመድቧል",
|
||||||
assignReviewer: "ግምገማ ተመድቧል",
|
assignReviewer: "ግምገማ ተመድቧል",
|
||||||
scheduled: "ምርመራ ተይዟል",
|
scheduled: "ምርመራ ተይዟል",
|
||||||
|
rescheduled: "ምርመራ ተዛውሯል",
|
||||||
inspectionPassed: "ምርመራ አልፏል",
|
inspectionPassed: "ምርመራ አልፏል",
|
||||||
inspectionFailed: "ምርመራ ወድቋል",
|
inspectionFailed: "ምርመራ ወድቋል",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const en = {
|
|||||||
vesselRegistrations: 'Vessel Registration',
|
vesselRegistrations: 'Vessel Registration',
|
||||||
vesselTransfers: 'Vessel Ownership Transfer',
|
vesselTransfers: 'Vessel Ownership Transfer',
|
||||||
seafarerRegistry: 'Seafarer Registry',
|
seafarerRegistry: 'Seafarer Registry',
|
||||||
|
biometricEnrollment: 'Biometric Enrollment',
|
||||||
seafarerRegistrationQueue: 'Seafarer Registration Queue',
|
seafarerRegistrationQueue: 'Seafarer Registration Queue',
|
||||||
applications: 'Applications',
|
applications: 'Applications',
|
||||||
paymentConfig: 'Payment Config',
|
paymentConfig: 'Payment Config',
|
||||||
@@ -881,6 +882,7 @@ export const en = {
|
|||||||
DRAFT: 'Draft',
|
DRAFT: 'Draft',
|
||||||
SUBMITTED: 'Submitted',
|
SUBMITTED: 'Submitted',
|
||||||
UNDER_REVIEW: 'Under Review',
|
UNDER_REVIEW: 'Under Review',
|
||||||
|
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
|
||||||
UNDER_EVALUATION: 'Under Evaluation',
|
UNDER_EVALUATION: 'Under Evaluation',
|
||||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||||
INSPECTION_PENDING: 'Inspection Pending',
|
INSPECTION_PENDING: 'Inspection Pending',
|
||||||
@@ -1002,6 +1004,9 @@ export const en = {
|
|||||||
morning: 'Morning',
|
morning: 'Morning',
|
||||||
afternoon: 'Afternoon',
|
afternoon: 'Afternoon',
|
||||||
schedule: 'Schedule',
|
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',
|
pickDate: 'Pick a date first',
|
||||||
passed: 'Passed',
|
passed: 'Passed',
|
||||||
failed: 'Failed',
|
failed: 'Failed',
|
||||||
@@ -1038,6 +1043,7 @@ export const en = {
|
|||||||
completeReview: 'Complete review',
|
completeReview: 'Complete review',
|
||||||
approveDocuments: 'Approve documents',
|
approveDocuments: 'Approve documents',
|
||||||
scheduleInspection: 'Schedule inspection',
|
scheduleInspection: 'Schedule inspection',
|
||||||
|
rescheduleInspection: 'Reschedule inspection',
|
||||||
recordInspection: 'Record inspection result',
|
recordInspection: 'Record inspection result',
|
||||||
finalApprove: 'Approve & issue',
|
finalApprove: 'Approve & issue',
|
||||||
requestAdjustment: 'Request adjustment',
|
requestAdjustment: 'Request adjustment',
|
||||||
@@ -1188,6 +1194,7 @@ export const en = {
|
|||||||
assign: 'Reassigned',
|
assign: 'Reassigned',
|
||||||
assignReviewer: 'Review assigned',
|
assignReviewer: 'Review assigned',
|
||||||
scheduled: 'Inspection scheduled',
|
scheduled: 'Inspection scheduled',
|
||||||
|
rescheduled: 'Inspection rescheduled',
|
||||||
inspectionPassed: 'Inspection passed',
|
inspectionPassed: 'Inspection passed',
|
||||||
inspectionFailed: 'Inspection failed',
|
inspectionFailed: 'Inspection failed',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconFilePlus,
|
IconFilePlus,
|
||||||
|
IconFingerprint,
|
||||||
IconGauge,
|
IconGauge,
|
||||||
IconGavel,
|
IconGavel,
|
||||||
IconHeart,
|
IconHeart,
|
||||||
@@ -25,9 +26,9 @@ import {
|
|||||||
IconTruck,
|
IconTruck,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
IconUserShield,
|
IconUserShield,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import type { NavSection } from '@ema-platform/ui';
|
import type { NavSection } from "@ema-platform/ui";
|
||||||
import { LICENSE_PERMISSIONS as P } from '@ema-platform/auth';
|
import { LICENSE_PERMISSIONS as P } from "@ema-platform/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every licence-type queue and its review workspace share one gate: the
|
* Every licence-type queue and its review workspace share one gate: the
|
||||||
@@ -35,6 +36,12 @@ import { LICENSE_PERMISSIONS as P } from '@ema-platform/auth';
|
|||||||
*/
|
*/
|
||||||
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
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];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The backoffice information architecture.
|
* The backoffice information architecture.
|
||||||
*
|
*
|
||||||
@@ -50,127 +57,282 @@ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
|||||||
*/
|
*/
|
||||||
export const NAV_SECTIONS: NavSection[] = [
|
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: [
|
items: [
|
||||||
{
|
{
|
||||||
to: '/licence-review',
|
to: "/licence-review",
|
||||||
label: 'nav.allApplications',
|
label: "nav.allApplications",
|
||||||
icon: IconListCheck,
|
icon: IconListCheck,
|
||||||
permissions: APPLICATION_QUEUE,
|
permissions: APPLICATION_QUEUE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// A disclosure, not a destination — each child deep-links the grid to
|
// A disclosure, not a destination — each child deep-links the grid to
|
||||||
// one type, which is a facet of the same workspace.
|
// one type, which is a facet of the same workspace.
|
||||||
label: 'nav.byType',
|
label: "nav.byType",
|
||||||
icon: IconTruck,
|
icon: IconTruck,
|
||||||
permissions: APPLICATION_QUEUE,
|
permissions: APPLICATION_QUEUE,
|
||||||
children: [
|
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/FREIGHT_FORWARDER",
|
||||||
{ to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription, permissions: APPLICATION_QUEUE },
|
label: "nav.typeFreightForwarder",
|
||||||
{ to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers, permissions: APPLICATION_QUEUE },
|
icon: IconTruck,
|
||||||
{ to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor, permissions: APPLICATION_QUEUE },
|
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',
|
to: "/licence-register",
|
||||||
label: 'nav.licenceRegister',
|
label: "nav.licenceRegister",
|
||||||
icon: IconListCheck,
|
icon: IconListCheck,
|
||||||
permissions: [P.VIEW_LICENSES],
|
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.
|
// Every figure on it is derived from the licence application queue.
|
||||||
to: '/logistics-head-dashboard',
|
to: "/logistics-head-dashboard",
|
||||||
label: 'nav.logisticsHeadDashboard',
|
label: "nav.logisticsHeadDashboard",
|
||||||
icon: IconGauge,
|
icon: IconGauge,
|
||||||
permissions: APPLICATION_QUEUE,
|
permissions: APPLICATION_QUEUE,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'nav.groupSeafarer',
|
label: "nav.groupSeafarer",
|
||||||
items: [
|
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',
|
to: "/seafarer-registry",
|
||||||
label: 'nav.exams',
|
label: "nav.seafarerRegistry",
|
||||||
|
icon: IconUsers,
|
||||||
|
permissions: [P.VIEW_SEAFARER_REGISTRY],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/biometric-enrollment",
|
||||||
|
label: "nav.biometricEnrollment",
|
||||||
|
icon: IconFingerprint,
|
||||||
|
permissions: [P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
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',
|
to: "/exam-results",
|
||||||
label: 'nav.examResults',
|
label: "nav.examResults",
|
||||||
icon: IconReport,
|
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: [
|
items: [
|
||||||
{
|
{
|
||||||
to: '/certificate-designer',
|
to: "/certificate-designer",
|
||||||
label: 'nav.certificateDesigner',
|
label: "nav.certificateDesigner",
|
||||||
icon: IconRosetteDiscountCheck,
|
icon: IconRosetteDiscountCheck,
|
||||||
permissions: [P.VIEW_TEMPLATES],
|
permissions: [P.VIEW_TEMPLATES],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/certificate-requirements',
|
to: "/certificate-requirements",
|
||||||
label: 'nav.certificateRequirements',
|
label: "nav.certificateRequirements",
|
||||||
icon: IconClipboardText,
|
icon: IconClipboardText,
|
||||||
permissions: [P.VIEW_LICENSE_TYPES],
|
permissions: [P.VIEW_LICENSE_TYPES],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/payment-config',
|
to: "/payment-config",
|
||||||
label: 'nav.paymentConfig',
|
label: "nav.paymentConfig",
|
||||||
icon: IconCreditCard,
|
icon: IconCreditCard,
|
||||||
permissions: [P.VIEW_PAYMENTS],
|
permissions: [P.VIEW_PAYMENTS],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'nav.groupAdministration',
|
label: "nav.groupAdministration",
|
||||||
items: [
|
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;
|
// Professions, locations and certifications have no dedicated keys;
|
||||||
// the config-view keys are the closest published contract.
|
// the config-view keys are the closest published contract.
|
||||||
to: '/configuration',
|
to: "/configuration",
|
||||||
label: 'nav.configuration',
|
label: "nav.configuration",
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
permissions: [P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES],
|
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,
|
// `/profile` deliberately absent: it is a property of the signed-in user,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { LicenseReviewPage } from '../features/license-review/pages/LicenseRevie
|
|||||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||||
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
||||||
import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage';
|
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. */
|
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
||||||
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
||||||
@@ -100,6 +101,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
||||||
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
||||||
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
||||||
|
{ path: 'biometric-enrollment', element: guard([P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS], <BiometricEnrollmentPage />) },
|
||||||
// Seafarer registration is not a licence: own queue, own review.
|
// Seafarer registration is not a licence: own queue, own review.
|
||||||
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
|
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
|
||||||
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },
|
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },
|
||||||
|
|||||||
@@ -42,14 +42,14 @@ export default defineConfig({
|
|||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
reportCompressedSize: true,
|
reportCompressedSize: true,
|
||||||
},
|
},
|
||||||
// Unit tests for the pure helpers behind a screen (formatters, URL state).
|
// Unit tests for the pure helpers behind a screen (formatters, URL state,
|
||||||
// Component tests are deliberately not set up: nothing here renders React,
|
// queue views). Component tests are deliberately not set up: nothing here
|
||||||
// so no jsdom environment or setup file is needed.
|
// renders React, so no jsdom environment or setup file is needed.
|
||||||
// test: {
|
test: {
|
||||||
// watch: false,
|
watch: false,
|
||||||
// globals: true,
|
globals: true,
|
||||||
// environment: 'node',
|
environment: 'node',
|
||||||
// include: ['src/**/*.spec.ts'],
|
include: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
|
||||||
// reporters: ['default'],
|
reporters: ['default'],
|
||||||
// },
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ interface Props {
|
|||||||
flagged?: Record<string, string>;
|
flagged?: Record<string, string>;
|
||||||
/** When set, only flagged slots accept a new upload. */
|
/** When set, only flagged slots accept a new upload. */
|
||||||
restrictToFlagged?: boolean;
|
restrictToFlagged?: boolean;
|
||||||
|
/**
|
||||||
|
* Requirement keys opened because a flagged section drives their condition —
|
||||||
|
* a category correction can make documents newly required, and those have to
|
||||||
|
* be uploadable even though the officer flagged no document.
|
||||||
|
*/
|
||||||
|
alsoUnlocked?: string[];
|
||||||
onUploaded: () => void;
|
onUploaded: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
@@ -55,6 +61,7 @@ export function DocumentSlots({
|
|||||||
ownerId,
|
ownerId,
|
||||||
flagged = {},
|
flagged = {},
|
||||||
restrictToFlagged = false,
|
restrictToFlagged = false,
|
||||||
|
alsoUnlocked = [],
|
||||||
onUploaded,
|
onUploaded,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -106,7 +113,11 @@ export function DocumentSlots({
|
|||||||
const uploaded = Boolean(existing?.files?.length);
|
const uploaded = Boolean(existing?.files?.length);
|
||||||
const fileUrl = existing?.files?.[0]?.url;
|
const fileUrl = existing?.files?.[0]?.url;
|
||||||
const flagRemark = flagged[requirement.key];
|
const flagRemark = flagged[requirement.key];
|
||||||
const locked = readOnly || (restrictToFlagged && !flagRemark);
|
const locked =
|
||||||
|
readOnly ||
|
||||||
|
(restrictToFlagged &&
|
||||||
|
!flagRemark &&
|
||||||
|
!alsoUnlocked.includes(requirement.key));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ export function LicenseCard({
|
|||||||
// only for a cached response from before those fields existed.
|
// only for a cached response from before those fields existed.
|
||||||
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
|
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
|
||||||
const expired = license.status === 'EXPIRED' || days < 0;
|
const expired = license.status === 'EXPIRED' || days < 0;
|
||||||
|
// Suspended, cancelled and superseded are none of them "valid until" their
|
||||||
|
// expiry date — the card used to say exactly that, because only EXPIRED was
|
||||||
|
// treated as not-current. A suspended licence read as a live one with a grey
|
||||||
|
// badge.
|
||||||
|
const current = license.status === 'ACTIVE' && !expired;
|
||||||
const renewable = license.renewable ?? false;
|
const renewable = license.renewable ?? false;
|
||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
const localized = useLocalized();
|
const localized = useLocalized();
|
||||||
@@ -100,9 +105,15 @@ export function LicenseCard({
|
|||||||
<Badge
|
<Badge
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
|
color={expired ? 'red' : current ? 'teal' : 'gray'}
|
||||||
>
|
>
|
||||||
{expired ? t('licensing.card.expired') : license.status}
|
{/* The raw enum was rendered here, so an Amharic page showed
|
||||||
|
"SUSPENDED" among otherwise translated text. */}
|
||||||
|
{expired
|
||||||
|
? t('licensing.card.expired')
|
||||||
|
: t(`licensing.card.status.${license.status}`, {
|
||||||
|
defaultValue: license.status,
|
||||||
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -113,8 +124,19 @@ export function LicenseCard({
|
|||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{expired
|
{expired
|
||||||
? t('licensing.card.expiredOn', { date: showDate(license.expiryDate) })
|
? t('licensing.card.expiredOn', { date: showDate(license.expiryDate) })
|
||||||
: t('licensing.card.validUntil', { date: showDate(license.expiryDate) })}
|
: current
|
||||||
|
? t('licensing.card.validUntil', { date: showDate(license.expiryDate) })
|
||||||
|
: t(`licensing.card.status.${license.status}`, {
|
||||||
|
defaultValue: license.status,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
{/* Why it stopped being current. The API returns it; the card threw
|
||||||
|
it away, leaving the holder to guess. */}
|
||||||
|
{!current && license.statusReason && (
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
{t('licensing.card.statusReason', { reason: license.statusReason })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<RequirePermission
|
<RequirePermission
|
||||||
anyOf={[
|
anyOf={[
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Anchor,
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
IconBuildingWarehouse,
|
IconBuildingWarehouse,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
|
IconLock,
|
||||||
IconShieldOff,
|
IconShieldOff,
|
||||||
IconShip,
|
IconShip,
|
||||||
IconTrendingUp,
|
IconTrendingUp,
|
||||||
@@ -45,9 +47,9 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
|
|||||||
CARGO_FREIGHT: IconBuildingWarehouse,
|
CARGO_FREIGHT: IconBuildingWarehouse,
|
||||||
SHIPPING_AGENCY: IconShip,
|
SHIPPING_AGENCY: IconShip,
|
||||||
INVESTMENT: IconTrendingUp,
|
INVESTMENT: IconTrendingUp,
|
||||||
// The three below are filtered out of this catalogue today
|
// The three below appear only when the applicant has declared a licence
|
||||||
// (requiresOperatorMode is false for all of them), and are listed only so
|
// type in them (see the family filter below); listed here so the record
|
||||||
// the record stays total if that ever changes.
|
// stays total either way.
|
||||||
MARITIME_PERSONNEL: IconShip,
|
MARITIME_PERSONNEL: IconShip,
|
||||||
VESSEL_SERVICES: IconAnchor,
|
VESSEL_SERVICES: IconAnchor,
|
||||||
WAIVER_SERVICES: IconShieldOff,
|
WAIVER_SERVICES: IconShieldOff,
|
||||||
@@ -81,15 +83,19 @@ export function LicenseCatalogue() {
|
|||||||
const { groups, orphans } = useMemo(() => {
|
const { groups, orphans } = useMemo(() => {
|
||||||
const active = (types?.items ?? [])
|
const active = (types?.items ?? [])
|
||||||
.filter((t) => t.isActive)
|
.filter((t) => t.isActive)
|
||||||
// Logistics licences only: this is the operator catalogue, not the
|
// The logistics family, plus whatever this applicant actually declared.
|
||||||
// seafarer certificate or vessel/seafarer document catalogue — those
|
//
|
||||||
// have their own entry points. `familyKind` is the real data-model
|
// `familyKind` is the real data-model classification and is what keeps
|
||||||
// classification (set on the type at seed time); `requiresOperatorMode`
|
// browse-all to the operator catalogue rather than every certificate and
|
||||||
// was the proxy this used before that column existed and happened to
|
// document type in the system. But it is not what decides eligibility:
|
||||||
// agree for every type seeded so far, but a type can only be trusted to
|
// the Operations tab also offers the personal registrations (seafarer,
|
||||||
// stay in sync with the catalogue it belongs in if the catalogue reads
|
// vessel) and the seafarer endorsement, which are DOCUMENT/CERTIFICATE
|
||||||
// its actual family instead of a flag with a different purpose.
|
// family, so an applicant who declared one of those was shown an empty
|
||||||
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE')
|
// catalogue — allowed to file, and offered nothing to file. Each of
|
||||||
|
// those keys already has an entry point at `/licensing/<key>/apply`
|
||||||
|
// (a router redirect for SEAFARER_REGISTRATION and SEAMAN_BOOK, the
|
||||||
|
// generic wizard for the rest), so the card leads somewhere real.
|
||||||
|
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE' || declared.has(t.id))
|
||||||
// Only what the applicant operates as. The server enforces the same rule
|
// Only what the applicant operates as. The server enforces the same rule
|
||||||
// on create; this is what stops them starting an application they will
|
// on create; this is what stops them starting an application they will
|
||||||
// be refused at the end of.
|
// be refused at the end of.
|
||||||
@@ -273,8 +279,8 @@ function LicenseTypeCard({
|
|||||||
withBorder
|
withBorder
|
||||||
radius="md"
|
radius="md"
|
||||||
padding="md"
|
padding="md"
|
||||||
style={{ cursor: 'pointer', height: '100%' }}
|
style={{ cursor: canApply ? 'pointer' : 'default', height: '100%' }}
|
||||||
onClick={() => onSelect(type)}
|
onClick={canApply ? () => onSelect(type) : undefined}
|
||||||
>
|
>
|
||||||
<Stack gap="xs" justify="space-between" h="100%">
|
<Stack gap="xs" justify="space-between" h="100%">
|
||||||
<Box>
|
<Box>
|
||||||
@@ -282,11 +288,19 @@ function LicenseTypeCard({
|
|||||||
<Text fw={600} size="sm" lh={1.35}>
|
<Text fw={600} size="sm" lh={1.35}>
|
||||||
{localized(type.name)}
|
{localized(type.name)}
|
||||||
</Text>
|
</Text>
|
||||||
<IconChevronRight
|
{canApply ? (
|
||||||
size={16}
|
<IconChevronRight
|
||||||
color="var(--mantine-color-dimmed)"
|
size={16}
|
||||||
style={{ flexShrink: 0, marginTop: 2 }}
|
color="var(--mantine-color-dimmed)"
|
||||||
/>
|
style={{ flexShrink: 0, marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconLock
|
||||||
|
size={16}
|
||||||
|
color="var(--mantine-color-dimmed)"
|
||||||
|
style={{ flexShrink: 0, marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{type.description && (
|
{type.description && (
|
||||||
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
|
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
|
||||||
@@ -325,13 +339,45 @@ function LicenseTypeCard({
|
|||||||
mt="sm"
|
mt="sm"
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={canApply ? undefined : 'gray'}
|
disabled={!canApply}
|
||||||
rightSection={<IconArrowRight size={14} />}
|
rightSection={<IconArrowRight size={14} />}
|
||||||
>
|
>
|
||||||
{canApply
|
{t('licensing.catalogue.startApplication')}
|
||||||
? t('licensing.catalogue.startApplication')
|
|
||||||
: t('licensing.catalogue.addToOperations')}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* A disabled button on its own only says "no". This says why, and
|
||||||
|
where to go about it — the licence is offered against a declared
|
||||||
|
mode of operation, and the server refuses a create for one the
|
||||||
|
applicant has not declared. Called out rather than set in dimmed
|
||||||
|
small print: it is the only thing on a locked card the applicant
|
||||||
|
can act on. */}
|
||||||
|
{!canApply && (
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
radius="md"
|
||||||
|
mt="sm"
|
||||||
|
p="xs"
|
||||||
|
>
|
||||||
|
<Text size="xs" lh={1.4}>
|
||||||
|
{t('licensing.catalogue.lockedHint')}
|
||||||
|
</Text>
|
||||||
|
<Anchor
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
mt={4}
|
||||||
|
onClick={(event) => {
|
||||||
|
// The card is inert while locked, but the anchor inside it
|
||||||
|
// must not re-trigger anything if that ever changes.
|
||||||
|
event.stopPropagation();
|
||||||
|
onSelect(type);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('licensing.catalogue.addToOperations')}
|
||||||
|
</Anchor>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
buildWizardSteps,
|
buildWizardSteps,
|
||||||
conditionHolds,
|
conditionHolds,
|
||||||
|
conditionSections,
|
||||||
|
sectionsDependingOn,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
extractValidationIssues,
|
extractValidationIssues,
|
||||||
useLocalized,
|
useLocalized,
|
||||||
@@ -352,14 +354,44 @@ export function LicenseApplicationPage() {
|
|||||||
),
|
),
|
||||||
[roundRemarks],
|
[roundRemarks],
|
||||||
);
|
);
|
||||||
const hasSectionRemarks = Object.keys(flaggedSections).length > 0;
|
const hasStaffRemarks = roundRemarks.some((r) => r.targetType === "STAFF");
|
||||||
const hasDocRemarks = Object.keys(flaggedDocuments).length > 0;
|
// Nothing at all came back for this round (a detail response that predates
|
||||||
|
// the remarks, say) — lock nothing rather than freeze the whole application
|
||||||
|
// with no way forward. Any remark present means the round is itemised, so
|
||||||
|
// only what the officer flagged opens: a documents-only round leaves every
|
||||||
|
// form section frozen, and a sections-only round leaves every document as
|
||||||
|
// filed.
|
||||||
|
const roundIsItemised = isAdjusting && roundRemarks.length > 0;
|
||||||
|
|
||||||
|
// An answer the officer flagged can decide which fields *other* sections
|
||||||
|
// require — the vessel category is the live example. Freeze those and the
|
||||||
|
// applicant is shown newly-required fields they cannot fill, and cannot
|
||||||
|
// resubmit; the server unlocks them the same way.
|
||||||
|
const cascadeUnlocked = useMemo(
|
||||||
|
() =>
|
||||||
|
sectionsDependingOn(
|
||||||
|
config?.licenseType?.formSchema?.sections ?? [],
|
||||||
|
new Set(Object.keys(flaggedSections)),
|
||||||
|
),
|
||||||
|
[config, flaggedSections],
|
||||||
|
);
|
||||||
|
const unlockedDocuments = useMemo(
|
||||||
|
() =>
|
||||||
|
(config?.documentRequirements ?? [])
|
||||||
|
.filter((requirement) =>
|
||||||
|
conditionSections(requirement.conditionExpression).some(
|
||||||
|
(sectionKey) => sectionKey in flaggedSections,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((requirement) => requirement.key),
|
||||||
|
[config, flaggedSections],
|
||||||
|
);
|
||||||
|
|
||||||
// A round that flagged no form sections carries no section locks — mirror of
|
|
||||||
// the server's fallback, without which a documents-only correction round
|
|
||||||
// froze every field and the applicant could not edit anything at all.
|
|
||||||
const isSectionLocked = (sectionKey: string) =>
|
const isSectionLocked = (sectionKey: string) =>
|
||||||
isAdjusting && hasSectionRemarks && !flaggedSections[sectionKey];
|
roundIsItemised &&
|
||||||
|
!flaggedSections[sectionKey] &&
|
||||||
|
!cascadeUnlocked.has(sectionKey);
|
||||||
|
const staffLocked = roundIsItemised && !hasStaffRemarks;
|
||||||
|
|
||||||
// Sections that share a group collapse onto one step, so the stepper stays
|
// Sections that share a group collapse onto one step, so the stepper stays
|
||||||
// short instead of showing a page per section.
|
// short instead of showing a page per section.
|
||||||
@@ -862,7 +894,7 @@ export function LicenseApplicationPage() {
|
|||||||
complete
|
complete
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!readOnly && (
|
{!readOnly && !staffLocked && (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -898,7 +930,7 @@ export function LicenseApplicationPage() {
|
|||||||
: ""}
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && !staffLocked && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
@@ -917,7 +949,7 @@ export function LicenseApplicationPage() {
|
|||||||
<StaffEvidence
|
<StaffEvidence
|
||||||
staffId={member.id}
|
staffId={member.id}
|
||||||
evidence={role.requiredEvidence}
|
evidence={role.requiredEvidence}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly || staffLocked}
|
||||||
onUploaded={refetch}
|
onUploaded={refetch}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -937,7 +969,8 @@ export function LicenseApplicationPage() {
|
|||||||
ownerType="APPLICATION"
|
ownerType="APPLICATION"
|
||||||
ownerId={appId}
|
ownerId={appId}
|
||||||
flagged={flaggedDocuments}
|
flagged={flaggedDocuments}
|
||||||
restrictToFlagged={isAdjusting && hasDocRemarks}
|
restrictToFlagged={roundIsItemised}
|
||||||
|
alsoUnlocked={unlockedDocuments}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onUploaded={() => {
|
onUploaded={() => {
|
||||||
refetchAttachments();
|
refetchAttachments();
|
||||||
|
|||||||
180
apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx
Normal file
180
apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconDownload,
|
||||||
|
IconFingerprint,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconScan,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { authStorage } from '@ema-platform/auth';
|
||||||
|
import { useGetMyBiometricEnrollmentsQuery } from '@ema-platform/api';
|
||||||
|
import { PdfPreviewModal } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||||
|
'http://localhost:3000/api';
|
||||||
|
|
||||||
|
async function fetchCertificate(): Promise<Blob> {
|
||||||
|
const token = authStorage.getToken();
|
||||||
|
if (!token) throw new Error('No auth token found');
|
||||||
|
const res = await fetch(`${API_BASE}/biometric-enrollments/mine/certificate`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch certificate (${res.status})`);
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODALITY_LABEL: Record<string, string> = {
|
||||||
|
FINGERPRINT: 'Fingerprint',
|
||||||
|
FACE: 'Face',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View-only: what's enrolled, plus a printable slip. Capture stays
|
||||||
|
* counter-side with a scanner — there is no self-enrollment flow here.
|
||||||
|
*/
|
||||||
|
export function BiometricsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: enrollments, isLoading } = useGetMyBiometricEnrollmentsQuery();
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const openPreview = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setPreviewUrl(URL.createObjectURL(await fetchCertificate()));
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
color: 'red',
|
||||||
|
title: 'Error',
|
||||||
|
message: err instanceof Error ? err.message : 'Could not load certificate',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
downloadBlob(await fetchCertificate(), 'biometric-enrollment-certificate.pdf');
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
color: 'red',
|
||||||
|
title: 'Error',
|
||||||
|
message: err instanceof Error ? err.message : 'Could not download certificate',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = enrollments ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconFingerprint size={22} />
|
||||||
|
<Title order={2}>{t('biometrics.title', 'Biometrics')}</Title>
|
||||||
|
</Group>
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
{t(
|
||||||
|
'biometrics.pageIntro',
|
||||||
|
'Fingerprint and face enrollment happen in person at an EMA counter. This page shows what is on file for you.',
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||||
|
{t('biometrics.empty', 'No biometric enrollment on file yet.')}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
|
{rows.map((e) => (
|
||||||
|
<Card key={e.id} withBorder radius="md" p="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={36} radius="md" color="blue" variant="light">
|
||||||
|
<IconScan size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="sm">
|
||||||
|
{MODALITY_LABEL[e.modality] ?? e.modality}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
Enrolled {new Date(e.enrolledAt).toLocaleDateString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Badge color="teal" variant="light">
|
||||||
|
{e.status}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={busy ? <Loader size={12} /> : <IconInfoCircle size={12} />}
|
||||||
|
onClick={openPreview}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{t('biometrics.view', 'View certificate')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconDownload size={12} />}
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{t('biometrics.download', 'Download')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<PdfPreviewModal
|
||||||
|
opened={!!previewUrl}
|
||||||
|
onClose={() => setPreviewUrl(null)}
|
||||||
|
url={previewUrl ?? ''}
|
||||||
|
title={t('biometrics.title', 'Biometrics')}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -802,6 +802,14 @@ export const am: Translations = {
|
|||||||
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
|
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
|
||||||
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
|
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
|
||||||
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
|
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
|
||||||
|
status: {
|
||||||
|
ACTIVE: 'የፀና',
|
||||||
|
EXPIRED: 'ጊዜው ያለፈበት',
|
||||||
|
SUSPENDED: 'የታገደ',
|
||||||
|
CANCELLED: 'የተሰረዘ',
|
||||||
|
SUPERSEDED: 'በአዲስ የምስክር ወረቀት የተተካ',
|
||||||
|
},
|
||||||
|
statusReason: 'ምክንያት፦ {{reason}}',
|
||||||
},
|
},
|
||||||
catalogue: {
|
catalogue: {
|
||||||
emptyTitle: 'የሚሰሩበትን የስራ ዘርፍ ይንገሩን',
|
emptyTitle: 'የሚሰሩበትን የስራ ዘርፍ ይንገሩን',
|
||||||
@@ -823,6 +831,8 @@ export const am: Translations = {
|
|||||||
evaluationOnly: 'ግምገማ ብቻ',
|
evaluationOnly: 'ግምገማ ብቻ',
|
||||||
startApplication: 'ማመልከቻ ጀምር',
|
startApplication: 'ማመልከቻ ጀምር',
|
||||||
addToOperations: 'ወደ ስራ ዘርፎቼ ጨምር',
|
addToOperations: 'ወደ ስራ ዘርፎቼ ጨምር',
|
||||||
|
lockedHint:
|
||||||
|
'ከተመዘገቡ የስራ ዘርፎችዎ ውስጥ ስላልሆነ እስካሁን ማመልከት አይችሉም።',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1282,4 +1292,49 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
documents: {
|
||||||
|
title: 'ሰነዶቼ',
|
||||||
|
subtitle: 'EMA ያወጣልዎት እያንዳንዱ ሰነድ፣ እንዲሁም ከመዝገቦችዎ ጋር የተያያዙ ፋይሎች።',
|
||||||
|
tabs: {
|
||||||
|
license: 'ፈቃዶች',
|
||||||
|
medical: 'ሕክምና',
|
||||||
|
seaService: 'የባህር አገልግሎት',
|
||||||
|
personal: 'የግል መረጃ',
|
||||||
|
},
|
||||||
|
issuedTitle: 'በ EMA የተሰጡ ሰነዶች',
|
||||||
|
licensesTitle: 'የምስክር ወረቀቶች እና ፈቃዶች',
|
||||||
|
kind: {
|
||||||
|
SEAMAN_BOOK: 'የመርከበኛ መጽሐፍ',
|
||||||
|
BTC_BASIC_TRAINING: 'የመሠረታዊ ስልጠና የምስክር ወረቀት (BTC)',
|
||||||
|
},
|
||||||
|
documentStatus: {
|
||||||
|
AWAITING_REGISTRATION: 'ምዝገባ በመጠበቅ ላይ',
|
||||||
|
PAYMENT_PENDING: 'ክፍያ በመጠበቅ ላይ',
|
||||||
|
PAID: 'ተከፍሏል',
|
||||||
|
PAYMENT_CONFIRMED: 'ሰነድ በመዘጋጀት ላይ',
|
||||||
|
SCHEDULED: 'የመውሰጃ ቀጠሮ ተይዟል',
|
||||||
|
ISSUED: 'ተሰጥቷል',
|
||||||
|
REJECTED: 'ተቀባይነት አላገኘም',
|
||||||
|
CANCELLED: 'ተሰርዟል',
|
||||||
|
},
|
||||||
|
view: 'ይመልከቱ',
|
||||||
|
notIssued: 'እስካሁን አልተሰጠም',
|
||||||
|
openFailed: 'ሰነዱን መክፈት አልተቻለም',
|
||||||
|
files: {
|
||||||
|
none: 'ምንም የተያያዘ ፋይል የለም።',
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።',
|
||||||
|
medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።',
|
||||||
|
seaService: 'እስካሁን በመዝገብ ላይ የባህር አገልግሎት መዝገብ የለም።',
|
||||||
|
},
|
||||||
|
personal: {
|
||||||
|
description: 'ከመርከበኛ ምዝገባዎ ጋር ያስገቡት ሰነዶች።',
|
||||||
|
noRegistration: 'እስካሁን የመርከበኛ ምዝገባ ስለሌለዎት በመዝገብ ላይ የግል ሰነዶች የሉም።',
|
||||||
|
startRegistration: 'ወደ መርከበኛ ምዝገባ ይሂዱ',
|
||||||
|
uploaded: 'ተሰቅሏል',
|
||||||
|
missing: 'አልተሰቀለም',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -802,6 +802,14 @@ export const en = {
|
|||||||
renewDays_one: 'Renew — expires in {{count}} day',
|
renewDays_one: 'Renew — expires in {{count}} day',
|
||||||
renewDays_other: 'Renew — expires in {{count}} days',
|
renewDays_other: 'Renew — expires in {{count}} days',
|
||||||
renewFailed: 'Could not start the renewal',
|
renewFailed: 'Could not start the renewal',
|
||||||
|
status: {
|
||||||
|
ACTIVE: 'Active',
|
||||||
|
EXPIRED: 'Expired',
|
||||||
|
SUSPENDED: 'Suspended',
|
||||||
|
CANCELLED: 'Cancelled',
|
||||||
|
SUPERSEDED: 'Replaced by a newer certificate',
|
||||||
|
},
|
||||||
|
statusReason: 'Reason: {{reason}}',
|
||||||
},
|
},
|
||||||
catalogue: {
|
catalogue: {
|
||||||
emptyTitle: 'Tell us what you operate as',
|
emptyTitle: 'Tell us what you operate as',
|
||||||
@@ -823,6 +831,8 @@ export const en = {
|
|||||||
evaluationOnly: 'Evaluation only',
|
evaluationOnly: 'Evaluation only',
|
||||||
startApplication: 'Start application',
|
startApplication: 'Start application',
|
||||||
addToOperations: 'Add to my operations',
|
addToOperations: 'Add to my operations',
|
||||||
|
lockedHint:
|
||||||
|
'Not one of your declared operations, so it cannot be applied for yet.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1284,6 +1294,53 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
documents: {
|
||||||
|
title: 'My documents',
|
||||||
|
subtitle:
|
||||||
|
'Every document EMA has issued you, and the files attached to your records.',
|
||||||
|
tabs: {
|
||||||
|
license: 'Licences',
|
||||||
|
medical: 'Medical',
|
||||||
|
seaService: 'Sea Service',
|
||||||
|
personal: 'Personal Data',
|
||||||
|
},
|
||||||
|
issuedTitle: 'EMA-issued documents',
|
||||||
|
licensesTitle: 'Certificates and licences',
|
||||||
|
kind: {
|
||||||
|
SEAMAN_BOOK: 'Seaman Book',
|
||||||
|
BTC_BASIC_TRAINING: 'Basic Training Certificate (BTC)',
|
||||||
|
},
|
||||||
|
documentStatus: {
|
||||||
|
AWAITING_REGISTRATION: 'Awaiting registration',
|
||||||
|
PAYMENT_PENDING: 'Payment pending',
|
||||||
|
PAID: 'Paid',
|
||||||
|
PAYMENT_CONFIRMED: 'Preparing document',
|
||||||
|
SCHEDULED: 'Pickup scheduled',
|
||||||
|
ISSUED: 'Issued',
|
||||||
|
REJECTED: 'Rejected',
|
||||||
|
CANCELLED: 'Cancelled',
|
||||||
|
},
|
||||||
|
view: 'View',
|
||||||
|
notIssued: 'Not issued yet',
|
||||||
|
openFailed: 'Could not open the document',
|
||||||
|
files: {
|
||||||
|
none: 'No files attached.',
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
licenses: 'No certificates or licences have been issued to you yet.',
|
||||||
|
medical: 'No medical certificates on file yet.',
|
||||||
|
seaService: 'No sea-service records on file yet.',
|
||||||
|
},
|
||||||
|
personal: {
|
||||||
|
description: 'The documents you submitted with your seafarer registration.',
|
||||||
|
noRegistration:
|
||||||
|
'You have no seafarer registration yet, so there are no personal documents on file.',
|
||||||
|
startRegistration: 'Go to seafarer registration',
|
||||||
|
uploaded: 'Uploaded',
|
||||||
|
missing: 'Not uploaded',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Translations = typeof en;
|
export type Translations = typeof en;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IconArrowsExchange,
|
IconArrowsExchange,
|
||||||
IconBell,
|
IconBell,
|
||||||
IconBook2,
|
IconBook2,
|
||||||
|
IconFingerprint,
|
||||||
IconFolderOpen,
|
IconFolderOpen,
|
||||||
IconHeadset,
|
IconHeadset,
|
||||||
IconHome2,
|
IconHome2,
|
||||||
@@ -131,6 +132,13 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
|||||||
icon: IconShieldCheck,
|
icon: IconShieldCheck,
|
||||||
permissions: [P.VIEW_OWN_CERTIFICATES],
|
permissions: [P.VIEW_OWN_CERTIFICATES],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: "/seafarer/biometrics",
|
||||||
|
label: "Biometrics",
|
||||||
|
i18nKey: "nav.biometrics",
|
||||||
|
icon: IconFingerprint,
|
||||||
|
permissions: [P.VIEW_OWN_BIOMETRICS],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: "/exams",
|
to: "/exams",
|
||||||
label: "Examinations",
|
label: "Examinations",
|
||||||
@@ -206,6 +214,7 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
|
|||||||
"/seaman-book": { i18nKey: "nav.seamanBook" },
|
"/seaman-book": { i18nKey: "nav.seamanBook" },
|
||||||
"/basic-training-certificate": { i18nKey: "nav.btc" },
|
"/basic-training-certificate": { i18nKey: "nav.btc" },
|
||||||
"/certificates": { i18nKey: "nav.certificates" },
|
"/certificates": { i18nKey: "nav.certificates" },
|
||||||
|
"/seafarer/biometrics": { i18nKey: "nav.biometrics" },
|
||||||
"/exams": { i18nKey: "nav.exams" },
|
"/exams": { i18nKey: "nav.exams" },
|
||||||
"/endorsements": { i18nKey: "nav.endorsements" },
|
"/endorsements": { i18nKey: "nav.endorsements" },
|
||||||
"/documents": { i18nKey: "nav.documents" },
|
"/documents": { i18nKey: "nav.documents" },
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { OperationsOnboardingPage } from "./features/onboarding/pages/Operations
|
|||||||
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||||
import { SupportPage } from "./features/support/pages/SupportPage";
|
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||||
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
|
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
|
||||||
|
import { BiometricsPage } from "./features/seafarer/pages/Biometrics";
|
||||||
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
||||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||||
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
|
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
|
||||||
@@ -205,6 +206,14 @@ export const router = createBrowserRouter([
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/seafarer/biometrics",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.VIEW_OWN_BIOMETRICS]}>
|
||||||
|
<BiometricsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ path: "/seafarer/records", element: <Navigate to="/seafarer/sea-service" replace /> },
|
{ path: "/seafarer/records", element: <Navigate to="/seafarer/sea-service" replace /> },
|
||||||
{
|
{
|
||||||
path: "/exams",
|
path: "/exams",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from './lib/features/location';
|
|||||||
export * from './lib/features/seafarer';
|
export * from './lib/features/seafarer';
|
||||||
export * from './lib/features/seafarer-registration';
|
export * from './lib/features/seafarer-registration';
|
||||||
export * from './lib/features/seafarer-document';
|
export * from './lib/features/seafarer-document';
|
||||||
|
export * from './lib/features/biometric-enrollment';
|
||||||
export * from './lib/features/vessel';
|
export * from './lib/features/vessel';
|
||||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||||
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';
|
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { baseApi } from '../../base-api';
|
||||||
|
import type { BiometricEnrollment, EnrollBiometric } from './biometric-enrollment.types';
|
||||||
|
|
||||||
|
const TAG = 'BiometricEnrollment' as const;
|
||||||
|
const forProfile = (profileId: string) => ({ type: TAG, id: profileId }) as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanner capture (fingerprint, face) stored per profile. No applicant-facing
|
||||||
|
* endpoint — enrollment happens at a counter with a scanner.
|
||||||
|
*/
|
||||||
|
export const biometricEnrollmentApi = baseApi
|
||||||
|
.enhanceEndpoints({ addTagTypes: [TAG] })
|
||||||
|
.injectEndpoints({
|
||||||
|
endpoints: (builder) => ({
|
||||||
|
enrollBiometric: builder.mutation<BiometricEnrollment, EnrollBiometric>({
|
||||||
|
query: (body) => ({ url: '/biometric-enrollments', method: 'POST', body }),
|
||||||
|
invalidatesTags: (r, error) => (error || !r ? [] : [forProfile(r.profileId)]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getBiometricEnrollments: builder.query<BiometricEnrollment[], string>({
|
||||||
|
query: (profileId) => ({ url: `/biometric-enrollments/profile/${profileId}` }),
|
||||||
|
providesTags: (_r, _e, profileId) => [forProfile(profileId)],
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Self-service, view-only: the caller's own live enrollments. */
|
||||||
|
getMyBiometricEnrollments: builder.query<BiometricEnrollment[], void>({
|
||||||
|
query: () => ({ url: '/biometric-enrollments/mine' }),
|
||||||
|
providesTags: [{ type: TAG, id: 'MINE' }],
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Dev/test only — the API reports false in production. */
|
||||||
|
getBiometricSimulateCapabilities: builder.query<{ simulateEnabled: boolean }, void>({
|
||||||
|
query: () => ({ url: '/biometric-enrollments/simulate/capabilities' }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
revokeBiometricEnrollment: builder.mutation<
|
||||||
|
BiometricEnrollment,
|
||||||
|
{ id: string; profileId: string; reason: string }
|
||||||
|
>({
|
||||||
|
query: ({ id, reason }) => ({
|
||||||
|
url: `/biometric-enrollments/${id}/revoke`,
|
||||||
|
method: 'POST',
|
||||||
|
body: { reason },
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { profileId }) => (error ? [] : [forProfile(profileId)]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Stamps the profile's BSID once enrollment is confirmed. Requires an active enrollment. */
|
||||||
|
generateBsid: builder.mutation<{ id: string; bsid: string | null }, string>({
|
||||||
|
query: (profileId) => ({
|
||||||
|
url: `/biometric-enrollments/profile/${profileId}/generate-bsid`,
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, profileId) => (error ? [] : [forProfile(profileId)]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
overrideExisting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useEnrollBiometricMutation,
|
||||||
|
useGetBiometricEnrollmentsQuery,
|
||||||
|
useGetMyBiometricEnrollmentsQuery,
|
||||||
|
useGetBiometricSimulateCapabilitiesQuery,
|
||||||
|
useRevokeBiometricEnrollmentMutation,
|
||||||
|
useGenerateBsidMutation,
|
||||||
|
} = biometricEnrollmentApi;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type BiometricModality = 'FINGERPRINT' | 'FACE';
|
||||||
|
export type BiometricEnrollmentStatus = 'ACTIVE' | 'REVOKED';
|
||||||
|
|
||||||
|
export interface BiometricEnrollment {
|
||||||
|
id: string;
|
||||||
|
profileId: string;
|
||||||
|
modality: BiometricModality;
|
||||||
|
templateFormat: string;
|
||||||
|
qualityScore: number | null;
|
||||||
|
deviceId: string | null;
|
||||||
|
status: BiometricEnrollmentStatus;
|
||||||
|
enrolledById: string;
|
||||||
|
enrolledAt: string;
|
||||||
|
consentAt: string;
|
||||||
|
revokedReason: string | null;
|
||||||
|
revokedById: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnrollBiometric {
|
||||||
|
profileId: string;
|
||||||
|
modality: BiometricModality;
|
||||||
|
/** Vendor SDK template, base64. Never the raw scan image. */
|
||||||
|
template: string;
|
||||||
|
templateFormat: string;
|
||||||
|
qualityScore?: number;
|
||||||
|
deviceId?: string;
|
||||||
|
/** ISO 8601 — when the subject consented to capture. */
|
||||||
|
consentAt: string;
|
||||||
|
}
|
||||||
2
libs/api/src/lib/features/biometric-enrollment/index.ts
Normal file
2
libs/api/src/lib/features/biometric-enrollment/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './biometric-enrollment.types';
|
||||||
|
export * from './biometric-enrollment-api';
|
||||||
@@ -1015,6 +1015,30 @@ export const licensingApi = baseApi
|
|||||||
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
|
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Moves a booked visit: another day, slot, inspector or place. */
|
||||||
|
rescheduleInspection: builder.mutation<
|
||||||
|
Inspection,
|
||||||
|
{
|
||||||
|
inspectionId: string;
|
||||||
|
applicationId: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
timeSlot: 'MORNING' | 'AFTERNOON';
|
||||||
|
inspectorId?: string;
|
||||||
|
location?: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
query: ({ inspectionId, scheduledDate, timeSlot, inspectorId, location, reason }) => ({
|
||||||
|
url: `/inspections/${inspectionId}/schedule`,
|
||||||
|
method: 'PATCH',
|
||||||
|
// applicationId is for cache invalidation only; the visit knows its
|
||||||
|
// own application.
|
||||||
|
body: { scheduledDate, timeSlot, inspectorId, location, reason },
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { applicationId }) =>
|
||||||
|
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
|
||||||
|
}),
|
||||||
|
|
||||||
getInspections: builder.query<Inspection[], string>({
|
getInspections: builder.query<Inspection[], string>({
|
||||||
query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }),
|
query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }),
|
||||||
providesTags: () => [listTag('Inspection')],
|
providesTags: () => [listTag('Inspection')],
|
||||||
@@ -1152,6 +1176,7 @@ export const {
|
|||||||
useScheduleIssuanceMutation,
|
useScheduleIssuanceMutation,
|
||||||
useIssueCertificateMutation,
|
useIssueCertificateMutation,
|
||||||
useScheduleInspectionMutation,
|
useScheduleInspectionMutation,
|
||||||
|
useRescheduleInspectionMutation,
|
||||||
useGetInspectionsQuery,
|
useGetInspectionsQuery,
|
||||||
useRecordInspectionResultMutation,
|
useRecordInspectionResultMutation,
|
||||||
useGetNotificationsQuery,
|
useGetNotificationsQuery,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
|||||||
DRAFT: 'Draft',
|
DRAFT: 'Draft',
|
||||||
SUBMITTED: 'Submitted',
|
SUBMITTED: 'Submitted',
|
||||||
UNDER_REVIEW: 'Under Review',
|
UNDER_REVIEW: 'Under Review',
|
||||||
|
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
|
||||||
UNDER_EVALUATION: 'Under Evaluation',
|
UNDER_EVALUATION: 'Under Evaluation',
|
||||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||||
INSPECTION_PENDING: 'Inspection Pending',
|
INSPECTION_PENDING: 'Inspection Pending',
|
||||||
@@ -93,6 +94,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
|||||||
DRAFT: 'gray',
|
DRAFT: 'gray',
|
||||||
SUBMITTED: 'blue',
|
SUBMITTED: 'blue',
|
||||||
UNDER_REVIEW: 'indigo',
|
UNDER_REVIEW: 'indigo',
|
||||||
|
AWAITING_BIOMETRICS: 'indigo',
|
||||||
UNDER_EVALUATION: 'indigo',
|
UNDER_EVALUATION: 'indigo',
|
||||||
RESUBMIT_REQUIRED: 'orange',
|
RESUBMIT_REQUIRED: 'orange',
|
||||||
INSPECTION_PENDING: 'cyan',
|
INSPECTION_PENDING: 'cyan',
|
||||||
@@ -132,6 +134,7 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
|||||||
DRAFT: 5,
|
DRAFT: 5,
|
||||||
SUBMITTED: 15,
|
SUBMITTED: 15,
|
||||||
UNDER_REVIEW: 30,
|
UNDER_REVIEW: 30,
|
||||||
|
AWAITING_BIOMETRICS: 30,
|
||||||
UNDER_EVALUATION: 45,
|
UNDER_EVALUATION: 45,
|
||||||
RESUBMIT_REQUIRED: 30,
|
RESUBMIT_REQUIRED: 30,
|
||||||
INSPECTION_PENDING: 55,
|
INSPECTION_PENDING: 55,
|
||||||
@@ -595,6 +598,49 @@ interface ConditionLike {
|
|||||||
anyOf?: ConditionLike[];
|
anyOf?: ConditionLike[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Section keys a condition reads, recursing `anyOf`.
|
||||||
|
*
|
||||||
|
* `FieldCondition.field` is a `sectionKey.fieldKey` path, so the prefix names
|
||||||
|
* the section whose answer decides the condition.
|
||||||
|
*/
|
||||||
|
export function conditionSections(
|
||||||
|
condition: FieldCondition | undefined | null,
|
||||||
|
): string[] {
|
||||||
|
if (!condition) return [];
|
||||||
|
if (condition.anyOf) return condition.anyOf.flatMap(conditionSections);
|
||||||
|
if (!condition.field) return [];
|
||||||
|
const [sectionKey] = condition.field.split('.');
|
||||||
|
return sectionKey ? [sectionKey] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sections whose visibility hangs on an answer in one of `flagged`.
|
||||||
|
*
|
||||||
|
* Mirrors the server's `sectionsDependingOn`: an officer flagging the section
|
||||||
|
* that holds the vessel category is asking for an answer that decides which
|
||||||
|
* fields in other sections are required, so those sections have to open too —
|
||||||
|
* otherwise the applicant sees newly-required fields they cannot edit and
|
||||||
|
* cannot resubmit.
|
||||||
|
*/
|
||||||
|
export function sectionsDependingOn(
|
||||||
|
sections: FormSectionConfig[],
|
||||||
|
flagged: Set<string>,
|
||||||
|
): Set<string> {
|
||||||
|
const dependent = new Set<string>();
|
||||||
|
if (flagged.size === 0) return dependent;
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
if (flagged.has(section.key)) continue;
|
||||||
|
const reads = [
|
||||||
|
section.showWhen,
|
||||||
|
...(section.fields ?? []).map((field) => field.showWhen),
|
||||||
|
].flatMap(conditionSections);
|
||||||
|
if (reads.some((key) => flagged.has(key))) dependent.add(section.key);
|
||||||
|
}
|
||||||
|
return dependent;
|
||||||
|
}
|
||||||
|
|
||||||
export function conditionHolds(
|
export function conditionHolds(
|
||||||
condition: FieldCondition | undefined | null,
|
condition: FieldCondition | undefined | null,
|
||||||
formData: Record<string, Record<string, unknown>>,
|
formData: Record<string, Record<string, unknown>>,
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export type LicenseStatus =
|
|||||||
| "DRAFT"
|
| "DRAFT"
|
||||||
| "SUBMITTED"
|
| "SUBMITTED"
|
||||||
| "UNDER_REVIEW"
|
| "UNDER_REVIEW"
|
||||||
|
// Seafarer registration only: same slot UNDER_REVIEW occupies elsewhere,
|
||||||
|
// but approval is blocked until the applicant's profile has a BSID.
|
||||||
|
| "AWAITING_BIOMETRICS"
|
||||||
| "UNDER_EVALUATION"
|
| "UNDER_EVALUATION"
|
||||||
// Employee filed their review; parked with the team leader for a decision.
|
// Employee filed their review; parked with the team leader for a decision.
|
||||||
| "REVIEW_REPORTED"
|
| "REVIEW_REPORTED"
|
||||||
|
|||||||
@@ -10,9 +10,17 @@ const TAG = 'SeafarerRegistration' as const;
|
|||||||
const LIST = { type: TAG, id: 'LIST' } as const;
|
const LIST = { type: TAG, id: 'LIST' } as const;
|
||||||
const item = (id: string) => ({ type: TAG, id }) as const;
|
const item = (id: string) => ({ type: TAG, id }) as const;
|
||||||
|
|
||||||
|
export type SeafarerRegistrationSortField =
|
||||||
|
| 'submittedAt'
|
||||||
|
| 'registrationNumber'
|
||||||
|
| 'lastName'
|
||||||
|
| 'status';
|
||||||
|
|
||||||
export interface SeafarerRegistrationListFilter {
|
export interface SeafarerRegistrationListFilter {
|
||||||
status?: SeafarerRegistrationStatus;
|
status?: SeafarerRegistrationStatus;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
sortBy?: SeafarerRegistrationSortField;
|
||||||
|
sortDir?: 'ASC' | 'DESC';
|
||||||
take?: number;
|
take?: number;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export const LICENSE_PERMISSIONS = {
|
|||||||
MANAGE_SEAFARER_STATUS: "can:manage:seafarer-status",
|
MANAGE_SEAFARER_STATUS: "can:manage:seafarer-status",
|
||||||
VIEW_SEAFARER_REGISTRY: "can:View:seafarer-registry",
|
VIEW_SEAFARER_REGISTRY: "can:View:seafarer-registry",
|
||||||
VERIFY_SEAFARER_RECORDS: "can:verify:seafarer-records",
|
VERIFY_SEAFARER_RECORDS: "can:verify:seafarer-records",
|
||||||
|
ENROLL_BIOMETRICS: "can:enroll:biometrics",
|
||||||
|
VIEW_BIOMETRICS: "can:View:biometrics",
|
||||||
VIEW_VESSEL_REGISTRY: "can:View:vessel-registry",
|
VIEW_VESSEL_REGISTRY: "can:View:vessel-registry",
|
||||||
MANAGE_VESSEL_STATUS: "can:manage:vessel-status",
|
MANAGE_VESSEL_STATUS: "can:manage:vessel-status",
|
||||||
APPROVE_QUESTION: "can:approve:exam-question",
|
APPROVE_QUESTION: "can:approve:exam-question",
|
||||||
@@ -81,6 +83,7 @@ export const PORTAL_PERMISSIONS = {
|
|||||||
APPLY_EXAM: "can:apply:exam",
|
APPLY_EXAM: "can:apply:exam",
|
||||||
VIEW_OWN_EXAM: "can:View:own-exam",
|
VIEW_OWN_EXAM: "can:View:own-exam",
|
||||||
VIEW_OWN_CERTIFICATES: "can:View:own-certificates",
|
VIEW_OWN_CERTIFICATES: "can:View:own-certificates",
|
||||||
|
VIEW_OWN_BIOMETRICS: "can:View:own-biometrics",
|
||||||
APPLY_VESSEL_REGISTRATION: "can:apply:vessel-registration",
|
APPLY_VESSEL_REGISTRATION: "can:apply:vessel-registration",
|
||||||
VIEW_OWN_VESSELS: "can:View:own-vessels",
|
VIEW_OWN_VESSELS: "can:View:own-vessels",
|
||||||
REPORT_VESSEL_INCIDENT: "can:report:own-vessel-incident",
|
REPORT_VESSEL_INCIDENT: "can:report:own-vessel-incident",
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export interface CurrentProfile {
|
|||||||
* registration is approved, null before that.
|
* registration is approved, null before that.
|
||||||
*/
|
*/
|
||||||
seafarerNumber?: string | null;
|
seafarerNumber?: string | null;
|
||||||
|
/** Biometric Subject ID — stamped by staff once biometric enrollment is confirmed. */
|
||||||
|
bsid?: string | null;
|
||||||
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
|
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
|
||||||
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
|
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
|
||||||
seafarerStatusReason?: string | null;
|
seafarerStatusReason?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user