Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-31 09:19:35 +03:00
30 changed files with 1768 additions and 1702 deletions

View File

@@ -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" />
) : (

View File

@@ -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) =>

View File

@@ -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

View File

@@ -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;

View File

@@ -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()}
/>
);
}

View File

@@ -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">

View File

@@ -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}

View File

@@ -471,9 +471,31 @@ export const am: Translations = {
unverified: "ያልተረጋገጠ",
tabs: {
profile: "መገለጫ",
signature: "ፊርማ",
security: "ደህንነት",
preferences: "ምርጫዎች",
},
signature: {
title: "የመፈረሚያ ፊርማ",
description: "እርስዎ በሚያጸድቋቸው ሰነዶች ላይ ይታተማል። አንድ ጊዜ ይሳሉ ወይም ምስል ይጫኑ።",
reissueNotice:
"ፊርማዎን መቀየር አስቀድሞ የተሰጠን ሰነድ አይለውጥም — ከአሁን በኋላ በሚፈርሙዋቸው ላይ ብቻ ይሠራል።",
current: "የተመዘገበ ፊርማ",
currentAlt: "የተቀመጠ ፊርማዎ",
none: "እስካሁን የተመዘገበ ፊርማ የለም። የሚያጸድቋቸው ሰነዶች ያለ ፊርማ ይሰጣሉ።",
modeDraw: "ይሳሉ",
modeUpload: "ይጫኑ",
save: "ፊርማ አስቀምጥ",
clear: "አጽዳ",
choose: "ምስል ይምረጡ",
fileHint: "PNG ወይም JPEG፣ እስከ 2 ሜባ።",
remove: "አስወግድ",
saved: "ፊርማ ተቀምጧል።",
removed: "ፊርማ ተወግዷል።",
badType: "PNG እና JPEG ምስሎች ብቻ ይፈቀዳሉ።",
tooLarge: "ምስሉ ከ2 ሜባ ይበልጣል።",
drawFailed: "ሥዕሉን ማንበብ አልተቻለም። እባክዎ እንደገና ይሞክሩ።",
},
personalHint: "በኦፊሴላዊ ኢማ ሰነዶች ላይ እንደሚታየው ስምዎ።",
languageTitle: "ቋንቋ",
languageHint: "በአስተዳደር ፓነል ውስጥ የሚጠቀሙትን ቋንቋ ይምረጡ።",

View File

@@ -470,9 +470,32 @@ export const en = {
unverified: 'Unverified',
tabs: {
profile: 'Profile',
signature: 'Signature',
security: 'Security',
preferences: 'Preferences',
},
signature: {
title: 'Signing signature',
description:
'Drawn onto the certificates you approve. Draw it once or upload an image.',
reissueNotice:
'Changing your signature does not alter a certificate already issued — it applies to whatever you sign from now on.',
current: 'Signature on file',
currentAlt: 'Your stored signature',
none: 'No signature on file yet. Certificates you approve will be issued without one.',
modeDraw: 'Draw',
modeUpload: 'Upload',
save: 'Save signature',
clear: 'Clear',
choose: 'Choose image',
fileHint: 'PNG or JPEG, up to 2 MB.',
remove: 'Remove',
saved: 'Signature saved.',
removed: 'Signature removed.',
badType: 'Only PNG and JPEG images are accepted.',
tooLarge: 'That image is larger than 2 MB.',
drawFailed: 'Could not read the drawing. Please try again.',
},
personalHint: 'Your name as it appears on official EMA documents.',
languageTitle: 'Language',
languageHint: 'Choose the language used across the admin panel.',

View File

@@ -17,7 +17,7 @@ export default defineConfig({
// port: 4201,
// proxy: {
// '/api': {
// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com'
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },