Merge branch 'estif-branch-1' into documentVault

This commit is contained in:
Estifanos Terefe
2026-08-28 15:25:50 +03:00
committed by GitHub
18 changed files with 872 additions and 68 deletions

View File

@@ -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 &amp; Enroll
</Button>
</Group>
</>
) : (
<Alert color="gray" icon={<IconAlertTriangle size={16} />} variant="light">
No scanner is wired yet, and capture simulation is off in this environment.
</Alert>
)}
</Card>
<Card withBorder radius="md" p="md">
<Text fz="sm" fw={600} mb="sm">Biometric Subject ID (BSID)</Text>
<Text fz="xs" c="dimmed" mb="sm">
Required before this registration can be approved. Generating it is final
confirm the capture is good first.
</Text>
<Group justify="space-between">
{bsid ? (
<StatusBadge tone="success" label={`BSID ${bsid}`} />
) : (
<Badge color="gray" variant="light">Not generated</Badge>
)}
{!bsid && (
<Button
size="xs"
onClick={handleGenerateBsid}
loading={generatingBsid}
disabled={!hasActive('FINGERPRINT') && !hasActive('FACE')}
>
Generate BSID
</Button>
)}
</Group>
</Card>
<Card withBorder radius="md" p="md">
<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;

View File

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

View File

@@ -93,6 +93,7 @@ export const am: Translations = {
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
seafarerRegistry: "የመርከበኞች መዝገብ",
biometricEnrollment: "ባዮሜትሪክ ምዝገባ",
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
applications: "ማመልከቻዎች",
paymentConfig: "የክፍያ ውቅረት",
@@ -868,6 +869,7 @@ export const am: Translations = {
DRAFT: "ረቂቅ",
SUBMITTED: "ቀርቧል",
UNDER_REVIEW: "በግምገማ ላይ",
AWAITING_BIOMETRICS: "ባዮሜትሪክ በመጠባበቅ ላይ",
UNDER_EVALUATION: "በምዘና ላይ",
RESUBMIT_REQUIRED: "እንደገና ማቅረብ ያስፈልጋል",
INSPECTION_PENDING: "ምርመራ በመጠባበቅ ላይ",

View File

@@ -92,6 +92,7 @@ export const en = {
vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Ownership Transfer',
seafarerRegistry: 'Seafarer Registry',
biometricEnrollment: 'Biometric Enrollment',
seafarerRegistrationQueue: 'Seafarer Registration Queue',
applications: 'Applications',
paymentConfig: 'Payment Config',
@@ -875,6 +876,7 @@ export const en = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',

View File

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

View File

@@ -48,6 +48,7 @@ import { LicenseReviewPage } from '../features/license-review/pages/LicenseRevie
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage';
import { BiometricEnrollmentPage } from '../features/biometric-enrollment/pages/BiometricEnrollmentPage';
/** Any-of gate shared by every licence-type queue and its review workspace. */
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
@@ -100,6 +101,7 @@ const router = createBrowserRouter([
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
{ 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.
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },

View File

@@ -51,9 +51,10 @@ const RECORD_STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
};
/**
* The files attached to one record, read from the attachment store the record
* owns. One query per card rather than a page-wide fetch: `/attachments` is
* owner-scoped, and RTK Query dedupes and caches them by owner anyway.
* Placeholder until this feature has a backend.
*
* This page previously rendered invented figures/records that were
* indistinguishable from real ones.
*/
function RecordFiles({
ownerType,

View 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>
);
}

View File

@@ -5,6 +5,7 @@ import {
IconArrowsExchange,
IconBell,
IconBook2,
IconFingerprint,
IconFolderOpen,
IconHeadset,
IconHome2,
@@ -131,6 +132,13 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
icon: IconShieldCheck,
permissions: [P.VIEW_OWN_CERTIFICATES],
},
{
to: "/seafarer/biometrics",
label: "Biometrics",
i18nKey: "nav.biometrics",
icon: IconFingerprint,
permissions: [P.VIEW_OWN_BIOMETRICS],
},
{
to: "/exams",
label: "Examinations",
@@ -206,6 +214,7 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
"/seaman-book": { i18nKey: "nav.seamanBook" },
"/basic-training-certificate": { i18nKey: "nav.btc" },
"/certificates": { i18nKey: "nav.certificates" },
"/seafarer/biometrics": { i18nKey: "nav.biometrics" },
"/exams": { i18nKey: "nav.exams" },
"/endorsements": { i18nKey: "nav.endorsements" },
"/documents": { i18nKey: "nav.documents" },

View File

@@ -28,6 +28,7 @@ import { OperationsOnboardingPage } from "./features/onboarding/pages/Operations
import { ProfilePage } from "./features/profile/pages/ProfilePage";
import { SupportPage } from "./features/support/pages/SupportPage";
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
import { BiometricsPage } from "./features/seafarer/pages/Biometrics";
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
import { ExamsPage } from "./features/exams/pages/ExamsPage";
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
@@ -205,6 +206,14 @@ export const router = createBrowserRouter([
</RequirePermission>
),
},
{
path: "/seafarer/biometrics",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_BIOMETRICS]}>
<BiometricsPage />
</RequirePermission>
),
},
{ path: "/seafarer/records", element: <Navigate to="/seafarer/sea-service" replace /> },
{
path: "/exams",

View File

@@ -7,6 +7,7 @@ export * from './lib/features/seafarer';
export * from './lib/features/seafarer-registration';
export * from './lib/features/seafarer-document';
export * from './lib/features/personal-document';
export * from './lib/features/biometric-enrollment';
export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -0,0 +1,2 @@
export * from './biometric-enrollment.types';
export * from './biometric-enrollment-api';

View File

@@ -63,6 +63,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
@@ -93,6 +94,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'indigo',
AWAITING_BIOMETRICS: 'indigo',
UNDER_EVALUATION: 'indigo',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'cyan',
@@ -132,6 +134,7 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
DRAFT: 5,
SUBMITTED: 15,
UNDER_REVIEW: 30,
AWAITING_BIOMETRICS: 30,
UNDER_EVALUATION: 45,
RESUBMIT_REQUIRED: 30,
INSPECTION_PENDING: 55,

View File

@@ -25,6 +25,9 @@ export type LicenseStatus =
| "DRAFT"
| "SUBMITTED"
| "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"
// Employee filed their review; parked with the team leader for a decision.
| "REVIEW_REPORTED"

View File

@@ -47,6 +47,8 @@ export const LICENSE_PERMISSIONS = {
MANAGE_SEAFARER_STATUS: "can:manage:seafarer-status",
VIEW_SEAFARER_REGISTRY: "can:View:seafarer-registry",
VERIFY_SEAFARER_RECORDS: "can:verify:seafarer-records",
ENROLL_BIOMETRICS: "can:enroll:biometrics",
VIEW_BIOMETRICS: "can:View:biometrics",
VIEW_VESSEL_REGISTRY: "can:View:vessel-registry",
MANAGE_VESSEL_STATUS: "can:manage:vessel-status",
APPROVE_QUESTION: "can:approve:exam-question",
@@ -81,6 +83,7 @@ export const PORTAL_PERMISSIONS = {
APPLY_EXAM: "can:apply:exam",
VIEW_OWN_EXAM: "can:View:own-exam",
VIEW_OWN_CERTIFICATES: "can:View:own-certificates",
VIEW_OWN_BIOMETRICS: "can:View:own-biometrics",
APPLY_VESSEL_REGISTRATION: "can:apply:vessel-registration",
VIEW_OWN_VESSELS: "can:View:own-vessels",
REPORT_VESSEL_INCIDENT: "can:report:own-vessel-incident",

View File

@@ -84,6 +84,8 @@ export interface CurrentProfile {
* registration is approved, null before that.
*/
seafarerNumber?: string | null;
/** Biometric Subject ID — stamped by staff once biometric enrollment is confirmed. */
bsid?: string | null;
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
seafarerStatusReason?: string | null;