diff --git a/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx b/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx new file mode 100644 index 000000000..87f56fbfa --- /dev/null +++ b/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx @@ -0,0 +1,253 @@ +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, + useGetBiometricEnrollmentsQuery, + 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): 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 ( + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + mb="sm" + /> + {isFetching && } + + + {(data?.items ?? []).map((r) => ( + onPick(r)} style={{ cursor: 'pointer' }}> + + {applicantName(r)} + {r.seafarerNumber} + + + ))} + {!isFetching && (data?.items ?? []).length === 0 && ( + + + No registered seafarer matches. + + + )} + +
+
+ ); +} + +/** 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(null); + const [modality, setModality] = useState('FINGERPRINT'); + const [deviceId, setDeviceId] = useState(''); + + const profileId = selected?.profileId ?? ''; + const { data: enrollments, isLoading } = useGetBiometricEnrollmentsQuery(profileId, { skip: !profileId }); + 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 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 ( + + + + {!selected ? ( + + ) : ( + + + +
+ {applicantName(selected)} + {selected.seafarerNumber} +
+ +
+
+ + + Capture + } mb="sm" variant="light"> + No scanner is wired yet — this simulates a capture so the rest of the flow can be tested. + + +