Files
emaui/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx
Nati e1f33be0e2 feat(biometric-enrollment): update profile picker to reflect seafarers awaiting enrolment and adjust UI messages
feat(seafarer-registration): add AWAITING_BIOMETRICS and UNDER_REVIEW statuses to registration constants and types
2026-08-29 08:48:49 +00:00

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 &amp; Enroll
</Button>
</Group>
</>
) : (
<Alert color="gray" icon={<IconAlertTriangle size={16} />} variant="light">
No scanner is wired yet, and capture simulation is off in this environment.
</Alert>
)}
</Card>
<Card withBorder radius="md" p="md">
<Text fz="sm" fw={600} mb="sm">Biometric Subject ID (BSID)</Text>
<Text fz="xs" c="dimmed" mb="sm">
Required before this registration can be approved. Generating it is final
confirm the capture is good first.
</Text>
<Group justify="space-between">
{bsid ? (
<StatusBadge tone="success" label={`BSID ${bsid}`} />
) : (
<Badge color="gray" variant="light">Not generated</Badge>
)}
{!bsid && (
<Button
size="xs"
onClick={handleGenerateBsid}
loading={generatingBsid}
disabled={!hasActive('FINGERPRINT') && !hasActive('FACE')}
>
Generate BSID
</Button>
)}
</Group>
</Card>
<Card withBorder radius="md" p="md">
<Text fz="sm" fw={600} mb="sm">On file</Text>
{isLoading ? (
<Loader size="sm" />
) : (
<Stack gap="xs">
{MODALITIES.map((m) => (
<Group key={m.value} justify="space-between" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
<Group gap="xs">
<ThemeIcon variant="light" color={hasActive(m.value) ? 'teal' : 'gray'} size={30} radius="md">
<IconFingerprint size={15} />
</ThemeIcon>
<Text fz="sm">{m.label}</Text>
</Group>
{hasActive(m.value) ? (
<Group gap="xs">
<StatusBadge tone="success" label="Enrolled" />
<Button
size="xs"
color="red"
variant="subtle"
loading={revoking}
onClick={() => {
const row = (enrollments ?? []).find((e) => e.modality === m.value);
if (row) handleRevoke(row.id);
}}
>
Revoke
</Button>
</Group>
) : (
<Badge color="gray" variant="light">Not enrolled</Badge>
)}
</Group>
))}
{(enrollments ?? []).map((e) => (
<Text key={e.id} fz="xs" c="dimmed">
{e.modality} captured {showDate(e.enrolledAt)}{e.deviceId ? ` · ${e.deviceId}` : ''}
</Text>
))}
</Stack>
)}
</Card>
</Stack>
)}
</Container>
);
}
export default BiometricEnrollmentPage;