fix(portal,backoffice): six reported issues in registration and the designer

Seafarer and vessel registration were filtered out of the operations
step by `requiresOperatorMode !== false`, so someone registering as a
seafarer or vessel owner landed on an onboarding screen that did not
describe them. Both now appear, grouped apart from the company modes:
declaring "I am a seafarer" is a different kind of statement from "my
company forwards freight".

The designer's preview was gated on the Handlebars source alone, which a
canvas layout does not have until the server compiles it on save -- so
the button was dead for exactly the designs the canvas exists for.

Editing looked broken rather than deliberately read-only: every seeded
template is PUBLISHED, and a published design is immutable because
certificates were issued from it. Says so, and offers the new-version
action that is the way forward.

Reviewing officers saw company, capital and staff tabs on seafarer
certificate applications, because PRESENTATION is keyed by the generic
licence keys and the fifty-odd rank-specific CoC/CoP keys fell through
to the company default. Matched by prefix instead, so a certificate
configured tomorrow gets the right presentation without a code change.

Seaman book and BTC are wired to the newly seeded licence types and no
longer marked "soon".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fitse-yotor
2026-08-16 22:21:00 +03:00
parent c58a727a96
commit f294f67ced
22 changed files with 1237 additions and 86 deletions

View File

@@ -0,0 +1,191 @@
import {
ActionIcon,
ColorInput,
Group,
NumberInput,
Paper,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api';
interface Props {
block: TemplateFieldPlacement | null;
variables: TemplateVariable[];
onChange: (block: TemplateFieldPlacement) => void;
onDelete: (id: string) => void;
disabled: boolean;
}
/** Everything about the selected block that is not its position on the page. */
export function BlockPropertiesPanel({
block,
variables,
onChange,
onDelete,
disabled,
}: Props) {
const { t } = useTranslation();
if (!block) {
return (
<Paper withBorder p="md" radius="md">
<Text size="sm" c="dimmed">
{t('designer.noBlockSelected', 'Select a block on the page to edit it.')}
</Text>
</Paper>
);
}
const isLiteral = block.variable === null;
return (
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
{t('designer.blockProperties', 'Selected block')}
</Text>
<Tooltip label={t('designer.deleteBlock', 'Remove block')}>
<ActionIcon
variant="subtle"
color="red"
disabled={disabled}
onClick={() => onDelete(block.id)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
<SegmentedControl
fullWidth
size="xs"
value={isLiteral ? 'text' : 'variable'}
disabled={disabled}
onChange={(value) =>
onChange(
value === 'text'
? { ...block, variable: null, text: block.text ?? '' }
: { ...block, variable: variables[0]?.key ?? 'companyName' },
)
}
data={[
{ value: 'variable', label: t('designer.blockVariable', 'Variable') },
{ value: 'text', label: t('designer.blockText', 'Fixed text') },
]}
/>
{isLiteral ? (
<TextInput
label={t('designer.blockTextLabel', 'Text')}
value={block.text ?? ''}
onChange={(e) => onChange({ ...block, text: e.currentTarget.value })}
disabled={disabled}
/>
) : (
<Select
label={t('designer.blockVariableLabel', 'Variable')}
data={variables.map((variable) => ({
value: variable.key,
label: variable.label,
}))}
value={block.variable}
onChange={(value) => onChange({ ...block, variable: value })}
searchable
disabled={disabled}
/>
)}
<Group gap="xs" grow>
<NumberInput
label={t('designer.blockFontSize', 'Font size')}
value={block.fontSize ?? 14}
onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })}
min={4}
max={200}
disabled={disabled}
/>
<NumberInput
label={t('designer.blockWidth', 'Width (%)')}
value={block.widthPct}
onChange={(value) =>
onChange({ ...block, widthPct: Math.min(100, Math.max(1, Number(value) || 1)) })
}
min={1}
max={100}
disabled={disabled}
/>
</Group>
<Group gap="xs" grow>
<NumberInput
label={t('designer.blockX', 'X (%)')}
value={block.xPct}
onChange={(value) =>
onChange({ ...block, xPct: Math.min(100, Math.max(0, Number(value) || 0)) })
}
min={0}
max={100}
decimalScale={2}
disabled={disabled}
/>
<NumberInput
label={t('designer.blockY', 'Y (%)')}
value={block.yPct}
onChange={(value) =>
onChange({ ...block, yPct: Math.min(100, Math.max(0, Number(value) || 0)) })
}
min={0}
max={100}
decimalScale={2}
disabled={disabled}
/>
</Group>
<SegmentedControl
fullWidth
size="xs"
value={block.align ?? 'left'}
disabled={disabled}
onChange={(value) =>
onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
}
data={[
{ value: 'left', label: t('designer.alignLeft', 'Left') },
{ value: 'center', label: t('designer.alignCenter', 'Centre') },
{ value: 'right', label: t('designer.alignRight', 'Right') },
]}
/>
<SegmentedControl
fullWidth
size="xs"
value={block.fontWeight ?? 'normal'}
disabled={disabled}
onChange={(value) =>
onChange({ ...block, fontWeight: value as TemplateFieldPlacement['fontWeight'] })
}
data={[
{ value: 'normal', label: t('designer.weightNormal', 'Normal') },
{ value: 'bold', label: t('designer.weightBold', 'Bold') },
]}
/>
<ColorInput
label={t('designer.blockColor', 'Colour')}
value={block.color ?? '#111111'}
onChange={(value) => onChange({ ...block, color: value })}
disabled={disabled}
format="hex"
/>
</Stack>
</Paper>
);
}

View File

@@ -2,6 +2,7 @@ import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useLocalized, type LicenseType } from '@ema-platform/api';
import { groupedTypeOptions } from '../config/designer';
interface Props {
licenseTypes: LicenseType[];
@@ -34,15 +35,18 @@ export function DesignerToolbar({
return (
<Group align="flex-end" mb="md" gap="sm">
{/* Grouped and searchable because the catalogue is 80+ types, over 50 of
them STCW certificates: a flat list buries the CoC/CoP a user is
looking for among the logistics operator licences. */}
<Select
label={t('designer.licenceType', 'Licence type')}
data={licenseTypes.map((type) => ({
value: type.id,
label: localized(type.name) || type.key,
}))}
data={groupedTypeOptions(licenseTypes, localized, t)}
value={typeId}
onChange={onTypeChange}
w={280}
searchable
nothingFoundMessage={t('designer.noTypeMatch', 'No licence type matches')}
maxDropdownHeight={340}
w={340}
/>
{/* Validity lives beside the design because it is the other half of

View File

@@ -1,23 +1,56 @@
import { Button, Group, Image, Paper, Stack, Text, TextInput } from '@mantine/core';
import {
Button,
Group,
Image,
NumberInput,
Paper,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
} from '@mantine/core';
import { IconPhotoUp, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { TemplateLogoCorner, TemplateLogoPlacement } from '@ema-platform/api';
interface Props {
backgroundUrl: string;
onBackgroundChange: (url: string) => void;
logoUrl: string;
onLogoChange: (url: string) => void;
logoPlacement: TemplateLogoPlacement;
onLogoPlacementChange: (placement: TemplateLogoPlacement) => void;
landscape: boolean;
onLandscapeChange: (landscape: boolean) => void;
disabled: boolean;
}
const CORNERS: { value: TemplateLogoCorner; labelKey: string; fallback: string }[] = [
{ value: 'TOP_LEFT', labelKey: 'designer.cornerTopLeft', fallback: 'Top left' },
{ value: 'TOP_CENTER', labelKey: 'designer.cornerTopCenter', fallback: 'Top centre' },
{ value: 'TOP_RIGHT', labelKey: 'designer.cornerTopRight', fallback: 'Top right' },
{ value: 'BOTTOM_LEFT', labelKey: 'designer.cornerBottomLeft', fallback: 'Bottom left' },
{ value: 'BOTTOM_RIGHT', labelKey: 'designer.cornerBottomRight', fallback: 'Bottom right' },
];
/**
* The artwork a certificate is printed on.
* Page-level settings: orientation, the artwork a certificate is printed on,
* and the authority's logo.
*
* A background, not the certificate itself — the number, QR code and holder's
* name stay in the template layer above it, so one design serves every
* certificate it issues.
* Orientation belongs here rather than beside the version name because it is a
* property of the page, and it decides the shape of the canvas everything else
* is positioned on.
*/
export function TemplateBackgroundPanel({
backgroundUrl,
onBackgroundChange,
logoUrl,
onLogoChange,
logoPlacement,
onLogoPlacementChange,
landscape,
onLandscapeChange,
disabled,
}: Props) {
const { t } = useTranslation();
@@ -27,16 +60,44 @@ export function TemplateBackgroundPanel({
<Stack gap="sm">
<div>
<Text fw={600} size="sm">
{t('designer.background', 'Background artwork')}
{t('designer.pageSetup', 'Page setup')}
</Text>
<Text size="xs" c="dimmed">
{t(
'designer.backgroundHint',
'Printed underneath the template. Certificate data is drawn on top, so the same artwork serves every certificate.',
'designer.pageSetupHint',
'Orientation, background artwork and the authority logo. Certificate data is drawn on top, so the same page serves every certificate.',
)}
</Text>
</div>
<Group gap="xl" align="flex-start">
<div>
<Text size="sm" fw={500} mb={4}>
{t('designer.orientation', 'Orientation')}
</Text>
<SegmentedControl
value={landscape ? 'landscape' : 'portrait'}
onChange={(value) => onLandscapeChange(value === 'landscape')}
disabled={disabled}
data={[
{
value: 'portrait',
label: t('designer.portrait', 'Portrait'),
},
{
value: 'landscape',
label: t('designer.landscape', 'Landscape'),
},
]}
/>
<Text size="xs" c="dimmed" mt={4}>
{landscape
? t('designer.a4Landscape', 'A4 — 297 × 210 mm')
: t('designer.a4Portrait', 'A4 — 210 × 297 mm')}
</Text>
</div>
</Group>
<Group align="flex-end" gap="sm">
<TextInput
label={t('designer.backgroundUrl', 'Artwork URL')}
@@ -78,6 +139,86 @@ export function TemplateBackgroundPanel({
</Group>
</Paper>
)}
<Group align="flex-end" gap="sm">
<TextInput
label={t('designer.logoUrl', 'Institute logo URL')}
placeholder="https://…"
value={logoUrl}
onChange={(e) => onLogoChange(e.currentTarget.value)}
disabled={disabled}
style={{ flex: 1 }}
/>
{logoUrl && (
<Button
variant="subtle"
color="red"
leftSection={<IconTrash size={16} />}
disabled={disabled}
onClick={() => onLogoChange('')}
>
{t('designer.removeLogo', 'Remove')}
</Button>
)}
</Group>
{logoUrl && (
<Group align="flex-end" gap="sm">
<Select
label={t('designer.logoCorner', 'Logo position')}
data={CORNERS.map((corner) => ({
value: corner.value,
label: t(corner.labelKey, corner.fallback),
}))}
value={logoPlacement.corner ?? 'TOP_LEFT'}
onChange={(value) =>
onLogoPlacementChange({
...logoPlacement,
corner: (value as TemplateLogoCorner) ?? 'TOP_LEFT',
})
}
disabled={disabled}
w={170}
/>
<NumberInput
label={t('designer.logoWidth', 'Width (% of page)')}
value={logoPlacement.widthPct ?? 18}
onChange={(value) =>
onLogoPlacementChange({
...logoPlacement,
widthPct: Number(value) || 0,
})
}
min={1}
max={100}
disabled={disabled}
w={150}
/>
<NumberInput
label={t('designer.logoOffset', 'Inset (%)')}
value={logoPlacement.offsetPct ?? 5}
onChange={(value) =>
onLogoPlacementChange({
...logoPlacement,
offsetPct: Number(value) || 0,
})
}
min={0}
max={100}
disabled={disabled}
w={120}
/>
<Image
src={logoUrl}
alt={t('designer.logoPreview', 'Logo preview')}
radius="sm"
fit="contain"
h={56}
w={56}
fallbackSrc="data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"
/>
</Group>
)}
</Stack>
</Paper>
);

View File

@@ -0,0 +1,288 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Paper, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
interface Props {
backgroundUrl: string;
logoUrl: string;
logoPlacement: TemplateLogoPlacement;
landscape: boolean;
placements: TemplateFieldPlacement[];
selectedId: string | null;
onSelect: (id: string | null) => void;
onChange: (placements: TemplateFieldPlacement[]) => void;
disabled: boolean;
}
/** A4 aspect ratio, the only page size the renderer is configured for. */
const A4_RATIO = 297 / 210;
const LOGO_CORNER_STYLE: Record<string, (offset: number) => React.CSSProperties> = {
TOP_LEFT: (o) => ({ top: `${o}%`, left: `${o}%` }),
TOP_CENTER: (o) => ({ top: `${o}%`, left: '50%', transform: 'translateX(-50%)' }),
TOP_RIGHT: (o) => ({ top: `${o}%`, right: `${o}%` }),
BOTTOM_LEFT: (o) => ({ bottom: `${o}%`, left: `${o}%` }),
BOTTOM_RIGHT: (o) => ({ bottom: `${o}%`, right: `${o}%` }),
};
type DragState = {
id: string;
mode: 'move' | 'resize';
pointerId: number;
/** Grab offset within the block, in percent, so it does not jump on grab. */
grabDxPct: number;
grabDyPct: number;
startWidthPct: number;
startXPct: number;
};
/**
* The visual certificate editor.
*
* Blocks are positioned in percentages of the page box, which is what lets the
* same layout render correctly in both orientations and at any zoom: the
* canvas scales with its container, and the compiled Handlebars uses the same
* percentages against the real A4 page.
*
* Pointer events rather than HTML5 drag-and-drop — the latter cannot report
* continuous positions during a drag, and offers no path to resizing.
*/
export function TemplateCanvas({
backgroundUrl,
logoUrl,
logoPlacement,
landscape,
placements,
selectedId,
onSelect,
onChange,
disabled,
}: Props) {
const { t } = useTranslation();
const pageRef = useRef<HTMLDivElement>(null);
const [drag, setDrag] = useState<DragState | null>(null);
// Held in a ref as well: the pointer handlers are bound to the window for the
// life of a drag, and would otherwise close over a stale placements array.
const placementsRef = useRef(placements);
placementsRef.current = placements;
const pointToPct = useCallback((clientX: number, clientY: number) => {
const rect = pageRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0 || rect.height === 0) return { xPct: 0, yPct: 0 };
return {
xPct: ((clientX - rect.left) / rect.width) * 100,
yPct: ((clientY - rect.top) / rect.height) * 100,
};
}, []);
useEffect(() => {
if (!drag) return;
function handleMove(event: PointerEvent) {
if (!drag) return;
const { xPct, yPct } = pointToPct(event.clientX, event.clientY);
onChange(
placementsRef.current.map((block) => {
if (block.id !== drag.id) return block;
if (drag.mode === 'resize') {
// Width follows the pointer's distance from the block's left edge,
// clamped so a block can neither invert nor leave the page.
const width = Math.min(
100 - drag.startXPct,
Math.max(5, xPct - drag.startXPct),
);
return { ...block, widthPct: Number(width.toFixed(2)) };
}
const nextX = Math.min(
100 - block.widthPct,
Math.max(0, xPct - drag.grabDxPct),
);
const nextY = Math.min(99, Math.max(0, yPct - drag.grabDyPct));
return {
...block,
xPct: Number(nextX.toFixed(2)),
yPct: Number(nextY.toFixed(2)),
};
}),
);
}
function handleUp() {
setDrag(null);
}
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
window.addEventListener('pointercancel', handleUp);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
window.removeEventListener('pointercancel', handleUp);
};
}, [drag, onChange, pointToPct]);
function startDrag(
event: React.PointerEvent,
block: TemplateFieldPlacement,
mode: 'move' | 'resize',
) {
if (disabled) return;
event.preventDefault();
event.stopPropagation();
onSelect(block.id);
const { xPct, yPct } = pointToPct(event.clientX, event.clientY);
setDrag({
id: block.id,
mode,
pointerId: event.pointerId,
grabDxPct: xPct - block.xPct,
grabDyPct: yPct - block.yPct,
startWidthPct: block.widthPct,
startXPct: block.xPct,
});
}
/** Arrow keys nudge the selected block, for placement finer than a drag. */
function handleKeyDown(event: React.KeyboardEvent, block: TemplateFieldPlacement) {
if (disabled) return;
const step = event.shiftKey ? 5 : 0.5;
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const delta = deltas[event.key];
if (!delta) return;
event.preventDefault();
onChange(
placements.map((candidate) =>
candidate.id === block.id
? {
...candidate,
xPct: Number(
Math.min(100 - candidate.widthPct, Math.max(0, candidate.xPct + delta[0])).toFixed(2),
),
yPct: Number(Math.min(99, Math.max(0, candidate.yPct + delta[1])).toFixed(2)),
}
: candidate,
),
);
}
const corner = logoPlacement.corner ?? 'TOP_LEFT';
const logoStyle = (LOGO_CORNER_STYLE[corner] ?? LOGO_CORNER_STYLE.TOP_LEFT)(
logoPlacement.offsetPct ?? 5,
);
return (
<Paper withBorder p="sm" radius="md">
<Text size="xs" c="dimmed" mb="xs">
{t(
'designer.canvasHint',
'Drag a block to move it, drag its right edge to resize, or use the arrow keys for fine placement.',
)}
</Text>
<Box
ref={pageRef}
onPointerDown={() => onSelect(null)}
style={{
position: 'relative',
width: '100%',
aspectRatio: landscape ? String(A4_RATIO) : String(1 / A4_RATIO),
background: '#ffffff',
border: '1px solid var(--mantine-color-gray-4)',
overflow: 'hidden',
// Blocks are positioned against this box, so it must not be static.
touchAction: 'none',
}}
>
{backgroundUrl && (
<img
src={backgroundUrl}
alt=""
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
pointerEvents: 'none',
}}
/>
)}
{logoUrl && (
<img
src={logoUrl}
alt=""
style={{
position: 'absolute',
width: `${logoPlacement.widthPct ?? 18}%`,
pointerEvents: 'none',
...logoStyle,
}}
/>
)}
{placements.map((block) => {
const isSelected = block.id === selectedId;
return (
<div
key={block.id}
role="button"
tabIndex={0}
onPointerDown={(event) => startDrag(event, block, 'move')}
onKeyDown={(event) => handleKeyDown(event, block)}
style={{
position: 'absolute',
left: `${block.xPct}%`,
top: `${block.yPct}%`,
width: `${block.widthPct}%`,
fontSize: block.fontSize ?? 14,
fontWeight: block.fontWeight ?? 'normal',
textAlign: block.align ?? 'left',
color: block.color ?? '#111111',
cursor: disabled ? 'default' : 'move',
outline: isSelected
? '2px solid var(--mantine-color-blue-6)'
: '1px dashed var(--mantine-color-gray-5)',
background: isSelected ? 'rgba(34,139,230,0.06)' : 'transparent',
boxSizing: 'border-box',
padding: 2,
lineHeight: 1.3,
wordWrap: 'break-word',
userSelect: 'none',
}}
>
{block.variable ? `{{${block.variable}}}` : block.text || ' '}
{isSelected && !disabled && (
<span
onPointerDown={(event) => startDrag(event, block, 'resize')}
style={{
position: 'absolute',
right: -4,
top: '50%',
transform: 'translateY(-50%)',
width: 8,
height: 18,
borderRadius: 2,
background: 'var(--mantine-color-blue-6)',
cursor: 'ew-resize',
}}
/>
)}
</div>
);
})}
</Box>
</Paper>
);
}

View File

@@ -1,4 +1,5 @@
import { Button, Code, ScrollArea, Stack, Text, Tooltip } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
interface Variable {
@@ -9,11 +10,22 @@ interface Variable {
interface Props {
variables: Variable[];
disabled: boolean;
/** Canvas mode: drops a block onto the page. Source mode: inserts at caret. */
canvasMode: boolean;
onInsert: (key: string) => void;
onAddBlock: (key: string) => void;
onAddTextBlock: () => void;
}
/** Placeholders the template can carry, inserted at the caret. */
export function TemplateVariableList({ variables, disabled, onInsert }: Props) {
/** Placeholders the design can carry — dropped on the page, or typed at the caret. */
export function TemplateVariableList({
variables,
disabled,
canvasMode,
onInsert,
onAddBlock,
onAddTextBlock,
}: Props) {
const { t } = useTranslation();
return (
@@ -22,8 +34,23 @@ export function TemplateVariableList({ variables, disabled, onInsert }: Props) {
{t('designer.variables', 'Placeholders')}
</Text>
<Text size="xs" c="dimmed">
{t('designer.variablesHint', 'Click to insert at the cursor.')}
{canvasMode
? t('designer.variablesCanvasHint', 'Click to add a block to the page.')
: t('designer.variablesHint', 'Click to insert at the cursor.')}
</Text>
{canvasMode && (
<Button
size="compact-xs"
variant="light"
leftSection={<IconPlus size={13} />}
disabled={disabled}
onClick={onAddTextBlock}
>
{t('designer.addTextBlock', 'Fixed text block')}
</Button>
)}
<ScrollArea.Autosize mah={480} type="hover">
<Stack gap={4}>
{variables.map((variable) => (
@@ -33,7 +60,9 @@ export function TemplateVariableList({ variables, disabled, onInsert }: Props) {
variant="default"
justify="flex-start"
disabled={disabled}
onClick={() => onInsert(variable.key)}
onClick={() =>
canvasMode ? onAddBlock(variable.key) : onInsert(variable.key)
}
>
<Code fz={10}>{`{{${variable.key}}}`}</Code>
</Button>