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 { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocalized, type LicenseType } from '@ema-platform/api'; import { useLocalized, type LicenseType } from '@ema-platform/api';
import { groupedTypeOptions } from '../config/designer';
interface Props { interface Props {
licenseTypes: LicenseType[]; licenseTypes: LicenseType[];
@@ -34,15 +35,18 @@ export function DesignerToolbar({
return ( return (
<Group align="flex-end" mb="md" gap="sm"> <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 <Select
label={t('designer.licenceType', 'Licence type')} label={t('designer.licenceType', 'Licence type')}
data={licenseTypes.map((type) => ({ data={groupedTypeOptions(licenseTypes, localized, t)}
value: type.id,
label: localized(type.name) || type.key,
}))}
value={typeId} value={typeId}
onChange={onTypeChange} 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 {/* 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 { IconPhotoUp, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TemplateLogoCorner, TemplateLogoPlacement } from '@ema-platform/api';
interface Props { interface Props {
backgroundUrl: string; backgroundUrl: string;
onBackgroundChange: (url: string) => void; onBackgroundChange: (url: string) => void;
logoUrl: string;
onLogoChange: (url: string) => void;
logoPlacement: TemplateLogoPlacement;
onLogoPlacementChange: (placement: TemplateLogoPlacement) => void;
landscape: boolean;
onLandscapeChange: (landscape: boolean) => void;
disabled: boolean; 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 * Orientation belongs here rather than beside the version name because it is a
* name stay in the template layer above it, so one design serves every * property of the page, and it decides the shape of the canvas everything else
* certificate it issues. * is positioned on.
*/ */
export function TemplateBackgroundPanel({ export function TemplateBackgroundPanel({
backgroundUrl, backgroundUrl,
onBackgroundChange, onBackgroundChange,
logoUrl,
onLogoChange,
logoPlacement,
onLogoPlacementChange,
landscape,
onLandscapeChange,
disabled, disabled,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -27,16 +60,44 @@ export function TemplateBackgroundPanel({
<Stack gap="sm"> <Stack gap="sm">
<div> <div>
<Text fw={600} size="sm"> <Text fw={600} size="sm">
{t('designer.background', 'Background artwork')} {t('designer.pageSetup', 'Page setup')}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t( {t(
'designer.backgroundHint', 'designer.pageSetupHint',
'Printed underneath the template. Certificate data is drawn on top, so the same artwork serves every certificate.', 'Orientation, background artwork and the authority logo. Certificate data is drawn on top, so the same page serves every certificate.',
)} )}
</Text> </Text>
</div> </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"> <Group align="flex-end" gap="sm">
<TextInput <TextInput
label={t('designer.backgroundUrl', 'Artwork URL')} label={t('designer.backgroundUrl', 'Artwork URL')}
@@ -78,6 +139,86 @@ export function TemplateBackgroundPanel({
</Group> </Group>
</Paper> </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> </Stack>
</Paper> </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 { Button, Code, ScrollArea, Stack, Text, Tooltip } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface Variable { interface Variable {
@@ -9,11 +10,22 @@ interface Variable {
interface Props { interface Props {
variables: Variable[]; variables: Variable[];
disabled: boolean; disabled: boolean;
/** Canvas mode: drops a block onto the page. Source mode: inserts at caret. */
canvasMode: boolean;
onInsert: (key: string) => void; onInsert: (key: string) => void;
onAddBlock: (key: string) => void;
onAddTextBlock: () => void;
} }
/** Placeholders the template can carry, inserted at the caret. */ /** Placeholders the design can carry — dropped on the page, or typed at the caret. */
export function TemplateVariableList({ variables, disabled, onInsert }: Props) { export function TemplateVariableList({
variables,
disabled,
canvasMode,
onInsert,
onAddBlock,
onAddTextBlock,
}: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -22,8 +34,23 @@ export function TemplateVariableList({ variables, disabled, onInsert }: Props) {
{t('designer.variables', 'Placeholders')} {t('designer.variables', 'Placeholders')}
</Text> </Text>
<Text size="xs" c="dimmed"> <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> </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"> <ScrollArea.Autosize mah={480} type="hover">
<Stack gap={4}> <Stack gap={4}>
{variables.map((variable) => ( {variables.map((variable) => (
@@ -33,7 +60,9 @@ export function TemplateVariableList({ variables, disabled, onInsert }: Props) {
variant="default" variant="default"
justify="flex-start" justify="flex-start"
disabled={disabled} disabled={disabled}
onClick={() => onInsert(variable.key)} onClick={() =>
canvasMode ? onAddBlock(variable.key) : onInsert(variable.key)
}
> >
<Code fz={10}>{`{{${variable.key}}}`}</Code> <Code fz={10}>{`{{${variable.key}}}`}</Code>
</Button> </Button>

View File

@@ -1,4 +1,4 @@
import type { LicenseTemplate } from '@ema-platform/api'; import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } from '@ema-platform/api';
/** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */ /** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */
export const API_BASE_URL = export const API_BASE_URL =
@@ -15,3 +15,57 @@ export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
export function pageOptionsFor(landscape: boolean) { export function pageOptionsFor(landscape: boolean) {
return { format: 'A4' as const, landscape, printBackground: true }; return { format: 'A4' as const, landscape, printBackground: true };
} }
/**
* Order the category groups appear in, and their fallback labels.
*
* Seafarer certification sits first: it holds over 50 of the 80-odd licence
* types, and it is what the designer is opened for most often. Any category
* not listed here still renders — it falls to the end under its own key —
* so a new category on the server does not silently hide its types.
*/
const CATEGORY_ORDER: { key: LicenseCategory; fallback: string }[] = [
{ key: 'MARITIME_PERSONNEL', fallback: 'Seafarer certification (CoC / CoP)' },
{ key: 'CARGO_FREIGHT', fallback: 'Cargo & freight' },
{ key: 'SHIPPING_AGENCY', fallback: 'Shipping agency' },
{ key: 'INVESTMENT', fallback: 'Investment & joint ventures' },
{ key: 'VESSEL_SERVICES', fallback: 'Vessel services' },
{ key: 'WAIVER_SERVICES', fallback: 'Waiver services' },
];
/**
* The licence-type picker's options, grouped by category.
*
* Grouping is what makes the picker usable at all: certificates of competency
* and proficiency are a different kind of thing from an operator's logistics
* licence, and a flat alphabetical list interleaves the two.
*/
export function groupedTypeOptions(
licenseTypes: LicenseType[],
localized: (value?: Bilingual) => string,
t: (key: string, fallback: string) => string,
) {
const byCategory = new Map<string, { value: string; label: string }[]>();
for (const type of licenseTypes) {
const option = { value: type.id, label: localized(type.name) || type.key };
const bucket = byCategory.get(type.category);
if (bucket) bucket.push(option);
else byCategory.set(type.category, [option]);
}
const known = CATEGORY_ORDER.map(({ key, fallback }) => ({
group: t(`designer.category.${key}`, fallback),
items: (byCategory.get(key) ?? []).sort((a, b) => a.label.localeCompare(b.label)),
}));
// Categories the client does not know about yet, so their types stay reachable.
const unknown = [...byCategory.entries()]
.filter(([key]) => !CATEGORY_ORDER.some((c) => c.key === key))
.map(([key, items]) => ({
group: key,
items: items.sort((a, b) => a.label.localeCompare(b.label)),
}));
return [...known, ...unknown].filter((group) => group.items.length > 0);
}

View File

@@ -0,0 +1,101 @@
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
/**
* Client-side twin of the server's `certificate-layout.compiler`.
*
* The preview route renders whatever `hbsSource` it is handed, so a canvas
* layout has to be compiled before it can be previewed — the server only
* compiles on save, and previewing unsaved edits is the whole point of the
* button. The two implementations must emit the same HTML; the server's copy
* is authoritative for what is stored, this one only ever reaches a preview.
*/
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function pct(value: number | undefined, fallback: number, min = 0): number {
if (typeof value !== 'number' || Number.isNaN(value)) return fallback;
return Math.min(100, Math.max(min, value));
}
const CORNER_STYLES: Record<string, (offset: number) => string> = {
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}%;`,
};
function logoHtml(logoUrl: string, placement: TemplateLogoPlacement): string {
if (!logoUrl.trim()) return '';
const corner = placement.corner ?? 'TOP_LEFT';
const width = pct(placement.widthPct, 18);
const offset = pct(placement.offsetPct, 5);
const position = (CORNER_STYLES[corner] ?? CORNER_STYLES.TOP_LEFT)(offset);
return ` <img class="ema-logo" src="${escapeHtml(logoUrl)}" alt="" style="position:absolute;${position}width:${width}%;" />\n`;
}
function blockHtml(block: TemplateFieldPlacement): string {
const x = pct(block.xPct, 0);
const y = pct(block.yPct, 0);
// Minimum 1%, matching the server compiler: a zero-width block would render
// as an invisible sliver rather than as the mistake it is.
const width = pct(block.widthPct, 30, 1);
const size = block.fontSize ?? 14;
const weight = block.fontWeight === 'bold' ? 'bold' : 'normal';
const align = block.align ?? 'left';
const color = escapeHtml(block.color ?? '#111111');
const content = block.variable
? `{{${block.variable}}}`
: escapeHtml(block.text ?? '');
const style =
`position:absolute;left:${x}%;top:${y}%;width:${width}%;` +
`font-size:${size}px;font-weight:${weight};text-align:${align};color:${color};`;
return ` <div class="ema-block" style="${style}">${content}</div>\n`;
}
export function compileLayoutToHbs(input: {
backgroundUrl: string;
logoUrl: string;
logoPlacement: TemplateLogoPlacement;
fieldPlacements: TemplateFieldPlacement[];
}): string {
const background = input.backgroundUrl.trim()
? ` <img class="ema-background" src="${escapeHtml(input.backgroundUrl)}" alt="" />\n`
: '';
const blocks = input.fieldPlacements.map(blockHtml).join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
@page { margin: 0; }
html, body { margin: 0; padding: 0; height: 100%; }
body { font-family: "Helvetica Neue", Arial, sans-serif; }
.ema-page { position: relative; width: 100%; height: 100vh; overflow: hidden; }
.ema-background {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
}
.ema-block { box-sizing: border-box; line-height: 1.3; word-wrap: break-word; }
</style>
</head>
<body>
<div class="ema-page">
${background}${logoHtml(input.logoUrl, input.logoPlacement)}${blocks} </div>
</body>
</html>
`;
}

View File

@@ -1,5 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { LicenseTemplate } from '@ema-platform/api'; import type {
LicenseTemplate,
TemplateFieldPlacement,
TemplateLogoPlacement,
} from '@ema-platform/api';
/** Stable ids for new blocks; `crypto.randomUUID` is not in every test env. */
function blockId(): string {
return `blk_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
}
/** /**
* Editor state for the selected version. * Editor state for the selected version.
@@ -14,6 +23,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [landscape, setLandscape] = useState(true); const [landscape, setLandscape] = useState(true);
const [backgroundUrl, setBackgroundUrl] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState('');
const [logoUrl, setLogoUrl] = useState('');
const [logoPlacement, setLogoPlacement] = useState<TemplateLogoPlacement>({});
const [placements, setPlacements] = useState<TemplateFieldPlacement[]>([]);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const editorRef = useRef<HTMLTextAreaElement>(null); const editorRef = useRef<HTMLTextAreaElement>(null);
const selected = useMemo( const selected = useMemo(
@@ -37,15 +50,65 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
setName(selected.name); setName(selected.name);
setLandscape(selected.pageOptions?.landscape ?? true); setLandscape(selected.pageOptions?.landscape ?? true);
setBackgroundUrl(selected.backgroundUrl ?? ''); setBackgroundUrl(selected.backgroundUrl ?? '');
setLogoUrl(selected.logoUrl ?? '');
setLogoPlacement(selected.logoPlacement ?? {});
setPlacements(selected.fieldPlacements ?? []);
setSelectedBlockId(null);
}, [selected]); }, [selected]);
const isPublished = selected?.status === 'PUBLISHED'; const isPublished = selected?.status === 'PUBLISHED';
// Compared as JSON because both are plain data the server round-trips; a
// reference check would mark the draft dirty on every render.
const placementsChanged =
JSON.stringify(placements) !== JSON.stringify(selected?.fieldPlacements ?? []);
const logoPlacementChanged =
JSON.stringify(logoPlacement) !== JSON.stringify(selected?.logoPlacement ?? {});
const dirty = const dirty =
Boolean(selected) && Boolean(selected) &&
(source !== selected?.hbsSource || (source !== selected?.hbsSource ||
name !== selected?.name || name !== selected?.name ||
landscape !== (selected?.pageOptions?.landscape ?? true) || landscape !== (selected?.pageOptions?.landscape ?? true) ||
backgroundUrl !== (selected?.backgroundUrl ?? '')); backgroundUrl !== (selected?.backgroundUrl ?? '') ||
logoUrl !== (selected?.logoUrl ?? '') ||
logoPlacementChanged ||
placementsChanged);
/** True once the canvas owns the layout, which locks the raw editor. */
const usesCanvas = placements.length > 0;
const selectedBlock =
placements.find((block) => block.id === selectedBlockId) ?? null;
/** Drops a new block near the top-left, where it is immediately visible. */
const addBlock = useCallback((variable: string | null, text?: string) => {
const block: TemplateFieldPlacement = {
id: blockId(),
variable,
text,
xPct: 10,
yPct: 10,
widthPct: 30,
fontSize: 14,
fontWeight: 'normal',
align: 'left',
color: '#111111',
};
setPlacements((prev) => [...prev, block]);
setSelectedBlockId(block.id);
}, []);
const updateBlock = useCallback((next: TemplateFieldPlacement) => {
setPlacements((prev) =>
prev.map((block) => (block.id === next.id ? next : block)),
);
}, []);
const deleteBlock = useCallback((id: string) => {
setPlacements((prev) => prev.filter((block) => block.id !== id));
setSelectedBlockId((current) => (current === id ? null : current));
}, []);
/** Inserts a placeholder where the caret is, rather than at the end. */ /** Inserts a placeholder where the caret is, rather than at the end. */
function insertVariable(key: string) { function insertVariable(key: string) {
@@ -76,6 +139,19 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
setLandscape, setLandscape,
backgroundUrl, backgroundUrl,
setBackgroundUrl, setBackgroundUrl,
logoUrl,
setLogoUrl,
logoPlacement,
setLogoPlacement,
placements,
setPlacements,
selectedBlockId,
setSelectedBlockId,
selectedBlock,
usesCanvas,
addBlock,
updateBlock,
deleteBlock,
editorRef, editorRef,
isPublished, isPublished,
dirty, dirty,

View File

@@ -1,6 +1,22 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Container, Group, Stack } from '@mantine/core'; import {
import { IconAlertCircle } from '@tabler/icons-react'; Alert,
Button,
Container,
Group,
Paper,
Stack,
Tabs,
Text,
TextInput,
} from '@mantine/core';
import {
IconAlertCircle,
IconCode,
IconLayoutBoard,
IconLock,
IconPlus,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
extractErrorMessage, extractErrorMessage,
@@ -17,14 +33,17 @@ import {
} from '@ema-platform/api'; } from '@ema-platform/api';
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui'; import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth'; import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
import { DesignerToolbar } from '../components/DesignerToolbar'; import { DesignerToolbar } from '../components/DesignerToolbar';
import { NewVersionModal } from '../components/NewVersionModal'; import { NewVersionModal } from '../components/NewVersionModal';
import { TemplateActionBar } from '../components/TemplateActionBar'; import { TemplateActionBar } from '../components/TemplateActionBar';
import { TemplateBackgroundPanel } from '../components/TemplateBackgroundPanel'; import { TemplateBackgroundPanel } from '../components/TemplateBackgroundPanel';
import { TemplateCanvas } from '../components/TemplateCanvas';
import { TemplateEditor } from '../components/TemplateEditor'; import { TemplateEditor } from '../components/TemplateEditor';
import { TemplateVariableList } from '../components/TemplateVariableList'; import { TemplateVariableList } from '../components/TemplateVariableList';
import { TemplateVersionList } from '../components/TemplateVersionList'; import { TemplateVersionList } from '../components/TemplateVersionList';
import { pageOptionsFor } from '../config/designer'; import { pageOptionsFor } from '../config/designer';
import { compileLayoutToHbs } from '../config/layout-compiler';
import { useDesignerActions } from '../hooks/useDesignerActions'; import { useDesignerActions } from '../hooks/useDesignerActions';
import { useTemplateDraft } from '../hooks/useTemplateDraft'; import { useTemplateDraft } from '../hooks/useTemplateDraft';
import { useTemplatePreview } from '../hooks/useTemplatePreview'; import { useTemplatePreview } from '../hooks/useTemplatePreview';
@@ -72,6 +91,7 @@ export function CertificateDesignerPage() {
const [newOpen, setNewOpen] = useState(false); const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [validityMonths, setValidityMonths] = useState<number>(12); const [validityMonths, setValidityMonths] = useState<number>(12);
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId); const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
@@ -156,23 +176,115 @@ export function CertificateDesignerPage() {
<TemplateBackgroundPanel <TemplateBackgroundPanel
backgroundUrl={draft.backgroundUrl} backgroundUrl={draft.backgroundUrl}
onBackgroundChange={draft.setBackgroundUrl} onBackgroundChange={draft.setBackgroundUrl}
disabled={editingLocked} logoUrl={draft.logoUrl}
/> onLogoChange={draft.setLogoUrl}
logoPlacement={draft.logoPlacement}
<TemplateEditor onLogoPlacementChange={draft.setLogoPlacement}
name={draft.name}
onNameChange={draft.setName}
source={draft.source}
onSourceChange={draft.setSource}
landscape={draft.landscape} landscape={draft.landscape}
onLandscapeChange={draft.setLandscape} onLandscapeChange={draft.setLandscape}
editorRef={draft.editorRef}
disabled={editingLocked} disabled={editingLocked}
isPublished={draft.isPublished}
/> />
{/* A published design is immutable by rule -- certificates were
issued from it -- so the editor is locked. Without this the
screen looks broken rather than deliberately read-only, which
is what "edit is not available" turns out to mean. */}
{draft.isPublished && (
<Alert
variant="light"
color="blue"
icon={<IconLock size={18} />}
title={t('designer.liveDesign', 'This is the live design')}
>
<Stack gap="xs" align="flex-start">
<Text size="sm">
{t(
'designer.liveDesignBody',
'Certificates have been issued from this version, so it cannot be changed. Create a new version to edit — it starts as a copy of this one, and only replaces it when you publish.',
)}
</Text>
{canEdit && (
<Button
size="xs"
leftSection={<IconPlus size={14} />}
onClick={startNewVersion}
>
{t('designer.newVersionFromThis', 'New version from this design')}
</Button>
)}
</Stack>
</Alert>
)}
<Tabs value={mode} onChange={(value) => setMode(value as 'canvas' | 'source')}>
<Tabs.List>
<Tabs.Tab value="canvas" leftSection={<IconLayoutBoard size={15} />}>
{t('designer.tabCanvas', 'Visual editor')}
</Tabs.Tab>
<Tabs.Tab value="source" leftSection={<IconCode size={15} />}>
{t('designer.tabSource', 'HTML source')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="canvas" pt="sm">
<Stack gap="sm">
<TextInput
label={t('designer.name', 'Version name')}
value={draft.name}
onChange={(e) => draft.setName(e.currentTarget.value)}
disabled={editingLocked}
/>
<TemplateCanvas
backgroundUrl={draft.backgroundUrl}
logoUrl={draft.logoUrl}
logoPlacement={draft.logoPlacement}
landscape={draft.landscape}
placements={draft.placements}
selectedId={draft.selectedBlockId}
onSelect={draft.setSelectedBlockId}
onChange={draft.setPlacements}
disabled={editingLocked}
/>
<BlockPropertiesPanel
block={draft.selectedBlock}
variables={variables}
onChange={draft.updateBlock}
onDelete={draft.deleteBlock}
disabled={editingLocked}
/>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="source" pt="sm">
{draft.usesCanvas && (
<Paper withBorder p="xs" mb="sm" bg="var(--mantine-color-yellow-light)">
<Text size="xs">
{t(
'designer.canvasOwnsSource',
'This design is laid out on the visual editor, which regenerates the HTML on every save. Edits made here will be overwritten — remove all blocks first to hand-write the template.',
)}
</Text>
</Paper>
)}
<TemplateEditor
name={draft.name}
onNameChange={draft.setName}
source={draft.source}
onSourceChange={draft.setSource}
landscape={draft.landscape}
onLandscapeChange={draft.setLandscape}
editorRef={draft.editorRef}
disabled={editingLocked}
isPublished={draft.isPublished}
/>
</Tabs.Panel>
</Tabs>
<TemplateActionBar <TemplateActionBar
hasSource={Boolean(draft.source.trim())} // A canvas layout has no Handlebars source until it is saved --
// the server compiles it -- so gating preview on the source alone
// left the button dead for exactly the designs the canvas is for.
hasSource={Boolean(draft.source.trim()) || draft.usesCanvas}
hasSelection={Boolean(draft.selected)} hasSelection={Boolean(draft.selected)}
isPublished={draft.isPublished} isPublished={draft.isPublished}
dirty={draft.dirty} dirty={draft.dirty}
@@ -182,7 +294,16 @@ export function CertificateDesignerPage() {
publishing={publishing} publishing={publishing}
onPreview={() => onPreview={() =>
openPreview({ openPreview({
hbsSource: draft.source, // A canvas layout is compiled here so the preview shows
// unsaved block moves; the server compiles the stored copy.
hbsSource: draft.usesCanvas
? compileLayoutToHbs({
backgroundUrl: draft.backgroundUrl,
logoUrl: draft.logoUrl,
logoPlacement: draft.logoPlacement,
fieldPlacements: draft.placements,
})
: draft.source,
licenseTypeId: typeId, licenseTypeId: typeId,
landscape: draft.landscape, landscape: draft.landscape,
}) })
@@ -193,9 +314,15 @@ export function CertificateDesignerPage() {
updateTemplate({ updateTemplate({
id: draft.selected!.id, id: draft.selected!.id,
name: draft.name, name: draft.name,
hbsSource: draft.source, // The server recompiles the HTML from the blocks when a
// canvas layout is present, so sending the stale source
// alongside it would only fight that.
hbsSource: draft.usesCanvas ? undefined : draft.source,
pageOptions: pageOptionsFor(draft.landscape), pageOptions: pageOptionsFor(draft.landscape),
backgroundUrl: draft.backgroundUrl || undefined, backgroundUrl: draft.backgroundUrl || undefined,
logoUrl: draft.logoUrl || undefined,
logoPlacement: draft.logoPlacement,
fieldPlacements: draft.placements,
}).unwrap(), }).unwrap(),
t('designer.saved', 'Draft saved'), t('designer.saved', 'Draft saved'),
) )
@@ -224,7 +351,10 @@ export function CertificateDesignerPage() {
<TemplateVariableList <TemplateVariableList
variables={variables} variables={variables}
disabled={editingLocked} disabled={editingLocked}
canvasMode={mode === 'canvas'}
onInsert={draft.insertVariable} onInsert={draft.insertVariable}
onAddBlock={(key) => draft.addBlock(key)}
onAddTextBlock={() => draft.addBlock(null, 'Text')}
/> />
</Group> </Group>
)} )}

View File

@@ -4,6 +4,7 @@ import {
IconFileDescription, IconFileDescription,
IconId, IconId,
IconRubberStamp, IconRubberStamp,
IconShieldCheck,
IconShip, IconShip,
IconTruck, IconTruck,
IconUsers, IconUsers,
@@ -112,15 +113,43 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
}, },
}; };
/**
* A seafarer certificate is judged on the person, not a company.
*
* Matched by prefix rather than listed: the catalogue holds over fifty
* rank-specific CoC/CoP keys and grows whenever EMA configures another, and an
* explicit map would silently fall back to the company presentation — showing
* a reviewing officer capital, staff-role and inspection tabs that a
* certificate application can never fill.
*/
const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_'];
const CERTIFICATE_SECTIONS: DetailSection[] = ['overview', 'documents'];
function isSeafarerCertificate(key: string): boolean {
return (
CERTIFICATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) ||
key === 'CERTIFICATE_OF_COMPETENCY' ||
key === 'CERTIFICATE_OF_PROFICIENCY'
);
}
/** Falls back to a generic presentation so an unseeded type still renders. */ /** Falls back to a generic presentation so an unseeded type still renders. */
export function presentationFor(key: string | undefined): LicenseTypePresentation { export function presentationFor(key: string | undefined): LicenseTypePresentation {
return ( if (key && PRESENTATION[key]) return PRESENTATION[key];
(key && PRESENTATION[key]) || {
key: key ?? 'UNKNOWN', if (key && isSeafarerCertificate(key)) {
icon: IconFileDescription, return {
detailSections: DEFAULT_SECTIONS, key,
} icon: IconShieldCheck,
); detailSections: CERTIFICATE_SECTIONS,
};
}
return {
key: key ?? 'UNKNOWN',
icon: IconFileDescription,
detailSections: DEFAULT_SECTIONS,
};
} }
export const LICENSE_TYPE_KEYS = Object.keys(PRESENTATION); export const LICENSE_TYPE_KEYS = Object.keys(PRESENTATION);

View File

@@ -79,6 +79,7 @@ export const am: Translations = {
dashboard: "ዳሽቦርድ", dashboard: "ዳሽቦርድ",
userManagement: "የተጠቃሚ አስተዳደር", userManagement: "የተጠቃሚ አስተዳደር",
seamanBookQueue: "የመርከበኞች መጽሐፍ ወረፋ", seamanBookQueue: "የመርከበኞች መጽሐፍ ወረፋ",
btcQueue: "የBTC ወረፋ",
cocQueue: "የCoC ወረፋ", cocQueue: "የCoC ወረፋ",
copQueue: "የCoP ወረፋ", copQueue: "የCoP ወረፋ",
endorsementCocQueue: "የCoC ማረጋገጫ ወረፋ", endorsementCocQueue: "የCoC ማረጋገጫ ወረፋ",

View File

@@ -63,6 +63,7 @@ export const en = {
dashboard: 'Dashboard', dashboard: 'Dashboard',
userManagement: 'User Management', userManagement: 'User Management',
seamanBookQueue: 'Seaman Book Queue', seamanBookQueue: 'Seaman Book Queue',
btcQueue: 'BTC Queue',
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard', vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
vesselRegistrationApplicationQueue: 'Vessel Registration Queue', vesselRegistrationApplicationQueue: 'Vessel Registration Queue',
vesselRegistrationQueue: 'Vessel Register', vesselRegistrationQueue: 'Vessel Register',

View File

@@ -104,7 +104,8 @@ export const NAV_SECTIONS: NavSection[] = [
{ to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE }, { to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE }, { to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE }, { to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true, permissions: APPLICATION_QUEUE }, { to: '/licence-review/type/SEAMAN_BOOK', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/BTC_BASIC_TRAINING', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE }, { to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE }, { to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] }, { to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },

View File

@@ -15,10 +15,12 @@ import {
Tooltip, Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconAnchor,
IconArrowRight, IconArrowRight,
IconBuildingWarehouse, IconBuildingWarehouse,
IconChevronRight, IconChevronRight,
IconFileText, IconFileText,
IconShieldOff,
IconShip, IconShip,
IconTrendingUp, IconTrendingUp,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@@ -42,9 +44,12 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
CARGO_FREIGHT: IconBuildingWarehouse, CARGO_FREIGHT: IconBuildingWarehouse,
SHIPPING_AGENCY: IconShip, SHIPPING_AGENCY: IconShip,
INVESTMENT: IconTrendingUp, INVESTMENT: IconTrendingUp,
// Filtered out of this catalogue (requiresOperatorMode is false), listed // The three below are filtered out of this catalogue today
// only so the record stays total if that ever changes. // (requiresOperatorMode is false for all of them), and are listed only so
// the record stays total if that ever changes.
MARITIME_PERSONNEL: IconShip, MARITIME_PERSONNEL: IconShip,
VESSEL_SERVICES: IconAnchor,
WAIVER_SERVICES: IconShieldOff,
}; };
function formatFee(amount: string | number | null, currency: string): string { function formatFee(amount: string | number | null, currency: string): string {

View File

@@ -22,6 +22,18 @@ import {
import { notify, ModalFooter } from '@ema-platform/ui'; import { notify, ModalFooter } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
/**
* Registrations an applicant makes for themselves rather than for a company.
*
* Named explicitly rather than inferred from `requiresOperatorMode: false`,
* because that flag is also false for things nobody declares up front — a
* waiver is requested per shipment, not adopted as an identity.
*/
const PERSONAL_REGISTRATION_KEYS = [
'SEAFARER_REGISTRATION',
'VESSEL_REGISTRATION',
];
/** /**
* The applicant's modes of operation — what they do, and therefore which * The applicant's modes of operation — what they do, and therefore which
* licences the portal offers them. * licences the portal offers them.
@@ -54,14 +66,35 @@ export function OperationsFormContent({
// the form reflects what was actually stored rather than what was typed. // the form reflects what was actually stored rather than what was typed.
useEffect(() => setSelected(declaredIds), [declaredIds]); useEffect(() => setSelected(declaredIds), [declaredIds]);
/**
* The operator licences, plus the two registrations an applicant declares
* for themselves.
*
* Seafarer and vessel registration are not company modes of operation, so
* they carry `requiresOperatorMode: false` and were filtered out here. But
* this screen is also where a new applicant says what they are, and someone
* registering as a seafarer or a vessel owner had no way to say so — they
* landed on an onboarding step that did not describe them.
*
* Listed separately below rather than mixed in, because declaring "I am a
* seafarer" is a different kind of statement from "my company forwards
* freight".
*/
const { operatorOptions, personalOptions } = useMemo(() => {
const active = (catalogue?.items ?? []).filter((t) => t.isActive);
return {
operatorOptions: active.filter((t) => t.requiresOperatorMode !== false),
personalOptions: active.filter(
(t) =>
t.requiresOperatorMode === false &&
PERSONAL_REGISTRATION_KEYS.includes(t.key),
),
};
}, [catalogue]);
const options = useMemo( const options = useMemo(
() => () => [...operatorOptions, ...personalOptions],
(catalogue?.items ?? []) [operatorOptions, personalOptions],
.filter((t) => t.isActive)
// A mode of operation is an operator licence; person-centric
// registrations (seafarer) cannot be declared as one.
.filter((t) => t.requiresOperatorMode !== false),
[catalogue],
); );
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
@@ -110,7 +143,7 @@ export function OperationsFormContent({
<Checkbox.Group value={selected} onChange={setSelected}> <Checkbox.Group value={selected} onChange={setSelected}>
<Stack gap="sm"> <Stack gap="sm">
{options.map((type) => ( {operatorOptions.map((type) => (
<Checkbox <Checkbox
key={type.id} key={type.id}
value={type.id} value={type.id}
@@ -129,6 +162,37 @@ export function OperationsFormContent({
} }
/> />
))} ))}
{/* Kept apart from the company modes above: declaring "I am a
seafarer" is a different kind of statement from "my company
forwards freight", and running them together reads as though
one person could be both at once. */}
{personalOptions.length > 0 && (
<>
<Text size="xs" fw={600} c="dimmed" mt="sm" tt="uppercase">
Registering as an individual or vessel owner
</Text>
{personalOptions.map((type) => (
<Checkbox
key={type.id}
value={type.id}
label={
<Group gap="xs" wrap="nowrap">
<Text size="sm">{localized(type.name)}</Text>
{declaredIds.includes(type.id) && (
<Badge size="xs" variant="light" color="teal">
Current
</Badge>
)}
</Group>
}
description={
type.description ? localized(type.description) : undefined
}
/>
))}
</>
)}
</Stack> </Stack>
</Checkbox.Group> </Checkbox.Group>

View File

@@ -1,21 +1,8 @@
import { Container } from '@mantine/core'; import { Navigate } from 'react-router-dom';
import { FeatureUnavailable } from '@ema-platform/ui';
/** /** The apply route forwards into the shared licensing wizard. */
* Placeholder until this feature has a backend.
*
* This page previously rendered invented figures/records that were
* indistinguishable from real ones.
*/
export function SeamanBookApplicationPage() { export function SeamanBookApplicationPage() {
return ( return <Navigate to="/licensing/SEAMAN_BOOK/apply" replace />;
<Container size="lg" py="xl">
<FeatureUnavailable
title="Apply for a Seaman Book"
description="Seaman Book applications are not connected to the backend yet."
/>
</Container>
);
} }
export default SeamanBookApplicationPage; export default SeamanBookApplicationPage;

View File

@@ -1,21 +1,12 @@
import { Container } from '@mantine/core'; import { Navigate } from 'react-router-dom';
import { FeatureUnavailable } from '@ema-platform/ui';
/** /**
* Placeholder until this feature has a backend. * The seaman book rides the config-driven licensing flow, like every other
* * issued document, so this route forwards to it rather than duplicating the
* This page previously rendered hardcoded sample records, which were * wizard. Kept as a route because the nav and older links point here.
* indistinguishable from real ones.
*/ */
export function SeamanBookPage() { export function SeamanBookPage() {
return ( return <Navigate to="/licensing/SEAMAN_BOOK/apply" replace />;
<Container size="lg" py="xl">
<FeatureUnavailable
title="Seaman Book"
description="Seaman Book applications are not connected to the backend yet."
/>
</Container>
);
} }
export default SeamanBookPage; export default SeamanBookPage;

View File

@@ -55,6 +55,8 @@ export const am: Translations = {
seaRecords: 'የባህር መዝገቦቼ', seaRecords: 'የባህር መዝገቦቼ',
myApplication: 'ማመልከቻዬ', myApplication: 'ማመልከቻዬ',
certificates: 'የምስክር ወረቀቶች', certificates: 'የምስክር ወረቀቶች',
seamanBook: 'የመርከበኛ መጽሐፍ',
btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት',
endorsements: 'ማረጋገጫዎች', endorsements: 'ማረጋገጫዎች',
vesselRegistrations: 'የመርከብ ምዝገባ', vesselRegistrations: 'የመርከብ ምዝገባ',
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር', vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',

View File

@@ -54,6 +54,8 @@ export const en = {
mtoLicense: 'MTO License', mtoLicense: 'MTO License',
waiver: 'Waiver', waiver: 'Waiver',
certificates: 'Certificates', certificates: 'Certificates',
seamanBook: 'Seaman Book',
btc: 'Basic Training Certificate',
endorsements: 'Endorsements', endorsements: 'Endorsements',
vesselRegistrations: 'Vessel Registration', vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Transfers', vesselTransfers: 'Vessel Transfers',

View File

@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
import { import {
IconArrowsExchange, IconArrowsExchange,
IconBell, IconBell,
IconBook2,
IconFolderOpen, IconFolderOpen,
IconHeadset, IconHeadset,
IconHome2, IconHome2,
@@ -76,7 +77,8 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
items: [ items: [
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList, permissions: [P.APPLY_SEAFARER_REGISTRATION] }, { to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList, permissions: [P.APPLY_SEAFARER_REGISTRATION] },
{ to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] }, { to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
{ to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.myApplication', icon: IconSend, soon: true, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] }, { to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.seamanBook', icon: IconBook2, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
{ to: '/licensing/BTC_BASIC_TRAINING/apply', label: 'Basic Training Certificate', i18nKey: 'nav.btc', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] }, { to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList, permissions: [P.VIEW_OWN_EXAM, P.APPLY_EXAM] }, { to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList, permissions: [P.VIEW_OWN_EXAM, P.APPLY_EXAM] },
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, permissions: [P.VIEW_OWN_CERTIFICATES] }, { to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, permissions: [P.VIEW_OWN_CERTIFICATES] },

View File

@@ -25,6 +25,8 @@ import type {
QueueFilter, QueueFilter,
RemarkTargetType, RemarkTargetType,
SavedQueueView, SavedQueueView,
TemplateFieldPlacement,
TemplateLogoPlacement,
TemplatePageOptions, TemplatePageOptions,
TemplateVariable, TemplateVariable,
} from './licensing.types'; } from './licensing.types';
@@ -550,6 +552,9 @@ export const licensingApi = baseApi
hbsSource?: string; hbsSource?: string;
pageOptions?: TemplatePageOptions; pageOptions?: TemplatePageOptions;
backgroundUrl?: string; backgroundUrl?: string;
logoUrl?: string;
logoPlacement?: TemplateLogoPlacement;
fieldPlacements?: TemplateFieldPlacement[];
} }
>({ >({
query: ({ id, ...body }) => ({ query: ({ id, ...body }) => ({

View File

@@ -90,7 +90,9 @@ export type LicenseCategory =
| 'CARGO_FREIGHT' | 'CARGO_FREIGHT'
| 'SHIPPING_AGENCY' | 'SHIPPING_AGENCY'
| 'INVESTMENT' | 'INVESTMENT'
| 'MARITIME_PERSONNEL'; | 'MARITIME_PERSONNEL'
| 'VESSEL_SERVICES'
| 'WAIVER_SERVICES';
export interface LicenseCategoryDefinition { export interface LicenseCategoryDefinition {
key: LicenseCategory; key: LicenseCategory;
@@ -436,6 +438,44 @@ export interface TemplatePageOptions {
printBackground?: boolean; printBackground?: boolean;
} }
/** Corner the institute logo is anchored to. */
export type TemplateLogoCorner =
| 'TOP_LEFT'
| 'TOP_CENTER'
| 'TOP_RIGHT'
| 'BOTTOM_LEFT'
| 'BOTTOM_RIGHT';
/** Where the institute logo sits on the certificate. */
export interface TemplateLogoPlacement {
corner?: TemplateLogoCorner;
/** Width as a percentage of page width. */
widthPct?: number;
/** Inset from the anchored corner, as a percentage of page width. */
offsetPct?: number;
}
/**
* One positioned block on the designer canvas.
*
* Percentages rather than pixels, so a layout survives an orientation change:
* the canvas and the rendered PDF agree without either knowing the other's
* dimensions.
*/
export interface TemplateFieldPlacement {
id: string;
/** Variable rendered here, or null when the block carries literal `text`. */
variable: string | null;
text?: string;
xPct: number;
yPct: number;
widthPct: number;
fontSize?: number;
fontWeight?: 'normal' | 'bold';
align?: 'left' | 'center' | 'right';
color?: string;
}
/** A certificate design authored in the backoffice. */ /** A certificate design authored in the backoffice. */
export interface LicenseTemplate { export interface LicenseTemplate {
id: string; id: string;
@@ -449,6 +489,13 @@ export interface LicenseTemplate {
* certificate — per-certificate data stays in the template layer above it. * certificate — per-certificate data stays in the template layer above it.
*/ */
backgroundUrl?: string | null; backgroundUrl?: string | null;
logoUrl?: string | null;
logoPlacement?: TemplateLogoPlacement | null;
/**
* Blocks positioned on the visual canvas. Null for a design authored as raw
* Handlebars — which is how the two editors stay distinguishable.
*/
fieldPlacements?: TemplateFieldPlacement[] | null;
status: TemplateStatus; status: TemplateStatus;
publishedAt: string | null; publishedAt: string | null;
createdAt: string; createdAt: string;