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