mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-06 13:35:05 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user