mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-01 17:03:28 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
11
.env.example
11
.env.example
@@ -12,10 +12,8 @@
|
||||
# frontend never speaks to Fayda directly.
|
||||
|
||||
# Base URL of the emaapi backend, including the /api prefix.
|
||||
# 3001, not 3000: the portal itself takes 3000 in development, because that is
|
||||
# the port in the Fayda redirect URI registered for local testing. Set PORT=3001
|
||||
# in emaapi's .env to match.
|
||||
VITE_BASE_API_URL=http://localhost:3001/api
|
||||
# The portal runs on 4200 and the local API runs on 3000.
|
||||
VITE_BASE_API_URL=http://localhost:3000/api
|
||||
|
||||
# Serve fixture data instead of calling the API. Any value other than "true"
|
||||
# uses the real backend.
|
||||
@@ -25,7 +23,7 @@ VITE_USE_MOCKS=false
|
||||
# Host ports published by docker-compose.yml. It also expects per-app env files
|
||||
# at apps/portal/.env and apps/backoffice/.env, which can each be a copy of this
|
||||
# file. Ignored when running the Vite dev servers, which serve the portal on
|
||||
# 3000 and the backoffice on 4201.
|
||||
# 4200 and the backoffice on 4201.
|
||||
# EMA_PORTAL_PORT=8021
|
||||
# EMA_BACKOFFICE_PORT=8022
|
||||
|
||||
@@ -34,6 +32,5 @@ VITE_USE_MOCKS=false
|
||||
# page at /callback and /signup/fayda/callback, and whichever path is registered
|
||||
# with Fayda must match the API's FAYDA_REDIRECT_URI exactly.
|
||||
#
|
||||
# The value being registered first is http://localhost:3001/callback, so the
|
||||
# portal's dev server now listens on 3000 and emaapi moves to 3001. Nothing
|
||||
# Register http://localhost:4200/callback with Fayda. Nothing
|
||||
# extra to run — `nx serve portal` already binds the right port.
|
||||
|
||||
@@ -91,7 +91,7 @@ npm run dev:all
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| ----------------------------- | ------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `VITE_BASE_API_URL` | Yes | `http://localhost:3001` | Base URL for all API requests |
|
||||
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
|
||||
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
|
||||
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
|
||||
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: "በአስተዳደር ፓነል ውስጥ የሚጠቀሙትን ቋንቋ ይምረጡ።",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
// },
|
||||
// },
|
||||
|
||||
46
apps/portal/src/app/features/profile/api/signature-api.ts
Normal file
46
apps/portal/src/app/features/profile/api/signature-api.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* The caller's specimen signature.
|
||||
*
|
||||
* Upload is multipart rather than the presign+PUT flow used for documents:
|
||||
* the API validates type and size on the way through, which it cannot do when
|
||||
* bytes go straight to storage. `signatureUrl` on the profile is an
|
||||
* object-storage key, so the stored signature is displayed through a
|
||||
* short-lived link from `GET me/signature` rather than read off the profile.
|
||||
*/
|
||||
const signatureApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: ['CurrentProfile', 'MySignature'] as const })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getMySignature: builder.query<{ url: string | null }, void>({
|
||||
query: () => ({ url: '/profiles/me/signature' }),
|
||||
providesTags: ['MySignature'],
|
||||
}),
|
||||
|
||||
uploadMySignature: builder.mutation<{ signatureUrl: string }, File>({
|
||||
query: (file) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
// No Content-Type header: fetch sets it with the multipart boundary,
|
||||
// and naming it here would omit the boundary and fail to parse.
|
||||
return { url: '/profiles/me/signature', method: 'POST', body };
|
||||
},
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : ['MySignature', 'CurrentProfile'],
|
||||
}),
|
||||
|
||||
deleteMySignature: builder.mutation<{ signatureUrl: null }, void>({
|
||||
query: () => ({ url: '/profiles/me/signature', method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : ['MySignature', 'CurrentProfile'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetMySignatureQuery,
|
||||
useUploadMySignatureMutation,
|
||||
useDeleteMySignatureMutation,
|
||||
} = signatureApi;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SignaturePad } from '@ema-platform/ui';
|
||||
import {
|
||||
useDeleteMySignatureMutation,
|
||||
useGetMySignatureQuery,
|
||||
useUploadMySignatureMutation,
|
||||
} from '../api/signature-api';
|
||||
|
||||
/**
|
||||
* The seafarer's own specimen signature, printed on documents issued to them
|
||||
* (`{{seafarerSignature}}`). Distinct from an officer's signing signature,
|
||||
* which the backoffice manages against a different endpoint.
|
||||
*/
|
||||
export function MySignaturePad() {
|
||||
const { data, isLoading } = useGetMySignatureQuery();
|
||||
const [upload, { isLoading: isUploading }] = useUploadMySignatureMutation();
|
||||
const [remove, { isLoading: isDeleting }] = useDeleteMySignatureMutation();
|
||||
|
||||
return (
|
||||
<SignaturePad
|
||||
currentUrl={data?.url ?? null}
|
||||
isLoading={isLoading}
|
||||
isUploading={isUploading}
|
||||
isDeleting={isDeleting}
|
||||
onUpload={(file) => upload(file).unwrap()}
|
||||
onDelete={() => remove().unwrap()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
IconMapPin,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSignature,
|
||||
IconShieldLock,
|
||||
IconSun,
|
||||
IconUser,
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
import { useSaveMyAddressMutation } from '../api/address-api';
|
||||
import { toAddressPayload } from '../types/address';
|
||||
import { OperationsFormContent } from '../components/OperationsFormContent';
|
||||
import { MySignaturePad } from '../components/SignaturePad';
|
||||
import { SEAFARER_PROFILE_REQUIREMENT } from '../components/RequireSeafarerProfile';
|
||||
import classes from './ProfilePage.module.css';
|
||||
|
||||
@@ -76,6 +78,7 @@ const VALID_TABS = [
|
||||
'profile',
|
||||
'address',
|
||||
'operations',
|
||||
'signature',
|
||||
'security',
|
||||
'preferences',
|
||||
];
|
||||
@@ -633,6 +636,9 @@ export function ProfilePage() {
|
||||
>
|
||||
{t('profile.tabs.operations')}
|
||||
</Tabs.Tab>
|
||||
<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>
|
||||
@@ -808,6 +814,13 @@ export function ProfilePage() {
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Signature (printed on issued documents) ---- */}
|
||||
<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">
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GENDER_OPTIONS,
|
||||
HAIR_COLOR_OPTIONS,
|
||||
MARITAL_STATUS_OPTIONS,
|
||||
RANK_TIER_OPTIONS,
|
||||
isEthiopianNationality,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useLocalized,
|
||||
@@ -130,6 +131,18 @@ export function ApplicantDetailsStep(p: StepProps) {
|
||||
options={departmentOptions}
|
||||
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
|
||||
/>
|
||||
<SelectField
|
||||
{...p}
|
||||
name="tier"
|
||||
label="Certificate Limitation"
|
||||
required
|
||||
options={RANK_TIER_OPTIONS}
|
||||
description={
|
||||
p.form.department === 'ENGINE'
|
||||
? 'Above covers ships of 3000 kW propulsion power or more; Below covers 750–3000 kW. Every Certificate of Competency you apply for is issued under this limit.'
|
||||
: 'Above covers ships of 3000 gross tonnage or more; Below covers 500–3000 GT. Every Certificate of Competency you apply for is issued under this limit.'
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -59,7 +59,7 @@ const STEPS = [
|
||||
*/
|
||||
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality'],
|
||||
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
||||
['placeOfBirth', 'department', 'tier', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
||||
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
||||
[],
|
||||
['declarationAccepted'],
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CopyButton,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
@@ -12,41 +11,20 @@ import {
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconDownload,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconFingerprint,
|
||||
IconIdBadge2,
|
||||
IconInfoCircle,
|
||||
IconScan,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { useGetMyBiometricEnrollmentsQuery } from '@ema-platform/api';
|
||||
import { PdfPreviewModal } from '@ema-platform/ui';
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
|
||||
async function fetchCertificate(): Promise<Blob> {
|
||||
const token = authStorage.getToken();
|
||||
if (!token) throw new Error('No auth token found');
|
||||
const res = await fetch(`${API_BASE}/biometric-enrollments/mine/certificate`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch certificate (${res.status})`);
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const MODALITY_LABEL: Record<string, string> = {
|
||||
FINGERPRINT: 'Fingerprint',
|
||||
@@ -54,44 +32,17 @@ const MODALITY_LABEL: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* View-only: what's enrolled, plus a printable slip. Capture stays
|
||||
* counter-side with a scanner — there is no self-enrollment flow here.
|
||||
* View-only: the seafarer's BSID and what is enrolled against it. Capture
|
||||
* stays counter-side with a scanner — there is no self-enrollment flow here.
|
||||
*/
|
||||
export function BiometricsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: enrollments, isLoading } = useGetMyBiometricEnrollmentsQuery();
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const openPreview = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setPreviewUrl(URL.createObjectURL(await fetchCertificate()));
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Error',
|
||||
message: err instanceof Error ? err.message : 'Could not load certificate',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
downloadBlob(await fetchCertificate(), 'biometric-enrollment-certificate.pdf');
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Error',
|
||||
message: err instanceof Error ? err.message : 'Could not download certificate',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
// The BSID lives on the profile, stamped by staff once a capture is
|
||||
// confirmed — it is not a property of any one enrollment, so it is read
|
||||
// from the profile rather than from the rows below.
|
||||
const { profile, isLoading: profileLoading } = useCurrentProfile();
|
||||
const bsid = profile?.bsid ?? null;
|
||||
|
||||
const rows = enrollments ?? [];
|
||||
|
||||
@@ -109,6 +60,45 @@ export function BiometricsPage() {
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb={rows.length || isLoading ? 'lg' : 0} align="flex-start">
|
||||
<ThemeIcon size={40} radius="md" color="indigo" variant="light">
|
||||
<IconIdBadge2 size={20} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{t('biometrics.bsidLabel', 'Biometric Subject ID')}
|
||||
</Text>
|
||||
{profileLoading ? (
|
||||
<Loader size="xs" mt={6} />
|
||||
) : bsid ? (
|
||||
<CopyButton value={bsid} timeout={1500}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? t('biometrics.copied', 'Copied') : t('biometrics.copy', 'Copy')}
|
||||
withArrow
|
||||
>
|
||||
<UnstyledButton onClick={copy}>
|
||||
<Group gap={6} align="center">
|
||||
<Text ff="monospace" fw={700} fz="lg">
|
||||
{bsid}
|
||||
</Text>
|
||||
{copied ? <IconCheck size={15} /> : <IconCopy size={15} />}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed" mt={2}>
|
||||
{t(
|
||||
'biometrics.bsidPending',
|
||||
'Not issued yet. Your BSID is generated once your enrolment is confirmed at the counter.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
@@ -147,34 +137,9 @@ export function BiometricsPage() {
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={busy ? <Loader size={12} /> : <IconInfoCircle size={12} />}
|
||||
onClick={openPreview}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('biometrics.view', 'View certificate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconDownload size={12} />}
|
||||
onClick={handleDownload}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('biometrics.download', 'Download')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<PdfPreviewModal
|
||||
opened={!!previewUrl}
|
||||
onClose={() => setPreviewUrl(null)}
|
||||
url={previewUrl ?? ''}
|
||||
title={t('biometrics.title', 'Biometrics')}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -338,9 +338,31 @@ export const am: Translations = {
|
||||
profile: 'መገለጫ',
|
||||
address: 'አድራሻ',
|
||||
operations: 'የስራ ዘርፍ',
|
||||
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: 'ሥዕሉን ማንበብ አልተቻለም። እባክዎ እንደገና ይሞክሩ።',
|
||||
},
|
||||
maritimeSection: {
|
||||
title: 'የባህር ሙያ መገለጫ',
|
||||
subtitle: 'የባህር ሙያ ዝርዝሮችዎ',
|
||||
|
||||
@@ -338,9 +338,32 @@ export const en = {
|
||||
profile: 'Profile',
|
||||
address: 'Address',
|
||||
operations: 'Operations',
|
||||
signature: 'Signature',
|
||||
security: 'Security',
|
||||
preferences: 'Preferences',
|
||||
},
|
||||
signature: {
|
||||
title: 'Specimen signature',
|
||||
description:
|
||||
'Drawn or uploaded once and printed on the documents issued to you.',
|
||||
reissueNotice:
|
||||
'Changing your signature does not alter a document already issued — it applies to whatever is issued from now on.',
|
||||
current: 'Signature on file',
|
||||
currentAlt: 'Your stored signature',
|
||||
none: 'No signature on file yet.',
|
||||
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.',
|
||||
},
|
||||
maritimeSection: {
|
||||
title: 'Maritime Profile',
|
||||
subtitle: 'Your professional maritime details',
|
||||
|
||||
@@ -9,20 +9,17 @@ export default defineConfig({
|
||||
// built-in default.
|
||||
envDir: "../../",
|
||||
cacheDir: "../../node_modules/.vite/apps/portal",
|
||||
// 3000, not the usual 4200: the Fayda redirect URI registered for local
|
||||
// testing is http://localhost:3001/callback, and the provider matches it
|
||||
// exactly. The API moves to 3001 to make room.
|
||||
server: { port: 3000, host: "localhost" },
|
||||
server: { port: 4200, host: "localhost" },
|
||||
// server: {
|
||||
// port: 4200,
|
||||
// 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,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
preview: { port: 3000, host: "localhost" },
|
||||
preview: { port: 4200, host: "localhost" },
|
||||
plugins: [react(), nxViteTsPaths()],
|
||||
resolve: {
|
||||
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
|
||||
|
||||
@@ -10,7 +10,7 @@ import { resolveSessionContext } from "../session";
|
||||
export const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.[
|
||||
"VITE_BASE_API_URL"
|
||||
]?.trim() || "http://localhost:3001/api";
|
||||
]?.trim() || "http://localhost:3000/api";
|
||||
|
||||
let _onTokenExpired: (() => Promise<string>) | null = null;
|
||||
let _onAuthFailure: (() => void) | null = null;
|
||||
|
||||
@@ -25,6 +25,19 @@ export const DEPARTMENT_OPTIONS = [
|
||||
{ value: 'CATERING', label: 'Catering' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The STCW limitation a Certificate of Competency is issued under.
|
||||
*
|
||||
* One choice, worded generically, because the threshold it means differs by
|
||||
* department — gross tonnage on deck, propulsion power in the engine room. The
|
||||
* certificate states the department-specific wording; the applicant only picks
|
||||
* which side of the line they serve.
|
||||
*/
|
||||
export const RANK_TIER_OPTIONS = [
|
||||
{ value: 'ABOVE', label: 'Above' },
|
||||
{ value: 'BELOW', label: 'Below' },
|
||||
];
|
||||
|
||||
export const HAIR_COLOR_OPTIONS = [
|
||||
{ value: 'BLACK', label: 'Black' },
|
||||
{ value: 'BROWN', label: 'Brown' },
|
||||
@@ -117,6 +130,8 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
||||
export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationStatus, string> = {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
RESUBMIT_REQUIRED: 'Corrections Requested',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
@@ -139,6 +154,8 @@ export const SEAFARER_REGISTRATION_STATUS_TONES: Record<
|
||||
> = {
|
||||
DRAFT: 'neutral',
|
||||
SUBMITTED: 'info',
|
||||
AWAITING_BIOMETRICS: 'pending',
|
||||
UNDER_REVIEW: 'info',
|
||||
RESUBMIT_REQUIRED: 'pending',
|
||||
APPROVED: 'success',
|
||||
REJECTED: 'danger',
|
||||
@@ -158,6 +175,7 @@ export const SEAFARER_REGISTRATION_FIELD_LABELS: Record<keyof SeafarerRegistrati
|
||||
passportNumber: 'Passport Number',
|
||||
passportExpiry: 'Passport Expiry Date',
|
||||
department: 'Department',
|
||||
tier: 'Certificate Limitation',
|
||||
locationId: 'Location',
|
||||
permanentAddress: 'Permanent Address',
|
||||
currentAddress: 'Current Address',
|
||||
@@ -192,7 +210,7 @@ export const SEAFARER_REGISTRATION_SECTIONS: {
|
||||
{
|
||||
key: 'identity',
|
||||
title: 'Identity',
|
||||
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department'],
|
||||
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department', 'tier'],
|
||||
},
|
||||
{
|
||||
key: 'address',
|
||||
@@ -220,6 +238,7 @@ const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value:
|
||||
gender: GENDER_OPTIONS,
|
||||
maritalStatus: MARITAL_STATUS_OPTIONS,
|
||||
department: DEPARTMENT_OPTIONS,
|
||||
tier: RANK_TIER_OPTIONS,
|
||||
hairColor: HAIR_COLOR_OPTIONS,
|
||||
eyeColor: EYE_COLOR_OPTIONS,
|
||||
bloodType: BLOOD_TYPE_OPTIONS,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
|
||||
|
||||
/** Above/Below — the STCW limitation a Certificate of Competency is issued under. */
|
||||
export type RankTier = 'ABOVE' | 'BELOW';
|
||||
|
||||
export type SeafarerRegistrationStatus =
|
||||
| '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'
|
||||
| 'RESUBMIT_REQUIRED'
|
||||
| 'APPROVED'
|
||||
@@ -30,6 +39,11 @@ export interface SeafarerRegistrationAnswers {
|
||||
passportNumber: string | null;
|
||||
passportExpiry: string | null;
|
||||
department: SeafarerDepartment | null;
|
||||
/**
|
||||
* The STCW ship-size limitation this seafarer's CoCs are issued under.
|
||||
* Read with `department` to pick the CoC ladder; CoP carries no tier.
|
||||
*/
|
||||
tier: RankTier | null;
|
||||
locationId: string | null;
|
||||
permanentAddress: string | null;
|
||||
currentAddress: string | null;
|
||||
|
||||
@@ -11,7 +11,7 @@ export const SESSION_HEADER_KEYS = {
|
||||
* Which app this bundle is, so it reads its own session and no one else's.
|
||||
*
|
||||
* Set by each app's store via `configureSessionScope`. Cookies ignore the
|
||||
* port, so `localhost:3001` and `localhost:4201` share one jar: without a
|
||||
* port, so `localhost:3000` and `localhost:4201` share one jar: without a
|
||||
* scope the backoffice would happily authenticate as whoever last signed into
|
||||
* the portal, and render a staff console with an applicant's permissions.
|
||||
*/
|
||||
|
||||
@@ -25,6 +25,8 @@ export * from "./lib/layout/SkipLink";
|
||||
export * from "./lib/input/PasswordRequirements";
|
||||
export * from "./lib/input/CountrySelect";
|
||||
export * from "./lib/input/PhoneInput";
|
||||
export * from "./lib/input/canvas-point";
|
||||
export * from "./lib/components/SignaturePad";
|
||||
export * from "./lib/input/phone";
|
||||
export * from "./lib/data/AdvancedTable";
|
||||
export * from "./lib/data/WaitingFor";
|
||||
|
||||
316
libs/ui/src/lib/components/SignaturePad.tsx
Normal file
316
libs/ui/src/lib/components/SignaturePad.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
IconWriting,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '../feedback/notify';
|
||||
import { useErrorHandler } from '../feedback/use-error-handler';
|
||||
import { toCanvasPoint } from '../input/canvas-point';
|
||||
|
||||
/** Mirrors the API's own limits (`ProfileService.saveSignature`). */
|
||||
const ACCEPTED = ['image/png', 'image/jpeg'];
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The drawing surface's backing-store size.
|
||||
*
|
||||
* Fixed rather than matched to the rendered element: this is what gets printed
|
||||
* on a Seaman Book, so the stored image must not vary with the width of the
|
||||
* browser window it happened to be drawn in. The canvas is displayed at
|
||||
* whatever width the layout gives it and scaled to these dimensions.
|
||||
*/
|
||||
const PAD_WIDTH = 800;
|
||||
const PAD_HEIGHT = 260;
|
||||
|
||||
/**
|
||||
* Captures a specimen signature, drawn or uploaded.
|
||||
*
|
||||
* Drawing is a plain canvas with pointer events — one element and ~40 lines,
|
||||
* where a signature-pad dependency would be a package to keep patched. Pointer
|
||||
* events (not mouse + touch separately) cover mouse, finger and stylus in one
|
||||
* set of handlers.
|
||||
*/
|
||||
export interface SignaturePadProps {
|
||||
/** Short-lived link to the signature on file, or null when there is none. */
|
||||
currentUrl: string | null;
|
||||
isLoading: boolean;
|
||||
isUploading: boolean;
|
||||
isDeleting: boolean;
|
||||
onUpload: (file: File) => Promise<unknown>;
|
||||
onDelete: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function SignaturePad({
|
||||
currentUrl,
|
||||
isLoading,
|
||||
isUploading,
|
||||
isDeleting,
|
||||
onUpload,
|
||||
onDelete,
|
||||
}: SignaturePadProps) {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const drawing = useRef(false);
|
||||
// Whether anything has actually been drawn — a blank canvas still encodes to
|
||||
// a valid PNG, so without this "Save" would happily store an empty image.
|
||||
const [hasInk, setHasInk] = useState(false);
|
||||
const [mode, setMode] = useState<'draw' | 'upload'>('draw');
|
||||
|
||||
const busy = isUploading || isDeleting;
|
||||
|
||||
const context = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = '#111';
|
||||
return ctx;
|
||||
}, []);
|
||||
|
||||
// The stored signature is flattened onto white before upload, so a canvas
|
||||
// left transparent would print as a black box on some renderers.
|
||||
const clear = useCallback(() => {
|
||||
const ctx = context();
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, PAD_WIDTH, PAD_HEIGHT);
|
||||
setHasInk(false);
|
||||
}, [context]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'draw') clear();
|
||||
}, [mode, clear]);
|
||||
|
||||
const pointAt = (event: React.PointerEvent<HTMLCanvasElement>) =>
|
||||
toCanvasPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget.getBoundingClientRect(),
|
||||
{ width: PAD_WIDTH, height: PAD_HEIGHT },
|
||||
);
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const ctx = context();
|
||||
if (!ctx) return;
|
||||
// Keeps strokes tracking the pointer when it leaves the canvas mid-signature
|
||||
// rather than ending the line at the edge.
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
drawing.current = true;
|
||||
const { x, y } = pointAt(event);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
// A tap with no movement should still leave a mark (a dot on an "i").
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
setHasInk(true);
|
||||
};
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawing.current) return;
|
||||
const ctx = context();
|
||||
if (!ctx) return;
|
||||
const { x, y } = pointAt(event);
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
drawing.current = false;
|
||||
};
|
||||
|
||||
const save = async (file: File) => {
|
||||
try {
|
||||
await onUpload(file);
|
||||
notify.success(t('profile.signature.saved'));
|
||||
if (mode === 'draw') clear();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDrawing = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !hasInk) return;
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
notify.error(t('profile.signature.drawFailed'));
|
||||
return;
|
||||
}
|
||||
void save(new File([blob], 'signature.png', { type: 'image/png' }));
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
// Validated here as well as server-side so the reason is immediate and the
|
||||
// user is not made to wait on an upload that is going to be rejected.
|
||||
const onFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
// Lets the same file be picked again after a rejection.
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
if (!ACCEPTED.includes(file.type)) {
|
||||
notify.error(t('profile.signature.badType'));
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
notify.error(t('profile.signature.tooLarge'));
|
||||
return;
|
||||
}
|
||||
void save(file);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await onDelete();
|
||||
notify.success(t('profile.signature.removed'));
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={5}>{t('profile.signature.title')}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('profile.signature.description')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t('profile.signature.reissueNotice')}
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : currentUrl ? (
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('profile.signature.current')}
|
||||
</Text>
|
||||
<Image
|
||||
src={currentUrl}
|
||||
alt={t('profile.signature.currentAlt')}
|
||||
fit="contain"
|
||||
h={120}
|
||||
bg="white"
|
||||
/>
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleDelete}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{t('profile.signature.remove')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('profile.signature.none')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={(value) => setMode(value as 'draw' | 'upload')}
|
||||
data={[
|
||||
{ value: 'draw', label: t('profile.signature.modeDraw') },
|
||||
{ value: 'upload', label: t('profile.signature.modeUpload') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{mode === 'draw' ? (
|
||||
<Stack gap="sm">
|
||||
<Box
|
||||
component="canvas"
|
||||
ref={canvasRef}
|
||||
width={PAD_WIDTH}
|
||||
height={PAD_HEIGHT}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: `${PAD_WIDTH} / ${PAD_HEIGHT}`,
|
||||
border: '1px dashed var(--mantine-color-gray-4)',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
background: '#fff',
|
||||
// Stops the browser panning/zooming the page mid-stroke on touch.
|
||||
touchAction: 'none',
|
||||
cursor: 'crosshair',
|
||||
}}
|
||||
/>
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconWriting size={16} />}
|
||||
onClick={saveDrawing}
|
||||
loading={isUploading}
|
||||
disabled={!hasInk || busy}
|
||||
>
|
||||
{t('profile.signature.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={clear}
|
||||
disabled={!hasInk || busy}
|
||||
>
|
||||
{t('profile.signature.clear')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
component="label"
|
||||
variant="light"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
loading={isUploading}
|
||||
disabled={busy}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('profile.signature.choose')}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept={ACCEPTED.join(',')}
|
||||
onChange={onFile}
|
||||
/>
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('profile.signature.fileHint')}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
30
libs/ui/src/lib/input/canvas-point.spec.ts
Normal file
30
libs/ui/src/lib/input/canvas-point.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { toCanvasPoint } from './canvas-point';
|
||||
|
||||
/**
|
||||
* Guards the scaling between a canvas's on-screen size and its backing store.
|
||||
* Getting this wrong offsets strokes from the cursor — worse the further from
|
||||
* the origin — which stays invisible until someone actually tries to sign.
|
||||
*/
|
||||
describe('toCanvasPoint', () => {
|
||||
const size = { width: 800, height: 260 };
|
||||
// Half scale: 400px wide on screen, 800 in the backing store.
|
||||
const rect = { left: 100, top: 50, width: 400, height: 130 };
|
||||
|
||||
it('maps the top-left corner to the origin', () => {
|
||||
expect(toCanvasPoint(100, 50, rect, size)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('maps the bottom-right corner to the full backing-store size', () => {
|
||||
expect(toCanvasPoint(500, 180, rect, size)).toEqual({ x: 800, y: 260 });
|
||||
});
|
||||
|
||||
it('scales a midpoint rather than using raw client pixels', () => {
|
||||
// Raw offset would be (200, 65) — half of the correct answer.
|
||||
expect(toCanvasPoint(300, 115, rect, size)).toEqual({ x: 400, y: 130 });
|
||||
});
|
||||
|
||||
it('is unscaled when the element is already the backing-store size', () => {
|
||||
const exact = { left: 0, top: 0, width: 800, height: 260 };
|
||||
expect(toCanvasPoint(123, 45, exact, size)).toEqual({ x: 123, y: 45 });
|
||||
});
|
||||
});
|
||||
20
libs/ui/src/lib/input/canvas-point.ts
Normal file
20
libs/ui/src/lib/input/canvas-point.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Pointer position in canvas coordinates.
|
||||
*
|
||||
* A canvas is displayed at whatever width the layout gives it, but drawn into a
|
||||
* fixed backing store, so a click at the right-hand edge of a 400px-wide
|
||||
* element has to land at x=width, not x=400. Skipping this scaling is the
|
||||
* classic canvas bug: strokes appear offset from the cursor, worsening the
|
||||
* further from the origin you draw.
|
||||
*/
|
||||
export function toCanvasPoint(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
rect: { left: number; top: number; width: number; height: number },
|
||||
size: { width: number; height: number },
|
||||
) {
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * size.width,
|
||||
y: ((clientY - rect.top) / rect.height) * size.height,
|
||||
};
|
||||
}
|
||||
2531
package-lock.json
generated
2531
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user