Merge remote-tracking branch 'origin/certficate' into feature/exam-attempt-domain

This commit is contained in:
mihretue
2026-08-17 11:19:17 +00:00
96 changed files with 12690 additions and 2701 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

@@ -0,0 +1,96 @@
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[];
typeId: string | null;
onTypeChange: (id: string | null) => void;
validityMonths: number;
onValidityChange: (months: number) => void;
currentValidityMonths?: number | null;
canEdit: boolean;
savingValidity: boolean;
onSaveValidity: () => void;
onNewVersion: () => void;
}
/** Licence type, certificate validity, and the entry point for a new version. */
export function DesignerToolbar({
licenseTypes,
typeId,
onTypeChange,
validityMonths,
onValidityChange,
currentValidityMonths,
canEdit,
savingValidity,
onSaveValidity,
onNewVersion,
}: Props) {
const { t } = useTranslation();
const localized = useLocalized();
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={groupedTypeOptions(licenseTypes, localized, t)}
value={typeId}
onChange={onTypeChange}
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
what a certificate promises. */}
<NumberInput
label={t('designer.validityYears', 'Valid for (years)')}
description={t('designer.validityHint', 'Applied when a licence is issued')}
value={Number((validityMonths / 12).toFixed(2))}
onChange={(value) => onValidityChange(Math.round(Number(value || 0) * 12))}
min={0.5}
max={20}
step={0.5}
decimalScale={1}
w={190}
disabled={!canEdit}
/>
<Tooltip
label={
canEdit
? t('designer.saveValidity', 'Save validity')
: t('designer.noPermission', 'You do not have permission')
}
>
<span>
<Button
variant="light"
loading={savingValidity}
disabled={!canEdit || !typeId || validityMonths === currentValidityMonths}
onClick={onSaveValidity}
>
{t('designer.saveValidity', 'Save validity')}
</Button>
</span>
</Tooltip>
<div style={{ flex: 1 }} />
<Button
leftSection={<IconPlus size={16} />}
disabled={!canEdit || !typeId}
onClick={onNewVersion}
>
{t('designer.newVersion', 'New version')}
</Button>
</Group>
);
}

View File

@@ -0,0 +1,51 @@
import { Button, Modal, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui';
interface Props {
opened: boolean;
name: string;
onNameChange: (value: string) => void;
creating: boolean;
onClose: () => void;
onCreate: () => void;
}
/** Starts a draft from the live design, or the built-in layout if there is none. */
export function NewVersionModal({
opened,
name,
onNameChange,
creating,
onClose,
onCreate,
}: Props) {
const { t } = useTranslation();
return (
<Modal opened={opened} onClose={onClose} title={t('designer.newVersion', 'New version')}>
<Stack>
<TextInput
label={t('designer.name', 'Version name')}
value={name}
onChange={(e) => onNameChange(e.currentTarget.value)}
withAsterisk
/>
<Text size="xs" c="dimmed">
{t(
'designer.newHint',
'Starts from the live design, or the built-in layout if this type has none.',
)}
</Text>
<ModalFooter>
<Button variant="default" onClick={onClose}>
{t('common.cancel', 'Cancel')}
</Button>
<Button loading={creating} disabled={!name.trim()} onClick={onCreate}>
{t('designer.create', 'Create')}
</Button>
</ModalFooter>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,101 @@
import { ActionIcon, Button, Group, Tooltip } from '@mantine/core';
import {
IconDeviceFloppy,
IconEye,
IconRosetteDiscountCheck,
IconTrash,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
interface Props {
hasSource: boolean;
hasSelection: boolean;
isPublished: boolean;
dirty: boolean;
canEdit: boolean;
canPublish: boolean;
saving: boolean;
publishing: boolean;
onPreview: () => void;
onSave: () => void;
onPublish: () => void;
onArchive: () => void;
onDelete: () => void;
}
/** Preview, save, publish, withdraw and delete for the selected version. */
export function TemplateActionBar({
hasSource,
hasSelection,
isPublished,
dirty,
canEdit,
canPublish,
saving,
publishing,
onPreview,
onSave,
onPublish,
onArchive,
onDelete,
}: Props) {
const { t } = useTranslation();
const publishHint = !canPublish
? t('designer.noPublishPermission', 'You cannot publish designs')
: dirty
? t('designer.saveFirst', 'Save your changes first')
: t('designer.publishHint', 'Makes this the live certificate design');
return (
<Group>
<Button
variant="light"
leftSection={<IconEye size={16} />}
onClick={onPreview}
disabled={!hasSource}
>
{t('designer.preview', 'Preview PDF')}
</Button>
<Button
leftSection={<IconDeviceFloppy size={16} />}
loading={saving}
disabled={!canEdit || isPublished || !dirty}
onClick={onSave}
>
{t('designer.save', 'Save draft')}
</Button>
<Tooltip label={publishHint}>
<span>
<Button
color="teal"
leftSection={<IconRosetteDiscountCheck size={16} />}
loading={publishing}
disabled={!canPublish || isPublished || dirty || !hasSelection}
onClick={onPublish}
>
{t('designer.publish', 'Publish')}
</Button>
</span>
</Tooltip>
<div style={{ flex: 1 }} />
{hasSelection && isPublished && canPublish && (
<Button variant="subtle" color="orange" onClick={onArchive}>
{t('designer.archive', 'Withdraw')}
</Button>
)}
{hasSelection && !isPublished && canEdit && (
<Tooltip label={t('designer.delete', 'Delete draft')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t('designer.delete', 'Delete draft')}
onClick={onDelete}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
}

View File

@@ -0,0 +1,225 @@
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' },
];
/**
* Page-level settings: orientation, the artwork a certificate is printed on,
* and the authority's logo.
*
* 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();
return (
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<div>
<Text fw={600} size="sm">
{t('designer.pageSetup', 'Page setup')}
</Text>
<Text size="xs" c="dimmed">
{t(
'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')}
placeholder="https://…"
value={backgroundUrl}
onChange={(e) => onBackgroundChange(e.currentTarget.value)}
disabled={disabled}
style={{ flex: 1 }}
/>
{backgroundUrl && (
<Button
variant="subtle"
color="red"
leftSection={<IconTrash size={16} />}
disabled={disabled}
onClick={() => onBackgroundChange('')}
>
{t('designer.removeBackground', 'Remove')}
</Button>
)}
</Group>
{backgroundUrl ? (
<Image
src={backgroundUrl}
alt={t('designer.backgroundPreview', 'Certificate background preview')}
radius="sm"
fit="contain"
mah={220}
fallbackSrc="data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"
/>
) : (
<Paper withBorder p="lg" radius="sm" bg="var(--mantine-color-gray-light)">
<Group justify="center" gap="xs">
<IconPhotoUp size={18} />
<Text size="sm" c="dimmed">
{t('designer.noBackground', 'No artwork — the design renders as HTML only.')}
</Text>
</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

@@ -0,0 +1,73 @@
import type { RefObject } from 'react';
import { Group, Paper, Switch, Text, TextInput, Textarea } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface Props {
name: string;
onNameChange: (value: string) => void;
source: string;
onSourceChange: (value: string) => void;
landscape: boolean;
onLandscapeChange: (value: boolean) => void;
editorRef: RefObject<HTMLTextAreaElement | null>;
disabled: boolean;
isPublished: boolean;
}
/** Name, orientation and the Handlebars source for the selected version. */
export function TemplateEditor({
name,
onNameChange,
source,
onSourceChange,
landscape,
onLandscapeChange,
editorRef,
disabled,
isPublished,
}: Props) {
const { t } = useTranslation();
return (
<>
<Group gap="sm" align="flex-end">
<TextInput
label={t('designer.name', 'Version name')}
value={name}
onChange={(e) => onNameChange(e.currentTarget.value)}
disabled={disabled}
style={{ flex: 1 }}
/>
<Switch
label={t('designer.landscape', 'Landscape')}
checked={landscape}
onChange={(e) => onLandscapeChange(e.currentTarget.checked)}
disabled={disabled}
/>
</Group>
{isPublished && (
<Paper withBorder p="xs" bg="var(--mantine-color-teal-light)">
<Text size="xs">
{t(
'designer.publishedLocked',
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
)}
</Text>
</Paper>
)}
<Textarea
ref={editorRef}
label={t('designer.source', 'Template (Handlebars + HTML)')}
value={source}
onChange={(e) => onSourceChange(e.currentTarget.value)}
disabled={disabled}
autosize
minRows={18}
maxRows={30}
styles={{ input: { fontFamily: 'monospace', fontSize: 12 } }}
/>
</>
);
}

View File

@@ -0,0 +1,75 @@
import { Button, Code, ScrollArea, Stack, Text, Tooltip } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
interface Variable {
key: string;
label: string;
}
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 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 (
<Stack gap="xs" w={230} style={{ flexShrink: 0 }}>
<Text fw={600} size="sm">
{t('designer.variables', 'Placeholders')}
</Text>
<Text size="xs" c="dimmed">
{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) => (
<Tooltip key={variable.key} label={variable.label} position="left">
<Button
size="compact-xs"
variant="default"
justify="flex-start"
disabled={disabled}
onClick={() =>
canvasMode ? onAddBlock(variable.key) : onInsert(variable.key)
}
>
<Code fz={10}>{`{{${variable.key}}}`}</Code>
</Button>
</Tooltip>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
);
}

View File

@@ -0,0 +1,49 @@
import { Badge, Card, Group, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { LicenseTemplate } from '@ema-platform/api';
import { STATUS_COLOR } from '../config/designer';
interface Props {
templates: LicenseTemplate[];
selectedId: string | null;
onSelect: (id: string) => void;
}
/** The version rail — every design ever authored for this licence type. */
export function TemplateVersionList({ templates, selectedId, onSelect }: Props) {
const { t } = useTranslation();
return (
<Stack gap="xs" w={240} style={{ flexShrink: 0 }}>
<Text fw={600} size="sm">
{t('designer.versions', 'Versions')}
</Text>
{templates.map((tpl) => (
<Card
key={tpl.id}
withBorder
padding="xs"
onClick={() => onSelect(tpl.id)}
style={{
cursor: 'pointer',
borderColor: tpl.id === selectedId ? 'var(--mantine-color-blue-5)' : undefined,
}}
>
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{tpl.name}
</Text>
<Text size="xs" c="dimmed">
v{tpl.version}
</Text>
</div>
<Badge size="xs" variant="light" color={STATUS_COLOR[tpl.status]}>
{tpl.status}
</Badge>
</Group>
</Card>
))}
</Stack>
);
}

View File

@@ -0,0 +1,71 @@
import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } from '@ema-platform/api';
/** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */
export const API_BASE_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
DRAFT: 'gray',
PUBLISHED: 'teal',
ARCHIVED: 'dark',
};
/** Page options sent with every save and preview — A4, background printed. */
export function pageOptionsFor(landscape: boolean) {
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

@@ -0,0 +1,30 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import { extractErrorMessage } from '@ema-platform/api';
/**
* Runs a designer mutation and reports the outcome once.
*
* Every action on this page succeeds or fails the same way, so the toast
* handling lives here rather than being repeated at each call site.
*/
export function useDesignerActions() {
const { t } = useTranslation();
return useCallback(
async (action: () => Promise<unknown>, success: string) => {
try {
await action();
notifications.show({ color: 'teal', title: success, message: '' });
} catch (err) {
notifications.show({
color: 'red',
title: t('designer.actionFailed', 'Action failed'),
message: extractErrorMessage(err),
});
}
},
[t],
);
}

View File

@@ -0,0 +1,160 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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.
*
* Selection defaults to the live design because that is the one staff usually
* open, and the fields reset whenever the selection changes so an edit can
* never leak from one version into another.
*/
export function useTemplateDraft(templates: LicenseTemplate[]) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [source, setSource] = useState('');
const [name, setName] = useState('');
const [landscape, setLandscape] = useState(true);
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 selected = useMemo(
() => templates.find((tpl) => tpl.id === selectedId) ?? null,
[templates, selectedId],
);
useEffect(() => {
if (!templates.length) {
setSelectedId(null);
return;
}
if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return;
const published = templates.find((tpl) => tpl.status === 'PUBLISHED');
setSelectedId((published ?? templates[0]).id);
}, [templates, selectedId]);
useEffect(() => {
if (!selected) return;
setSource(selected.hbsSource);
setName(selected.name);
setLandscape(selected.pageOptions?.landscape ?? true);
setBackgroundUrl(selected.backgroundUrl ?? '');
setLogoUrl(selected.logoUrl ?? '');
setLogoPlacement(selected.logoPlacement ?? {});
setPlacements(selected.fieldPlacements ?? []);
setSelectedBlockId(null);
}, [selected]);
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 =
Boolean(selected) &&
(source !== selected?.hbsSource ||
name !== selected?.name ||
landscape !== (selected?.pageOptions?.landscape ?? true) ||
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. */
function insertVariable(key: string) {
const el = editorRef.current;
const token = `{{${key}}}`;
if (!el) {
setSource((prev) => prev + token);
return;
}
const start = el.selectionStart ?? source.length;
const end = el.selectionEnd ?? start;
setSource(source.slice(0, start) + token + source.slice(end));
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
});
}
return {
selected,
selectedId,
setSelectedId,
source,
setSource,
name,
setName,
landscape,
setLandscape,
backgroundUrl,
setBackgroundUrl,
logoUrl,
setLogoUrl,
logoPlacement,
setLogoPlacement,
placements,
setPlacements,
selectedBlockId,
setSelectedBlockId,
selectedBlock,
usesCanvas,
addBlock,
updateBlock,
deleteBlock,
editorRef,
isPublished,
dirty,
insertVariable,
};
}

View File

@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import { extractErrorMessage } from '@ema-platform/api';
import { authStorage } from '@ema-platform/auth';
import { API_BASE_URL, pageOptionsFor } from '../config/designer';
interface PreviewArgs {
hbsSource: string;
licenseTypeId: string | null;
landscape: boolean;
}
/**
* Renders the editor's current contents, not the saved row, so unsaved edits
* are what you see. Opened as a blob so it never leaves a file behind.
*/
export function useTemplatePreview() {
const { t } = useTranslation();
return useCallback(
async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => {
try {
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
// and calls the API directly — which means spelling out the base URL and
// the bearer token that the shared baseQuery would normally attach.
const token = authStorage.getToken();
const response = await fetch(`${API_BASE_URL}/license-templates/preview`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
hbsSource,
licenseTypeId,
pageOptions: pageOptionsFor(landscape),
}),
});
if (!response.ok) throw new Error(await response.text());
const url = URL.createObjectURL(await response.blob());
window.open(url, '_blank', 'noopener');
// Give the new tab time to read it before revoking.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err) {
notifications.show({
color: 'red',
title: t('designer.previewFailed', 'Could not render the preview'),
message: extractErrorMessage(err),
});
}
},
[t],
);
}

View File

@@ -1,34 +1,22 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import {
ActionIcon,
Badge,
Alert,
Button,
Card,
Code,
Container,
Group,
Modal,
NumberInput,
Paper,
ScrollArea,
Select,
Stack,
Switch,
Tabs,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAlertCircle,
IconDeviceFloppy,
IconEye,
IconCode,
IconLayoutBoard,
IconLock,
IconPlus,
IconRosetteDiscountCheck,
IconTrash,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import {
extractErrorMessage,
@@ -39,26 +27,26 @@ import {
useGetLicenseTemplatesQuery,
useGetLicenseTypesQuery,
useGetTemplateVariablesQuery,
useLocalized,
usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation,
useUpdateLicenseTemplateMutation,
type LicenseTemplate,
} from '@ema-platform/api';
import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui';
import { authStorage, usePermissions } from '@ema-platform/auth';
import { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
/** Same resolution the shared RTK Query baseQuery uses. */
const API_BASE_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
DRAFT: 'gray',
PUBLISHED: 'teal',
ARCHIVED: 'dark',
};
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
import { DesignerToolbar } from '../components/DesignerToolbar';
import { NewVersionModal } from '../components/NewVersionModal';
import { TemplateActionBar } from '../components/TemplateActionBar';
import { TemplateBackgroundPanel } from '../components/TemplateBackgroundPanel';
import { TemplateCanvas } from '../components/TemplateCanvas';
import { TemplateEditor } from '../components/TemplateEditor';
import { TemplateVariableList } from '../components/TemplateVariableList';
import { TemplateVersionList } from '../components/TemplateVersionList';
import { pageOptionsFor } from '../config/designer';
import { compileLayoutToHbs } from '../config/layout-compiler';
import { useDesignerActions } from '../hooks/useDesignerActions';
import { useTemplateDraft } from '../hooks/useTemplateDraft';
import { useTemplatePreview } from '../hooks/useTemplatePreview';
/**
* Where the authority designs the certificate its licensees receive.
@@ -71,7 +59,6 @@ const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
*/
export function CertificateDesignerPage() {
const { t } = useTranslation();
const localized = useLocalized();
const { can } = usePermissions();
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
@@ -97,16 +84,16 @@ export function CertificateDesignerPage() {
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [source, setSource] = useState('');
const [name, setName] = useState('');
const [landscape, setLandscape] = useState(true);
const draft = useTemplateDraft(templates);
const run = useDesignerActions();
const openPreview = useTemplatePreview();
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
const editorRef = useRef<HTMLTextAreaElement>(null);
const [validityMonths, setValidityMonths] = useState<number>(12);
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
const [validityMonths, setValidityMonths] = useState<number>(12);
// Default to the first licence type so the page is never an empty shell.
useEffect(() => {
@@ -117,101 +104,14 @@ export function CertificateDesignerPage() {
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
}, [selectedType]);
const selected = useMemo(
() => templates.find((tpl) => tpl.id === selectedId) ?? null,
[templates, selectedId],
);
// Pick the live design by default — that is the one staff usually want.
useEffect(() => {
if (!templates.length) {
setSelectedId(null);
return;
}
if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return;
const published = templates.find((tpl) => tpl.status === 'PUBLISHED');
setSelectedId((published ?? templates[0]).id);
}, [templates, selectedId]);
useEffect(() => {
if (!selected) return;
setSource(selected.hbsSource);
setName(selected.name);
setLandscape(selected.pageOptions?.landscape ?? true);
}, [selected]);
const isPublished = selected?.status === 'PUBLISHED';
const dirty =
Boolean(selected) &&
(source !== selected?.hbsSource ||
name !== selected?.name ||
landscape !== (selected?.pageOptions?.landscape ?? true));
async function run(action: () => Promise<unknown>, success: string) {
try {
await action();
notifications.show({ color: 'teal', title: success, message: '' });
} catch (err) {
notifications.show({
color: 'red',
title: t('designer.actionFailed', 'Action failed'),
message: extractErrorMessage(err),
});
}
function startNewVersion() {
setNewName(
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
);
setNewOpen(true);
}
/** Inserts a placeholder where the caret is, rather than at the end. */
function insertVariable(key: string) {
const el = editorRef.current;
const token = `{{${key}}}`;
if (!el) {
setSource((prev) => prev + token);
return;
}
const start = el.selectionStart ?? source.length;
const end = el.selectionEnd ?? start;
setSource(source.slice(0, start) + token + source.slice(end));
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
});
}
/**
* Renders the editor's current contents, not the saved row, so unsaved edits
* are what you see. Opened as a blob so it never leaves a file behind.
*/
async function preview() {
try {
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
// and calls the API directly — which means spelling out the base URL and
// the bearer token that the shared baseQuery would normally attach.
const token = authStorage.getToken();
const response = await fetch(`${API_BASE_URL}/license-templates/preview`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
hbsSource: source,
licenseTypeId: typeId,
pageOptions: { format: 'A4', landscape, printBackground: true },
}),
});
if (!response.ok) throw new Error(await response.text());
const url = URL.createObjectURL(await response.blob());
window.open(url, '_blank', 'noopener');
// Give the new tab time to read it before revoking.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err) {
notifications.show({
color: 'red',
title: t('designer.previewFailed', 'Could not render the preview'),
message: extractErrorMessage(err),
});
}
}
const editingLocked = !canEdit || draft.isPublished;
return (
<Container size="xl" py="md">
@@ -223,74 +123,26 @@ export function CertificateDesignerPage() {
)}
/>
<Group align="flex-end" mb="md" gap="sm">
<Select
label={t('designer.licenceType', 'Licence type')}
data={(licenseTypes?.items ?? []).map((type) => ({
value: type.id,
label: localized(type.name) || type.key,
}))}
value={typeId}
onChange={(value) => {
setTypeId(value);
setSelectedId(null);
}}
w={280}
/>
{/* Validity lives beside the design because it is the other half of
what a certificate promises. */}
<NumberInput
label={t('designer.validityYears', 'Valid for (years)')}
description={t('designer.validityHint', 'Applied when a licence is issued')}
value={Number((validityMonths / 12).toFixed(2))}
onChange={(value) => setValidityMonths(Math.round(Number(value || 0) * 12))}
min={0.5}
max={20}
step={0.5}
decimalScale={1}
w={190}
disabled={!canEdit}
/>
<Tooltip
label={
canEdit
? t('designer.saveValidity', 'Save validity')
: t('designer.noPermission', 'You do not have permission')
}
>
<span>
<Button
variant="light"
loading={savingValidity}
disabled={!canEdit || !typeId || validityMonths === selectedType?.validityMonths}
onClick={() =>
run(
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
t('designer.validitySaved', 'Validity updated'),
)
}
>
{t('designer.saveValidity', 'Save validity')}
</Button>
</span>
</Tooltip>
<div style={{ flex: 1 }} />
<Button
leftSection={<IconPlus size={16} />}
disabled={!canEdit || !typeId}
onClick={() => {
setNewName(
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
);
setNewOpen(true);
}}
>
{t('designer.newVersion', 'New version')}
</Button>
</Group>
<DesignerToolbar
licenseTypes={licenseTypes?.items ?? []}
typeId={typeId}
onTypeChange={(value) => {
setTypeId(value);
draft.setSelectedId(null);
}}
validityMonths={validityMonths}
onValidityChange={setValidityMonths}
currentValidityMonths={selectedType?.validityMonths}
canEdit={canEdit}
savingValidity={savingValidity}
onSaveValidity={() =>
run(
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
t('designer.validitySaved', 'Validity updated'),
)
}
onNewVersion={startNewVersion}
/>
{isError ? (
<ErrorState
@@ -308,253 +160,223 @@ export function CertificateDesignerPage() {
)}
action={
canEdit
? {
label: t('designer.newVersion', 'New version'),
onClick: () => {
setNewName(`${selectedType?.name?.en ?? 'Certificate'} v1`);
setNewOpen(true);
},
}
? { label: t('designer.newVersion', 'New version'), onClick: startNewVersion }
: undefined
}
/>
) : (
<Group align="flex-start" gap="md" wrap="nowrap">
{/* Versions */}
<Stack gap="xs" w={240} style={{ flexShrink: 0 }}>
<Text fw={600} size="sm">
{t('designer.versions', 'Versions')}
</Text>
{templates.map((tpl) => (
<Card
key={tpl.id}
withBorder
padding="xs"
onClick={() => setSelectedId(tpl.id)}
style={{
cursor: 'pointer',
borderColor:
tpl.id === selectedId ? 'var(--mantine-color-blue-5)' : undefined,
}}
>
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{tpl.name}
</Text>
<Text size="xs" c="dimmed">
v{tpl.version}
</Text>
</div>
<Badge size="xs" variant="light" color={STATUS_COLOR[tpl.status]}>
{tpl.status}
</Badge>
</Group>
</Card>
))}
</Stack>
<TemplateVersionList
templates={templates}
selectedId={draft.selectedId}
onSelect={draft.setSelectedId}
/>
{/* Editor */}
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Group gap="sm" align="flex-end">
<TextInput
label={t('designer.name', 'Version name')}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
disabled={!canEdit || isPublished}
style={{ flex: 1 }}
/>
<Switch
label={t('designer.landscape', 'Landscape')}
checked={landscape}
onChange={(e) => setLandscape(e.currentTarget.checked)}
disabled={!canEdit || isPublished}
/>
</Group>
{isPublished && (
<Paper withBorder p="xs" bg="var(--mantine-color-teal-light)">
<Text size="xs">
{t(
'designer.publishedLocked',
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
)}
</Text>
</Paper>
)}
<Textarea
ref={editorRef}
label={t('designer.source', 'Template (Handlebars + HTML)')}
value={source}
onChange={(e) => setSource(e.currentTarget.value)}
disabled={!canEdit || isPublished}
autosize
minRows={18}
maxRows={30}
styles={{ input: { fontFamily: 'monospace', fontSize: 12 } }}
<TemplateBackgroundPanel
backgroundUrl={draft.backgroundUrl}
onBackgroundChange={draft.setBackgroundUrl}
logoUrl={draft.logoUrl}
onLogoChange={draft.setLogoUrl}
logoPlacement={draft.logoPlacement}
onLogoPlacementChange={draft.setLogoPlacement}
landscape={draft.landscape}
onLandscapeChange={draft.setLandscape}
disabled={editingLocked}
/>
<Group>
<Button
{/* 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"
leftSection={<IconEye size={16} />}
onClick={preview}
disabled={!source.trim()}
color="blue"
icon={<IconLock size={18} />}
title={t('designer.liveDesign', 'This is the live design')}
>
{t('designer.preview', 'Preview PDF')}
</Button>
<Button
leftSection={<IconDeviceFloppy size={16} />}
loading={saving}
disabled={!canEdit || isPublished || !dirty}
onClick={() =>
run(
() =>
updateTemplate({
id: selected!.id,
name,
hbsSource: source,
pageOptions: { format: 'A4', landscape, printBackground: true },
}).unwrap(),
t('designer.saved', 'Draft saved'),
)
}
>
{t('designer.save', 'Save draft')}
</Button>
<Tooltip
label={
!canPublish
? t('designer.noPublishPermission', 'You cannot publish designs')
: dirty
? t('designer.saveFirst', 'Save your changes first')
: t('designer.publishHint', 'Makes this the live certificate design')
}
>
<span>
<Button
color="teal"
leftSection={<IconRosetteDiscountCheck size={16} />}
loading={publishing}
disabled={!canPublish || isPublished || dirty || !selected}
onClick={() =>
run(
() => publishTemplate(selected!.id).unwrap(),
t('designer.published', 'Design published'),
)
}
>
{t('designer.publish', 'Publish')}
</Button>
</span>
</Tooltip>
<div style={{ flex: 1 }} />
{selected && isPublished && canPublish && (
<Button
variant="subtle"
color="orange"
onClick={() =>
run(
() => archiveTemplate(selected.id).unwrap(),
t('designer.archived', 'Design withdrawn'),
)
}
>
{t('designer.archive', 'Withdraw')}
</Button>
)}
{selected && !isPublished && canEdit && (
<Tooltip label={t('designer.delete', 'Delete draft')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t('designer.delete', 'Delete draft')}
onClick={() =>
run(
() => deleteTemplate(selected.id).unwrap(),
t('designer.deleted', 'Draft deleted'),
)
}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
<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
// 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)}
isPublished={draft.isPublished}
dirty={draft.dirty}
canEdit={canEdit}
canPublish={canPublish}
saving={saving}
publishing={publishing}
onPreview={() =>
openPreview({
// 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,
landscape: draft.landscape,
})
}
onSave={() =>
run(
() =>
updateTemplate({
id: draft.selected!.id,
name: draft.name,
// 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),
backgroundUrl: draft.backgroundUrl || undefined,
logoUrl: draft.logoUrl || undefined,
logoPlacement: draft.logoPlacement,
fieldPlacements: draft.placements,
}).unwrap(),
t('designer.saved', 'Draft saved'),
)
}
onPublish={() =>
run(
() => publishTemplate(draft.selected!.id).unwrap(),
t('designer.published', 'Design published'),
)
}
onArchive={() =>
run(
() => archiveTemplate(draft.selected!.id).unwrap(),
t('designer.archived', 'Design withdrawn'),
)
}
onDelete={() =>
run(
() => deleteTemplate(draft.selected!.id).unwrap(),
t('designer.deleted', 'Draft deleted'),
)
}
/>
</Stack>
{/* Placeholders */}
<Stack gap="xs" w={230} style={{ flexShrink: 0 }}>
<Text fw={600} size="sm">
{t('designer.variables', 'Placeholders')}
</Text>
<Text size="xs" c="dimmed">
{t('designer.variablesHint', 'Click to insert at the cursor.')}
</Text>
<ScrollArea.Autosize mah={480} type="hover">
<Stack gap={4}>
{variables.map((variable) => (
<Tooltip key={variable.key} label={variable.label} position="left">
<Button
size="compact-xs"
variant="default"
justify="flex-start"
disabled={!canEdit || isPublished}
onClick={() => insertVariable(variable.key)}
>
<Code fz={10}>{`{{${variable.key}}}`}</Code>
</Button>
</Tooltip>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
<TemplateVariableList
variables={variables}
disabled={editingLocked}
canvasMode={mode === 'canvas'}
onInsert={draft.insertVariable}
onAddBlock={(key) => draft.addBlock(key)}
onAddTextBlock={() => draft.addBlock(null, 'Text')}
/>
</Group>
)}
<Modal
<NewVersionModal
opened={newOpen}
name={newName}
onNameChange={setNewName}
creating={creating}
onClose={() => setNewOpen(false)}
title={t('designer.newVersion', 'New version')}
>
<Stack>
<TextInput
label={t('designer.name', 'Version name')}
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
withAsterisk
/>
<Text size="xs" c="dimmed">
{t(
'designer.newHint',
'Starts from the live design, or the built-in layout if this type has none.',
)}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setNewOpen(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
loading={creating}
disabled={!newName.trim()}
onClick={() =>
run(async () => {
const created = await createTemplate({
licenseTypeId: typeId as string,
name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap();
setSelectedId(created.id);
setNewOpen(false);
}, t('designer.created', 'Draft created'))
}
>
{t('designer.create', 'Create')}
</Button>
</ModalFooter>
</Stack>
</Modal>
onCreate={() =>
run(async () => {
const created = await createTemplate({
licenseTypeId: typeId as string,
name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap();
draft.setSelectedId(created.id);
setNewOpen(false);
}, t('designer.created', 'Draft created'))
}
/>
</Container>
);
}

View File

@@ -0,0 +1,159 @@
import { Badge, Group, Paper, Stack, Table, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useLocalized, type LicenseType } from '@ema-platform/api';
interface Props {
licenseType: LicenseType;
}
/**
* The STCW identity of a certificate type — regulation, level, and the
* function and capacity rows a Certificate of Competency prints.
*
* Read-only for now: these are seeded configuration, and editing them safely
* needs the approval workflow the designer guide describes. Showing them
* matters regardless — an officer configuring fees or documents has no other
* way to see what the certificate will actually claim.
*/
export function StcwMappingPanel({ licenseType }: Props) {
const { t } = useTranslation();
const localized = useLocalized();
if (!licenseType.stcwControlled && !licenseType.certificateCategory) {
return null;
}
const functions = licenseType.stcwFunctions ?? [];
const capacities = licenseType.stcwCapacities ?? [];
return (
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Group gap="xs">
<Text fw={600} size="sm">
{t('configuration.stcw.title', 'STCW mapping')}
</Text>
{licenseType.certificateCategory && (
<Badge size="sm" variant="light">
{licenseType.certificateCategory}
</Badge>
)}
{licenseType.stcwControlled && (
<Badge size="sm" variant="light" color="teal">
{t('configuration.stcw.controlled', 'STCW controlled')}
</Badge>
)}
</Group>
<Group gap="xl">
<Field
label={t('configuration.stcw.regulation', 'Regulation')}
value={licenseType.stcwRegulation}
/>
<Field
label={t('configuration.stcw.codeSection', 'Code section')}
value={licenseType.stcwCodeSection}
/>
<Field
label={t('configuration.stcw.department', 'Department')}
value={licenseType.stcwDepartment}
/>
<Field
label={t('configuration.stcw.level', 'Level')}
value={licenseType.competencyLevel}
/>
</Group>
{functions.length > 0 && (
<Stack gap={4}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{t('configuration.stcw.functions', 'Functions')}
</Text>
<Table withTableBorder withColumnBorders fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>{t('configuration.stcw.function', 'Function')}</Table.Th>
<Table.Th>{t('configuration.stcw.level', 'Level')}</Table.Th>
<Table.Th>
{t('configuration.stcw.limitation', 'Limitation')}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{functions.map((row, i) => (
<Table.Tr key={`${localized(row.function)}-${i}`}>
<Table.Td>{localized(row.function)}</Table.Td>
<Table.Td>{row.level}</Table.Td>
<Table.Td>
{row.limitation
? localized(row.limitation)
: t('configuration.stcw.none', 'None')}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
)}
{capacities.length > 0 && (
<Stack gap={4}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{t('configuration.stcw.capacities', 'Capacities')}
</Text>
<Table withTableBorder withColumnBorders fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>{t('configuration.stcw.capacity', 'Capacity')}</Table.Th>
<Table.Th>
{t('configuration.stcw.limitation', 'Limitation')}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{capacities.map((row, i) => (
<Table.Tr key={`${localized(row.capacity)}-${i}`}>
<Table.Td>{localized(row.capacity)}</Table.Td>
<Table.Td>
{row.limitation
? localized(row.limitation)
: t('configuration.stcw.none', 'None')}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
)}
{licenseType.prerequisiteLicenseKeys?.length ? (
<Stack gap={4}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{t('configuration.stcw.prerequisites', 'Must already hold')}
</Text>
<Group gap="xs">
{licenseType.prerequisiteLicenseKeys.map((key) => (
<Badge key={key} size="sm" variant="outline">
{key}
</Badge>
))}
</Group>
</Stack>
) : null}
</Stack>
</Paper>
);
}
function Field({ label, value }: { label: string; value?: string | null }) {
return (
<div>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value || '—'}
</Text>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/core';
import { IconCalendarEvent } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui';
import { useGetExamsQuery } from '../../exam/api/exam-api';
interface Props {
opened: boolean;
applicantName: string;
loading: boolean;
onClose: () => void;
onConfirm: (payload: {
examId: string;
admissionNumber?: string;
examDate?: string;
}) => void;
}
/**
* Places a candidate who has paid the examination fee into an existing sitting.
*
* Sessions are picked from the exam calendar rather than typed, because the
* candidate joins a scheduled sitting — this is an assignment, not the creation
* of a per-candidate appointment.
*/
export function ScheduleExamModal({
opened,
applicantName,
loading,
onClose,
onConfirm,
}: Props) {
const { t } = useTranslation();
const { data: exams, isLoading } = useGetExamsQuery(undefined, { skip: !opened });
const [examId, setExamId] = useState<string | null>(null);
const [admissionNumber, setAdmissionNumber] = useState('');
const options = (exams?.items ?? []).map((exam) => ({
value: exam.id,
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
.filter(Boolean)
.join(' — '),
}));
const selected = exams?.items?.find((exam) => exam.id === examId);
function confirm() {
if (!examId) return;
onConfirm({
examId,
admissionNumber: admissionNumber.trim() || undefined,
examDate: selected?.date ? String(selected.date) : undefined,
});
}
return (
<Modal
opened={opened}
onClose={onClose}
title={t('review.actions.scheduleExam', 'Schedule exam')}
>
<Stack>
<Text size="sm" c="dimmed">
{t('review.scheduleExam.intro', {
defaultValue:
'Assign {{applicant}} to a scheduled sitting. They will be notified with the date and their admission number.',
applicant: applicantName,
})}
</Text>
{!isLoading && options.length === 0 ? (
<Alert color="orange" icon={<IconCalendarEvent size={16} />}>
{t(
'review.scheduleExam.noSessions',
'No exam sessions exist yet. Create one in the Exams area first.',
)}
</Alert>
) : (
<Select
label={t('review.scheduleExam.session', 'Exam session')}
placeholder={t('review.scheduleExam.pick', 'Choose a sitting')}
data={options}
value={examId}
onChange={setExamId}
disabled={isLoading}
searchable
withAsterisk
/>
)}
<TextInput
label={t('review.scheduleExam.admissionNumber', 'Admission number')}
description={t(
'review.scheduleExam.admissionHint',
'Leave blank to let the system issue one.',
)}
value={admissionNumber}
onChange={(e) => setAdmissionNumber(e.currentTarget.value)}
/>
<ModalFooter>
<Button variant="default" onClick={onClose}>
{t('common.cancel', 'Cancel')}
</Button>
<Button loading={loading} disabled={!examId} onClick={confirm}>
{t('review.scheduleExam.confirm', 'Schedule')}
</Button>
</ModalFooter>
</Stack>
</Modal>
);
}

View File

@@ -29,6 +29,7 @@ export type ActionId =
| 'final-approve'
| 'request-adjustment'
| 'reject'
| 'schedule-exam'
| 'confirm-payment'
| 'print'
| 'copy-link'
@@ -190,6 +191,17 @@ export const ACTIONS: ActionDefinition[] = [
requiresReason: true,
irreversible: true,
},
{
id: 'schedule-exam',
tier: 'primary',
labelKey: 'review.actions.scheduleExam',
// Only after the examination fee clears — scheduling an unpaid candidate
// is what the EXAM_PAID gate exists to prevent.
from: ['EXAM_PAID'],
permissions: ['can:schedule:exam-candidate'],
emphasis: 'filled',
color: 'cyan',
},
{
id: 'confirm-payment',
tier: 'primary',

View File

@@ -4,6 +4,7 @@ import {
IconFileDescription,
IconId,
IconRubberStamp,
IconShieldCheck,
IconShip,
IconTruck,
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. */
export function presentationFor(key: string | undefined): LicenseTypePresentation {
return (
(key && PRESENTATION[key]) || {
key: key ?? 'UNKNOWN',
icon: IconFileDescription,
detailSections: DEFAULT_SECTIONS,
}
);
if (key && PRESENTATION[key]) return PRESENTATION[key];
if (key && isSeafarerCertificate(key)) {
return {
key,
icon: IconShieldCheck,
detailSections: CERTIFICATE_SECTIONS,
};
}
return {
key: key ?? 'UNKNOWN',
icon: IconFileDescription,
detailSections: DEFAULT_SECTIONS,
};
}
export const LICENSE_TYPE_KEYS = Object.keys(PRESENTATION);

View File

@@ -44,6 +44,7 @@ import {
useAssignApplicationMutation,
useCompleteReviewMutation,
useConfirmPaymentMutation,
useScheduleExamMutation,
useEscalateApplicationMutation,
useFinalApproveMutation,
useGetApplicationForReviewQuery,
@@ -76,6 +77,7 @@ import {
} from '../../components/DecisionConfirmModal';
import { ActivityRail } from '../../components/ActivityRail';
import { DocumentsTab } from '../../components/DocumentsTab';
import { ScheduleExamModal } from '../../components/ScheduleExamModal';
import { computeSla } from '../../sla';
import { reviewStaffColumns } from './columns';
import { evaluateEligibility, presentationFor } from '../../config/license-types';
@@ -152,6 +154,7 @@ export function LicenseReviewPage() {
const [scheduleInspection] = useScheduleInspectionMutation();
const [recordResult] = useRecordInspectionResultMutation();
const [confirmPayment] = useConfirmPaymentMutation();
const [scheduleExam, { isLoading: schedulingExam }] = useScheduleExamMutation();
const [holdApplication] = useHoldApplicationMutation();
const [resumeApplication] = useResumeApplicationMutation();
const [escalateApplication] = useEscalateApplicationMutation();
@@ -169,6 +172,7 @@ export function LicenseReviewPage() {
const [inspectionOpen, setInspectionOpen] = useState(false);
const [inspectionDate, setInspectionDate] = useState('');
const [resultOpen, setResultOpen] = useState(false);
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
const [findings, setFindings] = useState('');
const [checklist, setChecklist] = useState<
Record<string, 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'>
@@ -329,6 +333,11 @@ export function LicenseReviewPage() {
case 'record-inspection':
setResultOpen(true);
return;
// Needs a session picked before anything is sent, so it opens its own
// modal rather than going through the generic confirm step.
case 'schedule-exam':
setScheduleExamOpen(true);
return;
case 'copy-link':
navigator.clipboard.writeText(window.location.href);
notifications.show({
@@ -905,6 +914,30 @@ export function LicenseReviewPage() {
onConfirm={submitDecision}
/>
<ScheduleExamModal
opened={scheduleExamOpen}
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
loading={schedulingExam}
onClose={() => setScheduleExamOpen(false)}
onConfirm={async (payload) => {
try {
await scheduleExam({ id, ...payload }).unwrap();
notifications.show({
color: 'teal',
title: t('review.done.scheduleExam', 'Exam scheduled'),
message: '',
});
setScheduleExamOpen(false);
} catch (err) {
notifications.show({
color: 'red',
title: t('review.actionFailed', 'Action failed'),
message: extractErrorMessage(err),
});
}
}}
/>
<Modal
opened={inspectionOpen}
onClose={() => setInspectionOpen(false)}

View File

@@ -0,0 +1,290 @@
import { useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Collapse,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconBook2,
IconCertificate,
IconChevronDown,
IconChevronUp,
IconEye,
IconHeart,
IconId,
IconSearch,
IconShieldCheck,
IconUser,
IconUsers,
} from '@tabler/icons-react';
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
interface Seafarer {
id: string;
name: string;
nationality: string;
dob: string;
rank: string;
seamanBookNo: string;
seamanBookExpiry: string;
btcNo: string | null;
bsidNo: string | null;
medicalExpiry: string;
medicalStatus: 'Valid' | 'Expiring' | 'Expired';
cocCerts: { type: string; no: string; expiry: string }[];
status: 'Active' | 'Inactive' | 'Suspended';
}
const RANK_OPTIONS = ['All', 'Master', 'Chief Engineer', 'Officer of the Watch', 'Able Seaman', 'Deck Rating'];
const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended'];
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
const STATUS_COLOR: Record<string, string> = { Active: 'teal', Inactive: 'gray', Suspended: 'red' };
// ---------------------------------------------------------------------------
// Detail modal
// ---------------------------------------------------------------------------
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
if (!sf) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} {sf.id}</Text></Group>}
size="xl"
radius="lg"
>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Paper withBorder radius="md" p="md">
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text>
<Stack gap={4}>
<Group justify="space-between"><Text fz="xs" c="dimmed">Full Name</Text><Text fz="xs" fw={600}>{sf.name}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge></Group>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Documents</Text>
<Stack gap={4}>
<Group justify="space-between"><Text fz="xs" c="dimmed">Seaman Book</Text><Text fz="xs" fw={600}>{sf.seamanBookNo}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">SB Expiry</Text><Text fz="xs">{sf.seamanBookExpiry}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">BTC No.</Text><Text fz="xs">{sf.btcNo ?? '—'}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">BSID No.</Text><Text fz="xs">{sf.bsidNo ?? '—'}</Text></Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Medical Expiry</Text>
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalExpiry} ({sf.medicalStatus})</Badge>
</Group>
</Stack>
</Paper>
</SimpleGrid>
{sf.cocCerts.length > 0 && (
<Paper withBorder radius="md" p="md">
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text>
<Table fz="xs" verticalSpacing="xs">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Type', 'Certificate No.', 'Expiry'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sf.cocCerts.map((c) => (
<Table.Tr key={c.no}>
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td>
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td>
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
)}
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeafarerRegistryPage() {
// The register is served whole and filtered in the browser: the filters are
// instant facets over a page-sized list, and a round trip per keystroke
// would make the search feel slower than the data it is searching.
const { data } = useApiQuery<{ total: number; items: Seafarer[] }>({
url: '/seafarer-registry',
method: 'GET',
});
const seafarers = data?.items ?? [];
const [search, setSearch] = useState('');
const [rankFilter, setRankFilter] = useState('All');
const [statusFilter, setStatusFilter] = useState('All');
const [medicalFilter, setMedicalFilter] = useState('All');
const [selected, setSelected] = useState<Seafarer | null>(null);
const [filtersOpen, setFiltersOpen] = useState(true);
const filtered = seafarers.filter((sf) => {
const q = search.toLowerCase();
const matchSearch = !q || sf.name.toLowerCase().includes(q) || sf.id.toLowerCase().includes(q) || sf.seamanBookNo.toLowerCase().includes(q);
const matchRank = rankFilter === 'All' || sf.rank === rankFilter;
const matchStatus = statusFilter === 'All' || sf.status === statusFilter;
const matchMed = medicalFilter === 'All' || sf.medicalStatus === medicalFilter;
return matchSearch && matchRank && matchStatus && matchMed;
});
const KPI = [
{ label: 'Total Seafarers', value: seafarers.length, color: 'blue', icon: IconUsers },
{ label: 'Active', value: seafarers.filter((s) => s.status === 'Active').length, color: 'teal', icon: IconUser },
{ label: 'Medical Expiring',value: seafarers.filter((s) => s.medicalStatus === 'Expiring').length, color: 'orange', icon: IconHeart },
{ label: 'Medical Expired', value: seafarers.filter((s) => s.medicalStatus === 'Expired').length, color: 'red', icon: IconHeart },
{ label: 'With CoC', value: seafarers.filter((s) => s.cocCerts.length > 0).length, color: 'violet', icon: IconShieldCheck },
{ label: 'Without BSID', value: seafarers.filter((s) => !s.bsidNo).length, color: 'yellow', icon: IconId },
];
return (
<Stack gap="md">
<div>
<Title order={3}>Seafarer Registry</Title>
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text>
</div>
{/* KPIs */}
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
{KPI.map((k) => {
const KIcon = k.icon;
return (
<Card key={k.label} withBorder radius="md" p="sm">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size={28} radius="sm" color={k.color} variant="light"><KIcon size={14} /></ThemeIcon>
<div style={{ minWidth: 0 }}>
<Text fz="lg" fw={800} lh={1}>{k.value}</Text>
<Text fz="xs" c="dimmed" lh={1.2} style={{ lineHeight: 1.2 }}>{k.label}</Text>
</div>
</Group>
</Card>
);
})}
</SimpleGrid>
{/* Search + filters */}
<Paper withBorder radius="lg" p="xl">
<Group mb="sm" gap="sm" justify="space-between">
<TextInput
placeholder="Search by name, seafarer ID, or seaman book…"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1, minWidth: 200 }}
size="sm"
/>
<Button
variant="subtle"
size="xs"
rightSection={filtersOpen ? <IconChevronUp size={12} /> : <IconChevronDown size={12} />}
onClick={() => setFiltersOpen((o) => !o)}
>
Filters
</Button>
</Group>
<Collapse in={filtersOpen}>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm" mb="md">
<Select label="Rank" data={RANK_OPTIONS} value={rankFilter} onChange={(v) => setRankFilter(v ?? 'All')} size="sm" />
<Select label="Status" data={STATUS_OPTIONS} value={statusFilter} onChange={(v) => setStatusFilter(v ?? 'All')} size="sm" />
<Select label="Medical Status" data={MEDICAL_OPTIONS} value={medicalFilter} onChange={(v) => setMedicalFilter(v ?? 'All')} size="sm" />
</SimpleGrid>
<Divider mb="md" />
</Collapse>
<Text fz="xs" c="dimmed" mb="sm">{filtered.length} seafarer(s) found</Text>
<Table highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Seafarer ID', 'Name', 'Rank', 'Seaman Book', 'BTC', 'BSID', 'Medical', 'CoC/CoP', 'Status', ''].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((sf) => (
<Table.Tr key={sf.id}>
<Table.Td><Text fz="xs" fw={600} c="blue.7">{sf.id}</Text></Table.Td>
<Table.Td><Text fz="xs" fw={600}>{sf.name}</Text><Text fz="xs" c="dimmed">{sf.dob}</Text></Table.Td>
<Table.Td><Text fz="xs">{sf.rank}</Text></Table.Td>
<Table.Td>
<Group gap={4}>
<IconBook2 size={11} color="var(--mantine-color-blue-6)" />
<Text fz="xs">{sf.seamanBookNo}</Text>
</Group>
</Table.Td>
<Table.Td>
{sf.btcNo
? <Group gap={4}><IconCertificate size={11} color="var(--mantine-color-teal-6)" /><Text fz="xs">{sf.btcNo}</Text></Group>
: <Badge color="red" variant="light" size="xs">Missing</Badge>}
</Table.Td>
<Table.Td>
{sf.bsidNo
? <Group gap={4}><IconId size={11} color="var(--mantine-color-violet-6)" /><Text fz="xs">{sf.bsidNo}</Text></Group>
: <Badge color="red" variant="light" size="xs">Missing</Badge>}
</Table.Td>
<Table.Td>
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalStatus}</Badge>
<Text fz="xs" c="dimmed">{sf.medicalExpiry}</Text>
</Table.Td>
<Table.Td>
{sf.cocCerts.length > 0
? <Badge color="violet" variant="light" size="xs">{sf.cocCerts.length} cert(s)</Badge>
: <Text fz="xs" c="dimmed"></Text>}
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge>
</Table.Td>
<Table.Td>
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>
<IconEye size={13} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={10} style={{ textAlign: 'center', padding: '2rem' }}>
<Text c="dimmed" fz="sm">No seafarers match your search.</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />
</Stack>
);
}

View File

@@ -1,34 +0,0 @@
import { Button, Tooltip } from '@mantine/core';
import { IconShieldCog } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import type { ProfileRow } from './columns';
export function seafarerStatusActionColumn(
t: TFunction,
handlers: { onStatus: (profile: ProfileRow) => void },
): AdvancedColumn<ProfileRow> {
return {
header: '',
label: t('seafarerRegistry.columns.actions', 'Actions'),
cell: ({ row }) =>
row.original.seafarerNumber ? (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.MANAGE_SEAFARER_STATUS]}
hideOnly
>
<Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => handlers.onStatus(row.original)}
>
{t('seafarerRegistry.statusAction', 'Status')}
</Button>
</Tooltip>
</RequirePermission>
) : null,
};
}

View File

@@ -1,93 +0,0 @@
import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
export interface ProfileRow {
id: string;
firstName: string;
middleName?: string;
lastName: string;
gender?: string;
type?: string;
isComplete?: boolean;
seafarerNumber?: string | null;
seafarerStatus?: string | null;
seafarerDepartment?: string | null;
seafarerStatusReason?: string | null;
profession?: { name?: { en?: string } };
address?: { idNumber?: string; nationality?: string; primaryPhoneNumber?: string };
}
export const SEAFARER_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
PENDING: 'yellow',
SUSPENDED: 'orange',
INACTIVE: 'gray',
};
export const DEPARTMENT_LABELS: Record<string, string> = {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
};
export function seafarerRegistryColumns(
t: TFunction,
handlers: { onDetail: (profile: ProfileRow) => void },
): AdvancedColumn<ProfileRow>[] {
return [
{
header: t('seafarerRegistry.columns.name', 'Name'),
cell: ({ row }) => (
<Text
size="sm"
fw={500}
style={{ cursor: 'pointer' }}
onClick={() => handlers.onDetail(row.original)}
>
{[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')}
</Text>
),
},
{
header: t('seafarerRegistry.columns.number', 'Seafarer №'),
cell: ({ row }) => <Text size="sm" ff="monospace">{row.original.seafarerNumber ?? '—'}</Text>,
},
{
header: t('seafarerRegistry.columns.department', 'Department'),
cell: ({ row }) => (
<Text size="sm">
{row.original.seafarerDepartment
? t(
`seafarerRegistry.departments.${row.original.seafarerDepartment}`,
DEPARTMENT_LABELS[row.original.seafarerDepartment] ?? row.original.seafarerDepartment,
)
: '—'}
</Text>
),
},
{
header: t('seafarerRegistry.columns.idNumber', 'ID number'),
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.idNumber ?? '—'}</Text>,
},
{
header: t('seafarerRegistry.columns.phone', 'Phone'),
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.primaryPhoneNumber ?? '—'}</Text>,
},
{
header: t('seafarerRegistry.columns.status', 'Status'),
cell: ({ row }) =>
row.original.seafarerNumber ? (
<Badge size="sm" variant="light" color={SEAFARER_STATUS_COLORS[row.original.seafarerStatus ?? ''] ?? 'gray'}>
{t(`seafarerRegistry.status.${row.original.seafarerStatus}`, row.original.seafarerStatus ?? '')}
</Badge>
) : (
<Badge size="sm" variant="light" color={row.original.isComplete ? 'teal' : 'gray'}>
{row.original.isComplete
? t('seafarerRegistry.notRegistered', 'Not registered')
: t('seafarerRegistry.incomplete', 'Incomplete')}
</Badge>
),
},
];
}

View File

@@ -1,411 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Card,
Container,
Drawer,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
Textarea,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconSearch,
IconStethoscope,
} from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
useApiQuery,
useGetMedicalForProfileQuery,
useGetSeaServiceForProfileQuery,
useUpdateSeafarerStatusMutation,
} from '@ema-platform/api';
import {
DEPARTMENT_LABELS,
SEAFARER_STATUS_COLORS,
seafarerRegistryColumns,
type ProfileRow,
} from './columns';
import { seafarerStatusActionColumn } from './actions';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
};
/** The registered seafarer's records, read-only (verification is module 06). */
function SeafarerDetailDrawer({
profile,
onClose,
}: {
profile: ProfileRow | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const profileId = profile?.id ?? '';
const { data: seaService, isLoading: loadingSea } =
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
const { data: medical, isLoading: loadingMedical } =
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
const showDate = useDateDisplayer();
return (
<Drawer
opened={Boolean(profile)}
onClose={onClose}
position="right"
size="lg"
title={
profile
? [profile.firstName, profile.middleName, profile.lastName]
.filter(Boolean)
.join(' ')
: ''
}
>
{profile && (
<Stack>
<Group gap="xl">
<div>
<Text size="xs" c="dimmed" tt="uppercase">
{t('seafarerRegistry.drawer.seafarerNumber', 'Seafarer number')}
</Text>
<Text fw={700} ff="monospace">
{profile.seafarerNumber ?? '—'}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase">
{t('seafarerRegistry.drawer.department', 'Department')}
</Text>
<Text fw={600}>
{profile.seafarerDepartment
? t(
`seafarerRegistry.departments.${profile.seafarerDepartment}`,
DEPARTMENT_LABELS[profile.seafarerDepartment] ??
profile.seafarerDepartment,
)
: '—'}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase">
{t('seafarerRegistry.drawer.status', 'Status')}
</Text>
<Badge
color={
SEAFARER_STATUS_COLORS[profile.seafarerStatus ?? ''] ?? 'gray'
}
>
{profile.seafarerStatus
? t(`seafarerRegistry.status.${profile.seafarerStatus}`, profile.seafarerStatus)
: t('seafarerRegistry.drawer.notRegistered', 'NOT REGISTERED')}
</Badge>
</div>
</Group>
{profile.seafarerStatusReason && (
<Text size="sm" c="dimmed">
{t('seafarerRegistry.drawer.statusReason', {
reason: profile.seafarerStatusReason,
defaultValue: 'Status reason: {{reason}}',
})}
</Text>
)}
<Tabs defaultValue="sea-service" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
{t('seafarerRegistry.drawer.seaServiceTab', 'Sea Service')}
</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
{t('seafarerRegistry.drawer.medicalTab', 'Medical')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="sea-service" pt="sm">
{loadingSea ? (
<Loader size="sm" />
) : (seaService ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
{t('seafarerRegistry.drawer.noSeaService', 'No sea-service records.')}
</Text>
) : (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('seafarerRegistry.drawer.vessel', 'Vessel')}</Table.Th>
<Table.Th>{t('seafarerRegistry.drawer.rank', 'Rank')}</Table.Th>
<Table.Th>{t('seafarerRegistry.drawer.period', 'Period')}</Table.Th>
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(seaService ?? []).map((record) => (
<Table.Tr key={record.id}>
<Table.Td>
{record.vesselName}
{record.imoNumber && (
<Text size="xs" c="dimmed">
{t('seafarerRegistry.drawer.imoPrefix', {
number: record.imoNumber,
defaultValue: 'IMO {{number}}',
})}
</Text>
)}
</Table.Td>
<Table.Td>{record.rank}</Table.Td>
<Table.Td>
{showDate(record.engagementDate)} {showDate(record.dischargeDate)}
</Table.Td>
<Table.Td>
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
{t(`seafarerRegistry.recordStatus.${record.status}`, record.status)}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Tabs.Panel>
<Tabs.Panel value="medical" pt="sm">
{loadingMedical ? (
<Loader size="sm" />
) : (medical ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
{t('seafarerRegistry.drawer.noMedical', 'No medical certificates.')}
</Text>
) : (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('seafarerRegistry.drawer.issuer', 'Issuer')}</Table.Th>
<Table.Th>{t('seafarerRegistry.drawer.validity', 'Validity')}</Table.Th>
<Table.Th>{t('seafarerRegistry.drawer.fitness', 'Fitness')}</Table.Th>
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(medical ?? []).map((certificate) => (
<Table.Tr key={certificate.id}>
<Table.Td>{certificate.issuerName}</Table.Td>
<Table.Td>
{showDate(certificate.issueDate)} {showDate(certificate.expiryDate)}
</Table.Td>
<Table.Td>{certificate.fitnessStatus}</Table.Td>
<Table.Td>
<Badge
size="sm"
color={RECORD_STATUS_COLORS[certificate.status]}
>
{t(`seafarerRegistry.recordStatus.${certificate.status}`, certificate.status)}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Tabs.Panel>
</Tabs>
</Stack>
)}
</Drawer>
);
}
/** US-SEA-013: suspend / reinstate / close, always with a reason. */
function StatusModal({
profile,
onClose,
onDone,
}: {
profile: ProfileRow | null;
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation();
const [status, setStatus] = useState<string | null>(null);
const [reason, setReason] = useState('');
const [updateStatus, { isLoading }] = useUpdateSeafarerStatusMutation();
const submit = async () => {
if (!profile || !status) return;
try {
await updateStatus({
profileId: profile.id,
status: status as 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'PENDING',
reason,
}).unwrap();
notify.success(t('seafarerRegistry.modal.updated', 'Seafarer status updated'));
onClose();
onDone();
} catch (error) {
notify.error(
extractErrorMessage(error, t('seafarerRegistry.modal.updateFailed', 'Could not update the status')),
);
}
};
return (
<Modal
opened={Boolean(profile)}
onClose={onClose}
title={t('seafarerRegistry.modal.title', 'Change seafarer status')}
centered
>
<Stack>
<Text size="sm" c="dimmed">
{t('seafarerRegistry.modal.body', {
number: profile?.seafarerNumber,
status: profile?.seafarerStatus
? t(`seafarerRegistry.status.${profile.seafarerStatus}`, profile.seafarerStatus)
: profile?.seafarerStatus,
defaultValue:
'{{number}} — currently {{status}}. The reason is recorded and visible to the seafarer.',
})}
</Text>
<Select
label={t('seafarerRegistry.modal.newStatus', 'New status')}
required
data={[
{ value: 'SUSPENDED', label: t('seafarerRegistry.modal.suspend', 'Suspend') },
{ value: 'INACTIVE', label: t('seafarerRegistry.modal.close', 'Close') },
{ value: 'ACTIVE', label: t('seafarerRegistry.modal.reinstate', 'Reinstate') },
].filter((o) => o.value !== profile?.seafarerStatus)}
value={status}
onChange={setStatus}
/>
<Textarea
label={t('seafarerRegistry.modal.reason', 'Reason')}
required
minRows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
{t('seafarerRegistry.modal.cancel', 'Cancel')}
</Button>
<Button
color={status === 'ACTIVE' ? 'green' : 'orange'}
disabled={!status || reason.trim().length < 3}
loading={isLoading}
onClick={submit}
>
{t('seafarerRegistry.modal.confirm', 'Confirm')}
</Button>
</Group>
</Stack>
</Modal>
);
}
/**
* Registered seafarer profiles, read from the real profiles endpoint.
*
* Registration review itself happens in the licence queue (the
* SEAFARER_REGISTRATION application type); this page is the resulting
* register — numbers, departments, statuses, and each seafarer's records.
*/
export function SeafarerRegistryPage() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [detail, setDetail] = useState<ProfileRow | null>(null);
const [statusTarget, setStatusTarget] = useState<ProfileRow | null>(null);
const { data, isLoading, refetch } = useApiQuery<{
total: number;
items: ProfileRow[];
}>({
url: '/profiles',
method: 'GET',
params: { q: 'i=profession,address&t=200' },
});
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const items = (data?.items ?? []).filter((p) => {
if (!search.trim()) return true;
const term = search.toLowerCase();
return [
p.firstName,
p.middleName,
p.lastName,
p.address?.idNumber,
p.seafarerNumber,
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(term));
});
const page = paginate(items);
const columns = [
...seafarerRegistryColumns(t, { onDetail: setDetail }),
seafarerStatusActionColumn(t, { onStatus: setStatusTarget }),
];
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{t('seafarerRegistry.title', 'Seafarer registry')}</Title>
<Text size="sm" c="dimmed">
{t('seafarerRegistry.profileCount', {
count: data?.total ?? 0,
defaultValue: '{{count}} profile(s)',
})}
</Text>
</div>
<TextInput
placeholder={t('seafarerRegistry.searchPlaceholder', 'Name, ID or seafarer number')}
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
</Group>
<Card withBorder padding={0}>
<AdvancedTable
columns={columns}
data={page.rows}
tableName={t('seafarerRegistry.title', 'Seafarer registry')}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isLoading}
emptyText={
search
? t('seafarerRegistry.emptySearch', 'No profiles match that search.')
: t('seafarerRegistry.emptyNone', 'No seafarers registered yet.')
}
/>
</Card>
<SeafarerDetailDrawer profile={detail} onClose={() => setDetail(null)} />
<StatusModal
profile={statusTarget}
onClose={() => setStatusTarget(null)}
onDone={refetch}
/>
</Container>
);
}
export default SeafarerRegistryPage;

View File

@@ -1,6 +1,9 @@
import { landingAm } from "@ema-platform/ui";
import type { Translations } from "./en";
export const am: Translations = {
landing: landingAm,
app: {
name: "ኢማ አስተዳደር",
shortName: "ኢማ",
@@ -76,6 +79,7 @@ export const am: Translations = {
dashboard: "ዳሽቦርድ",
userManagement: "የተጠቃሚ አስተዳደር",
seamanBookQueue: "የመርከበኞች መጽሐፍ ወረፋ",
btcQueue: "የBTC ወረፋ",
cocQueue: "የCoC ወረፋ",
copQueue: "የCoP ወረፋ",
endorsementCocQueue: "የCoC ማረጋገጫ ወረፋ",
@@ -951,6 +955,7 @@ export const am: Translations = {
finalApprove: "አጽድቅ እና ስጥ",
requestAdjustment: "ማስተካከያ ጠይቅ",
reject: "አትቀበል",
scheduleExam: "የፈተና ቀጠሮ ስጥ",
confirmPayment: "ክፍያ አረጋግጥ",
print: "ሰነድ አትም",
copyLink: "አገናኝ ቅዳ",
@@ -993,6 +998,7 @@ export const am: Translations = {
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
},
notifications: {
fallback: "ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።",
@@ -1301,4 +1307,68 @@ export const am: Translations = {
view: "ይመልከቱ",
noUrl: "URL የለም",
},
authShell: {
toggleTheme: "ገጽታ ቀይር",
brandTitle: "የባህር ትራንስፖርት ፈቃድ አሰጣጥ ቀላል ሆኗል።",
brandSubtitle: "ለመርከብ እና ለመርከበኛ ፈቃዶች ያመልክቱ፣ ሰነዶችን ይስቀሉ፣ እና እያንዳንዱን ማመልከቻ በአንድ ደህንነቱ በተጠበቀ መተግበሪያ ላይ ይከታተሉ።",
feature1: "ማመልከቻዎችን በመስመር ላይ 24/7 ያስገቡ",
feature2: "የቅጽበት ሁኔታ ክትትል እና ማንቂያ",
feature3: "በእንግሊዝኛ እና በአማርኛ ይገኛል",
copyright: "© 2026 የኢትዮጵያ የባህር ትራንስፖርት ባለስልጣን",
},
login: {
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
subtitle: "መለያዎን ለመድረስ ይግቡ።",
emailOrPhoneLabel: "ኢሜይል ወይም ስልክ",
emailOrPhonePlaceholder: "you@example.com",
passwordLabel: "የይለፍ ቃል",
passwordPlaceholder: "የይለፍ ቃልዎ",
rememberMe: "አስታውሰኝ",
forgotPassword: "የይለፍ ቃል ረሱ?",
signIn: "ግባ",
noAccount: "መለያ የለዎትም? ",
createOne: "አንድ ይፍጠሩ",
},
signup: {
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
phoneRequired: "ስልክ ቁጥር ያስፈልጋል",
confirmPasswordRequired: "የይለፍ ቃልዎን ያረጋግጡ",
passwordsDontMatch: "የይለፍ ቃላት አይመሳሰሉም",
brandTitle: "የ{{appName}} ማህበረሰብን ይቀላቀሉ።",
brandSubtitle: "የ{{appName}} አገልግሎቶችን ለመድረስ መለያዎን ይፍጠሩ።",
title: "መለያ ይፍጠሩ",
subtitle: "ለመጀመር አንድ ደቂቃ ብቻ ይወስዳል።",
nameEnLabel: "ስም (እንግሊዝኛ)",
nameEnPlaceholder: "አበበ በቀለ",
nameAmLabel: "ስም (አማርኛ)",
nameAmPlaceholder: "ስም",
emailLabel: "ኢሜይል አድራሻ",
emailPlaceholder: "you@example.com",
usernameLabel: "የተጠቃሚ ስም",
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
phoneLabel: "ስልክ ቁጥር",
phonePlaceholder: "+251 911 234 567",
passwordLabel: "የይለፍ ቃል",
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
confirmPasswordPlaceholder: "የይለፍ ቃል እንደገና ያስገቡ",
agreeToThe: "እስማማለሁ ከ",
termsAndPrivacy: "ውሎች እና የግላዊነት ፖሊሲ",
createAccount: "መለያ ይፍጠሩ",
haveAccount: "አስቀድሞ መለያ አለዎት? ",
signIn: "ግባ",
passwordRule: {
minLength: "ቢያንስ {{min}} ቁምፊዎች",
lowercase: "አንድ ትንሽ ፊደል",
uppercase: "አንድ ትልቅ ፊደል",
number: "አንድ ቁጥር",
special: "አንድ ልዩ ምልክት",
},
},
};

View File

@@ -1,4 +1,8 @@
import { landingEn } from '@ema-platform/ui';
export const en = {
landing: landingEn,
app: {
name: 'EMA Admin',
shortName: 'EMA',
@@ -59,6 +63,7 @@ export const en = {
dashboard: 'Dashboard',
userManagement: 'User Management',
seamanBookQueue: 'Seaman Book Queue',
btcQueue: 'BTC Queue',
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
vesselRegistrationApplicationQueue: 'Vessel Registration Queue',
vesselRegistrationQueue: 'Vessel Register',
@@ -952,6 +957,7 @@ export const en = {
finalApprove: 'Approve & issue',
requestAdjustment: 'Request adjustment',
reject: 'Reject',
scheduleExam: 'Schedule exam',
confirmPayment: 'Confirm payment',
print: 'Print dossier',
copyLink: 'Copy link',
@@ -993,6 +999,7 @@ export const en = {
resume: 'Returns application {{number}} to the stage it was held from.',
escalate: 'Raises application {{number}} to a supervisor for a decision.',
'confirm-payment': 'Confirms settlement for application {{number}}.',
'schedule-exam': 'Assigns {{applicant}} to an exam session for application {{number}}.',
},
notifications: {
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
@@ -1297,6 +1304,70 @@ export const en = {
view: 'View',
noUrl: 'No URL',
},
authShell: {
toggleTheme: 'Toggle theme',
brandTitle: 'Maritime licensing, made simple.',
brandSubtitle: 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
feature1: 'Submit applications online, 24/7',
feature2: 'Real-time status tracking & alerts',
feature3: 'Available in English & አማርኛ',
copyright: '© 2026 Ethiopian Maritime Authority',
},
login: {
emailOrPhoneInvalid: 'Enter a valid email or phone number (+2519xxxxxxxx)',
passwordMinLength: 'Password must be at least 8 characters',
welcome: 'Welcome to {{appName}}',
subtitle: 'Sign in to access your account.',
emailOrPhoneLabel: 'Email or phone',
emailOrPhonePlaceholder: 'you@example.com',
passwordLabel: 'Password',
passwordPlaceholder: 'Your password',
rememberMe: 'Remember me',
forgotPassword: 'Forgot password?',
signIn: 'Sign in',
noAccount: "Don't have an account? ",
createOne: 'Create one',
},
signup: {
usernameMinLength: 'Username must be at least 3 characters',
nameEnRequired: 'Name (English) is required',
phoneRequired: 'Phone number is required',
confirmPasswordRequired: 'Confirm your password',
passwordsDontMatch: 'Passwords do not match',
brandTitle: "Join {{appName}}'s community.",
brandSubtitle: 'Create your account to access {{appName}} features.',
title: 'Create account',
subtitle: 'It only takes a minute to get started.',
nameEnLabel: 'Name (English)',
nameEnPlaceholder: 'Abebe Bekele',
nameAmLabel: 'Name (Amharic)',
nameAmPlaceholder: 'ስም',
emailLabel: 'Email address',
emailPlaceholder: 'you@example.com',
usernameLabel: 'Username',
usernamePlaceholder: 'Choose a username',
phoneLabel: 'Phone number',
phonePlaceholder: '+251 911 234 567',
passwordLabel: 'Password',
passwordPlaceholder: 'At least 8 characters',
confirmPasswordLabel: 'Confirm password',
confirmPasswordPlaceholder: 'Re-enter password',
agreeToThe: 'I agree to the ',
termsAndPrivacy: 'Terms & Privacy Policy',
createAccount: 'Create account',
haveAccount: 'Already have an account? ',
signIn: 'Sign in',
passwordRule: {
minLength: 'At least {{min}} characters',
lowercase: 'One lowercase letter',
uppercase: 'One uppercase letter',
number: 'One number',
special: 'One special character',
},
},
};
export type Translations = typeof en;

View File

@@ -35,7 +35,16 @@ export function BackofficeLayout() {
const [collapsed, setCollapsed] = useState(false);
const user = useAppSelector((state) => state.auth.user);
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const { can } = usePermissions();
const { permissions: granted, known } = usePermissions();
// TEMPORARY diagnostic — remove once the sidebar is confirmed working.
// eslint-disable-next-line no-console
console.log(
'[NAV] known=', known,
'granted=', granted.length,
'| cookies:', document.cookie.split('; ').map((c) => c.split('=')[0]).filter((n) => n.includes('token')),
'| token tail:', (document.cookie.match(/ema-backoffice-auth-token=([^;]+)/)?.[1] ?? 'NONE').slice(-12),
);
// Badges reflect real pending work. One grouped request on a timer, shared
// by the sidebar and the top bar via the RTK cache.
@@ -53,17 +62,10 @@ export function BackofficeLayout() {
: item,
),
}));
return filterByPermissions(
withBadges,
// `can` already fails open when the token carries no permission claim,
// so this only ever removes items we are sure the user cannot use.
withBadges
.flatMap((section) => section.items)
.flatMap((item) => [item, ...(item.children ?? [])])
.flatMap((item) => item.permissions ?? [])
.filter((permission) => can([permission])),
);
}, [counts?.unassigned, can]);
// Unfiltered until the grant list has loaded, matching PortalLayout and
// RequirePermission: a moment of extra nav beats a flash of empty nav.
return known ? filterByPermissions(withBadges, granted) : withBadges;
}, [counts?.unassigned, granted, known]);
/** Flat list used for breadcrumbs and active-route lookup. */
const navItems = useMemo<NavItem[]>(

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/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: '/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_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },

View File

@@ -0,0 +1,13 @@
import Cookies from 'js-cookie';
import { LandingPage } from '@ema-platform/ui';
import { authStorage } from '@ema-platform/auth';
/**
* Public `/` — mounts the shared landing page. Backoffice has no /signup
* (enableSignup: false) and no /verify route, so those props are omitted.
*/
export function LandingRoute() {
const token = authStorage.getToken() ?? Cookies.get('auth-token');
return <LandingPage primaryHref={token ? '/dashboard' : '/login'} />;
}

View File

@@ -15,6 +15,7 @@ import {
import { AuthLayout } from '../layouts/AuthLayout';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { ProtectedRoute } from './ProtectedRoute';
import { LandingRoute } from './LandingRoute';
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
import UserManagementPage from '../features/user-management/UserManagementPage';
import { ProfilePage } from '../features/profile/pages/ProfilePage';
@@ -61,7 +62,7 @@ const router = createBrowserRouter([
],
},
{ path: '/um/*', element: <UserManagementPage /> },
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{ path: '/', element: <LandingRoute /> },
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
{
element: <ProtectedRoute />,
@@ -69,7 +70,6 @@ const router = createBrowserRouter([
{
element: <BackofficeLayout />,
children: [
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'vessel-registration-head-dashboard', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationHeadDashboardPage />) },
{ path: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, <LogisticsHeadDashboardPage />) },

View File

@@ -1,5 +1,9 @@
import { configureStore } from '@reduxjs/toolkit';
import { baseApi, configureTokenRefresh } from '@ema-platform/api';
import {
baseApi,
configureSessionScope,
configureTokenRefresh,
} from '@ema-platform/api';
import {
authReducer,
signupReducer,
@@ -13,6 +17,9 @@ import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
import { preferencesReducer } from './preferences.slice';
configureAuthStorage('ema-backoffice', true);
// Cookies are shared across ports on localhost, so the API layer must be told
// which app it belongs to — otherwise it reads the portal's token.
configureSessionScope('ema-backoffice');
const preloadedAuth = (() => {
const token = authStorage.getToken();

View File

@@ -39,7 +39,7 @@ window.__USER_MANAGEMENT_BRANDING__ = {
organizationName: 'Ethiopian Maritime Authority',
logoSrc: '/assets/emaLogo.jpg',
logoAlt: 'EMA Logo',
homePath: '/',
homePath: '/dashboard',
moduleBasePath: '/user-management',
backToAppPath: '/dashboard',
backToAppLabel: 'Back to dashboard',