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

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

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View 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>
);
}

View 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 });
});
});

View 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,
};
}