mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 03:05:43 +00:00
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:
@@ -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" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user