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
This commit is contained in:
Nati
2026-08-29 08:48:49 +00:00
parent 760eee9155
commit e1f33be0e2
4 changed files with 47 additions and 39 deletions

View File

@@ -14,11 +14,10 @@ import {
TextInput, TextInput,
ThemeIcon, ThemeIcon,
} from '@mantine/core'; } from '@mantine/core';
import { IconAlertTriangle, IconFingerprint, IconPrinter, IconScan, IconSearch, IconX } from '@tabler/icons-react'; import { IconAlertTriangle, IconFingerprint, IconScan, IconSearch, IconX } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks'; import { useDebouncedValue } from '@mantine/hooks';
import { import {
extractErrorMessage, extractErrorMessage,
openAuthedDocument,
useEnrollBiometricMutation, useEnrollBiometricMutation,
useGenerateBsidMutation, useGenerateBsidMutation,
useGetBiometricEnrollmentsQuery, useGetBiometricEnrollmentsQuery,
@@ -52,12 +51,19 @@ function fakeTemplate(): string {
return btoa(String.fromCharCode(...bytes)); return btoa(String.fromCharCode(...bytes));
} }
/** Pick a registered seafarer to enroll — approved registrations carry a profileId. */ /**
* 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 }) { function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debounced] = useDebouncedValue(search, 300); const [debounced] = useDebouncedValue(search, 300);
const { data, isFetching } = useListSeafarerRegistrationsQuery({ const { data, isFetching } = useListSeafarerRegistrationsQuery({
status: 'APPROVED', status: 'AWAITING_BIOMETRICS',
search: debounced || undefined, search: debounced || undefined,
take: 10, take: 10,
}); });
@@ -65,7 +71,7 @@ function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }
return ( return (
<Card withBorder radius="md" p="md"> <Card withBorder radius="md" p="md">
<TextInput <TextInput
placeholder="Search seafarer by name, ID or registration number…" placeholder="Search seafarers awaiting enrolment…"
leftSection={<IconSearch size={14} />} leftSection={<IconSearch size={14} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -78,14 +84,16 @@ function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }
<Table.Tr key={r.id} onClick={() => onPick(r)} style={{ cursor: 'pointer' }}> <Table.Tr key={r.id} onClick={() => onPick(r)} style={{ cursor: 'pointer' }}>
<Table.Td> <Table.Td>
<Text fz="sm" fw={600}>{applicantName(r)}</Text> <Text fz="sm" fw={600}>{applicantName(r)}</Text>
<Text fz="xs" c="dimmed" ff="monospace">{r.seafarerNumber}</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.Td>
</Table.Tr> </Table.Tr>
))} ))}
{!isFetching && (data?.items ?? []).length === 0 && ( {!isFetching && (data?.items ?? []).length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Text fz="sm" c="dimmed">No registered seafarer matches.</Text> <Text fz="sm" c="dimmed">No seafarer is waiting on enrolment.</Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
)} )}
@@ -116,7 +124,6 @@ export function BiometricEnrollmentPage() {
const [bsid, setBsid] = useState<string | null>(null); const [bsid, setBsid] = useState<string | null>(null);
const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation(); const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation();
const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation(); const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation();
const [printing, setPrinting] = useState(false);
const hasActive = useMemo( const hasActive = useMemo(
() => (m: BiometricModality) => (enrollments ?? []).some((e) => e.modality === m), () => (m: BiometricModality) => (enrollments ?? []).some((e) => e.modality === m),
@@ -161,26 +168,11 @@ export function BiometricEnrollmentPage() {
} }
} }
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 ( return (
<Container size="md" py="md"> <Container size="md" py="md">
<PageHeader <PageHeader
title="Biometric Enrollment" title="Biometric Enrollment"
subtitle="Capture a fingerprint or face template for a registered seafarer, and print the enrollment slip." subtitle="Capture a fingerprint or face template for a seafarer awaiting enrolment, then issue their BSID."
/> />
{!selected ? ( {!selected ? (
@@ -196,7 +188,7 @@ export function BiometricEnrollmentPage() {
<Group justify="space-between"> <Group justify="space-between">
<div> <div>
<Text fw={600}>{applicantName(selected)}</Text> <Text fw={600}>{applicantName(selected)}</Text>
<Text fz="xs" c="dimmed" ff="monospace">{selected.seafarerNumber}</Text> <Text fz="xs" c="dimmed" ff="monospace">{selected.registrationNumber}</Text>
</div> </div>
<Button <Button
variant="subtle" variant="subtle"
@@ -260,18 +252,7 @@ export function BiometricEnrollmentPage() {
</Card> </Card>
<Card withBorder radius="md" p="md"> <Card withBorder radius="md" p="md">
<Group justify="space-between" mb="sm"> <Text fz="sm" fw={600} mb="sm">On file</Text>
<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 ? ( {isLoading ? (
<Loader size="sm" /> <Loader size="sm" />
) : ( ) : (

View File

@@ -63,8 +63,13 @@ export function SeafarerRegistrationReviewPage() {
} }
const { registration, attachments } = data; const { registration, attachments } = data;
// Decided straight off the queue — no claim step. // Decided straight off the queue — no claim step. AWAITING_BIOMETRICS is
const canDecide = registration.status === 'SUBMITTED'; // deliberately excluded: approval is blocked on a BSID that only exists once
// the applicant has been enrolled, so the decision is not the reviewer's to
// take yet. SUBMITTED stays decidable for files that predate the gate.
const awaitingBiometrics = registration.status === 'AWAITING_BIOMETRICS';
const canDecide =
registration.status === 'UNDER_REVIEW' || registration.status === 'SUBMITTED';
const busy = approving || rejecting || requesting; const busy = approving || rejecting || requesting;
async function run(action: () => Promise<unknown>, done: string) { async function run(action: () => Promise<unknown>, done: string) {
@@ -141,6 +146,18 @@ export function SeafarerRegistrationReviewPage() {
} }
/> />
{awaitingBiometrics && (
<Alert
color="blue"
icon={<IconAlertTriangle size={16} />}
title="Awaiting biometric enrolment"
mb="md"
>
This registration cannot be decided yet. The applicant has to be
enrolled at a counter and issued a BSID first the registration moves
to Under Review automatically once that happens.
</Alert>
)}
{registration.status === 'RESUBMIT_REQUIRED' && ( {registration.status === 'RESUBMIT_REQUIRED' && (
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md"> <Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
{registration.reviewRemark} {registration.reviewRemark}

View File

@@ -130,6 +130,8 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationStatus, string> = { export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationStatus, string> = {
DRAFT: 'Draft', DRAFT: 'Draft',
SUBMITTED: 'Submitted', SUBMITTED: 'Submitted',
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
UNDER_REVIEW: 'Under Review',
RESUBMIT_REQUIRED: 'Corrections Requested', RESUBMIT_REQUIRED: 'Corrections Requested',
APPROVED: 'Approved', APPROVED: 'Approved',
REJECTED: 'Rejected', REJECTED: 'Rejected',
@@ -152,6 +154,8 @@ export const SEAFARER_REGISTRATION_STATUS_TONES: Record<
> = { > = {
DRAFT: 'neutral', DRAFT: 'neutral',
SUBMITTED: 'info', SUBMITTED: 'info',
AWAITING_BIOMETRICS: 'pending',
UNDER_REVIEW: 'info',
RESUBMIT_REQUIRED: 'pending', RESUBMIT_REQUIRED: 'pending',
APPROVED: 'success', APPROVED: 'success',
REJECTED: 'danger', REJECTED: 'danger',

View File

@@ -5,6 +5,12 @@ export type RankTier = 'ABOVE' | 'BELOW';
export type SeafarerRegistrationStatus = export type SeafarerRegistrationStatus =
| 'DRAFT' | 'DRAFT'
// Filed, waiting on the counter-side biometric capture that produces the
// BSID approval is blocked on.
| 'AWAITING_BIOMETRICS'
// BSID issued — the file is a reviewer's to decide.
| 'UNDER_REVIEW'
// Retained for registrations filed before biometrics moved ahead of review.
| 'SUBMITTED' | 'SUBMITTED'
| 'RESUBMIT_REQUIRED' | 'RESUBMIT_REQUIRED'
| 'APPROVED' | 'APPROVED'