mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 14:15:45 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -14,11 +14,10 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} 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 {
|
||||
extractErrorMessage,
|
||||
openAuthedDocument,
|
||||
useEnrollBiometricMutation,
|
||||
useGenerateBsidMutation,
|
||||
useGetBiometricEnrollmentsQuery,
|
||||
@@ -52,12 +51,19 @@ function fakeTemplate(): string {
|
||||
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 }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced] = useDebouncedValue(search, 300);
|
||||
const { data, isFetching } = useListSeafarerRegistrationsQuery({
|
||||
status: 'APPROVED',
|
||||
status: 'AWAITING_BIOMETRICS',
|
||||
search: debounced || undefined,
|
||||
take: 10,
|
||||
});
|
||||
@@ -65,7 +71,7 @@ function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<TextInput
|
||||
placeholder="Search seafarer by name, ID or registration number…"
|
||||
placeholder="Search seafarers awaiting enrolment…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
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.Td>
|
||||
<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.Tr>
|
||||
))}
|
||||
{!isFetching && (data?.items ?? []).length === 0 && (
|
||||
<Table.Tr>
|
||||
<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.Tr>
|
||||
)}
|
||||
@@ -116,7 +124,6 @@ export function BiometricEnrollmentPage() {
|
||||
const [bsid, setBsid] = useState<string | null>(null);
|
||||
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),
|
||||
@@ -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 (
|
||||
<Container size="md" py="md">
|
||||
<PageHeader
|
||||
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 ? (
|
||||
@@ -196,7 +188,7 @@ export function BiometricEnrollmentPage() {
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<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>
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -260,18 +252,7 @@ export function BiometricEnrollmentPage() {
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<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>
|
||||
<Text fz="sm" fw={600} mb="sm">On file</Text>
|
||||
{isLoading ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
|
||||
@@ -38,6 +38,7 @@ const SCOPES: { value: NumberFormatScope; label: string }[] = [
|
||||
{ value: 'SEAFARER_NUMBER', label: 'Seafarer Number' },
|
||||
{ value: 'SEAMAN_BOOK_NUMBER', label: 'Seaman Book Number' },
|
||||
{ value: 'BTC_NUMBER', label: 'BTC Number' },
|
||||
{ value: 'BSID', label: 'Biometric Subject ID (BSID)' },
|
||||
];
|
||||
|
||||
const scopeLabel = (scope: NumberFormatScope) =>
|
||||
|
||||
@@ -51,7 +51,8 @@ export interface UpdateProfessionPayload {
|
||||
export type NumberFormatScope =
|
||||
| 'SEAFARER_NUMBER'
|
||||
| 'SEAMAN_BOOK_NUMBER'
|
||||
| 'BTC_NUMBER';
|
||||
| 'BTC_NUMBER'
|
||||
| 'BSID';
|
||||
|
||||
/**
|
||||
* The shape of a generated identifier — prefix, optional year, separator and
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* The officer's own signing signature — drawn onto certificates they approve
|
||||
* (`{{signatureImage}}`), as distinct from the seafarer's specimen signature
|
||||
* that the portal manages.
|
||||
*
|
||||
* Scoped to the caller: the API resolves the employee record from the token,
|
||||
* so no employee id is passed and nobody can upload on another's behalf.
|
||||
*/
|
||||
const signatureApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: ['MyEmployeeSignature'] as const })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getMyEmployeeSignature: builder.query<{ url: string | null }, void>({
|
||||
query: () => ({ url: '/employee-signatures/me' }),
|
||||
providesTags: ['MyEmployeeSignature'],
|
||||
}),
|
||||
|
||||
uploadMyEmployeeSignature: builder.mutation<{ id: string }, File>({
|
||||
query: (file) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
// No Content-Type header: fetch sets it with the multipart boundary.
|
||||
return { url: '/employee-signatures/me', method: 'POST', body };
|
||||
},
|
||||
invalidatesTags: (_r, error) => (error ? [] : ['MyEmployeeSignature']),
|
||||
}),
|
||||
|
||||
deleteMyEmployeeSignature: builder.mutation<{ removed: boolean }, void>({
|
||||
query: () => ({ url: '/employee-signatures/me', method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : ['MyEmployeeSignature']),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetMyEmployeeSignatureQuery,
|
||||
useUploadMyEmployeeSignatureMutation,
|
||||
useDeleteMyEmployeeSignatureMutation,
|
||||
} = signatureApi;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SignaturePad } from '@ema-platform/ui';
|
||||
import {
|
||||
useDeleteMyEmployeeSignatureMutation,
|
||||
useGetMyEmployeeSignatureQuery,
|
||||
useUploadMyEmployeeSignatureMutation,
|
||||
} from '../api/signature-api';
|
||||
|
||||
/** The signature drawn onto certificates this officer approves. */
|
||||
export function MySignaturePad() {
|
||||
const { data, isLoading } = useGetMyEmployeeSignatureQuery();
|
||||
const [upload, { isLoading: isUploading }] =
|
||||
useUploadMyEmployeeSignatureMutation();
|
||||
const [remove, { isLoading: isDeleting }] =
|
||||
useDeleteMyEmployeeSignatureMutation();
|
||||
|
||||
return (
|
||||
<SignaturePad
|
||||
currentUrl={data?.url ?? null}
|
||||
isLoading={isLoading}
|
||||
isUploading={isUploading}
|
||||
isDeleting={isDeleting}
|
||||
onUpload={(file) => upload(file).unwrap()}
|
||||
onDelete={() => remove().unwrap()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
IconMail,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSignature,
|
||||
IconShieldLock,
|
||||
IconSun,
|
||||
IconUser,
|
||||
@@ -43,12 +44,18 @@ import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { ActiveSessions, setUser } from '@ema-platform/auth';
|
||||
import {
|
||||
ActiveSessions,
|
||||
LICENSE_PERMISSIONS,
|
||||
setUser,
|
||||
usePermissions,
|
||||
} from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { setLayoutMode } from '../../../store/preferences.slice';
|
||||
import type { LayoutMode } from '../../../store/preferences.slice';
|
||||
import { MySignaturePad } from '../components/MySignaturePad';
|
||||
import classes from './ProfilePage.module.css';
|
||||
|
||||
function getInitials(name: string, fallback: string) {
|
||||
@@ -77,6 +84,10 @@ export function ProfilePage() {
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
const { handleError } = useErrorHandler();
|
||||
// Only officers who approve applications ever sign a certificate, so nobody
|
||||
// else is asked for a signature they would never use.
|
||||
const { can } = usePermissions();
|
||||
const canSign = can([LICENSE_PERMISSIONS.APPROVE_APPLICATION]);
|
||||
|
||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
@@ -318,6 +329,11 @@ export function ProfilePage() {
|
||||
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
|
||||
{t('profile.tabs.profile')}
|
||||
</Tabs.Tab>
|
||||
{canSign && (
|
||||
<Tabs.Tab value="signature" leftSection={<IconSignature size={18} />}>
|
||||
{t('profile.tabs.signature')}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
||||
{t('profile.tabs.security')}
|
||||
</Tabs.Tab>
|
||||
@@ -405,6 +421,15 @@ export function ProfilePage() {
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Signature (drawn onto certificates this officer approves) ---- */}
|
||||
{canSign && (
|
||||
<Tabs.Panel value="signature" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<MySignaturePad />
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
|
||||
{/* ---- Security ---- */}
|
||||
<Tabs.Panel value="security" pt="md">
|
||||
<Stack gap="lg">
|
||||
|
||||
@@ -63,8 +63,13 @@ export function SeafarerRegistrationReviewPage() {
|
||||
}
|
||||
|
||||
const { registration, attachments } = data;
|
||||
// Decided straight off the queue — no claim step.
|
||||
const canDecide = registration.status === 'SUBMITTED';
|
||||
// Decided straight off the queue — no claim step. AWAITING_BIOMETRICS is
|
||||
// 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;
|
||||
|
||||
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' && (
|
||||
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
|
||||
{registration.reviewRemark}
|
||||
|
||||
Reference in New Issue
Block a user