Merge branch 'feature/exam-attempt-domain' of github.com:Tria-plc/emaui into feature/exam-attempt-domain

This commit is contained in:
mihretue
2026-08-17 14:43:45 +03:00
79 changed files with 11034 additions and 2376 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],
function startNewVersion() {
setNewName(
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
);
// 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),
});
}
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) => {
<DesignerToolbar
licenseTypes={licenseTypes?.items ?? []}
typeId={typeId}
onTypeChange={(value) => {
setTypeId(value);
setSelectedId(null);
draft.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={() =>
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'),
)
}
>
{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>
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">
<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}
/>
{/* A published design is immutable by rule -- certificates were
issued from it -- so the editor is locked. Without this the
screen looks broken rather than deliberately read-only, which
is what "edit is not available" turns out to mean. */}
{draft.isPublished && (
<Alert
variant="light"
color="blue"
icon={<IconLock size={18} />}
title={t('designer.liveDesign', 'This is the live design')}
>
<Stack gap="xs" align="flex-start">
<Text size="sm">
{t(
'designer.liveDesignBody',
'Certificates have been issued from this version, so it cannot be changed. Create a new version to edit — it starts as a copy of this one, and only replaces it when you publish.',
)}
</Text>
{canEdit && (
<Button
size="xs"
leftSection={<IconPlus size={14} />}
onClick={startNewVersion}
>
{t('designer.newVersionFromThis', 'New version from this design')}
</Button>
)}
</Stack>
</Alert>
)}
<Tabs value={mode} onChange={(value) => setMode(value as 'canvas' | 'source')}>
<Tabs.List>
<Tabs.Tab value="canvas" leftSection={<IconLayoutBoard size={15} />}>
{t('designer.tabCanvas', 'Visual editor')}
</Tabs.Tab>
<Tabs.Tab value="source" leftSection={<IconCode size={15} />}>
{t('designer.tabSource', 'HTML source')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="canvas" pt="sm">
<Stack gap="sm">
<TextInput
label={t('designer.name', 'Version name')}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
disabled={!canEdit || isPublished}
style={{ flex: 1 }}
value={draft.name}
onChange={(e) => draft.setName(e.currentTarget.value)}
disabled={editingLocked}
/>
<Switch
label={t('designer.landscape', 'Landscape')}
checked={landscape}
onChange={(e) => setLandscape(e.currentTarget.checked)}
disabled={!canEdit || isPublished}
<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}
/>
</Group>
<BlockPropertiesPanel
block={draft.selectedBlock}
variables={variables}
onChange={draft.updateBlock}
onDelete={draft.deleteBlock}
disabled={editingLocked}
/>
</Stack>
</Tabs.Panel>
{isPublished && (
<Paper withBorder p="xs" bg="var(--mantine-color-teal-light)">
<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.publishedLocked',
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
'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>
)}
<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 } }}
<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>
<Group>
<Button
variant="light"
leftSection={<IconEye size={16} />}
onClick={preview}
disabled={!source.trim()}
>
{t('designer.preview', 'Preview PDF')}
</Button>
<Button
leftSection={<IconDeviceFloppy size={16} />}
loading={saving}
disabled={!canEdit || isPublished || !dirty}
onClick={() =>
<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: selected!.id,
name,
hbsSource: source,
pageOptions: { format: 'A4', landscape, printBackground: true },
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'),
)
}
>
{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={() =>
onPublish={() =>
run(
() => publishTemplate(selected!.id).unwrap(),
() => publishTemplate(draft.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={() =>
onArchive={() =>
run(
() => archiveTemplate(selected.id).unwrap(),
() => archiveTemplate(draft.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={() =>
onDelete={() =>
run(
() => deleteTemplate(selected.id).unwrap(),
() => deleteTemplate(draft.selected!.id).unwrap(),
t('designer.deleted', 'Draft deleted'),
)
}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</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>
</Group>
)}
<Modal
opened={newOpen}
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.',
</Stack>
<TemplateVariableList
variables={variables}
disabled={editingLocked}
canvasMode={mode === 'canvas'}
onInsert={draft.insertVariable}
onAddBlock={(key) => draft.addBlock(key)}
onAddTextBlock={() => draft.addBlock(null, 'Text')}
/>
</Group>
)}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setNewOpen(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
loading={creating}
disabled={!newName.trim()}
onClick={() =>
<NewVersionModal
opened={newOpen}
name={newName}
onNameChange={setNewName}
creating={creating}
onClose={() => setNewOpen(false)}
onCreate={() =>
run(async () => {
const created = await createTemplate({
licenseTypeId: typeId as string,
name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap();
setSelectedId(created.id);
draft.setSelectedId(created.id);
setNewOpen(false);
}, t('designer.created', 'Draft created'))
}
>
{t('designer.create', 'Create')}
</Button>
</ModalFooter>
</Stack>
</Modal>
/>
</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]) || {
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

@@ -79,6 +79,7 @@ export const am: Translations = {
dashboard: "ዳሽቦርድ",
userManagement: "የተጠቃሚ አስተዳደር",
seamanBookQueue: "የመርከበኞች መጽሐፍ ወረፋ",
btcQueue: "የBTC ወረፋ",
cocQueue: "የCoC ወረፋ",
copQueue: "የCoP ወረፋ",
endorsementCocQueue: "የCoC ማረጋገጫ ወረፋ",
@@ -954,6 +955,7 @@ export const am: Translations = {
finalApprove: "አጽድቅ እና ስጥ",
requestAdjustment: "ማስተካከያ ጠይቅ",
reject: "አትቀበል",
scheduleExam: "የፈተና ቀጠሮ ስጥ",
confirmPayment: "ክፍያ አረጋግጥ",
print: "ሰነድ አትም",
copyLink: "አገናኝ ቅዳ",
@@ -996,6 +998,7 @@ export const am: Translations = {
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
},
notifications: {
fallback: "ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።",

View File

@@ -63,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',
@@ -956,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',
@@ -997,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}}.',

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

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

@@ -4,6 +4,10 @@ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
export default defineConfig({
root: __dirname,
// Env lives at the workspace root, shared with the portal — without this
// Vite looks in apps/backoffice and VITE_BASE_API_URL silently falls back to
// its built-in default.
envDir: '../../',
cacheDir: '../../node_modules/.vite/apps/backoffice',
server: {
port: 4201,

View File

@@ -0,0 +1,124 @@
import { useState } from 'react';
import { ActionIcon, Button, Popover, TextInput } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic';
import { DayPicker as GregorianDayPicker } from '@daypicker/react';
import { IconCalendarEvent } from '@tabler/icons-react';
import { EthDateTime } from 'ethiopian-calendar-date-converter';
import '@daypicker/react/dist/style.css';
const EC_MONTHS_AM = [
'መስከረም', 'ጥቅምት', 'ኅዳር', 'ታህሳስ', 'ጥር', 'የካቲት',
'መጋቢት', 'ሚያዝያ', 'ግንቦት', 'ሰኔ', 'ሐምሌ', 'ነሐሴ', 'ጳጉሜ',
];
function toAmharicDisplay(date: Date): string {
try {
const eth = EthDateTime.fromEuropeanDate(date);
return `${EC_MONTHS_AM[eth.month - 1]} ${eth.date}/${eth.year}`;
} catch {
return date.toLocaleDateString('en-US');
}
}
export function toEthiopicDateLabel(date: Date): string {
try {
const eth = EthDateTime.fromEuropeanDate(date);
return `EC ${eth.year}-${String(eth.month).padStart(2, '0')}-${String(eth.date).padStart(2, '0')}`;
} catch {
return date.toLocaleDateString('en-US');
}
}
export interface AmharicDatePickerProps {
label?: string;
value?: Date | null;
onChange?: (date: Date | null) => void;
required?: boolean;
placeholder?: string;
}
export function AmharicDatePicker({
label,
value,
onChange,
required,
placeholder,
}: AmharicDatePickerProps) {
const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>('AMH');
const [opened, { close, toggle }] = useDisclosure(false);
const displayValue = value
? calendarType === 'EN'
? value.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: toAmharicDisplay(value)
: '';
return (
<Popover
opened={opened}
onChange={close}
position="bottom"
width="auto"
trapFocus
withArrow
>
<Popover.Target>
<TextInput
label={label}
required={required}
value={displayValue}
readOnly
placeholder={placeholder}
onClick={toggle}
leftSection={
<Button
variant="light"
size="compact-xs"
onClick={(e) => {
e.stopPropagation();
setCalendarType((prev) => (prev === 'EN' ? 'AMH' : 'EN'));
}}
aria-label="Switch calendar type"
>
{calendarType}
</Button>
}
leftSectionWidth="calc(4.375rem * var(--mantine-scale))"
rightSection={
<ActionIcon size="md" variant="transparent" onClick={toggle}>
<IconCalendarEvent size={20} />
</ActionIcon>
}
/>
</Popover.Target>
<Popover.Dropdown p="md">
{calendarType === 'AMH' ? (
<EthiopicDayPicker
mode="single"
selected={value ?? undefined}
numerals="latn"
onSelect={(date: Date | undefined) => {
onChange?.(date ?? null);
close();
}}
/>
) : (
<GregorianDayPicker
mode="single"
selected={value ?? undefined}
onSelect={(date: Date | undefined) => {
onChange?.(date ?? null);
close();
}}
/>
)}
</Popover.Dropdown>
</Popover>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from 'react';
import {
TextInput,
UnstyledButton,
rem,
type TextInputProps,
} from '@mantine/core';
export interface BilingualValue {
en: string;
am: string;
}
interface BilingualInputProps
extends Omit<TextInputProps, 'value' | 'onChange' | 'rightSection' | 'rightSectionWidth'> {
value: BilingualValue;
onChange: (value: BilingualValue) => void;
}
export function BilingualInput({
label,
value,
onChange,
required,
placeholder,
...rest
}: BilingualInputProps) {
const [lang, setLang] = useState<'en' | 'am'>('en');
const toggle = () => setLang((l) => (l === 'en' ? 'am' : 'en'));
return (
<TextInput
label={label}
required={required}
placeholder={placeholder ?? (lang === 'en' ? 'Enter in English' : 'በአማርኛ ያስገቡ')}
value={value[lang]}
onChange={(e) => onChange({ ...value, [lang]: e.currentTarget.value })}
rightSection={
<UnstyledButton
onClick={toggle}
aria-label={`Switch to ${lang === 'en' ? 'Amharic' : 'English'}`}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(28),
height: rem(20),
borderRadius: rem(4),
fontSize: rem(10),
fontWeight: 700,
letterSpacing: '0.05em',
background:
lang === 'en'
? 'var(--mantine-color-blue-1)'
: 'var(--mantine-color-teal-1)',
color:
lang === 'en'
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-teal-7)',
cursor: 'pointer',
transition: 'background 150ms ease',
}}
>
{lang === 'en' ? 'EN' : 'AM'}
</UnstyledButton>
}
styles={{
input: {
paddingRight: rem(42),
},
}}
{...rest}
/>
);
}

View File

@@ -1,21 +1,481 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { useRef, useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
FileButton,
Group,
List,
Modal,
Paper,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertTriangle,
IconBook2,
IconCalendar,
IconCheck,
IconCircleCheck,
IconDownload,
IconInfoCircle,
IconRefresh,
IconShieldCheck,
IconTrash,
IconUpload,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface BSTRecord {
issuer: string;
issueDate: string;
expiryDate: string;
certNumber: string;
fileName: string;
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
}
const STATUS_COLOR: Record<string, string> = {
Valid: 'teal',
Expiring: 'orange',
Expired: 'red',
'Pending Verification': 'yellow',
};
/** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */
interface BstProgress {
modules: { key: string; label: string; licenseTypeKey: string; done: boolean }[];
completed: number;
total: number;
complete: boolean;
}
// `short` doubles as the key the API reports each module under, so the two
// stay matched without a second lookup table between them.
const BST_COMPONENTS = [
{ label: 'Personal Survival Techniques', short: 'PST', course: 'IMO 1.19' },
{ label: 'Fire Prevention & Fire Fighting', short: 'FPFF', course: 'IMO 1.20' },
{ label: 'Elementary First Aid', short: 'EFA', course: 'IMO 1.13' },
{ label: 'Personal Safety & Social Responsibility', short: 'PSSR', course: 'IMO 1.21' },
{ label: 'Security Awareness', short: 'SSA', course: 'STCW A-VI/6' },
];
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function daysUntil(dateStr: string) {
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
}
// ---------------------------------------------------------------------------
// Upload modal
// ---------------------------------------------------------------------------
function UploadModal({
opened,
onClose,
onUploaded,
}: {
opened: boolean;
onClose: () => void;
onUploaded: (record: BSTRecord) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [issuer, setIssuer] = useState('');
const [certNumber, setCertNumber] = useState('');
const [issueDate, setIssueDate] = useState('');
const [expiryDate, setExpiryDate] = useState('');
const [submitting, setSubmitting] = useState(false);
const resetRef = useRef<() => void>(null);
const reset = () => {
setFile(null);
setIssuer('');
setCertNumber('');
setIssueDate('');
setExpiryDate('');
resetRef.current?.();
};
const handleSubmit = async () => {
if (!file || !issuer || !certNumber || !issueDate || !expiryDate) {
notify.error('Please fill all required fields and upload the certificate file.');
return;
}
setSubmitting(true);
await new Promise((r) => setTimeout(r, 1000));
setSubmitting(false);
onUploaded({
issuer,
issueDate,
expiryDate,
certNumber,
fileName: file.name,
status: 'Pending Verification',
});
notify.success('Basic Safety Training certificate submitted for verification.');
reset();
onClose();
};
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function BasicSafetyTrainingPage() {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Basic Safety Training"
description="BST records are not connected to the backend yet."
<Modal
opened={opened}
onClose={() => {
reset();
onClose();
}}
title="Upload Basic Safety Training Certificate"
size="md"
centered
>
<Stack gap="sm">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Text fz="xs">
Upload your combined BST certificate issued by an EMA-approved training institution.
The certificate must cover all 5 components (PST, FPFF, EFA, PSSR, SHPT).
</Text>
</Alert>
<TextInput
label="Issuing Institution"
placeholder="e.g. Bahirdar Maritime School"
required
value={issuer}
onChange={(e) => setIssuer(e.currentTarget.value)}
size="sm"
/>
</Container>
<TextInput
label="Certificate Number"
placeholder="e.g. BST-2024-BMS-001"
required
value={certNumber}
onChange={(e) => setCertNumber(e.currentTarget.value)}
size="sm"
/>
<Group grow>
<TextInput
label="Issue Date"
type="date"
required
value={issueDate}
onChange={(e) => setIssueDate(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Expiry Date"
type="date"
required
value={expiryDate}
onChange={(e) => setExpiryDate(e.currentTarget.value)}
size="sm"
/>
</Group>
<div>
<Text fz="sm" fw={500} mb={4}>
Certificate File <Text span c="red">*</Text>
</Text>
{file ? (
<Card withBorder radius="sm" p="xs">
<Group gap="xs">
<IconCircleCheck size={14} color="var(--mantine-color-teal-6)" />
<Text fz="xs" flex={1} truncate>
{file.name}
</Text>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => {
setFile(null);
resetRef.current?.();
}}
>
<IconTrash size={12} />
</Button>
</Group>
</Card>
) : (
<FileButton
resetRef={resetRef}
onChange={setFile}
accept="application/pdf,image/jpeg,image/png"
>
{(props) => (
<Button
size="sm"
variant="default"
leftSection={<IconUpload size={13} />}
fullWidth
{...props}
>
Choose File (PDF / JPG / PNG)
</Button>
)}
</FileButton>
)}
</div>
<Group justify="flex-end" mt="xs">
<Button
variant="default"
onClick={() => {
reset();
onClose();
}}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
loading={submitting}
leftSection={<IconCheck size={14} />}
>
Submit
</Button>
</Group>
</Stack>
</Modal>
);
}
export default BasicSafetyTrainingPage;
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function BasicSafetyTrainingPage() {
const [record, setRecord] = useState<BSTRecord | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// Which of the five modules the seafarer actually holds. The certificate
// itself is the evidence, so this is read from the issued licences rather
// than tracked separately — two places to record it would disagree.
const { data: bst, isLoading: bstLoading } = useApiQuery<BstProgress>({
url: '/bst/my',
method: 'GET',
});
const doneByKey = new Map(
(bst?.modules ?? []).map((m) => [m.key, m.done]),
);
const days = record?.expiryDate ? daysUntil(record.expiryDate) : null;
const isExpiringSoon = days !== null && days <= 180 && days > 0;
const isExpired = days !== null && days <= 0;
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Title order={3}>Basic Safety Training Certificate</Title>
<Text fz="sm" c="dimmed">
STCW Chapter VI/1 mandatory for all seafarers before joining a vessel.
</Text>
</div>
{record && (
<Badge
size="lg"
variant="light"
color={STATUS_COLOR[record.status]}
leftSection={<IconShieldCheck size={14} />}
>
{record.status}
</Badge>
)}
</Group>
{/* Expiry alert */}
{isExpired && (
<Alert variant="light" color="red" icon={<IconAlertTriangle size={15} />}>
<Text fz="sm">
Your BST certificate has <strong>expired</strong>. Upload a renewed certificate to remain eligible.
</Text>
</Alert>
)}
{isExpiringSoon && (
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={15} />}>
<Text fz="sm">
Your BST certificate expires in <strong>{days} days</strong>. Renew before it lapses.
</Text>
</Alert>
)}
{/* Certificate card */}
{record ? (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="sm">
<ThemeIcon size={48} radius="md" color={STATUS_COLOR[record.status]} variant="light">
<IconShieldCheck size={24} />
</ThemeIcon>
<div>
<Text fw={700}>Basic Safety Training (BST)</Text>
<Text fz="xs" c="dimmed">Combined certificate all 5 STCW components</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[record.status]} variant="light">
{record.status}
</Badge>
</Group>
<Stack gap="xs" mb="md">
<Group justify="space-between">
<Text fz="sm" c="dimmed">Certificate Number</Text>
<Text fz="sm" fw={600}>{record.certNumber}</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Issuing Institution</Text>
<Text fz="sm">{record.issuer}</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Issue Date</Text>
<Text fz="sm">{formatDate(record.issueDate)}</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Expiry Date</Text>
<Text
fz="sm"
fw={600}
c={isExpired ? 'red' : isExpiringSoon ? 'orange' : undefined}
>
{formatDate(record.expiryDate)}
{days !== null && days > 0 && (
<Text span fz="xs" c="dimmed" ml={6}>({days} days remaining)</Text>
)}
</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">File</Text>
<Text fz="sm">{record.fileName}</Text>
</Group>
</Stack>
<Group gap="xs">
<Button size="sm" variant="light" leftSection={<IconDownload size={14} />}>
Download
</Button>
<Button
size="sm"
variant="subtle"
color="orange"
leftSection={<IconRefresh size={14} />}
onClick={() => setModalOpen(true)}
>
Replace / Renew
</Button>
</Group>
</Paper>
) : (
<Paper withBorder radius="lg" p="xl" style={{ borderStyle: 'dashed' }}>
<Stack align="center" gap="md">
<ThemeIcon size={64} radius="xl" color="gray" variant="light">
<IconShieldCheck size={32} />
</ThemeIcon>
<div style={{ textAlign: 'center' }}>
<Text fw={700} fz="lg" mb={4}>No BST Certificate Uploaded</Text>
<Text fz="sm" c="dimmed" maw={420}>
You must upload a valid Basic Safety Training certificate issued by an
EMA-approved institution before applying for a Seaman Book.
</Text>
</div>
<Button
leftSection={<IconUpload size={16} />}
onClick={() => setModalOpen(true)}
size="md"
>
Upload BST Certificate
</Button>
</Stack>
</Paper>
)}
{/* Components covered */}
<Paper withBorder radius="lg" p="lg">
<Group gap="xs" mb="md">
<IconBook2 size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">Certificate Components (STCW VI/1)</Text>
</Group>
<Text fz="xs" c="dimmed" mb="sm">
A combined BST certificate from an EMA-approved institution covers all five components:
</Text>
<List
spacing="xs"
size="sm"
icon={
<ThemeIcon size={18} radius="xl" color="teal" variant="light">
<IconCheck size={11} />
</ThemeIcon>
}
>
{BST_COMPONENTS.map((c) => {
const done = doneByKey.get(c.short);
return (
<List.Item
key={c.short}
icon={
<ThemeIcon
size={18}
radius="xl"
color={done ? 'teal' : 'gray'}
variant={done ? 'light' : 'outline'}
>
<IconCheck size={11} />
</ThemeIcon>
}
>
<Group gap="xs" display="inline-flex">
<Text fz="sm" fw={600}>{c.short}</Text>
<Text fz="sm" c="dimmed"> {c.label}</Text>
<Badge size="xs" variant="outline" color="gray">{c.course}</Badge>
{/* Only stated once known: an absent badge reads as "not
loaded", where a "Not held" badge would read as fact. */}
{!bstLoading && (
<Badge size="xs" variant="light" color={done ? 'teal' : 'gray'}>
{done ? 'Held' : 'Not held'}
</Badge>
)}
</Group>
</List.Item>
);
})}
</List>
</Paper>
{/* Info */}
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="xs">
<IconCalendar size={16} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">Validity & Renewal</Text>
</Group>
<Box>
<Text fz="xs" c="dimmed" lh={1.6}>
BST certificates are typically valid for <strong>5 years</strong>. PST and FPFF components
require evidence of maintained competence at the 5-year point (STCW Reg. VI/1).
EFA and PSSR do not have a mandatory 5-year revalidation under STCW but your
institution's combined certificate carries a unified expiry date.
Certificates must be from <strong>EMA-approved training institutions</strong>.
</Text>
</Box>
</Paper>
<UploadModal
opened={modalOpen}
onClose={() => setModalOpen(false)}
onUploaded={(rec) => setRecord(rec)}
/>
</Stack>
);
}

View File

@@ -0,0 +1,373 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Loader,
Modal,
Paper,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
Tooltip,
rem,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconArrowRight,
IconBook2,
IconCertificate,
IconClock,
IconDownload,
IconEye,
IconInfoCircle,
IconShieldCheck,
} from '@tabler/icons-react';
import { authStorage, useCurrentProfile } from '@ema-platform/auth';
import { useApiQuery } from '@ema-platform/api';
import {
useGetMySeaServiceRecordsQuery,
useGetMyMedicalCertificatesQuery,
} from '@ema-platform/api';
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
/** What `/certificates/my` returns: what is held, and what is still in flight. */
interface CertificatesOverview {
certificates: {
id: string;
type: string;
issued: string;
expiry: string;
status: string;
}[];
applications: {
id: string;
applicationId: string;
type: string;
submitted: string;
status: string;
}[];
}
/**
* Badge colour per workflow status.
*
* Keyed by the values the API reports rather than display strings, so an
* unmapped status falls back to grey instead of rendering colourless.
*/
const STATUS_COLOR: Record<string, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'yellow',
UNDER_EVALUATION: 'yellow',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'grape',
INSPECTION_COMPLETED: 'grape',
ELIGIBILITY_APPROVED: 'teal',
EXAM_PAYMENT_PENDING: 'orange',
EXAM_PAID: 'blue',
EXAM_SCHEDULED: 'indigo',
EXAM_PASSED: 'teal',
EXAM_FAILED: 'red',
APPROVED: 'teal',
REJECTED: 'red',
PAYMENT_PENDING: 'orange',
PAID: 'blue',
PAYMENT_CONFIRMED: 'blue',
CERTIFICATE_ISSUED: 'teal',
COMPLETED: 'teal',
ACTIVE: 'teal',
EXPIRED: 'red',
SUSPENDED: 'orange',
CANCELLED: 'gray',
SUPERSEDED: 'gray',
};
/** Turns `EXAM_PAYMENT_PENDING` into something a person reads. */
function humanStatus(status: string): string {
return status
.toLowerCase()
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
return new Date(value).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
const API_BASE =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
async function generateCertificate(profileId: string): Promise<Blob> {
const token = authStorage.getToken();
if (!token) throw new Error('No auth token found');
const res = await fetch(
`${API_BASE}/profiles/generate-seafarer-certificate/${profileId}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) throw new Error(`Failed to generate certificate (${res.status})`);
return res.blob();
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function CertificatesPage() {
const navigate = useNavigate();
const profileId = authStorage.getProfileId() ?? '';
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewTitle, setPreviewTitle] = useState('');
const [loading, setLoading] = useState(false);
const { data } = useApiQuery<CertificatesOverview>({
url: '/certificates/my',
method: 'GET',
});
const certificates = data?.certificates ?? [];
const applications = data?.applications ?? [];
// eligibleForCoc is computed server-side (seafarer registration approved,
// plus a verified sea service record and a verified medical certificate)
// so the button and the API's own eligibility check can never disagree.
// The three queries below only build the human-readable reason list for
// the tooltip/banner — the gate itself is the one boolean.
const { profile, eligibleForCoc: canApply } = useCurrentProfile();
const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery();
const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery();
const seafarerApproved = profile?.seafarerStatus === 'ACTIVE';
const hasVerifiedSeaService = (seaServiceRecords ?? []).some((r) => r.status === 'VERIFIED');
const hasVerifiedMedical = (medicalCertificates ?? []).some((c) => c.status === 'VERIFIED');
const missingReasons = [
!seafarerApproved && 'Your seafarer registration is not yet approved.',
!hasVerifiedSeaService && 'No verified sea service record on file.',
!hasVerifiedMedical && 'No verified medical certificate on file.',
].filter((r): r is string => Boolean(r));
const openPreview = async (profileId: string, title: string) => {
setLoading(true);
try {
const blob = await generateCertificate(profileId);
const url = URL.createObjectURL(blob);
setPreviewTitle(title);
setPreviewUrl(url);
} catch (err) {
notifications.show({
color: 'red',
title: 'Error',
message: err instanceof Error ? err.message : 'Could not generate certificate',
});
} finally {
setLoading(false);
}
};
const handleDownload = async (profileId: string, title: string) => {
try {
const blob = await generateCertificate(profileId);
downloadBlob(blob, `certificate-${Date.now()}.pdf`);
notifications.show({
color: 'teal',
title: 'Downloaded',
message: 'Certificate PDF downloaded successfully',
});
} catch (err) {
notifications.show({
color: 'red',
title: 'Error',
message: err instanceof Error ? err.message : 'Could not download certificate',
});
}
};
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Title order={3}>Certificates (CoC / CoP)</Title>
<Text fz="sm" c="dimmed">Certificate of Competency and Certificate of Proficiency under STCW</Text>
</div>
<Tooltip
label={missingReasons.join(' ')}
disabled={canApply}
multiline
w={260}
events={{ hover: true, focus: true, touch: true }}
>
{/* Tooltip needs a hoverable child even while the button itself is
disabled, so the reason still shows on hover. */}
<span>
<Button
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/certificates/apply')}
disabled={!canApply}
>
Apply for CoC / CoP
</Button>
</span>
</Tooltip>
</Group>
{!canApply && (
<Alert variant="light" color="orange" icon={<IconInfoCircle size={15} />}>
<Text fz="sm" fw={600}>Not yet eligible to apply</Text>
<Text fz="xs" c="dimmed">{missingReasons.join(' ')}</Text>
</Alert>
)}
{/* Info banner */}
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="md" wrap="nowrap">
<ThemeIcon size={48} radius="md" color="blue" variant="light"><IconShieldCheck size={24} /></ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} fz="sm">What is a CoC / CoP?</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
{[
{ icon: IconBook2, color: 'blue', title: 'Certificate of Competency (CoC)', desc: 'Authorizes the holder to serve as an officer or master on board a ship under STCW.' },
{ icon: IconCertificate, color: 'teal', title: 'Certificate of Proficiency (CoP)', desc: 'Certifies completion of specific STCW training for specialized duties on board.' },
{ icon: IconClock, color: 'orange', title: 'Validity', desc: 'CoC/CoP certificates are valid for 5 years and must be revalidated before expiry.' },
].map(({ icon: Icon, color, title, desc }) => (
<Card key={title} withBorder radius="md" p="sm">
<Group gap="xs" mb={4}>
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
<Text fz="xs" fw={700}>{title}</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
</Card>
))}
</SimpleGrid>
</Stack>
</Group>
</Paper>
{/* Active applications */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Applications</Text>
{applications.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No active CoC/CoP applications. Click "Apply for CoC / CoP" to start.
</Alert>
) : (
<Table highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['App ID', 'Certificate Type', 'Submitted', 'Exam Date / Status Note', '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>
{applications.map((app) => (
<Table.Tr key={app.id}>
<Table.Td><Text fz="xs" fw={600} c="blue.7">{app.id}</Text></Table.Td>
<Table.Td><Text fz="xs" fw={500} maw={220} style={{ lineHeight: 1.3 }}>{app.type}</Text></Table.Td>
<Table.Td><Text fz="xs">{formatDate(app.submitted)}</Text></Table.Td>
<Table.Td>
<Text fz="xs" c="dimmed" maw={200} lh={1.3}>
{humanStatus(app.status)}
</Text>
</Table.Td>
<Table.Td>
<Badge
color={STATUS_COLOR[app.status] ?? 'gray'}
variant="light"
size="sm"
>
{humanStatus(app.status)}
</Badge>
</Table.Td>
<Table.Td>
<Text
fz="xs"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/applications/${app.applicationId}`)}
>
Details
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Paper>
{/* Issued certificates */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Certificates</Text>
{certificates.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No certificates issued yet.
</Alert>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{certificates.map((cert) => (
<Card key={cert.id} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShieldCheck size={18} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">{cert.type}</Text>
<Text fz="xs" c="dimmed">{cert.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[cert.status] ?? "gray"} variant="light">{cert.status}</Badge>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{cert.issued}</Text></div>
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{cert.expiry}</Text></div>
</SimpleGrid>
<Group mt="sm" gap="xs">
<Button size="xs" variant="light" leftSection={loading ? <Loader size={12} /> : <IconEye size={12} />} onClick={() => openPreview(profileId, cert.type)}>View</Button>
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />} onClick={() => handleDownload(profileId, cert.type)}>Download</Button>
</Group>
</Card>
))}
</SimpleGrid>
)}
</Paper>
{/* Preview modal */}
<Modal
opened={!!previewUrl}
onClose={() => setPreviewUrl(null)}
title={<Text fw={700} fz="sm">{previewTitle}</Text>}
size="95vw"
radius="lg"
fullScreen
>
<iframe
src={previewUrl ?? ''}
style={{ width: '100%', height: '90vh', border: 'none', borderRadius: 8 }}
title={previewTitle}
/>
</Modal>
</Stack>
);
}

View File

@@ -1,64 +0,0 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconCertificate } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
export function certificateColumns(deps: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
onDownload: (license: IssuedLicense) => void;
}): AdvancedColumn<IssuedLicense>[] {
return [
{
header: 'Certificate №',
cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}>
{row.original.certificateNumber}
</Text>
),
},
{
header: 'Type',
cell: ({ row }) => deps.localized(row.original.licenseType?.name),
},
{
header: 'Issued',
cell: ({ row }) => deps.showDate(row.original.issueDate),
},
{
header: 'Expires',
cell: ({ row }) => deps.showDate(row.original.expiryDate),
},
{
header: 'Status',
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={row.original.status === 'ACTIVE' ? 'green' : 'red'}
>
{row.original.status}
</Badge>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) =>
deps.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) ? (
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => deps.onDownload(row.original)}
>
Download
</Button>
) : null,
},
];
}

View File

@@ -1,247 +0,0 @@
import {
Alert,
Badge,
Button,
Card,
Group,
List,
Loader,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconCircleCheck,
IconCircleX,
IconInfoCircle,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import {
STATUS_COLORS,
STATUS_LABELS,
TERMINAL_STATUSES,
extractErrorMessage,
useLocalized,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetMyMedicalCertificatesQuery,
useGetMySeaTimeQuery,
} from '@ema-platform/api';
import { useCurrentProfile, usePermissions } from '@ema-platform/auth';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { certificateColumns } from './columns';
const CERTIFICATE_TYPE_KEYS = [
'CERTIFICATE_OF_COMPETENCY',
'CERTIFICATE_OF_PROFICIENCY',
];
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
return (
<List.Item
icon={
<ThemeIcon
color={ok ? 'teal' : 'red'}
variant="light"
size="sm"
radius="xl"
>
{ok ? <IconCircleCheck size={14} /> : <IconCircleX size={14} />}
</ThemeIcon>
}
>
{label}
</List.Item>
);
}
/**
* CoC / CoP home (US-CERT-002…004, 009): eligibility at a glance, the two
* application entry points, and the seafarer's certificate applications and
* issued certificates. The wizard itself is the config-driven licensing flow.
*/
export function CertificatesPage() {
const navigate = useNavigate();
const { profile, isLoading: loadingProfile } = useCurrentProfile();
const { data: seaTime } = useGetMySeaTimeQuery();
const { data: medicals } = useGetMyMedicalCertificatesQuery();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const { data: licenses, refetch: refetchLicenses } = useGetMyLicensesQuery();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const showDate = useDateDisplayer();
const localized = useLocalized();
const issuedTable = useServerTable();
const { can } = usePermissions();
const registered =
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
const today = new Date().toISOString().slice(0, 10);
const hasMedical = (medicals ?? []).some(
(certificate) =>
certificate.status !== 'REJECTED' && certificate.expiryDate >= today,
);
const verifiedDays = seaTime?.totalDays ?? 0;
const certificateApplications = (applications?.items ?? []).filter((app) =>
CERTIFICATE_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
);
const inFlight = certificateApplications.filter(
(app) => !TERMINAL_STATUSES.includes(app.status),
);
const issued = (licenses?.items ?? []).filter((license) =>
CERTIFICATE_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
);
async function download(licenseId: string) {
try {
const result = await getCertificateUrl(licenseId).unwrap();
window.open(result.url, '_blank', 'noopener');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not fetch certificate'));
}
}
if (loadingProfile || loadingApplications) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
const pagedIssued = issuedTable.paginate(issued);
return (
<Stack maw={860} mx="auto">
<Title order={2}>My Certificates</Title>
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600} mb={6}>
Eligibility
</Text>
<List spacing={4} size="sm">
<EligibilityItem
ok={registered}
label={
registered
? `Registered seafarer (${profile?.seafarerNumber})`
: 'Active seafarer registration required'
}
/>
<EligibilityItem
ok={hasMedical}
label={
hasMedical
? 'Current medical certificate on file'
: 'A current medical certificate is required'
}
/>
<EligibilityItem
ok={verifiedDays > 0}
label={`Verified sea time: ${verifiedDays} days (CoC needs 360, CoP 90)`}
/>
</List>
</div>
<Stack gap="xs">
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() =>
navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')
}
>
Apply for CoC
</Button>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() =>
navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')
}
>
Apply for CoP
</Button>
</Stack>
</Group>
{!registered && (
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
Complete your{' '}
<Text
component="span"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/seafarer-registration')}
>
seafarer registration
</Text>{' '}
first certificate applications are refused without it.
</Alert>
)}
</Card>
{inFlight.length > 0 && (
<Stack gap="xs">
<Title order={4}>Applications in progress</Title>
{inFlight.map((app) => (
<Card key={app.id} withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600}>{app.applicationNumber}</Text>
<Text size="xs" c="dimmed">
{localized(app.licenseType?.name)}
</Text>
</div>
<Group>
<Badge color={STATUS_COLORS[app.status]}>
{STATUS_LABELS[app.status]}
</Badge>
<Button
size="compact-sm"
variant="light"
onClick={() =>
navigate(
`/licensing/${app.licenseType?.key}/applications/${app.id}`,
)
}
>
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
? 'Continue'
: 'View'}
</Button>
</Group>
</Group>
</Card>
))}
</Stack>
)}
<Stack gap="xs">
<Title order={4}>Issued certificates</Title>
<AdvancedTable
tableName="Issued certificates"
columns={certificateColumns({
can,
localized,
showDate,
onDownload: (license) => download(license.id),
})}
data={pagedIssued.rows}
itemCount={pagedIssued.itemCount}
pageIndex={pagedIssued.pageIndex}
onPageChange={issuedTable.setPageIndex}
pageSize={issuedTable.pageSize}
refresh={refetchLicenses}
emptyText="No certificates issued yet."
/>
</Stack>
</Stack>
);
}
export default CertificatesPage;

View File

@@ -1,255 +1,476 @@
import { useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
FileInput,
Group,
List,
Loader,
Modal,
Paper,
SimpleGrid,
Stack,
Table,
Stepper,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconArrowLeft,
IconArrowRight,
IconCertificate,
IconCheck,
IconCircleCheck,
IconCircleX,
IconClock,
IconDownload,
IconEye,
IconFileDescription,
IconInfoCircle,
IconRubberStamp,
IconShieldCheck,
IconUpload,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import {
STATUS_COLORS,
STATUS_LABELS,
TERMINAL_STATUSES,
extractErrorMessage,
useLocalized,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
} from '@ema-platform/api';
import { useCurrentProfile } from '@ema-platform/auth';
import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
// ---------------------------------------------------------------------------
// Mock data — existing endorsement applications
// ---------------------------------------------------------------------------
/** What `/endorsements/my` returns. */
interface EndorsementsOverview {
issued: {
id: string;
endorsementNo: string;
cocType: string;
foreignCocNo: string;
issuingCountry: string;
issued: string;
expiry: string;
status: string;
}[];
applications: {
id: string;
applicationId: string;
cocType: string;
foreignCocNo: string;
issuingCountry: string;
submitted: string;
status: string;
}[];
}
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
// Keyed by the workflow's own status values so an unmapped one falls back to
// grey rather than rendering colourless.
const STATUS_COLOR: Record<string, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'yellow',
UNDER_EVALUATION: 'yellow',
RESUBMIT_REQUIRED: 'orange',
APPROVED: 'teal',
REJECTED: 'red',
PAYMENT_PENDING: 'orange',
PAYMENT_CONFIRMED: 'blue',
CERTIFICATE_ISSUED: 'teal',
COMPLETED: 'teal',
ACTIVE: 'teal',
EXPIRED: 'red',
SUSPENDED: 'orange',
};
function humanStatus(status: string): string {
return status
.toLowerCase()
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
return new Date(value).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
// blank PDF
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
// ---------------------------------------------------------------------------
// Application wizard
// ---------------------------------------------------------------------------
interface Docs {
foreignCoc: File | null;
translation: File | null;
medical: File | null;
seamanBook: File | null;
photo: File | null;
}
function ApplicationWizard({ onDone }: { onDone: () => void }) {
const [step, setStep] = useState(0);
const [cocNo, setCocNo] = useState('');
const [issuer, setIssuer] = useState('');
const [country, setCountry] = useState('');
const [cocType, setCocType] = useState('');
const [issueDate, setIssueDate] = useState('');
const [expiryDate, setExpiryDate] = useState('');
const [docs, setDocs] = useState<Docs>({ foreignCoc: null, translation: null, medical: null, seamanBook: null, photo: null });
const [submitted, setSubmitted] = useState(false);
const step0Ok = !!cocNo && !!issuer && !!country && !!cocType && !!issueDate && !!expiryDate;
const step1Ok = !!docs.foreignCoc && !!docs.medical && !!docs.seamanBook && !!docs.photo;
if (submitted) {
return (
<List.Item
icon={
<ThemeIcon
color={ok ? 'teal' : 'red'}
variant="light"
size="sm"
radius="xl"
>
{ok ? <IconCircleCheck size={14} /> : <IconCircleX size={14} />}
</ThemeIcon>
<Stack gap="lg" align="center" py="xl">
<ThemeIcon size={72} radius="xl" color="teal" variant="light"><IconCircleCheck size={40} /></ThemeIcon>
<Title order={3} ta="center">Application Submitted</Title>
<Text c="dimmed" ta="center" maw={400}>
Your endorsement application has been submitted. EMA officers will verify your documents
and notify you of the outcome. Reference: <strong>END-APP-2025-NEW</strong>
</Text>
<Button onClick={onDone}>Back to Endorsements</Button>
</Stack>
);
}
>
{label}
return (
<Stack gap="lg">
<Stepper active={step} size="sm">
<Stepper.Step label="Foreign CoC Details" description="Certificate information" />
<Stepper.Step label="Upload Documents" description="Required documents" />
<Stepper.Step label="Payment" description="Pay endorsement fee" />
<Stepper.Step label="Review & Submit" description="Final check" />
</Stepper>
{/* Step 0 — Foreign CoC details */}
{step === 0 && (
<Paper withBorder radius="lg" p="xl">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="lg">
<Text fz="sm">
<strong>STCW Regulation I/10</strong> EMA will endorse your foreign CoC so it is
recognised for service on Ethiopian-flagged vessels. The endorsement is valid
for the same period as your foreign CoC.
</Text>
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="Foreign CoC Number" placeholder="e.g. PHL-COC-2022-0045" value={cocNo} onChange={(e) => setCocNo(e.currentTarget.value)} required />
<TextInput label="Issuing Country" placeholder="e.g. Philippines" value={country} onChange={(e) => setCountry(e.currentTarget.value)} required />
<TextInput label="Issuing Authority / Administration" placeholder="e.g. Maritime Industry Authority (MARINA)" value={issuer} onChange={(e) => setIssuer(e.currentTarget.value)} required />
<TextInput label="Certificate Type" placeholder="e.g. Officer in Charge of a Navigational Watch" value={cocType} onChange={(e) => setCocType(e.currentTarget.value)} required />
<TextInput label="Issue Date" type="date" value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} required />
<TextInput label="Expiry Date" type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.currentTarget.value)} required />
</SimpleGrid>
</Paper>
)}
{/* Step 1 — Documents */}
{step === 1 && (
<Stack gap="md">
<Alert variant="light" color="yellow" icon={<IconAlertCircle size={15} />}>
<Text fz="sm">
A <strong>certified translation</strong> is required if your foreign CoC is not in English.
All documents must be clear, legible, and complete.
</Text>
</Alert>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Required Documents</Text>
<Stack gap="md">
{[
{ key: 'foreignCoc' as keyof Docs, label: 'Foreign CoC (Original or Certified Copy)', required: true },
{ key: 'translation' as keyof Docs, label: 'Certified Translation (only if CoC is not in English)', required: false },
{ key: 'medical' as keyof Docs, label: 'Valid Medical Fitness Certificate (STCW Reg I/2)', required: true },
{ key: 'seamanBook' as keyof Docs, label: 'Ethiopian Seaman Book', required: true },
{ key: 'photo' as keyof Docs, label: 'Passport-Size Photo', required: true },
].map((slot) => (
<FileInput
key={slot.key}
label={<Group gap={4}><Text fz="sm" fw={500}>{slot.label}</Text>{slot.required && <Badge size="xs" color="red" variant="light">Required</Badge>}</Group>}
placeholder="Click to upload"
leftSection={<IconUpload size={14} />}
value={docs[slot.key]}
onChange={(f) => setDocs((prev) => ({ ...prev, [slot.key]: f }))}
accept=".pdf,.jpg,.jpeg,.png"
clearable
/>
))}
</Stack>
</Paper>
{/* Upload checklist */}
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fz="xs" fw={700} mb="sm" tt="uppercase" c="dimmed">Upload Checklist</Text>
<Stack gap={4}>
{[
{ label: 'Foreign CoC', done: !!docs.foreignCoc },
{ label: 'Medical Cert', done: !!docs.medical },
{ label: 'Seaman Book', done: !!docs.seamanBook },
{ label: 'Photo', done: !!docs.photo },
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={18} radius="xl" color={item.done ? 'teal' : 'gray'} variant={item.done ? 'filled' : 'light'}>
{item.done ? <IconCheck size={11} /> : <IconFileDescription size={11} />}
</ThemeIcon>
<Text fz="xs" c={item.done ? undefined : 'dimmed'}>{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
)}
{/* Step 2 — Payment */}
{step === 2 && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Endorsement Fee</Text>
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
{[
{ label: 'Application Processing Fee', amount: 300 },
{ label: 'Document Verification Fee', amount: 200 },
{ label: 'Endorsement Issuance Fee', amount: 500 },
].map(({ label, amount }) => (
<Group key={label} justify="space-between" mb="xs">
<Text fz="sm">{label}</Text>
<Text fz="sm" fw={600}>ETB {amount}</Text>
</Group>
))}
<Divider my="xs" />
<Group justify="space-between">
<Text fw={800}>Total</Text>
<Text fw={800} fz="lg" c="blue">ETB 1,000</Text>
</Group>
</Paper>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
<Text fz="sm">
Transfer the fee to <strong>CBE Account: 1000-XXXXX-EMA</strong> and upload the receipt below.
</Text>
</Alert>
<FileInput label="Payment Receipt" placeholder="Upload bank transfer receipt" leftSection={<IconUpload size={14} />} mt="md" accept=".pdf,.jpg,.jpeg,.png" />
</Paper>
)}
{/* Step 3 — Review */}
{step === 3 && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="lg">Review Your Application</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="lg">
{[
['CoC Number', cocNo],
['Country', country],
['Issuer', issuer],
['CoC Type', cocType],
['Issue Date', issueDate],
['Expiry Date', expiryDate],
].map(([label, value]) => (
<div key={label}>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
</div>
))}
</SimpleGrid>
<Divider mb="md" />
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb="xs">Uploaded Documents</Text>
<List spacing="xs" size="sm">
{[
{ label: 'Foreign CoC', file: docs.foreignCoc },
{ label: 'Medical Certificate', file: docs.medical },
{ label: 'Seaman Book', file: docs.seamanBook },
{ label: 'Photo', file: docs.photo },
{ label: 'Translation', file: docs.translation },
].map(({ label, file }) => file && (
<List.Item key={label} icon={<ThemeIcon size={18} radius="xl" color="teal" variant="filled"><IconCheck size={11} /></ThemeIcon>}>
<Text fz="sm">{label}: <Text span c="blue.7">{file.name}</Text></Text>
</List.Item>
))}
</List>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />} mt="lg">
<Text fz="xs">
By submitting you confirm that all information is accurate and the documents are genuine.
Providing false information is an offence under the Maritime Code.
</Text>
</Alert>
</Paper>
)}
{/* Navigation */}
<Group justify="space-between" mt="md">
<Button variant="default" leftSection={<IconArrowLeft size={14} />} onClick={() => setStep(s => s - 1)} disabled={step === 0}>
Back
</Button>
{step < 3 ? (
<Button
rightSection={<IconArrowRight size={14} />}
disabled={(step === 0 && !step0Ok) || (step === 1 && !step1Ok)}
onClick={() => setStep(s => s + 1)}
>
Next
</Button>
) : (
<Button color="teal" leftSection={<IconCircleCheck size={14} />} onClick={() => setSubmitted(true)}>
Submit Application
</Button>
)}
</Group>
</Stack>
);
}
/**
* Endorsements home, mirroring CertificatesPage: eligibility at a glance, the
* two application entry points (CoC / GOC), and the seafarer's endorsement
* applications and issued endorsements. The wizard itself is the
* config-driven licensing flow.
*/
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function EndorsementPage() {
const navigate = useNavigate();
const { profile, isLoading: loadingProfile } = useCurrentProfile();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const showDate = useDateDisplayer();
const localized = useLocalized();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const [applying, setApplying] = useState(false);
const [previewId, setPreviewId] = useState<string | null>(null);
const registered =
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
const { data } = useApiQuery<EndorsementsOverview>({
url: '/endorsements/my',
method: 'GET',
});
const endorsementApps = data?.applications ?? [];
const issuedEndorsements = data?.issued ?? [];
const endorsementApplications = (applications?.items ?? []).filter((app) =>
ENDORSEMENT_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
);
const inFlight = endorsementApplications.filter(
(app) => !TERMINAL_STATUSES.includes(app.status),
);
const issued = (licenses?.items ?? []).filter((license) =>
ENDORSEMENT_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
);
async function download(licenseId: string) {
try {
const result = await getCertificateUrl(licenseId).unwrap();
window.open(result.url, '_blank', 'noopener');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not fetch endorsement'));
}
}
if (loadingProfile || loadingApplications) {
if (applying) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Stack maw={860} mx="auto">
<Title order={2}>My Endorsements</Title>
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start">
<Stack gap="md">
<Group gap="sm">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => setApplying(false)}>Back</Button>
<div>
<Text fw={600} mb={6}>
Eligibility
</Text>
<List spacing={4} size="sm">
<EligibilityItem
ok={registered}
label={
registered
? `Registered seafarer (${profile?.seafarerNumber})`
: 'Active seafarer registration required'
}
/>
</List>
<Title order={3}>Apply for Endorsement</Title>
<Text fz="sm" c="dimmed">STCW Reg I/10 Flag State Endorsement of Foreign CoC</Text>
</div>
<Stack gap="xs">
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')}
>
Endorse a CoC
</Button>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')}
>
Endorse a GOC
</Group>
<ApplicationWizard onDone={() => setApplying(false)} />
</Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Title order={3}>Endorsements</Title>
<Text fz="sm" c="dimmed">STCW Reg I/10 Flag-state endorsement of foreign-issued Certificates of Competency</Text>
</div>
<Button leftSection={<IconRubberStamp size={15} />} rightSection={<IconArrowRight size={15} />} onClick={() => setApplying(true)}>
Apply for Endorsement
</Button>
</Group>
{/* Info panel */}
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="md" wrap="nowrap">
<ThemeIcon size={48} radius="md" color="blue" variant="light"><IconRubberStamp size={24} /></ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} fz="sm">What is an Endorsement?</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
{[
{ icon: IconShieldCheck, color: 'blue', title: 'Flag-State Recognition', desc: 'Under STCW Reg I/10, Ethiopia (flag state) must endorse foreign CoC certificates before a seafarer can serve on Ethiopian-flagged vessels.' },
{ icon: IconCircleCheck, color: 'teal', title: 'Co-Terminous Validity', desc: 'The endorsement is valid for the same period as your foreign CoC. It must be revalidated whenever the foreign CoC is revalidated.' },
{ icon: IconClock, color: 'orange', title: 'Processing Time', desc: 'Typical processing time is 1015 working days after all documents are verified.' },
].map(({ icon: Icon, color, title, desc }) => (
<Card key={title} withBorder radius="md" p="sm">
<Group gap="xs" mb={4}>
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
<Text fz="xs" fw={700}>{title}</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
</Card>
))}
</SimpleGrid>
</Stack>
</Group>
{!registered && (
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
Complete your{' '}
<Text
component="span"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/seafarer-registration')}
>
seafarer registration
</Text>{' '}
first endorsement applications are refused without it.
</Paper>
{/* Active applications */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Endorsement Applications</Text>
{endorsementApps.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No active endorsement applications.
</Alert>
)}
</Card>
{inFlight.length > 0 && (
<Stack gap="xs">
<Title order={4}>Applications in progress</Title>
{inFlight.map((app) => (
<Card key={app.id} withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600}>{app.applicationNumber}</Text>
<Text size="xs" c="dimmed">
{localized(app.licenseType?.name)}
</Text>
</div>
<Group>
<Badge color={STATUS_COLORS[app.status]}>
{STATUS_LABELS[app.status]}
</Badge>
<Button
size="compact-sm"
variant="light"
onClick={() =>
navigate(
`/licensing/${app.licenseType?.key}/applications/${app.id}`,
)
}
>
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
? 'Continue'
: 'View'}
</Button>
</Group>
</Group>
</Card>
))}
</Stack>
)}
<Stack gap="xs">
<Title order={4}>Issued endorsements</Title>
{issued.length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
No endorsements issued yet.
</Text>
</Card>
) : (
<Table.ScrollContainer minWidth={640}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Certificate </Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Expires</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{issued.map((license) => (
<Table.Tr key={license.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{license.certificateNumber}
</Text>
</Table.Td>
<Table.Td>{localized(license.licenseType?.name)}</Table.Td>
<Table.Td>{showDate(license.issueDate)}</Table.Td>
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={license.status === 'ACTIVE' ? 'green' : 'red'}
>
{license.status}
</Badge>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => download(license.id)}
>
Download
</Button>
</Table.Td>
</Table.Tr>
<Stack gap="sm">
{endorsementApps.map((app) => (
<Paper key={app.id} withBorder radius="md" p="md">
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
<div>
<Text fz="sm" fw={700}>{app.cocType}</Text>
<Text fz="xs" c="dimmed">CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {formatDate(app.submitted)}</Text>
</div>
</Group>
<Group gap="xs">
<Badge color={STATUS_COLOR[app.status] ?? "gray"} variant="light">{humanStatus(app.status)}</Badge>
<Text fz="xs" c="blue.7" fw={600}>{app.id}</Text>
</Group>
</Group>
<Alert variant="light" color={STATUS_COLOR[app.status] ?? "gray"} icon={<IconInfoCircle size={13} />} p="xs" mt="sm">
<Text fz="xs">{humanStatus(app.status)}</Text>
</Alert>
</Paper>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
)}
</Paper>
{/* Issued endorsements */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Endorsements</Text>
{issuedEndorsements.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No endorsements issued yet.
</Alert>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{issuedEndorsements.map((end) => (
<Card key={end.id} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">{end.cocType}</Text>
<Text fz="xs" c="dimmed">{end.endorsementNo}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[end.status] ?? "gray"} variant="light">{humanStatus(end.status)}</Badge>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs" mb="sm">
<div><Text fz="xs" c="dimmed">Foreign CoC No.</Text><Text fz="sm" fw={500}>{end.foreignCocNo}</Text></div>
<div><Text fz="xs" c="dimmed">Issuing Country</Text><Text fz="sm" fw={500}>{end.issuingCountry}</Text></div>
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{end.issued}</Text></div>
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{end.expiry}</Text></div>
</SimpleGrid>
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />} onClick={() => setPreviewId(end.id)}>View</Button>
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
</Card>
))}
</SimpleGrid>
)}
</Paper>
{/* Preview modal */}
<Modal
opened={!!previewId}
onClose={() => setPreviewId(null)}
title={<Text fw={700} fz="sm">Endorsement Certificate</Text>}
size="xl"
radius="lg"
>
<iframe src={BLANK_PDF} style={{ width: '100%', height: '70vh', border: 'none', borderRadius: rem(8) }} title="Endorsement" />
</Modal>
</Stack>
);
}
export default EndorsementPage;

View File

@@ -0,0 +1,82 @@
import { Button } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { LicenseApplication } from '@ema-platform/api';
interface Props {
app: LicenseApplication;
t: TFunction;
requesting: boolean;
paying: boolean;
onRequestExamFee: (app: LicenseApplication) => void;
onPay: (app: LicenseApplication) => void;
}
/**
* What the candidate can do while an examined certificate is in its exam leg.
*
* Kept apart from the general actions column because these statuses only ever
* occur on types that examine — folding them into that column would put five
* more branches into a cell that already reads as a chain of ternaries.
*
* Returns null for every other status, so the caller can render it
* unconditionally.
*/
export function ExamStageActions({
app,
t,
requesting,
paying,
onRequestExamFee,
onPay,
}: Props) {
// Eligible but not yet committed to sitting, or sat and not passed: both are
// the same decision — ask for the fee that buys a sitting.
if (app.status === 'ELIGIBILITY_APPROVED' || app.status === 'EXAM_FAILED') {
const retake = app.status === 'EXAM_FAILED';
return (
<Button
size="xs"
variant="filled"
color={retake ? 'orange' : 'teal'}
loading={requesting}
onClick={() => onRequestExamFee(app)}
>
{retake
? t('applications.actions.bookRetake', 'Book a resit')
: t('applications.actions.bookExam', 'Book exam')}
</Button>
);
}
if (app.status === 'EXAM_PAYMENT_PENDING') {
return (
<Button
size="xs"
variant="filled"
color="yellow"
loading={paying}
onClick={() => onPay(app)}
>
{t('applications.actions.payExamFee', {
defaultValue: 'Pay exam fee ({{amount}} {{currency}})',
amount: Number(app.feeAmount ?? 0).toLocaleString(),
currency: app.feeCurrency,
})}
</Button>
);
}
// Paid and scheduled are both waiting states — nothing for the candidate to
// do, so say so rather than offering a button that does nothing.
if (app.status === 'EXAM_PAID' || app.status === 'EXAM_SCHEDULED') {
return (
<Button size="xs" variant="subtle" disabled>
{app.status === 'EXAM_PAID'
? t('applications.actions.awaitingDate', 'Awaiting exam date')
: t('applications.actions.examScheduled', 'Exam scheduled')}
</Button>
);
}
return null;
}

View File

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

View File

@@ -4,6 +4,16 @@ import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { LicenseApplication } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
import { ExamStageActions } from '../../components/ExamStageActions';
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
const EXAM_STAGE_STATUSES = [
'ELIGIBILITY_APPROVED',
'EXAM_PAYMENT_PENDING',
'EXAM_PAID',
'EXAM_SCHEDULED',
'EXAM_FAILED',
];
export function applicationActionsColumn(
t: TFunction,
@@ -13,9 +23,12 @@ export function applicationActionsColumn(
bypassEnabled: boolean;
bypassing: boolean;
isPaying: boolean;
/** True while the exam fee is being raised for a booking or a resit. */
requestingExamFee: boolean;
onBypass: (app: LicenseApplication) => void;
onCertificate: (app: LicenseApplication) => void;
onPay: (app: LicenseApplication) => void;
onRequestExamFee: (app: LicenseApplication) => void;
onOpen: (app: LicenseApplication) => void;
},
): AdvancedColumn<LicenseApplication> {
@@ -27,7 +40,20 @@ export function applicationActionsColumn(
const app = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
{deps.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
{/* Renders only during the exam leg; null everywhere else. */}
<ExamStageActions
app={app}
t={t}
requesting={deps.requestingExamFee}
paying={deps.isPaying}
onRequestExamFee={deps.onRequestExamFee}
onPay={deps.onPay}
/>
{/* Both fee stops are bypassable — an examined certificate is
otherwise untestable without a live gateway. */}
{deps.bypassEnabled &&
(app.status === 'PAYMENT_PENDING' ||
app.status === 'EXAM_PAYMENT_PENDING') && (
<Button
size="xs"
variant="default"
@@ -56,8 +82,11 @@ export function applicationActionsColumn(
</Button>
)}
{/* In PAYMENT_PENDING this button initiates payment, so it needs
that grant; every other status it merely opens the wizard. */}
{(app.status !== 'PAYMENT_PENDING' ||
that grant; every other status it merely opens the wizard.
Suppressed during the exam leg, where ExamStageActions already
supplies the action that matters. */}
{!EXAM_STAGE_STATUSES.includes(app.status) &&
(app.status !== 'PAYMENT_PENDING' ||
deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && (
<Button
size="xs"

View File

@@ -46,6 +46,7 @@ import {
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetPaymentCapabilitiesQuery,
useRequestExamPaymentMutation,
type LicenseStatus,
} from '@ema-platform/api';
import {
@@ -98,6 +99,8 @@ export function MyApplicationsPage() {
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const [requestExamPayment, { isLoading: requestingExamFee }] =
useRequestExamPaymentMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense();
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
@@ -131,6 +134,33 @@ export function MyApplicationsPage() {
}
}
/**
* Raises the examination fee, for a first sitting or a resit.
*
* Payment is a separate step: this only moves the application to
* EXAM_PAYMENT_PENDING, and the Pay button that then appears hands off to
* the provider the same way every other fee does.
*/
async function requestExamFee(applicationId: string) {
try {
await requestExamPayment(applicationId).unwrap();
notifications.show({
color: 'teal',
title: t('applications.examFeeRequested', 'Exam fee ready'),
message: t(
'applications.examFeeRequestedBody',
'Pay the examination fee and you will be scheduled for a sitting.',
),
});
} catch (err) {
notifications.show({
color: 'red',
title: t('applications.examFeeFailed', 'Could not book the exam'),
message: extractErrorMessage(err),
});
}
}
/**
* Opens the certificate belonging to an application.
*
@@ -247,9 +277,11 @@ export function MyApplicationsPage() {
bypassEnabled: capabilities?.bypassEnabled ?? false,
bypassing,
isPaying,
requestingExamFee,
onBypass: (app) => handleBypass(app.id),
onCertificate: (app) => openCertificateForApplication(app.id),
onPay: (app) => pay(app.id),
onRequestExamFee: (app) => requestExamFee(app.id),
onOpen: (app) =>
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
}),

View File

@@ -1,21 +1,339 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { useRef, useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
FileButton,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Timeline,
Title,
} from '@mantine/core';
import {
IconAlertCircle,
IconAlertTriangle,
IconCalendar,
IconCheck,
IconCircleCheck,
IconDownload,
IconFileDescription,
IconHeart,
IconInfoCircle,
IconTrash,
IconUpload,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function MedicalCertificatePage() {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Medical certificate"
description="Medical certificates are not connected to the backend yet."
/>
</Container>
);
interface MedicalCert {
id: string;
issuedBy: string;
issuedDate: string;
expiryDate: string;
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending' | 'Rejected';
restrictions: string;
/** Days left, computed server-side so every screen agrees on the date. */
daysRemaining?: number;
}
export default MedicalCertificatePage;
/** The medical card's whole state, as `/medical/my` returns it. */
interface MedicalOverview {
current: MedicalCert | null;
history: MedicalCert[];
warningDays: number;
}
const STATUS_COLOR: Record<string, string> = {
Valid: 'teal', Expiring: 'orange', Expired: 'red', Pending: 'yellow',
};
function daysUntil(dateStr: string): number {
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function MedicalCertificatePage() {
// The card's whole state comes from one call: the current certificate, the
// ones before it, and the validity the server computed. Deriving "expiring"
// in the browser would let a wrong client clock disagree with the gate that
// blocks an application.
const { data: medical } = useApiQuery<MedicalOverview>({
url: '/medical/my',
method: 'GET',
});
const current = medical?.current ?? null;
const history = medical?.history ?? [];
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [doctorName, setDoctorName] = useState('');
const [issuedDate, setIssuedDate] = useState('');
const [expiryDate, setExpiryDate] = useState('');
const [submitting, setSubmitting] = useState(false);
const resetRef = useRef<() => void>(null);
// Server's count where it gave one: it is the same figure the eligibility
// gate uses, and a browser clock that is wrong or in another timezone would
// otherwise show a different number than the officer sees.
const days = current
? (current.daysRemaining ?? daysUntil(current.expiryDate))
: 0;
const progressVal = current
? Math.max(0, Math.min(100, (days / 730) * 100))
: 0;
const handleSubmit = async () => {
if (!uploadedFile || !issuedDate || !expiryDate) {
notify.error('Please fill all fields and upload the certificate file.');
return;
}
setSubmitting(true);
await new Promise((r) => setTimeout(r, 1200));
setSubmitting(false);
notify.success('Medical certificate submitted for verification. EMA will review within 2 working days.');
setUploadedFile(null);
setDoctorName('');
setIssuedDate('');
setExpiryDate('');
resetRef.current?.();
};
return (
<Stack gap="md">
{/* Header */}
<div>
<Title order={3}>Medical Certificate</Title>
<Text fz="sm" c="dimmed">
STCW requires a valid medical certificate for all seafarers. Valid for 2 years (1 year if under 18).
</Text>
</div>
{/* Validity alert */}
{current && days <= 90 && days > 0 && (
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={17} />}>
Your medical certificate expires in <strong>{days} days</strong> ({formatDate(current.expiryDate)}).
Please visit an EMA-approved medical centre and upload your renewed certificate below.
</Alert>
)}
{current && days <= 0 && (
<Alert variant="light" color="red" icon={<IconAlertCircle size={17} />}>
Your medical certificate <strong>has expired</strong>. You cannot join a vessel until a valid certificate is uploaded and verified.
</Alert>
)}
{!current && (
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
No medical certificate on record. Upload your certificate below to complete your profile and apply for a Seaman Book.
</Alert>
)}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{/* Current certificate */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="red" size={36} radius="md">
<IconHeart size={18} />
</ThemeIcon>
<Text fw={700}>Current Certificate</Text>
</Group>
{current ? (
<Stack gap="sm">
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
<Badge color={STATUS_COLOR[current.status]} variant="light">{current.status}</Badge>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Certificate ID</Text>
<Text fz="sm">{current.id}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issued By</Text>
<Text fz="sm" ta="right" maw={200}>{current.issuedBy}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issue Date</Text>
<Text fz="sm">{formatDate(current.issuedDate)}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Expiry Date</Text>
<Text fz="sm" fw={700} c={days <= 90 ? 'orange' : days <= 0 ? 'red' : undefined}>
{formatDate(current.expiryDate)}
</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Restrictions</Text>
<Text fz="sm">{current.restrictions}</Text>
</Group>
{/* Validity bar */}
<Box mt="xs">
<Group justify="space-between" mb={4}>
<Text fz="xs" c="dimmed">Validity remaining</Text>
<Text fz="xs" fw={600} c={days <= 90 ? 'orange' : 'teal'}>{Math.max(0, days)} days</Text>
</Group>
<Progress
value={progressVal}
color={days <= 30 ? 'red' : days <= 90 ? 'orange' : 'teal'}
radius="xl"
size="sm"
/>
</Box>
<Button
size="xs"
variant="light"
leftSection={<IconDownload size={13} />}
mt="xs"
>
Download Certificate
</Button>
</Stack>
) : (
<Box ta="center" py="xl">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
<IconFileDescription size={22} />
</ThemeIcon>
<Text fz="sm" c="dimmed">No certificate on record</Text>
</Box>
)}
</Paper>
{/* Upload new certificate */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconUpload size={18} />
</ThemeIcon>
<Text fw={700}>{current ? 'Upload Renewal' : 'Upload Certificate'}</Text>
</Group>
<Stack gap="sm">
<TextInput
label="Issuing Doctor / Medical Centre"
placeholder="e.g. Dr. Alemu Bekele — EMA Medical Centre"
value={doctorName}
onChange={(e) => setDoctorName(e.currentTarget.value)}
size="sm"
/>
<SimpleGrid cols={2} spacing="sm">
<TextInput
label="Issue Date"
type="date"
value={issuedDate}
onChange={(e) => setIssuedDate(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Expiry Date"
type="date"
value={expiryDate}
onChange={(e) => setExpiryDate(e.currentTarget.value)}
size="sm"
/>
</SimpleGrid>
<div>
<Text fz="sm" fw={500} mb={4}>Certificate File <Text span c="red">*</Text></Text>
{uploadedFile ? (
<Card withBorder radius="sm" p="xs">
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{uploadedFile.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setUploadedFile(null); resetRef.current?.(); }}>
<IconTrash size={13} />
</Button>
</Group>
</Card>
) : (
<FileButton resetRef={resetRef} onChange={setUploadedFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
Choose File (PDF / JPG / PNG, max 5MB)
</Button>
)}
</FileButton>
)}
</div>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Text fz="xs">
Your certificate will be reviewed by an EMA Medical Officer within <strong>2 working days</strong>.
Notifications will be sent by email and SMS.
</Text>
</Alert>
<Button
leftSection={<IconCheck size={15} />}
onClick={handleSubmit}
loading={submitting}
disabled={!uploadedFile || !issuedDate || !expiryDate}
>
Submit for Verification
</Button>
</Stack>
</Paper>
</SimpleGrid>
{/* Notification schedule */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconCalendar size={18} />
</ThemeIcon>
<Text fw={700}>Expiry Notification Schedule</Text>
</Group>
<Timeline active={days <= 30 ? 2 : days <= 60 ? 1 : days <= 90 ? 0 : -1} bulletSize={24} lineWidth={2}>
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="90 Days Before Expiry">
<Text fz="xs" c="dimmed">First reminder time to book your medical examination</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconAlertTriangle size={13} />} title="60 Days Before Expiry">
<Text fz="xs" c="dimmed">Second reminder urgent renewal required</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="30 Days Before Expiry">
<Text fz="xs" c="dimmed">Final reminder certificate expires very soon</Text>
</Timeline.Item>
</Timeline>
</Paper>
{/* History */}
{history.length > 0 && (
<Paper withBorder radius="lg" p="lg">
<Text fw={700} mb="md">Certificate History</Text>
<Stack gap="xs">
{history.map((cert) => (
<Card key={cert.id} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="wrap" gap="xs">
<Group gap="sm">
<ThemeIcon variant="light" color="gray" size={32} radius="md">
<IconFileDescription size={16} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>{cert.id}</Text>
<Text fz="xs" c="dimmed">{formatDate(cert.issuedDate)} {formatDate(cert.expiryDate)}</Text>
</div>
</Group>
<Group gap="xs">
<Badge color={STATUS_COLOR[cert.status]} variant="light" size="sm">{cert.status}</Badge>
<Button size="xs" variant="subtle" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
</Group>
</Card>
))}
</Stack>
</Paper>
)}
</Stack>
);
}

View File

@@ -2,6 +2,25 @@ import { useNavigate } from 'react-router-dom';
import { Container, Paper, Stack, Text, Title } from '@mantine/core';
import { OperationsFormContent } from '../../profile/components/OperationsFormContent';
/**
* Where a fresh applicant lands after declaring themselves. Someone who says
* "I own a vessel" came here to register, so they are taken straight to that
* form instead of a dashboard that only links to it. A seafarer goes to
* `/profile` instead — registration is built from the profile
* (`RequireSeafarerProfile`), and a brand-new signup has none of it yet, so
* sending them straight to the wizard would only bounce them back here.
* Seafarer wins when both are ticked; the other form is one nav click away.
*/
const NEXT_STEP: Record<string, string> = {
SEAFARER_REGISTRATION: '/profile',
VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply',
};
function nextStepFor(selectedKeys: string[]): string {
const key = Object.keys(NEXT_STEP).find((k) => selectedKeys.includes(k));
return key ? NEXT_STEP[key] : '/dashboard';
}
/**
* The one thing a new applicant is asked for beyond their credentials.
*
@@ -28,7 +47,9 @@ export function OperationsOnboardingPage() {
</Text>
</div>
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<OperationsFormContent onSaved={() => navigate('/dashboard')} />
<OperationsFormContent
onSaved={(keys) => navigate(nextStepFor(keys))}
/>
</Paper>
</Stack>
</Container>

View File

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

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Badge,
Box,
Button,
@@ -30,6 +31,7 @@ import {
IconCircleCheckFilled,
IconDeviceDesktop,
IconDeviceFloppy,
IconInfoCircle,
IconLock,
IconMail,
IconBuildingWarehouse,
@@ -49,7 +51,7 @@ import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
import { useApiMutation, useLocalized } from '@ema-platform/api';
import { setUser, useCurrentProfile } from '@ema-platform/auth';
import { PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import type { AuthUser } from '@ema-platform/auth';
@@ -66,6 +68,7 @@ import {
import { useSaveMyAddressMutation } from '../api/address-api';
import { toAddressPayload } from '../types/address';
import { OperationsFormContent } from '../components/OperationsFormContent';
import { SEAFARER_PROFILE_REQUIREMENT } from '../components/RequireSeafarerProfile';
import classes from './ProfilePage.module.css';
/** Tab keys addressable via the URL hash. */
@@ -160,11 +163,22 @@ export function ProfilePage() {
isLoading: profileResolving,
completeness,
missing,
isReadyFor,
refetch: refetchProfile,
} = useCurrentProfile();
const [updateProfile] = useApiMutation<unknown>();
const [saveMyAddress, { isLoading: isSavingAddress }] = useSaveMyAddressMutation();
// Only shown to applicants who can actually register as a seafarer, and
// only while the profile is still missing what that registration is built
// from — same `SEAFARER_PROFILE_REQUIREMENT` the RequireSeafarerProfile
// gate and its redirect toast use, so all three surfaces agree on what
// "ready" means. Disappears on its own once the gaps close.
const { can } = usePermissions();
const showSeafarerBanner =
can([PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]) &&
!isReadyFor(SEAFARER_PROFILE_REQUIREMENT);
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
const [dataLoading, setDataLoading] = useState(true);
@@ -292,7 +306,7 @@ export function ProfilePage() {
saves.push(
updateProfile({
url: `/profiles/${profileId}`,
method: 'PATCH',
method: 'PUT',
body: profileName,
}).unwrap(),
);
@@ -466,6 +480,12 @@ export function ProfilePage() {
<Stack gap="lg" maw={900}>
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
{showSeafarerBanner && (
<Alert variant="light" color="blue" icon={<IconInfoCircle size={18} />}>
{t('profileGate.seafarerBanner')}
</Alert>
)}
{/* Profile summary */}
<Paper p="lg" shadow="sm" radius="lg" withBorder>
<Group align="center" wrap="nowrap">

View File

@@ -5,6 +5,8 @@ import type { AddressValues } from '../components/AddressFormContent';
export interface AddressPayload {
idType: string;
idNumber: string;
passportNumber?: string;
passportExpiry?: string;
nationality: string;
regionId?: string;
cityId?: string;
@@ -14,6 +16,8 @@ export interface AddressPayload {
woredaId?: string;
kebeleId?: string;
streetAddress?: string;
/** Where the seafarer currently lives, when different from the address above. */
currentAddress?: string;
primaryPhoneNumber: string;
secondaryPhoneNumber?: string;
email?: string;

View File

@@ -0,0 +1,701 @@
import { useEffect, useState } from 'react';
import {
ActionIcon,
Alert,
Avatar,
Badge,
Box,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Skeleton,
Stack,
Table,
Tabs,
Text,
TextInput,
Textarea,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconAnchor,
IconArrowLeft,
IconBook,
IconBriefcase,
IconCertificate,
IconCheck,
IconClock,
IconEdit,
IconFileText,
IconHeartbeat,
IconHistory,
IconLayoutDashboard,
IconPlus,
IconPrinter,
IconShip,
IconUser,
IconX,
} from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import type { Seafarer } from './SeafarerRegistryPage';
// ---------------------------------------------------------------------------
// Extended profile types
// ---------------------------------------------------------------------------
interface TrainingRecord {
id: string;
course: string;
institution: string;
certNo: string;
issueDate: string;
expiry: string;
status: 'Approved' | 'Pending' | 'Expired';
}
interface MedicalRecord {
id: string;
examType: string;
issuedBy: string;
issueDate: string;
expiry: string;
result: 'Fit' | 'Unfit' | 'Conditional';
remarks: string;
}
interface SeaServiceRecord {
id: string;
vesselName: string;
vesselType: string;
rank: string;
flag: string;
from: string;
to: string;
engagementPort: string;
}
interface CertificationRecord {
id: string;
name: string;
certNo: string;
issuedBy: string;
issueDate: string;
expiry: string;
type: string;
status: 'Valid' | 'Expired' | 'Pending';
}
interface HistoryEntry {
id: string;
action: string;
performedBy: string;
date: string;
notes: string;
}
interface SeafarerProfile extends Seafarer {
dob: string;
nationalId: string;
passportNo: string;
bookNumber: string;
permanentAddress: string;
training: TrainingRecord[];
medical: MedicalRecord[];
seaService: SeaServiceRecord[];
certifications: CertificationRecord[];
history: HistoryEntry[];
}
// ---------------------------------------------------------------------------
// Dummy API — replace bodies with real fetch calls
// ---------------------------------------------------------------------------
async function fetchSeafarerProfile(id: string): Promise<SeafarerProfile> {
await new Promise((r) => setTimeout(r, 800));
return {
id,
seafarerId: 'SF-2024-0001',
firstName: 'Abebe',
lastName: 'Girma',
email: 'abebe.g@email.com',
gender: 'Male',
nationality: 'Ethiopian',
mobile: '+251 911 234 567',
region: 'Addis Ababa',
registeredAt: '2024-01-10',
medicalStatus: 'Fit',
bookStatus: 'Active',
status: 'Active',
dob: '1988-03-15',
nationalId: 'ET-1234567',
passportNo: 'EP123456',
bookNumber: 'SB-2024-0001',
permanentAddress: 'Bole Sub-City, Woreda 03, House No. 456, Addis Ababa',
training: [
{ id: '1', course: 'Personal Survival Techniques', institution: 'Ethiopian Maritime Institute', certNo: 'PST-2023-0456', issueDate: '2023-01-10', expiry: '2028-01-14', status: 'Approved' },
{ id: '2', course: 'Fire Prevention and Fire Fighting', institution: 'Djibouti Maritime Academy', certNo: 'FFF-2023-0789', issueDate: '2023-03-05', expiry: '2028-03-07', status: 'Approved' },
{ id: '3', course: 'Elementary First Aid', institution: 'Ethiopian Maritime Institute', certNo: 'EFA-2023-0102', issueDate: '2023-01-10', expiry: '2028-01-10', status: 'Approved' },
],
medical: [
{ id: '1', examType: 'STCW Medical Certificate', issuedBy: 'EMA Medical Center', issueDate: '2023-06-15', expiry: '2025-06-15', result: 'Fit', remarks: 'No medical conditions noted.' },
{ id: '2', examType: 'Pre-Employment Medical', issuedBy: 'Addis Ababa General Hospital', issueDate: '2022-01-10', expiry: '2024-01-10', result: 'Fit', remarks: 'All tests within normal range.' },
],
seaService: [
{ id: '1', vesselName: 'MV Ethiopian Star', vesselType: 'Bulk Carrier', rank: 'Ordinary Seaman', flag: 'Ethiopia', from: '2022-03-01', to: '2023-02-28', engagementPort: 'Djibouti' },
{ id: '2', vesselName: 'MV Red Sea Express', vesselType: 'Container Ship', rank: 'Able Seaman', flag: 'Djibouti', from: '2023-04-01', to: '2024-03-31', engagementPort: 'Berbera' },
],
certifications: [
{ id: '1', name: 'STCW Basic Safety Training', certNo: 'BST-2023-0001', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-01-15', expiry: '2028-01-15', type: 'STCW', status: 'Valid' },
{ id: '2', name: 'Certificate of Competency — Deck Rating', certNo: 'COC-2023-0234', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-07-01', expiry: '2028-07-01', type: 'COC', status: 'Valid' },
],
history: [
{ id: '1', action: 'Profile Created', performedBy: 'System', date: '2024-01-10', notes: 'Initial registration submitted.' },
{ id: '2', action: 'Status → Active', performedBy: 'Admin Officer', date: '2024-01-15', notes: 'All documents verified and approved.' },
{ id: '3', action: 'Training Record Added', performedBy: 'Abebe Girma', date: '2024-02-20', notes: 'PST certificate uploaded.' },
],
};
}
async function updateSeafarerStatus(_id: string, _status: string): Promise<void> {
await new Promise((r) => setTimeout(r, 600));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal', Pending: 'yellow', Suspended: 'red',
Approved: 'teal', Expired: 'red', Valid: 'teal',
Fit: 'teal', Unfit: 'red', Conditional: 'orange',
};
function Chip({ value }: { value: string }) {
return <Badge color={STATUS_COLOR[value] ?? 'gray'} variant="light" radius="sm" size="sm">{value}</Badge>;
}
function InfoField({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
</div>
);
}
function SectionCard({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Text fw={700} fz="sm">{title}</Text>
{action}
</Group>
<Divider mb="md" />
{children}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Overview
// ---------------------------------------------------------------------------
function OverviewTab({ profile, onStatusChange }: { profile: SeafarerProfile; onStatusChange: (s: 'Active' | 'Suspended') => void }) {
return (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<SectionCard title="Personal Information">
<SimpleGrid cols={3} spacing="md">
<InfoField label="Seafarer ID" value={profile.seafarerId} />
<InfoField label="First Name" value={profile.firstName} />
<InfoField label="Last Name" value={profile.lastName} />
<InfoField label="Gender" value={profile.gender} />
<InfoField label="Date of Birth" value={profile.dob} />
<InfoField label="Nationality" value={profile.nationality} />
<InfoField label="National ID" value={profile.nationalId} />
<InfoField label="Passport No." value={profile.passportNo} />
</SimpleGrid>
</SectionCard>
<SectionCard title="Contact & Status">
<SimpleGrid cols={3} spacing="md" mb="md">
<InfoField label="Mobile" value={profile.mobile} />
<InfoField label="Email" value={profile.email} />
<InfoField label="Region" value={profile.region} />
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Reg. Status</Text>
<Chip value={profile.status} />
</div>
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Medical Status</Text>
<Chip value={profile.medicalStatus} />
</div>
<InfoField label="Book Number" value={profile.bookNumber} />
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Book Status</Text>
<Chip value={profile.bookStatus} />
</div>
</SimpleGrid>
<Divider mb="md" />
<Group gap="xs">
{profile.status !== 'Active' && (
<Button size="xs" color="teal" leftSection={<IconCheck size={13} />} onClick={() => onStatusChange('Active')}>
Approve
</Button>
)}
{profile.status !== 'Suspended' && (
<Button size="xs" color="red" variant="light" leftSection={<IconX size={13} />} onClick={() => onStatusChange('Suspended')}>
Suspend
</Button>
)}
<Button size="xs" variant="default" leftSection={<IconFileText size={13} />} onClick={() => notify.info('Documents — coming soon.')}>
Documents
</Button>
</Group>
</SectionCard>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="xs">Permanent Address</Text>
<Text fz="sm" c="dimmed">{profile.permanentAddress || '—'}</Text>
</Paper>
</SimpleGrid>
);
}
// ---------------------------------------------------------------------------
// Tab: Training
// ---------------------------------------------------------------------------
function TrainingTab({ records, onAdd }: { records: TrainingRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Training Records</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Training</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Course', 'Institution', 'Cert. No.', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.course}</Text></Table.Td>
<Table.Td>{r.institution}</Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{r.certNo}</Text></Table.Td>
<Table.Td>{r.issueDate}</Table.Td>
<Table.Td>{r.expiry}</Table.Td>
<Table.Td><Chip value={r.status} /></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View training — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No training records found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Medical
// ---------------------------------------------------------------------------
function MedicalTab({ records, onAdd }: { records: MedicalRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Medical Records</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Record</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Exam Type', 'Issued By', 'Issue Date', 'Expiry', 'Result', 'Remarks', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.examType}</Text></Table.Td>
<Table.Td>{r.issuedBy}</Table.Td>
<Table.Td>{r.issueDate}</Table.Td>
<Table.Td>{r.expiry}</Table.Td>
<Table.Td><Chip value={r.result} /></Table.Td>
<Table.Td><Text fz="xs" c="dimmed" style={{ maxWidth: rem(180) }} lineClamp={1}>{r.remarks}</Text></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View medical record — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No medical records found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Sea Service
// ---------------------------------------------------------------------------
function SeaServiceTab({ records, onAdd }: { records: SeaServiceRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Sea Service Records</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Service</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Vessel Name', 'Type', 'Rank', 'Flag', 'From', 'To', 'Engagement Port', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.vesselName}</Text></Table.Td>
<Table.Td>{r.vesselType}</Table.Td>
<Table.Td>{r.rank}</Table.Td>
<Table.Td>{r.flag}</Table.Td>
<Table.Td>{r.from}</Table.Td>
<Table.Td>{r.to}</Table.Td>
<Table.Td>{r.engagementPort}</Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View sea service — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No sea service records found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Certifications
// ---------------------------------------------------------------------------
function CertificationsTab({ records, onAdd }: { records: CertificationRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Certifications</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Certification</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Certificate', 'Cert. No.', 'Type', 'Issued By', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.name}</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{r.certNo}</Text></Table.Td>
<Table.Td><Badge variant="outline" size="xs" radius="sm">{r.type}</Badge></Table.Td>
<Table.Td>{r.issuedBy}</Table.Td>
<Table.Td>{r.issueDate}</Table.Td>
<Table.Td>{r.expiry}</Table.Td>
<Table.Td><Chip value={r.status} /></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View certificate — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No certifications found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: History
// ---------------------------------------------------------------------------
function HistoryTab({ entries }: { entries: HistoryEntry[] }) {
return (
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="md">Activity History</Text>
<Divider mb="md" />
<Stack gap="sm">
{entries.map((e) => (
<Group key={e.id} gap="md" wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="blue" size={32} radius="xl" style={{ flexShrink: 0, marginTop: 2 }}>
<IconClock size={15} />
</ThemeIcon>
<div style={{ flex: 1 }}>
<Group gap="xs" align="center">
<Text fz="sm" fw={600}>{e.action}</Text>
<Text fz="xs" c="dimmed">by {e.performedBy}</Text>
</Group>
<Text fz="xs" c="dimmed">{e.date}</Text>
{e.notes && <Text fz="xs" mt={2}>{e.notes}</Text>}
</div>
</Group>
))}
{entries.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No history found.</Text>}
</Stack>
</Paper>
);
}
// ---------------------------------------------------------------------------
// Add Record Modal (generic)
// ---------------------------------------------------------------------------
function AddTrainingModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Training Record" size="lg">
<Stack gap="sm">
<TextInput label="Course Name" placeholder="e.g. Personal Survival Techniques" required />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Institution" placeholder="Training institution" />
<TextInput label="Certificate No." placeholder="CERT-0000" />
</SimpleGrid>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" />
<TextInput label="Expiry Date" type="date" />
</SimpleGrid>
<Select label="Status" data={['Approved', 'Pending', 'Expired']} defaultValue="Pending" />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Training record added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
function AddMedicalModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Medical Record" size="lg">
<Stack gap="sm">
<TextInput label="Exam Type" placeholder="e.g. STCW Medical Certificate" required />
<TextInput label="Issued By" placeholder="Issuing authority" />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" />
<TextInput label="Expiry Date" type="date" />
</SimpleGrid>
<Select label="Result" data={['Fit', 'Unfit', 'Conditional']} defaultValue="Fit" />
<Textarea label="Remarks" placeholder="Any notes" autosize minRows={2} />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Medical record added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
function AddSeaServiceModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Sea Service Record" size="lg">
<Stack gap="sm">
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Vessel Name" placeholder="MV Name" required />
<TextInput label="Vessel Type" placeholder="e.g. Bulk Carrier" />
</SimpleGrid>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Rank" placeholder="e.g. Able Seaman" />
<TextInput label="Flag" placeholder="Country" />
</SimpleGrid>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="From" type="date" />
<TextInput label="To" type="date" />
</SimpleGrid>
<TextInput label="Engagement Port" placeholder="Port name" />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Sea service record added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
function AddCertModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Certification" size="lg">
<Stack gap="sm">
<TextInput label="Certificate Name" placeholder="e.g. STCW Basic Safety Training" required />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Certificate No." placeholder="CERT-0000" />
<Select label="Type" data={['STCW', 'COC', 'COE', 'GMDSS', 'Other']} placeholder="Select type" />
</SimpleGrid>
<TextInput label="Issued By" placeholder="Issuing authority" />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" />
<TextInput label="Expiry Date" type="date" />
</SimpleGrid>
<Select label="Status" data={['Valid', 'Expired', 'Pending']} defaultValue="Valid" />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Certification added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeafarerProfilePage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [profile, setProfile] = useState<SeafarerProfile | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<string | null>('overview');
const [trainingModal, trainingModalHandlers] = useDisclosure(false);
const [medicalModal, medicalModalHandlers] = useDisclosure(false);
const [seaServiceModal, seaServiceModalHandlers] = useDisclosure(false);
const [certModal, certModalHandlers] = useDisclosure(false);
useEffect(() => {
if (!id) return;
fetchSeafarerProfile(id)
.then(setProfile)
.catch(() => notify.error('Failed to load seafarer profile.'))
.finally(() => setLoading(false));
}, [id]);
const handleStatusChange = async (newStatus: 'Active' | 'Suspended') => {
if (!profile) return;
try {
await updateSeafarerStatus(profile.id, newStatus);
setProfile((p) => p ? { ...p, status: newStatus } : p);
notify.success(`Status updated to ${newStatus}.`);
} catch {
notify.error('Failed to update status.');
}
};
const initials = profile ? `${profile.firstName[0]}${profile.lastName[0]}` : '??';
return (
<Stack gap="md">
{/* Breadcrumb */}
<Group gap="xs" align="center">
<ActionIcon variant="subtle" size="sm" onClick={() => navigate('/seafarer-registry')}>
<IconArrowLeft size={16} />
</ActionIcon>
<Text fz="sm" c="dimmed" style={{ cursor: 'pointer' }} onClick={() => navigate('/seafarer-registry')}>
Seafarer Registry
</Text>
<Text fz="sm" c="dimmed">/</Text>
<Text fz="sm" fw={500}>
{loading ? <Skeleton width={100} height={14} /> : `${profile?.firstName} ${profile?.lastName}`}
</Text>
</Group>
{/* Profile header card */}
<Paper withBorder radius="md" p="lg">
{loading ? (
<Group gap="md">
<Skeleton circle height={64} />
<Stack gap={6} style={{ flex: 1 }}>
<Skeleton height={20} width={200} />
<Skeleton height={14} width={300} />
<Skeleton height={14} width={400} />
</Stack>
</Group>
) : profile ? (
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="lg" wrap="nowrap" align="flex-start">
<Avatar size={64} radius="xl" color="blue" style={{ fontSize: rem(22) }}>
{initials}
</Avatar>
<div>
<Title order={3} lh={1.2}>{profile.firstName} {profile.lastName}</Title>
<Text fz="sm" c="dimmed" mt={2}>
{profile.seafarerId} · Registered {profile.registeredAt}
</Text>
<Group gap="lg" mt={6} wrap="wrap">
<Text fz="sm"><Text span fw={600}>Gender:</Text> {profile.gender}</Text>
<Text fz="sm"><Text span fw={600}>DOB:</Text> {profile.dob}</Text>
<Text fz="sm"><Text span fw={600}>Nationality:</Text> {profile.nationality}</Text>
<Text fz="sm"><Text span fw={600}>Mobile:</Text> {profile.mobile}</Text>
<Text fz="sm"><Text span fw={600}>Email:</Text> {profile.email}</Text>
</Group>
</div>
</Group>
<Stack gap="xs" align="flex-end" style={{ flexShrink: 0 }}>
<Badge color={STATUS_COLOR[profile.status] ?? 'gray'} variant="filled" radius="sm">{profile.status}</Badge>
<Button size="xs" variant="default" leftSection={<IconEdit size={13} />} onClick={() => notify.info('Edit profile — coming soon.')}>
Edit Profile
</Button>
<Button size="xs" variant="default" leftSection={<IconPrinter size={13} />} onClick={() => notify.info('Print — coming soon.')}>
Print Profile
</Button>
</Stack>
</Group>
) : (
<Alert color="red">Profile not found.</Alert>
)}
</Paper>
{/* Tabs */}
{!loading && profile && (
<Tabs value={activeTab} onChange={setActiveTab} variant="outline">
<Tabs.List mb="md">
<Tabs.Tab value="overview" leftSection={<IconLayoutDashboard size={15} />}>Overview</Tabs.Tab>
<Tabs.Tab value="training" leftSection={<IconBook size={15} />}>Training</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconHeartbeat size={15} />}>Medical</Tabs.Tab>
<Tabs.Tab value="sea-service" leftSection={<IconShip size={15} />}>Sea Service</Tabs.Tab>
<Tabs.Tab value="certifications" leftSection={<IconCertificate size={15} />}>Certifications</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<IconHistory size={15} />}>History</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewTab profile={profile} onStatusChange={handleStatusChange} />
</Tabs.Panel>
<Tabs.Panel value="training">
<TrainingTab records={profile.training} onAdd={trainingModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="medical">
<MedicalTab records={profile.medical} onAdd={medicalModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="sea-service">
<SeaServiceTab records={profile.seaService} onAdd={seaServiceModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="certifications">
<CertificationsTab records={profile.certifications} onAdd={certModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="history">
<HistoryTab entries={profile.history} />
</Tabs.Panel>
</Tabs>
)}
{/* Modals */}
<AddTrainingModal opened={trainingModal} onClose={trainingModalHandlers.close} />
<AddMedicalModal opened={medicalModal} onClose={medicalModalHandlers.close} />
<AddSeaServiceModal opened={seaServiceModal} onClose={seaServiceModalHandlers.close} />
<AddCertModal opened={certModal} onClose={certModalHandlers.close} />
</Stack>
);
}

View File

@@ -1,268 +0,0 @@
import {
Alert,
Badge,
Button,
Card,
Group,
List,
Loader,
Paper,
Progress,
Stack,
Text,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconArrowRight,
IconCircleCheck,
IconClipboardList,
IconInfoCircle,
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
useGetMyApplicationsQuery,
} from '@ema-platform/api';
import {
PORTAL_PERMISSIONS,
RequirePermission,
useCurrentProfile,
} from '@ema-platform/auth';
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
const DEPARTMENT_LABELS: Record<string, string> = {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
};
const SEAFARER_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
PENDING: 'yellow',
SUSPENDED: 'orange',
INACTIVE: 'gray',
};
/**
* The seafarer's registration home (US-SEA-001…007).
*
* Registration itself runs through the config-driven licensing wizard — this
* page is the state machine around it: start a registration, resume or track
* the one in flight, or show the registered identity once approved.
*/
export function SeafarerRegistrationPage() {
const navigate = useNavigate();
const { profile, isLoading: loadingProfile } = useCurrentProfile();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
// The latest registration application, in flight or decided.
const registration = useMemo(() => {
const mine = (applications?.items ?? []).filter(
(app) => app.licenseType?.key === REGISTRATION_TYPE_KEY,
);
return mine.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0];
}, [applications]);
if (loadingProfile || loadingApplications) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
// ------------------------------------------------------------- registered
if (profile?.seafarerNumber) {
const status = profile.seafarerStatus ?? 'ACTIVE';
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
<Card withBorder radius="md" p="xl">
<Stack>
<Group justify="space-between">
<Group>
<IconCircleCheck size={32} color="var(--mantine-color-green-6)" />
<div>
<Text fw={700} size="lg">
Registered Seafarer
</Text>
<Text c="dimmed" size="sm">
Your official seafarer profile with the Ethiopian Maritime
Authority.
</Text>
</div>
</Group>
<Badge color={SEAFARER_STATUS_COLORS[status] ?? 'gray'} size="lg">
{status}
</Badge>
</Group>
<Group gap="xl" mt="sm">
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Seafarer Number
</Text>
<Text fw={700} ff="monospace" size="lg">
{profile.seafarerNumber}
</Text>
</div>
{profile.seafarerDepartment && (
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Department
</Text>
<Text fw={600}>
{DEPARTMENT_LABELS[profile.seafarerDepartment] ??
profile.seafarerDepartment}
</Text>
</div>
)}
</Group>
{status === 'SUSPENDED' && profile.seafarerStatusReason && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
Your profile is suspended: {profile.seafarerStatusReason}
</Alert>
)}
</Stack>
</Card>
<Paper withBorder radius="md" p="lg">
<Group justify="space-between">
<div>
<Text fw={600}>Sea service &amp; medical records</Text>
<Text size="sm" c="dimmed">
Keep your sea-service history and medical certificates up to
date certificate and seaman-book applications draw on them.
</Text>
</div>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/seafarer/records')}
>
My records
</Button>
</Group>
</Paper>
</Stack>
);
}
// -------------------------------------------------------------- in flight
if (registration && !TERMINAL_STATUSES.includes(registration.status)) {
const isDraft = registration.status === 'DRAFT';
const needsAction = registration.status === 'RESUBMIT_REQUIRED';
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
<Card withBorder radius="md" p="xl">
<Stack>
<Group justify="space-between">
<div>
<Text fw={700}>{registration.applicationNumber}</Text>
<Text size="sm" c="dimmed">
Submitted registrations are reviewed by an EMA registration
officer; you will be notified of every decision.
</Text>
</div>
<Badge color={STATUS_COLORS[registration.status]} size="lg">
{STATUS_LABELS[registration.status]}
</Badge>
</Group>
<Progress value={STATUS_PROGRESS[registration.status]} />
{needsAction && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
The registration officer asked for corrections. Open the
application to see exactly what needs fixing.
</Alert>
)}
<Group>
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() =>
navigate(
isDraft || needsAction
? `/licensing/${REGISTRATION_TYPE_KEY}/apply`
: `/licensing/${REGISTRATION_TYPE_KEY}/applications/${registration.id}`,
)
}
>
{isDraft
? 'Continue registration'
: needsAction
? 'Fix and resubmit'
: 'View application'}
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}
// ------------------------------------------------------- not yet started
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
{registration?.status === 'REJECTED' && (
<Alert color="red" title="Previous registration rejected">
{registration.rejectionReason ??
'Your previous registration was rejected. You may register again.'}
</Alert>
)}
<Card withBorder radius="md" p="xl">
<Stack>
<Group>
<IconAnchor size={32} color="var(--mantine-color-blue-6)" />
<div>
<Text fw={700} size="lg">
Register as a seafarer
</Text>
<Text c="dimmed" size="sm">
Approval creates your official seafarer profile with a unique
seafarer number the identity every maritime service builds on.
</Text>
</div>
</Group>
<Text fw={600} size="sm" mt="sm">
You will need:
</Text>
<List
size="sm"
spacing={4}
icon={<IconClipboardList size={16} color="var(--mantine-color-blue-5)" />}
>
<List.Item>A passport-size photograph</List.Item>
<List.Item>Your National ID (Fayda) or Kebele ID</List.Item>
<List.Item>Your educational certificate</List.Item>
<List.Item>
A medical fitness certificate and passport, if you already hold
them
</List.Item>
</List>
<Group mt="md">
<RequirePermission
anyOf={[PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]}
hideOnly
>
<Button
size="md"
rightSection={<IconArrowRight size={18} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
>
Start registration
</Button>
</RequirePermission>
</Group>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,379 @@
import { useEffect, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Loader,
Menu,
Paper,
Select,
SimpleGrid,
Skeleton,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconClock,
IconDotsVertical,
IconEdit,
IconEye,
IconFileExport,
IconSearch,
IconUserCheck,
IconUsers,
IconUserX,
IconX,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Seafarer {
id: string;
seafarerId: string;
firstName: string;
lastName: string;
email: string;
gender: 'Male' | 'Female';
nationality: string;
mobile: string;
region: string;
registeredAt: string;
medicalStatus: 'Fit' | 'Unfit' | 'Pending';
bookStatus: 'Active' | 'Expired' | 'Suspended' | 'Pending';
status: 'Active' | 'Pending' | 'Suspended';
}
// ---------------------------------------------------------------------------
// Dummy API — replace with real fetch later
// ---------------------------------------------------------------------------
async function fetchSeafarers(): Promise<Seafarer[]> {
await new Promise((r) => setTimeout(r, 900));
return [
{
id: '1',
seafarerId: 'SF-2024-0001',
firstName: 'Abebe',
lastName: 'Girma',
email: 'abebe.g@email.com',
gender: 'Male',
nationality: 'Ethiopian',
mobile: '+251 911 234 567',
region: 'Addis Ababa',
registeredAt: '2024-01-10',
medicalStatus: 'Fit',
bookStatus: 'Active',
status: 'Active',
},
{
id: '2',
seafarerId: 'SF-2024-0002',
firstName: 'Sara',
lastName: 'Tadesse',
email: 'sara.t@email.com',
gender: 'Female',
nationality: 'Ethiopian',
mobile: '+251 922 345 678',
region: 'Dire Dawa',
registeredAt: '2024-02-14',
medicalStatus: 'Pending',
bookStatus: 'Pending',
status: 'Pending',
},
{
id: '3',
seafarerId: 'SF-2024-0003',
firstName: 'Dawit',
lastName: 'Bekele',
email: 'dawit.b@email.com',
gender: 'Male',
nationality: 'Ethiopian',
mobile: '+251 933 456 789',
region: 'Oromia',
registeredAt: '2024-03-05',
medicalStatus: 'Fit',
bookStatus: 'Expired',
status: 'Suspended',
},
{
id: '4',
seafarerId: 'SF-2024-0004',
firstName: 'Hana',
lastName: 'Mulugeta',
email: 'hana.m@email.com',
gender: 'Female',
nationality: 'Ethiopian',
mobile: '+251 944 567 890',
region: 'Amhara',
registeredAt: '2024-04-20',
medicalStatus: 'Fit',
bookStatus: 'Active',
status: 'Active',
},
];
}
// ---------------------------------------------------------------------------
// Stat card
// ---------------------------------------------------------------------------
function StatCard({
label,
value,
icon: Icon,
color,
loading,
}: {
label: string;
value: number;
icon: typeof IconUsers;
color: string;
loading: boolean;
}) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<div>
{loading ? (
<Skeleton height={28} width={40} mb={6} />
) : (
<Title order={2} lh={1}>{value}</Title>
)}
<Text fz="sm" c="dimmed" mt={4}>{label}</Text>
</div>
<ThemeIcon variant="light" color={color} size={46} radius="md">
<Icon size={22} stroke={1.6} />
</ThemeIcon>
</Group>
</Card>
);
}
// ---------------------------------------------------------------------------
// Status badges
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal',
Pending: 'yellow',
Suspended: 'red',
Expired: 'orange',
Fit: 'teal',
Unfit: 'red',
};
function StatusBadge({ value }: { value: string }) {
return (
<Badge
color={STATUS_COLOR[value] ?? 'gray'}
variant="light"
radius="sm"
size="sm"
>
{value}
</Badge>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeafarerRegistryPage() {
const navigate = useNavigate();
const [seafarers, setSeafarers] = useState<Seafarer[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
useEffect(() => {
fetchSeafarers()
.then(setSeafarers)
.catch(() => notify.error('Failed to load seafarers.'))
.finally(() => setLoading(false));
}, []);
const stats = {
total: seafarers.length,
active: seafarers.filter((s) => s.status === 'Active').length,
pending: seafarers.filter((s) => s.status === 'Pending').length,
suspended: seafarers.filter((s) => s.status === 'Suspended').length,
};
const filtered = seafarers.filter((s) => {
const q = search.toLowerCase();
const matchSearch =
!q ||
s.seafarerId.toLowerCase().includes(q) ||
`${s.firstName} ${s.lastName}`.toLowerCase().includes(q) ||
s.mobile.includes(q) ||
s.email.toLowerCase().includes(q);
const matchStatus = !statusFilter || s.status === statusFilter;
return matchSearch && matchStatus;
});
const rows = filtered.map((s) => (
<Table.Tr key={s.id}>
<Table.Td>
<Text fz="sm" fw={600} c="blue.7" style={{ cursor: 'pointer' }} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
{s.seafarerId}
</Text>
</Table.Td>
<Table.Td>
<div>
<Text fz="sm" fw={500}>{s.firstName} {s.lastName}</Text>
<Text fz="xs" c="dimmed">{s.email}</Text>
</div>
</Table.Td>
<Table.Td><Text fz="sm">{s.gender}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.nationality}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
<Table.Td><StatusBadge value={s.medicalStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.bookStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.status} /></Table.Td>
<Table.Td>
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconDotsVertical size={15} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
View
</Menu.Item>
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
Edit
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconX size={14} />} color="red" onClick={() => notify.info('Suspend — coming soon.')}>
Suspend
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start">
<div>
<Title order={3}>Seafarer Registry</Title>
<Text fz="sm" c="dimmed">Manage all registered seafarers</Text>
</div>
<Button
leftSection={<IconAnchor size={16} />}
onClick={() => navigate('/seafarer-registration')}
>
+ New Registration
</Button>
</Group>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatCard label="Total Seafarers" value={stats.total} icon={IconUsers} color="blue" loading={loading} />
<StatCard label="Active" value={stats.active} icon={IconUserCheck} color="teal" loading={loading} />
<StatCard label="Pending" value={stats.pending} icon={IconClock} color="yellow" loading={loading} />
<StatCard label="Suspended" value={stats.suspended} icon={IconUserX} color="red" loading={loading} />
</SimpleGrid>
{/* Table card */}
<Paper withBorder radius="md">
{/* Toolbar */}
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Seafarer List</Text>
<Group gap="sm" wrap="nowrap">
<TextInput
placeholder="Search by name, ID or mobile…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ minWidth: rem(260) }}
size="sm"
rightSection={
search ? (
<ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}>
<IconX size={13} />
</ActionIcon>
) : null
}
/>
<Select
placeholder="All Status"
data={['Active', 'Pending', 'Suspended']}
value={statusFilter}
onChange={setStatusFilter}
clearable
size="sm"
style={{ width: rem(140) }}
/>
<ActionIcon
variant="default"
size={34}
title="Export"
onClick={() => notify.info('Export — coming soon.')}
>
<IconFileExport size={16} />
</ActionIcon>
</Group>
</Group>
{/* Table */}
{loading ? (
<Stack gap="xs" p="md">
{[...Array(4)].map((_, i) => <Skeleton key={i} height={44} radius="sm" />)}
</Stack>
) : filtered.length === 0 ? (
<Box py="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
<IconUsers size={22} />
</ThemeIcon>
<Text fz="sm" c="dimmed">No seafarers found</Text>
{(search || statusFilter) && (
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setSearch(''); setStatusFilter(null); }}>
Clear filters
</Button>
)}
</Box>
) : (
<Table highlightOnHover striped withColumnBorders={false} verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Seafarer ID', 'Name', 'Gender', 'Nationality', 'Mobile', 'Region', 'Reg. Date', 'Medical', 'Book Status', 'Status', ''].map((h) => (
<Table.Th key={h} style={{ whiteSpace: 'nowrap', fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>
{h}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
)}
{/* Footer */}
{!loading && filtered.length > 0 && (
<Group px="md" py="sm" justify="space-between">
<Text fz="xs" c="dimmed">Showing {filtered.length} of {seafarers.length} seafarers</Text>
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="dimmed">Data loaded</Text>
</Group>
</Group>
)}
</Paper>
</Stack>
);
}

View File

@@ -1,21 +1,595 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { useRef, useState } from 'react';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
Paper,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,
IconBook2,
IconCheck,
IconCircleCheck,
IconCreditCard,
IconHeart,
IconId,
IconInfoCircle,
IconShieldCheck,
IconTrash,
IconUpload,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered invented figures/records that were
* indistinguishable from real ones.
*/
export function SeamanBookApplicationPage() {
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
const STEPS = [
{ label: 'Relevant Certificate' },
{ label: 'Medical Certificate' },
{ label: 'Payment' },
{ label: 'Review & Submit' },
];
// ---------------------------------------------------------------------------
// Fee table — Seaman Book + BTC shown separately, paid together
// ---------------------------------------------------------------------------
const FEES = [
{ label: 'Seaman Book — Application Fee', amount: 500 },
{ label: 'Seaman Book — Document Verification Fee',amount: 200 },
{ label: 'Basic Training Certificate (BTC) — Application Fee', amount: 300 },
{ label: 'BTC — Document Verification Fee', amount: 100 },
{ label: 'BSID — Application Fee', amount: 100 },
{ label: 'BSID — Card Production Fee', amount: 150 },
];
const TOTAL = FEES.reduce((s, f) => s + f.amount, 0);
// ---------------------------------------------------------------------------
// Step indicator
// ---------------------------------------------------------------------------
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Apply for a Seaman Book"
description="Seaman Book applications are not connected to the backend yet."
/>
</Container>
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap">
{STEPS.map((step, i) => {
const isDone = completed.includes(i);
const isCurrent = active === i;
return (
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
<Box style={{
width: rem(40), height: rem(40), borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : isCurrent ? 'var(--mantine-color-blue-7)' : 'var(--mantine-color-gray-1)',
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34,139,230,0.2)' : 'none',
flexShrink: 0, transition: 'all 0.2s ease',
}}>
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
)}
</Box>
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
{isDone ? `${step.label}` : step.label}
</Text>
</Stack>
{i < STEPS.length - 1 && (
<Box style={{
flex: 1, height: rem(2),
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
marginBottom: rem(22),
}} />
)}
</Group>
);
})}
</Group>
</Box>
);
}
export default SeamanBookApplicationPage;
function SectionHead({ title }: { title: string }) {
return (
<>
<Divider mt="md" mb="xs" />
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
</>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeamanBookApplicationPage() {
const navigate = useNavigate();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// Relevant Certificate
const [relCertNumber, setRelCertNumber] = useState('');
const [relIssuer, setRelIssuer] = useState('');
const [relIssueDate, setRelIssueDate] = useState('');
const [relExpiryDate, setRelExpiryDate] = useState('');
const [relFile, setRelFile] = useState<File | null>(null);
const relResetRef = useRef<() => void>(null);
// Medical
const [medCertNumber, setMedCertNumber] = useState('');
const [medIssuer, setMedIssuer] = useState('');
const [medIssueDate, setMedIssueDate] = useState('');
const [medExpiryDate, setMedExpiryDate] = useState('');
const [medFile, setMedFile] = useState<File | null>(null);
const medResetRef = useRef<() => void>(null);
// Payment
const [paymentMethod, setPaymentMethod] = useState<'cbe' | 'telebirr' | null>(null);
const [paymentRef, setPaymentRef] = useState('');
const [paymentDate, setPaymentDate] = useState('');
const [paymentFile, setPaymentFile] = useState<File | null>(null);
const payResetRef = useRef<() => void>(null);
// Validation
const relComplete = !!relFile && !!relCertNumber.trim() && !!relIssuer.trim() && !!relIssueDate && !!relExpiryDate;
const medComplete = !!medFile && !!medCertNumber.trim() && !!medIssuer.trim() && !!medIssueDate && !!medExpiryDate;
const payComplete = !!paymentMethod && !!paymentRef.trim() && !!paymentDate;
const canNext = () => {
if (active === 0) return relComplete;
if (active === 1) return medComplete;
if (active === 2) return payComplete;
return true;
};
const next = () => {
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
setActive((c) => c + 1);
};
const prev = () => setActive((c) => c - 1);
const handleSubmit = async () => {
setSubmitting(true);
try {
await new Promise((r) => setTimeout(r, 1400));
notify.success('Application submitted! Reference: SB-BTC-2025-001');
navigate('/seaman-book');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<div>
<Title order={3}>Seaman Book, BTC & BSID Application</Title>
<Text fz="sm" c="dimmed">
One application covers your Seaman Book, Basic Training Certificate (BTC), and BSID Step {active + 1} of {STEPS.length}
</Text>
</div>
{/* What you will receive banner */}
<Paper withBorder radius="md" p="md" bg="blue.0" style={{ borderColor: 'var(--mantine-color-blue-2)' }}>
<Group gap="lg" wrap="wrap">
<Group gap="xs">
<IconBook2 size={18} color="var(--mantine-color-blue-6)" stroke={1.5} />
<Text fz="sm" fw={600} c="blue.8">Seaman Book</Text>
</Group>
<Text fz="sm" c="dimmed">+</Text>
<Group gap="xs">
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" stroke={1.5} />
<Text fz="sm" fw={600} c="teal.8">Basic Training Certificate (BTC)</Text>
</Group>
<Text fz="sm" c="dimmed">+</Text>
<Group gap="xs">
<IconId size={18} color="var(--mantine-color-violet-6)" stroke={1.5} />
<Text fz="sm" fw={600} c="violet.8">BSID (Biometric Seafarer ID)</Text>
</Group>
</Group>
</Paper>
<StepIndicator active={active} completed={completed} />
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Text fw={700} fz="lg">{STEPS[active]?.label}</Text>
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
</Group>
{/* ── Step 1: Relevant Certificate ────────────────────────────── */}
{active === 0 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Upload your relevant certificate issued by an EMA-approved training institution. This is the prerequisite for your Basic Training Certificate (BTC).
</Alert>
<SectionHead title="Relevant Certificate Details" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Certificate Number"
placeholder="e.g. CERT-2024-001"
required
value={relCertNumber}
onChange={(e) => setRelCertNumber(e.currentTarget.value)}
/>
<TextInput
label="Issuing Institution"
placeholder="e.g. Bahirdar Maritime School"
required
value={relIssuer}
onChange={(e) => setRelIssuer(e.currentTarget.value)}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Issue Date"
type="date"
required
value={relIssueDate}
onChange={(e) => setRelIssueDate(e.currentTarget.value)}
/>
<TextInput
label="Expiry Date"
type="date"
required
value={relExpiryDate}
onChange={(e) => setRelExpiryDate(e.currentTarget.value)}
/>
</SimpleGrid>
<SectionHead title="Upload Certificate" />
<Card
withBorder
radius="md"
p="md"
style={{
borderStyle: relFile ? 'solid' : 'dashed',
borderColor: relFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
maxWidth: rem(420),
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8),
background: relFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconShieldCheck size={20} color={relFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">Relevant Certificate <Text span c="red">*</Text></Text>
<Text fz="xs" c="dimmed">PDF, JPG or PNG max 5MB</Text>
</div>
</Group>
{relFile ? (
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{relFile.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setRelFile(null); relResetRef.current?.(); }}>
<IconTrash size={13} />
</Button>
</Group>
) : (
<FileButton resetRef={relResetRef} onChange={setRelFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
Choose File
</Button>
)}
</FileButton>
)}
</Card>
</Stack>
)}
{/* ── Step 2: Medical ─────────────────────────────────────────── */}
{active === 1 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Upload your valid medical certificate from an EMA-approved medical centre. Required for both Seaman Book and BTC issuance.
</Alert>
<SectionHead title="Medical Certificate Details" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Certificate Number" placeholder="e.g. MC-2024-001" required value={medCertNumber} onChange={(e) => setMedCertNumber(e.currentTarget.value)} />
<TextInput label="Issuing Medical Centre" placeholder="e.g. EMA Medical Centre" required value={medIssuer} onChange={(e) => setMedIssuer(e.currentTarget.value)} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Issue Date" type="date" required value={medIssueDate} onChange={(e) => setMedIssueDate(e.currentTarget.value)} />
<TextInput label="Expiry Date" type="date" required value={medExpiryDate} onChange={(e) => setMedExpiryDate(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Upload Certificate" />
<Card withBorder radius="md" p="md" style={{
borderStyle: medFile ? 'solid' : 'dashed',
borderColor: medFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
maxWidth: rem(420),
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8),
background: medFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconHeart size={20} color={medFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">Medical Certificate <Text span c="red">*</Text></Text>
<Text fz="xs" c="dimmed">PDF, JPG or PNG max 5MB</Text>
</div>
</Group>
{medFile ? (
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{medFile.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setMedFile(null); medResetRef.current?.(); }}>
<IconTrash size={13} />
</Button>
</Group>
) : (
<FileButton resetRef={medResetRef} onChange={setMedFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
Choose File
</Button>
)}
</FileButton>
)}
</Card>
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
Only certificates from <strong>EMA-approved medical centres</strong> are accepted.
</Alert>
</Stack>
)}
{/* ── Step 3: Payment ─────────────────────────────────────────── */}
{active === 2 && (
<Stack gap="md">
{/* Fee breakdown — SB + BTC shown separately */}
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fw={700} fz="sm" mb="xs">Fee Breakdown</Text>
<Text fz="xs" c="dimmed" mb="md">Your payment covers both the Seaman Book and Basic Training Certificate (BTC).</Text>
{/* SB fees */}
<Text fz="xs" fw={700} tt="uppercase" c="blue.7" mb={6}>Seaman Book</Text>
{FEES.filter(f => f.label.startsWith('Seaman Book')).map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label.replace('Seaman Book — ', '')}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider my="xs" />
{/* BTC fees */}
<Text fz="xs" fw={700} tt="uppercase" c="teal.7" mb={6}>Basic Training Certificate (BTC)</Text>
{FEES.filter(f => f.label.startsWith('Basic Training') || f.label.startsWith('BTC')).map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label.replace('Basic Training Certificate (BTC) — ', '').replace('BTC — ', '')}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider my="xs" />
{/* BSID fees */}
<Divider my="xs" />
<Text fz="xs" fw={700} tt="uppercase" c="violet.7" mb={6}>BSID (Biometric Seafarer ID)</Text>
{FEES.filter(f => f.label.startsWith('BSID')).map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label.replace('BSID — ', '')}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider mt="xs" mb="sm" />
<Group justify="space-between">
<Text fz="sm" fw={800}>Total Amount Due</Text>
<Text fz="md" fw={800} c="blue">ETB {TOTAL.toFixed(2)}</Text>
</Group>
</Paper>
<SectionHead title="Select Payment Method" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" style={{ maxWidth: rem(600) }}>
{/* CBE */}
<Card withBorder radius="md" p="md" onClick={() => { setPaymentMethod('cbe'); setPaymentRef(''); }}
style={{
cursor: 'pointer',
borderColor: paymentMethod === 'cbe' ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-default-border)',
borderWidth: paymentMethod === 'cbe' ? 2 : 1,
background: paymentMethod === 'cbe' ? 'var(--mantine-color-blue-light)' : undefined,
}}>
<Group gap="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8), flexShrink: 0,
background: 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconCreditCard size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={700} fz="sm">CBE Bank Transfer</Text>
<Text fz="xs" c="dimmed">Commercial Bank of Ethiopia</Text>
</div>
{paymentMethod === 'cbe' && <IconCircleCheck size={18} color="var(--mantine-color-blue-6)" style={{ marginLeft: 'auto' }} />}
</Group>
</Card>
{/* Telebirr */}
<Card withBorder radius="md" p="md" onClick={() => { setPaymentMethod('telebirr'); setPaymentRef(''); }}
style={{
cursor: 'pointer',
borderColor: paymentMethod === 'telebirr' ? 'var(--mantine-color-violet-6)' : 'var(--mantine-color-default-border)',
borderWidth: paymentMethod === 'telebirr' ? 2 : 1,
background: paymentMethod === 'telebirr' ? 'var(--mantine-color-violet-light)' : undefined,
}}>
<Group gap="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8), flexShrink: 0,
background: 'var(--mantine-color-violet-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconCreditCard size={22} color="var(--mantine-color-violet-6)" stroke={1.5} />
</Box>
<div>
<Text fw={700} fz="sm">Telebirr</Text>
<Text fz="xs" c="dimmed">Ethio Telecom Mobile Money</Text>
</div>
{paymentMethod === 'telebirr' && <IconCircleCheck size={18} color="var(--mantine-color-violet-6)" style={{ marginLeft: 'auto' }} />}
</Group>
</Card>
</SimpleGrid>
{paymentMethod === 'cbe' && (
<>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
Transfer <strong>ETB {TOTAL.toFixed(2)}</strong> to CBE Account <strong>1000123456789</strong> (EMA Maritime Authority). Use your full name as description.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="CBE Transaction Reference" placeholder="e.g. CBE-TXN-20240510-001" required value={paymentRef} onChange={(e) => setPaymentRef(e.currentTarget.value)} />
<TextInput label="Payment Date" type="date" required value={paymentDate} onChange={(e) => setPaymentDate(e.currentTarget.value)} />
</SimpleGrid>
</>
)}
{paymentMethod === 'telebirr' && (
<>
<Alert variant="light" color="violet" icon={<IconInfoCircle size={15} />}>
Send <strong>ETB {TOTAL.toFixed(2)}</strong> to Telebirr <strong>+251 11 551 0000</strong> (EMA Maritime Authority). Screenshot and upload your confirmation.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="Telebirr Transaction ID" placeholder="e.g. TLB-2024-001234" required value={paymentRef} onChange={(e) => setPaymentRef(e.currentTarget.value)} />
<TextInput label="Payment Date" type="date" required value={paymentDate} onChange={(e) => setPaymentDate(e.currentTarget.value)} />
</SimpleGrid>
</>
)}
{paymentMethod && (
<>
<SectionHead title="Upload Receipt (optional)" />
<Card withBorder radius="md" p="md" style={{
borderStyle: paymentFile ? 'solid' : 'dashed',
borderColor: paymentFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
maxWidth: rem(420),
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8),
background: paymentFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconCreditCard size={20} color={paymentFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{paymentMethod === 'telebirr' ? 'Telebirr Screenshot' : 'Bank Receipt'}</Text>
<Text fz="xs" c="dimmed">PDF, JPG or PNG max 5MB</Text>
</div>
</Group>
{paymentFile ? (
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{paymentFile.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setPaymentFile(null); payResetRef.current?.(); }}>
<IconTrash size={13} />
</Button>
</Group>
) : (
<FileButton resetRef={payResetRef} onChange={setPaymentFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
Upload Receipt
</Button>
)}
</FileButton>
)}
</Card>
</>
)}
</Stack>
)}
{/* ── Step 4: Review ──────────────────────────────────────────── */}
{active === 3 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Submitting this application will initiate processing for both your <strong>Seaman Book</strong> and <strong>Basic Training Certificate (BTC)</strong>.
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Relevant Certificate</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Certificate No." value={relCertNumber} />
<ReviewRow label="Issuing Institution" value={relIssuer} />
<ReviewRow label="Issue Date" value={relIssueDate} />
<ReviewRow label="Expiry Date" value={relExpiryDate} />
<ReviewRow label="Document" value={relFile?.name ?? '—'} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Medical Certificate</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Certificate No." value={medCertNumber} />
<ReviewRow label="Issuing Centre" value={medIssuer} />
<ReviewRow label="Issue Date" value={medIssueDate} />
<ReviewRow label="Expiry Date" value={medExpiryDate} />
<ReviewRow label="Document" value={medFile?.name ?? '—'} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Payment</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Payment Method" value={paymentMethod === 'cbe' ? 'CBE Bank Transfer' : 'Telebirr'} />
<ReviewRow label="Transaction Reference" value={paymentRef} />
<ReviewRow label="Payment Date" value={paymentDate} />
<ReviewRow label="Total Paid" value={`ETB ${TOTAL.toFixed(2)}`} />
<ReviewRow label="Receipt" value={paymentFile?.name ?? 'Not uploaded'} />
</SimpleGrid>
</Paper>
</Stack>
)}
{/* Navigation */}
<Group justify="space-between" mt="xl">
<Button variant="default" onClick={() => navigate('/seaman-book')}>Cancel</Button>
<Group gap="sm">
{active > 0 && (
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={prev}>Previous</Button>
)}
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} onClick={next} disabled={!canNext()}>
Next Step
</Button>
) : (
<Button color="blue" leftSection={<IconBook2 size={16} />} onClick={handleSubmit} loading={submitting}>
Submit Application
</Button>
)}
</Group>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,21 +1,374 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { useNavigate } from 'react-router-dom';
import { useApiQuery } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Stepper,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertCircle,
IconBook2,
IconCheck,
IconCircleCheck,
IconClock,
IconFileDescription,
IconHeart,
IconInfoCircle,
IconPrinter,
IconShield,
IconX,
} from '@tabler/icons-react';
/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
interface SeamanBookOverview {
application: {
id: string;
applicationId: string;
status: string;
submittedAt: string;
} | null;
book: {
id: string;
issuedDate: string;
expiryDate: string;
status: string;
} | null;
eligibility: {
hasProfile: boolean;
hasSeafarerNumber: boolean;
hasMedical: boolean;
medicalExpiry: string | null;
bstComplete: boolean;
bstModules: { key: string; label: string; done: boolean }[];
};
eligible: boolean;
}
/**
* Placeholder until this feature has a backend.
* The stages an application passes through, for the progress stepper.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
* Derived from the application's status rather than stored as a timeline:
* the status is what the workflow actually moves, so a second record of the
* same journey would only drift out of step with it.
*/
export function SeamanBookPage() {
const STAGES: { label: string; statuses: string[] }[] = [
{ label: 'Submitted', statuses: ['SUBMITTED', 'UNDER_REVIEW', 'UNDER_EVALUATION'] },
{ label: 'Under Review', statuses: ['UNDER_REVIEW', 'UNDER_EVALUATION'] },
{ label: 'Inspection', statuses: ['INSPECTION_PENDING', 'INSPECTION_COMPLETED'] },
{ label: 'Approved', statuses: ['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED'] },
{ label: 'Issued', statuses: ['CERTIFICATE_ISSUED', 'COMPLETED'] },
];
/** How far along the stepper a status sits; -1 for a draft. */
function stageIndexFor(status: string | undefined): number {
if (!status || status === 'DRAFT') return -1;
let reached = -1;
STAGES.forEach((stage, i) => {
if (stage.statuses.includes(status)) reached = i;
});
// A status past the last named stage (e.g. REJECTED) still shows the
// journey taken rather than collapsing the stepper to nothing.
return reached;
}
// Keyed by the workflow's own status values, not display strings: the badge
// reads whatever the API reports, and an unmapped status falls back to grey
// rather than vanishing.
const STATUS_COLOR: Record<string, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'yellow',
UNDER_EVALUATION: 'yellow',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'grape',
INSPECTION_COMPLETED: 'grape',
APPROVED: 'teal',
REJECTED: 'red',
ON_HOLD: 'orange',
PAYMENT_PENDING: 'orange',
PAID: 'blue',
PAYMENT_CONFIRMED: 'blue',
CERTIFICATE_ISSUED: 'teal',
COMPLETED: 'teal',
};
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Seaman Book"
description="Seaman Book applications are not connected to the backend yet."
/>
</Container>
<Group gap="xs">
<ThemeIcon size={22} radius="xl" variant={ok ? 'filled' : 'light'} color={ok ? 'teal' : 'red'}>
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
</ThemeIcon>
<Text fz="sm" c={ok ? undefined : 'dimmed'}>{label}</Text>
</Group>
);
}
export default SeamanBookPage;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SeamanBookPage() {
const navigate = useNavigate();
const { data, isLoading } = useApiQuery<SeamanBookOverview>({
url: '/seaman-book/my',
method: 'GET',
});
const application = data?.application ?? null;
const eligibility = data?.eligibility;
const bstItems = eligibility?.bstModules ?? [];
const bstDone = bstItems.filter((b) => b.done).length;
// The server decides: the same checklist gates the submission, so a screen
// that judged eligibility for itself could offer a button the API refuses.
const isEligible = data?.eligible ?? false;
const submitted = Boolean(application);
const activeStep = stageIndexFor(application?.status);
return (
<Stack gap="md">
{/* Header */}
<div>
<Title order={3}>My Application Seaman Book & BTC</Title>
<Text fz="sm" c="dimmed">
A Seaman Book is your official maritime identity document. It records your sea service and must be
held before joining any vessel.
</Text>
</div>
{/* Active application status */}
{application && (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconBook2 size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Application {application.id}</Text>
<Text fz="xs" c="dimmed">
Submitted {formatDate(application.submittedAt)}
</Text>
</div>
</Group>
<Badge
color={STATUS_COLOR[application.status] ?? 'gray'}
variant="light"
size="lg"
>
{application.status.replaceAll('_', ' ')}
</Badge>
</Group>
{/* Progress stepper */}
<Stepper active={activeStep} size="sm" color="teal">
{STAGES.map((stage, i) => (
<Stepper.Step
key={stage.label}
label={stage.label}
description={i <= activeStep ? 'Done' : 'Pending'}
icon={
i <= activeStep ? (
<IconCircleCheck size={16} />
) : (
<IconClock size={16} />
)
}
/>
))}
</Stepper>
{data?.book && (
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
Your Seaman Book <strong>{data.book.id}</strong> has been issued.
Please visit the EMA office to collect it, bringing your National ID.
</Alert>
)}
</Paper>
)}
{/* No active application — eligibility + apply */}
{!submitted && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{/* Eligibility checklist */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color={isEligible ? 'teal' : 'orange'} size={36} radius="md">
<IconShield size={18} />
</ThemeIcon>
<Text fw={700}>Eligibility Requirements</Text>
</Group>
<Stack gap="sm">
<EligibilityItem
label="Profile completed (name, DOB, nationality)"
ok={Boolean(eligibility?.hasProfile)}
/>
<EligibilityItem
label="Registered seafarer number issued"
ok={Boolean(eligibility?.hasSeafarerNumber)}
/>
<EligibilityItem
label={
eligibility?.medicalExpiry
? `Valid medical certificate (expires ${formatDate(eligibility.medicalExpiry)})`
: 'Valid medical certificate uploaded'
}
ok={Boolean(eligibility?.hasMedical)}
/>
<Divider
label={`Basic Safety Training (all ${bstItems.length || 5} required)`}
labelPosition="left"
my={4}
/>
{bstItems.map((item) => (
<EligibilityItem key={item.key} label={item.label} ok={item.done} />
))}
{!isLoading && !isEligible && (
<Alert variant="light" color="orange" icon={<IconAlertCircle size={15} />} mt="xs" p="sm">
<Text fz="xs">
Complete all requirements above before applying.
{bstItems.length > bstDone
? ` Missing BST: ${bstItems.length - bstDone} certificate(s).`
: ''}
</Text>
</Alert>
)}
{isEligible && (
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />} mt="xs" p="sm">
<Text fz="xs">You meet all requirements. You may proceed with your application.</Text>
</Alert>
)}
</Stack>
</Paper>
{/* Application form */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconFileDescription size={18} />
</ThemeIcon>
<Text fw={700}>New Application</Text>
</Group>
<Stack gap="sm">
<Text fz="sm" c="dimmed" lh={1.6}>
Upon submitting your application, EMA Registration Officers will verify your profile,
documents, medical certificate, and Basic Safety Training certificates. You will be
notified at each stage by email and SMS.
</Text>
<Divider />
<Text fw={600} fz="sm">What will be verified:</Text>
<Stack gap={6}>
{[
'Full seafarer profile',
'National ID / Fayda authenticity',
'Medical certificate validity',
'All 5 Basic Safety Training certificates',
'Passport size photo',
].map((item) => (
<Group key={item} gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="sm">{item}</Text>
</Group>
))}
</Stack>
<Divider />
<SimpleGrid cols={2} spacing="xs">
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconClock size={15} color="var(--mantine-color-blue-6)" />
<div>
<Text fz="xs" c="dimmed">Processing time</Text>
<Text fz="sm" fw={600}>57 working days</Text>
</div>
</Group>
</Card>
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconHeart size={15} color="var(--mantine-color-red-6)" />
<div>
<Text fz="xs" c="dimmed">Medical validity</Text>
<Text fz="sm" fw={600}>2 years (STCW)</Text>
</div>
</Group>
</Card>
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Text fz="xs">
Application fee will be communicated during the review process. Payment can be made online or at the EMA office.
</Text>
</Alert>
<Button
leftSection={<IconBook2 size={16} />}
onClick={() => navigate('/seaman-book/apply')}
disabled={!isEligible}
size="md"
>
Start Application
</Button>
{!isEligible && (
<Text fz="xs" c="dimmed" ta="center">
Complete all eligibility requirements to enable this button.
</Text>
)}
</Stack>
</Paper>
</SimpleGrid>
)}
{/* Info box */}
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="sm">
<IconInfoCircle size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">About the Seaman Book</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{ icon: IconBook2, title: 'Official Identity', desc: 'Internationally recognized maritime identity document required before joining any vessel.' },
{ icon: IconFileDescription, title: 'Service Record', desc: 'Records all your sea service, vessel assignments, and employment history.' },
{ icon: IconShield, title: 'STCW Compliance', desc: 'Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.' },
].map(({ icon: Icon, title, desc }) => (
<Box key={title}>
<Group gap="xs" mb={4}>
<Icon size={16} color="var(--mantine-color-blue-6)" />
<Text fz="sm" fw={600}>{title}</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.5}>{desc}</Text>
</Box>
))}
</SimpleGrid>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,114 @@
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Card,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconClock,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';
// Sample mock — in production this comes from the API
const MOCK_MY_VESSELS = [
{
id: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
status: 'Under Review',
submittedDate: '2024-03-15',
},
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray', 'Under Review': 'yellow', Approved: 'teal', Rejected: 'red', 'Correction Required': 'orange',
};
export function VesselOwnerDashboardPage() {
const navigate = useNavigate();
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconShip size={24} />
</ThemeIcon>
<div>
<Title order={3}>My Vessels</Title>
<Text fz="sm" c="dimmed">Manage your vessel registrations</Text>
</div>
</Group>
<Button leftSection={<IconAnchor size={16} />} onClick={() => navigate('/vessel-registration/apply')}>
Register New Vessel
</Button>
</Group>
{MOCK_MY_VESSELS.length === 0 ? (
<Paper withBorder radius="lg" p="xl">
<Stack align="center" gap="md" py="xl">
<ThemeIcon size={56} radius="xl" color="blue" variant="light">
<IconAnchor size={30} />
</ThemeIcon>
<Title order={4} ta="center">No Vessels Registered</Title>
<Text fz="sm" c="dimmed" ta="center" maw={400}>
You haven't registered any vessels yet. Click "Register New Vessel" to begin the application process.
</Text>
<Button onClick={() => navigate('/vessel-registration/apply')}>Start Registration</Button>
</Stack>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{MOCK_MY_VESSELS.map((v) => (
<Card key={v.id} withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light">
<IconShip size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{v.vesselName}</Text>
<Text fz="xs" c="dimmed">{v.id}</Text>
</div>
</Group>
<Text fz="xs" fw={600} c={`${STATUS_COLOR[v.status]}.6`}>{v.status}</Text>
</Group>
<Stack gap={4} mt="sm">
<Group gap="xs">
<Text fz="xs" c="dimmed">Category:</Text>
<Text fz="xs">{v.category}</Text>
</Group>
<Group gap="xs">
<Text fz="xs" c="dimmed">Type:</Text>
<Text fz="xs">{v.vesselType}</Text>
</Group>
<Group gap="xs">
<Text fz="xs" c="dimmed">Submitted:</Text>
<Text fz="xs">{v.submittedDate}</Text>
</Group>
</Stack>
<Button size="xs" variant="light" fullWidth mt="sm" onClick={() => navigate('/vessel-registration')}>
View Details
</Button>
</Card>
))}
</SimpleGrid>
)}
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
Vessel registration is valid for <strong>5 years</strong> from the approval date. You will be notified when renewal is due.
</Alert>
</Stack>
);
}

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Box,
Button,
Center,
Divider,
Group,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconAlertCircle,
IconLock,
IconMail,
IconShip,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
export function VesselOwnerLoginPage() {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [loginTrigger] = useApiMutation<{ token: string; user: { id: string; name: string } }>();
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
setError('Please enter your email and password.');
return;
}
setError('');
setLoading(true);
try {
await loginTrigger({
url: '/auth/vessel-owner/login',
method: 'POST',
body: { email, password },
}).unwrap();
notify.success('Login successful. Welcome!');
navigate('/vessel-owner/dashboard');
} catch {
setError('Invalid email or password. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="xl" w="100%" maw={440} px="md">
{/* Brand */}
<Stack align="center" gap="xs">
<ThemeIcon size={64} radius="xl" color="blue" variant="light">
<IconShip size={36} />
</ThemeIcon>
<Title order={2} ta="center">Vessel Owner Portal</Title>
<Text fz="sm" c="dimmed" ta="center">
Ethiopian Maritime Affairs Authority
</Text>
</Stack>
<Paper withBorder radius="lg" p="xl" w="100%" shadow="sm">
<Group gap="xs" mb="lg">
<IconAnchor size={20} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="lg">Sign In</Text>
</Group>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
{error}
</Alert>
)}
<Stack gap="md">
<TextInput
label="Email Address"
placeholder="owner@example.com"
leftSection={<IconMail size={16} />}
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
leftSection={<IconLock size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
<Anchor fz="sm" ta="right" onClick={() => navigate('/vessel-owner/forgot-password')}>
Forgot password?
</Anchor>
<Button fullWidth size="md" loading={loading} onClick={handleLogin} leftSection={<IconAnchor size={16} />}>
Sign In
</Button>
</Stack>
<Divider my="md" label="Don't have an account?" labelPosition="center" />
<Button
fullWidth
variant="light"
onClick={() => navigate('/vessel-owner/register')}
>
Create Vessel Owner Account
</Button>
</Paper>
<Text fz="xs" c="dimmed" ta="center">
This portal is exclusively for vessel owners. For seafarer services,{' '}
<Anchor fz="xs" onClick={() => navigate('/login')}>sign in here</Anchor>.
</Text>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,199 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Box,
Button,
Divider,
Group,
Paper,
PasswordInput,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconAlertCircle,
IconCheck,
IconLock,
IconMail,
IconPhone,
IconShip,
IconUser,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
const OWNER_TYPES = [
'Individual (Private Owner)',
'Private Company / PLC',
'State Enterprise',
'NGO / Non-Profit',
'Government Agency',
];
export function VesselOwnerRegisterPage() {
const navigate = useNavigate();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [ownerType, setOwnerType] = useState<string | null>(null);
const [nationalIdOrTin, setNationalIdOrTin] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [registerTrigger] = useApiMutation<{ id: string }>();
const canSubmit = !!fullName.trim() && !!email.trim() && !!phone.trim() && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
const handleRegister = async () => {
if (!canSubmit) {
setError('Please fill in all required fields. Passwords must match.');
return;
}
setError('');
setLoading(true);
try {
await registerTrigger({
url: '/auth/vessel-owner/register',
method: 'POST',
body: { fullName, email, phone, ownerType, nationalIdOrTin, password },
}).unwrap();
setSuccess(true);
notify.success('Account created! You can now sign in.');
} catch {
setError('Registration failed. This email may already be registered.');
} finally {
setLoading(false);
}
};
if (success) {
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Paper withBorder radius="lg" p="xl" w="100%" maw={440} shadow="sm" mx="md">
<Stack align="center" gap="md">
<ThemeIcon size={56} radius="xl" color="teal" variant="light">
<IconCheck size={30} />
</ThemeIcon>
<Title order={3} ta="center">Account Created!</Title>
<Text fz="sm" c="dimmed" ta="center">
Your vessel owner account has been created. You can now sign in and submit vessel registration applications.
</Text>
<Button fullWidth onClick={() => navigate('/vessel-owner/login')}>
Sign In Now
</Button>
</Stack>
</Paper>
</Box>
);
}
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="xl" w="100%" maw={540} px="md">
<Stack align="center" gap="xs">
<ThemeIcon size={64} radius="xl" color="blue" variant="light">
<IconShip size={36} />
</ThemeIcon>
<Title order={2} ta="center">Create Vessel Owner Account</Title>
<Text fz="sm" c="dimmed" ta="center">Ethiopian Maritime Affairs Authority</Text>
</Stack>
<Paper withBorder radius="lg" p="xl" w="100%" shadow="sm">
<Group gap="xs" mb="lg">
<IconAnchor size={20} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="lg">Owner Registration</Text>
</Group>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">{error}</Alert>
)}
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label="Full Name / Company Name"
placeholder="e.g. Abebe Girma"
leftSection={<IconUser size={16} />}
required
value={fullName}
onChange={(e) => setFullName(e.currentTarget.value)}
/>
<Select
label="Owner Type"
placeholder="Select type"
required
data={OWNER_TYPES}
value={ownerType}
onChange={setOwnerType}
/>
<TextInput
label="Email Address"
placeholder="owner@example.com"
leftSection={<IconMail size={16} />}
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<TextInput
label="Phone Number"
placeholder="+251 9XX XXX XXX"
leftSection={<IconPhone size={16} />}
required
value={phone}
onChange={(e) => setPhone(e.currentTarget.value)}
/>
<TextInput
label="National ID / TIN"
placeholder="ET-0000000 or TIN"
required
value={nationalIdOrTin}
onChange={(e) => setNationalIdOrTin(e.currentTarget.value)}
/>
</SimpleGrid>
<Divider label="Set Password" labelPosition="center" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<PasswordInput
label="Password"
placeholder="Min. 8 characters"
leftSection={<IconLock size={16} />}
required
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<PasswordInput
label="Confirm Password"
placeholder="Repeat password"
leftSection={<IconLock size={16} />}
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
error={confirmPassword && password !== confirmPassword ? 'Passwords do not match' : undefined}
/>
</SimpleGrid>
<Button fullWidth size="md" loading={loading} disabled={!canSubmit} onClick={handleRegister}>
Create Account
</Button>
</Stack>
<Divider my="md" label="Already have an account?" labelPosition="center" />
<Button fullWidth variant="light" onClick={() => navigate('/vessel-owner/login')}>
Sign In
</Button>
</Paper>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,440 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconArrowRight,
IconCircleCheck,
IconFileDescription,
IconInfoCircle,
IconTransferIn,
IconUser,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// Minimal vessel type for the approved vessel list
interface ApprovedVessel {
id: string;
vesselName: string;
category: string;
vesselType: string;
ownerName: string;
ownerNationalIdOrTin: string;
ownerPhone: string;
status: string;
}
const MOCK_APPROVED_VESSELS: ApprovedVessel[] = [
{
id: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
ownerName: 'Abebe Girma',
ownerNationalIdOrTin: 'ET-9812345',
ownerPhone: '+251 911 234 567',
status: 'Approved',
},
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TransferStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected';
export interface OwnershipTransferRequest {
id: string;
vesselId: string;
vesselName: string;
category: string;
vesselType: string;
currentOwnerName: string;
currentOwnerIdOrTin: string;
currentOwnerPhone: string;
newOwnerName: string;
newOwnerIdOrTin: string;
newOwnerPhone: string;
newOwnerEmail: string;
newOwnerAddress: string;
transferReason: string;
remarks: string;
status: TransferStatus;
submittedDate: string;
approvalDate: string | null;
}
export const MOCK_TRANSFER_REQUESTS: OwnershipTransferRequest[] = [
{
id: 'OT-2024-001',
vesselId: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
currentOwnerName: 'Abebe Girma',
currentOwnerIdOrTin: 'ET-9812345',
currentOwnerPhone: '+251 911 234 567',
newOwnerName: 'Tigist Haile',
newOwnerIdOrTin: 'ET-7743210',
newOwnerPhone: '+251 922 876 543',
newOwnerEmail: 'tigist.haile@email.com',
newOwnerAddress: 'Bahir Dar, Amhara Region',
transferReason: 'Sale',
remarks: 'Vessel sold to new owner. Bill of sale attached.',
status: 'Pending',
submittedDate: '2024-06-01',
approvalDate: null,
},
];
const TRANSFER_REASONS = [
'Sale / Purchase',
'Inheritance',
'Gift / Donation',
'Corporate Restructuring',
'Court Order',
'Other',
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
};
// ---------------------------------------------------------------------------
// Transfer request card
// ---------------------------------------------------------------------------
function TransferCard({ req }: { req: OwnershipTransferRequest }) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="violet" variant="light">
<IconTransferIn size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{req.vesselName}</Text>
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
</Group>
<Divider my="xs" />
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'From', value: req.currentOwnerName },
{ label: 'To', value: req.newOwnerName },
{ label: 'Reason', value: req.transferReason },
{ label: 'Submitted', value: req.submittedDate },
].map((r) => (
<div key={r.label}>
<Text fz="xs" c="dimmed">{r.label}</Text>
<Text fz="sm" fw={500}>{r.value}</Text>
</div>
))}
</SimpleGrid>
{req.status === 'Approved' && (
<Alert icon={<IconCircleCheck size={14} />} color="teal" mt="sm" py="xs">
Transfer approved on {req.approvalDate}. New certificates issued to {req.newOwnerName}.
</Alert>
)}
{req.status === 'Rejected' && req.remarks && (
<Alert icon={<IconAlertCircle size={14} />} color="red" mt="sm" py="xs">
Rejected: {req.remarks}
</Alert>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function OwnershipTransferPage() {
const navigate = useNavigate();
const [myVessels, setMyVessels] = useState<ApprovedVessel[]>([]);
const [transfers, setTransfers] = useState<OwnershipTransferRequest[]>(MOCK_TRANSFER_REQUESTS);
const [modalOpen, setModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [fetchTrigger] = useApiMutation<ApprovedVessel[]>();
const [submitTrigger] = useApiMutation<{ id: string }>();
const fetched = useRef(false);
// Form state
const [selectedVesselId, setSelectedVesselId] = useState<string | null>(null);
const [newOwnerName, setNewOwnerName] = useState('');
const [newOwnerIdOrTin, setNewOwnerIdOrTin] = useState('');
const [newOwnerPhone, setNewOwnerPhone] = useState('');
const [newOwnerEmail, setNewOwnerEmail] = useState('');
const [newOwnerAddress, setNewOwnerAddress] = useState('');
const [transferReason, setTransferReason] = useState<string | null>(null);
const [notes, setNotes] = useState('');
const [billOfSale, setBillOfSale] = useState<File | null>(null);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
.unwrap()
.then((data) => setMyVessels(Array.isArray(data) ? data : [data]))
.catch(() => {
// Fall back to mock approved vessels
setMyVessels(MOCK_APPROVED_VESSELS);
});
}, [fetchTrigger]);
const selectedVessel = myVessels.find((v) => v.id === selectedVesselId) ?? null;
const canSubmit = !!selectedVesselId && !!newOwnerName.trim() && !!newOwnerIdOrTin.trim() &&
!!newOwnerPhone.trim() && !!transferReason && !!billOfSale;
const resetForm = () => {
setSelectedVesselId(null);
setNewOwnerName('');
setNewOwnerIdOrTin('');
setNewOwnerPhone('');
setNewOwnerEmail('');
setNewOwnerAddress('');
setTransferReason(null);
setNotes('');
setBillOfSale(null);
};
const handleSubmit = async () => {
if (!selectedVessel) return;
setSubmitting(true);
try {
await submitTrigger({
url: '/vessel-ownership-transfers',
method: 'POST',
body: {
vesselId: selectedVessel.id,
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail,
newOwnerAddress, transferReason, notes,
},
}).unwrap();
// Optimistic local update
const newReq: OwnershipTransferRequest = {
id: `OT-${Date.now()}`,
vesselId: selectedVessel.id,
vesselName: selectedVessel.vesselName,
category: selectedVessel.category,
vesselType: selectedVessel.vesselType,
currentOwnerName: selectedVessel.ownerName,
currentOwnerIdOrTin: selectedVessel.ownerNationalIdOrTin,
currentOwnerPhone: selectedVessel.ownerPhone,
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail, newOwnerAddress,
transferReason: transferReason ?? '',
remarks: notes,
status: 'Pending',
submittedDate: new Date().toISOString().split('T')[0],
approvalDate: null,
};
setTransfers((prev) => [newReq, ...prev]);
resetForm();
setModalOpen(false);
notify.success('Ownership transfer request submitted successfully.');
} catch {
// Still add optimistically on API error (mock mode)
const newReq: OwnershipTransferRequest = {
id: `OT-${Date.now()}`,
vesselId: selectedVessel.id,
vesselName: selectedVessel.vesselName,
category: selectedVessel.category,
vesselType: selectedVessel.vesselType,
currentOwnerName: selectedVessel.ownerName,
currentOwnerIdOrTin: selectedVessel.ownerNationalIdOrTin,
currentOwnerPhone: selectedVessel.ownerPhone,
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail, newOwnerAddress,
transferReason: transferReason ?? '',
remarks: notes,
status: 'Pending',
submittedDate: new Date().toISOString().split('T')[0],
approvalDate: null,
};
setTransfers((prev) => [newReq, ...prev]);
resetForm();
setModalOpen(false);
notify.success('Ownership transfer request submitted.');
} finally {
setSubmitting(false);
}
};
const vesselOptions = myVessels
.filter((v) => v.status === 'Approved')
.map((v) => ({ value: v.id, label: `${v.vesselName} (${v.id})` }));
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="violet" variant="light">
<IconTransferIn size={24} />
</ThemeIcon>
<div>
<Title order={3}>Ownership Transfer</Title>
<Text fz="sm" c="dimmed">Request transfer of vessel ownership to another party</Text>
</div>
</Group>
<Button
leftSection={<IconTransferIn size={16} />}
color="violet"
onClick={() => setModalOpen(true)}
disabled={vesselOptions.length === 0}
>
Request Transfer
</Button>
</Group>
{vesselOptions.length === 0 && (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
You must have at least one <strong>approved</strong> vessel registration to request an ownership transfer.{' '}
<Text span fz="sm" c="blue.6" style={{ cursor: 'pointer' }} onClick={() => navigate('/vessel-registration')}>
View my registrations
</Text>
</Alert>
)}
{/* How it works */}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-violet-light)">
<Group gap="xs" mb="sm">
<IconInfoCircle size={16} color="var(--mantine-color-violet-7)" />
<Text fw={600} fz="sm" c="violet.7">How Ownership Transfer Works</Text>
</Group>
<Stack gap={6}>
{[
"Submit a transfer request with the new owner's details and a Bill of Sale",
'The Maritime Authority reviews and verifies the transfer documents',
'Upon approval, ownership is officially transferred in the registry',
'New certificates are automatically generated for the new owner',
'The new owner receives: Certificate of Nationality, Certificate of Ownership, Certificate of Registration (sea-going) or Inland Registration Certificate (inland)',
].map((step, i) => (
<Group key={i} gap="xs" align="flex-start">
<ThemeIcon size={20} radius="xl" color="violet" variant="light" style={{ flexShrink: 0, marginTop: 2 }}>
<Text fz="xs" fw={700}>{i + 1}</Text>
</ThemeIcon>
<Text fz="sm">{step}</Text>
</Group>
))}
</Stack>
</Paper>
{/* Existing transfer requests */}
<div>
<Text fw={700} fz="sm" mb="sm">My Transfer Requests</Text>
{transfers.length === 0 ? (
<Paper withBorder radius="md" p="xl">
<Text fz="sm" c="dimmed" ta="center">No transfer requests submitted yet.</Text>
</Paper>
) : (
<Stack gap="sm">
{transfers.map((req) => <TransferCard key={req.id} req={req} />)}
</Stack>
)}
</div>
{/* Transfer request modal */}
<Modal
opened={modalOpen}
onClose={() => { setModalOpen(false); resetForm(); }}
title="Request Ownership Transfer"
size="lg"
>
<Stack gap="md">
<Alert icon={<IconAlertCircle size={15} />} color="orange" variant="light">
Ownership transfer is permanent. Ensure all details are correct before submitting.
</Alert>
<Select
label="Select Vessel"
placeholder="Choose an approved vessel"
required
data={vesselOptions}
value={selectedVesselId}
onChange={setSelectedVesselId}
/>
{selectedVessel && (
<Paper withBorder radius="sm" p="sm" bg="var(--mantine-color-gray-0)">
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb={4}>Current Owner</Text>
<SimpleGrid cols={2} spacing="xs">
<div><Text fz="xs" c="dimmed">Name</Text><Text fz="sm">{selectedVessel.ownerName}</Text></div>
<div><Text fz="xs" c="dimmed">ID / TIN</Text><Text fz="sm">{selectedVessel.ownerNationalIdOrTin}</Text></div>
</SimpleGrid>
</Paper>
)}
<Divider label="New Owner Details" labelPosition="center" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="New Owner Full Name / Company" placeholder="e.g. Tigist Haile" required value={newOwnerName} onChange={(e) => setNewOwnerName(e.currentTarget.value)} leftSection={<IconUser size={15} />} />
<TextInput label="National ID / TIN" placeholder="e.g. ET-0000000" required value={newOwnerIdOrTin} onChange={(e) => setNewOwnerIdOrTin(e.currentTarget.value)} />
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" required value={newOwnerPhone} onChange={(e) => setNewOwnerPhone(e.currentTarget.value)} />
<TextInput label="Email Address" placeholder="owner@example.com" value={newOwnerEmail} onChange={(e) => setNewOwnerEmail(e.currentTarget.value)} />
<TextInput label="Address" placeholder="City, Region" value={newOwnerAddress} onChange={(e) => setNewOwnerAddress(e.currentTarget.value)} />
<Select label="Reason for Transfer" placeholder="Select reason" required data={TRANSFER_REASONS} value={transferReason} onChange={setTransferReason} />
</SimpleGrid>
<Divider label="Supporting Document" labelPosition="center" />
{/* Bill of Sale upload */}
<Card withBorder radius="md" p="md" style={{ borderStyle: 'dashed', borderColor: billOfSale ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)' }}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{ width: rem(44), height: rem(44), borderRadius: rem(8), background: 'var(--mantine-color-violet-light)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<IconFileDescription size={22} color="var(--mantine-color-violet-6)" />
</Box>
<div>
<Text fw={600} fz="sm">Bill of Sale / Transfer Document <Text span c="red">*</Text></Text>
<Text fz="xs" c="dimmed">Legal document confirming the transfer of ownership</Text>
</div>
</Group>
{billOfSale ? (
<Group gap="xs">
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{billOfSale.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => setBillOfSale(null)}>Remove</Button>
</Group>
) : (
<FileButton onChange={setBillOfSale} accept="application/pdf,image/jpeg,image/png">
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
<Textarea label="Additional Notes" placeholder="Any additional information for the authority..." value={notes} onChange={(e) => setNotes(e.currentTarget.value)} rows={3} />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => { setModalOpen(false); resetForm(); }}>Cancel</Button>
<Button color="violet" disabled={!canSubmit} loading={submitting} leftSection={<IconArrowRight size={15} />} onClick={handleSubmit}>
Submit Transfer Request
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,678 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconArrowLeft,
IconArrowRight,
IconCamera,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconId,
IconInfoCircle,
IconShieldCheck,
IconShip,
IconWaveSine,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const STEPS = [
{ label: 'Vessel Category' },
{ label: 'Vessel Details' },
{ label: 'Technical & Ownership' },
{ label: 'Documents Upload' },
{ label: 'Review & Submit' },
];
const VESSEL_TYPES_INLAND = [
'Passenger Ferry', 'Cargo Barge', 'Fishing Vessel', 'Tug Boat',
'Dredger', 'Patrol/Inspection Boat', 'Pleasure Craft', 'Water Taxi',
];
const VESSEL_TYPES_SEAGOING = [
'Container Ship', 'Bulk Carrier', 'Tanker', 'General Cargo',
'Ro-Ro Vessel', 'Passenger/Cruise Ship', 'Fishing Vessel', 'Trawler',
'Yacht/Pleasure Craft', 'Chemical Tanker', 'LPG Carrier', 'Multi-Purpose Vessel',
];
const ENGINE_TYPES = [
'Diesel Engine', 'Dual-Fuel Engine', 'Electric Motor', 'Hybrid Diesel-Electric',
'Steam Turbine', 'Gas Turbine', 'Outboard Motor', 'Inboard Petrol Engine',
];
const HULL_MATERIALS = [
'Steel', 'Aluminum', 'Fiberglass/GRP', 'Wood', 'Ferro-Cement',
];
const PASSENGER_VESSEL_TYPES = new Set([
'Passenger Ferry', 'Passenger/Cruise Ship', 'Water Taxi', 'Yacht/Pleasure Craft', 'Pleasure Craft',
]);
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
}
// ---------------------------------------------------------------------------
// Step indicator (matches SeafarerRegistrationPage pattern)
// ---------------------------------------------------------------------------
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap">
{STEPS.map((step, i) => {
const isDone = completed.includes(i);
const isCurrent = active === i;
return (
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
<Box
style={{
width: rem(40),
height: rem(40),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: isCurrent
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-gray-1)',
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
{isDone ? (
<IconCheck size={18} color="white" stroke={2.5} />
) : (
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
{i + 1}
</Text>
)}
</Box>
<Text
fz="xs"
fw={isCurrent ? 700 : 400}
c={isCurrent ? 'blue.7' : 'dimmed'}
style={{ whiteSpace: 'nowrap' }}
>
{isDone ? `${step.label}` : step.label}
</Text>
</Stack>
{i < STEPS.length - 1 && (
<Box
style={{
flex: 1,
height: rem(2),
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: 'var(--mantine-color-gray-2)',
marginBottom: rem(22),
}}
/>
)}
</Group>
);
})}
</Group>
</Box>
);
}
function SectionHead({ title }: { title: string }) {
return (
<>
<Divider mt="md" mb="xs" />
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
</>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
function DocCard({
slot,
file,
onFile,
}: {
slot: DocSlot;
file: File | null;
onFile: (f: File | null) => void;
}) {
const resetRef = useRef<() => void>(null);
const SlotIcon = slot.icon;
return (
<Card
withBorder
radius="md"
p="md"
style={{
borderStyle: 'dashed',
borderColor: file
? 'var(--mantine-color-teal-5)'
: 'var(--mantine-color-default-border)',
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box
style={{
width: rem(44),
height: rem(44),
borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">
{slot.label}
{slot.required && <Text span c="red" ml={3}>*</Text>}
</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => { onFile(null); resetRef.current?.(); }}
>
Remove
</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="xs" variant="default" {...props}>
Choose File
</Button>
)}
</FileButton>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function VesselRegistrationApplicationPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// Step 0 — Category
const [category, setCategory] = useState<string | null>(null);
// Step 1 — Vessel Details
const [vesselName, setVesselName] = useState('');
const [vesselType, setVesselType] = useState<string | null>(null);
const [capacityValue, setCapacityValue] = useState<string | number>('');
const [vesselLengthM, setVesselLengthM] = useState<string | number>('');
const [flagState, setFlagState] = useState('Ethiopia');
const [portOfRegistry, setPortOfRegistry] = useState('');
// Step 2 — Technical & Ownership
const [imoOrHullNumber, setImoOrHullNumber] = useState('');
const [manufacturerShipyard, setManufacturerShipyard] = useState('');
const [yearBuilt, setYearBuilt] = useState<string | number>('');
const [engineType, setEngineType] = useState<string | null>(null);
const [enginePowerKw, setEnginePowerKw] = useState<string | number>('');
const [numberOfEngines, setNumberOfEngines] = useState<string | number>('');
const [hullMaterial, setHullMaterial] = useState<string | null>(null);
const [ownerName, setOwnerName] = useState('');
const [ownerNationalIdOrTin, setOwnerNationalIdOrTin] = useState('');
const [ownerPhone, setOwnerPhone] = useState('');
const [ownerAddress, setOwnerAddress] = useState('');
// Step 3 — Documents
const [files, setFiles] = useState<Record<string, File | null>>({
vesselPhotos: null, proofOfOwnership: null, shipParticulars: null, insuranceCertificate: null,
});
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
// Reset vessel type when category changes
useEffect(() => { setVesselType(null); }, [category]);
// Derived
const vesselTypeOptions = category === 'Inland Waterway Vessel' ? VESSEL_TYPES_INLAND : VESSEL_TYPES_SEAGOING;
const capacityLabel = PASSENGER_VESSEL_TYPES.has(vesselType ?? '') ? 'Passenger Capacity' : 'Gross Tonnage (GT)';
const idLabel = category === 'Sea-going Vessel (International)' ? 'IMO Number' : 'Hull/Registration Number';
// Inland: only vessel photos required
// Sea-going: vessel photos + proof of ownership + ship particulars + insurance
const docSlots: DocSlot[] = category === 'Inland Waterway Vessel'
? [
{ key: 'vesselPhotos', label: 'Vessel Photos (min. 2)', description: 'Clear exterior and interior photos of the vessel', required: true, icon: IconCamera },
]
: [
{ key: 'vesselPhotos', label: 'Vessel Photos (min. 2)', description: 'Clear exterior and interior photos of the vessel', required: true, icon: IconCamera },
{ key: 'proofOfOwnership', label: 'Proof of Ownership / Bill of Sale', description: 'Legal document proving ownership of the vessel', required: true, icon: IconFileDescription },
{ key: 'shipParticulars', label: 'Ship Particulars', description: 'Detailed technical specifications issued by the shipyard', required: true, icon: IconId },
{ key: 'insuranceCertificate', label: 'Insurance Certificate', description: 'Valid hull and machinery insurance policy', required: true, icon: IconShieldCheck },
];
const canNext = () => {
if (active === 0) return !!category;
if (active === 1) return (
!!vesselName.trim() && !!vesselType && !!capacityValue && !!vesselLengthM &&
!!flagState.trim() && !!portOfRegistry.trim()
);
if (active === 2) return (
!!imoOrHullNumber.trim() && !!manufacturerShipyard.trim() && !!yearBuilt &&
!!engineType && !!enginePowerKw && !!numberOfEngines && !!hullMaterial &&
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && !!ownerPhone.trim()
);
if (active === 3) return category === 'Inland Waterway Vessel'
? !!files.vesselPhotos
: !!files.vesselPhotos && !!files.proofOfOwnership && !!files.shipParticulars && !!files.insuranceCertificate;
return true;
};
const next = () => {
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
setActive((c) => c + 1);
};
const prev = () => setActive((c) => c - 1);
const handleSubmit = async () => {
setSubmitting(true);
try {
await submitTrigger({
url: '/vessel-registrations',
method: 'POST',
body: {
category, vesselName, vesselType, capacityLabel, capacityValue, vesselLengthM,
flagState, portOfRegistry, imoOrHullNumber, manufacturerShipyard, yearBuilt,
engineType, enginePowerKw, numberOfEngines, hullMaterial,
ownerName, ownerNationalIdOrTin, ownerPhone, ownerAddress,
},
}).unwrap();
notify.success('Vessel registration submitted successfully!');
navigate('/vessel-registration');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registration')}>
Back
</Button>
</Group>
<div>
<Title order={3}>Vessel Registration Application</Title>
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} {STEPS[active].label}</Text>
</div>
<StepIndicator active={active} completed={completed} />
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
</Group>
{/* ── Step 0: Vessel Category ──────────────────────────────── */}
{active === 0 && (
<Stack gap="md">
<Text fz="sm" c="dimmed">Select the primary use category of the vessel to be registered.</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{[
{
value: 'Inland Waterway Vessel',
icon: IconWaveSine,
title: 'Inland Waterway Vessel',
desc: 'Vessels operating on lakes, rivers, and inland waterways within Ethiopia (e.g. Lake Tana, Hawassa, Blue Nile)',
},
{
value: 'Sea-going Vessel (International)',
icon: IconShip,
title: 'Sea-going Vessel (International)',
desc: 'Vessels operating in international waters, Red Sea, Gulf of Aden, and ocean routes',
},
].map((opt) => {
const Icon = opt.icon;
const selected = category === opt.value;
return (
<Card
key={opt.value}
withBorder
radius="md"
p="lg"
style={{
cursor: 'pointer',
borderColor: selected ? 'var(--mantine-color-blue-5)' : 'var(--mantine-color-default-border)',
borderWidth: selected ? 2 : 1,
background: selected ? 'var(--mantine-color-blue-light)' : undefined,
transition: 'all 0.15s ease',
}}
onClick={() => { setCategory(opt.value); next(); }}
>
<ThemeIcon size={48} radius="md" color="blue" variant={selected ? 'filled' : 'light'} mb="sm">
<Icon size={26} />
</ThemeIcon>
<Text fw={700} fz="md" mb={4}>{opt.title}</Text>
<Text fz="sm" c="dimmed">{opt.desc}</Text>
</Card>
);
})}
</SimpleGrid>
</Stack>
)}
{/* ── Step 1: Vessel Details ───────────────────────────────── */}
{active === 1 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Category: <strong>{category}</strong>
</Alert>
<SectionHead title="Vessel Identification" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Vessel Name"
placeholder="e.g. Lake Tana Star"
required
value={vesselName}
onChange={(e) => setVesselName(e.currentTarget.value)}
/>
<Select
label="Vessel Type"
placeholder="Select vessel type"
required
data={vesselTypeOptions}
value={vesselType}
onChange={setVesselType}
/>
<NumberInput
label={capacityLabel}
placeholder="Enter value"
required
min={1}
value={capacityValue}
onChange={setCapacityValue}
/>
<NumberInput
label="Vessel Length (meters)"
placeholder="e.g. 32"
required
min={1}
value={vesselLengthM}
onChange={setVesselLengthM}
/>
<TextInput
label="Flag State"
required
value={flagState}
onChange={(e) => setFlagState(e.currentTarget.value)}
/>
<TextInput
label="Registration Area"
placeholder="e.g. Bahir Dar"
required
value={portOfRegistry}
onChange={(e) => setPortOfRegistry(e.currentTarget.value)}
/>
</SimpleGrid>
</Stack>
)}
{/* ── Step 2: Technical & Ownership ───────────────────────── */}
{active === 2 && (
<Stack gap="md">
<SectionHead title="Technical Specifications" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label={idLabel}
placeholder={category === 'Sea-going Vessel (International)' ? 'IMO0000000' : 'ETH-INL-0000'}
required
value={imoOrHullNumber}
onChange={(e) => setImoOrHullNumber(e.currentTarget.value)}
/>
<TextInput
label="Manufacturer / Shipyard Name"
placeholder="e.g. Hyundai Heavy Industries"
required
value={manufacturerShipyard}
onChange={(e) => setManufacturerShipyard(e.currentTarget.value)}
/>
<NumberInput
label="Year Built"
placeholder="e.g. 2019"
required
min={1900}
max={new Date().getFullYear()}
value={yearBuilt}
onChange={setYearBuilt}
/>
<Select
label="Engine Type"
placeholder="Select engine type"
required
data={ENGINE_TYPES}
value={engineType}
onChange={setEngineType}
/>
<NumberInput
label="Engine Power (kW)"
placeholder="e.g. 450"
required
min={1}
value={enginePowerKw}
onChange={setEnginePowerKw}
/>
<NumberInput
label="Number of Engines"
placeholder="e.g. 2"
required
min={1}
max={12}
value={numberOfEngines}
onChange={setNumberOfEngines}
/>
<Select
label="Hull Material"
placeholder="Select material"
required
data={HULL_MATERIALS}
value={hullMaterial}
onChange={setHullMaterial}
/>
</SimpleGrid>
<SectionHead title="Owner Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Owner Name / Company"
placeholder="e.g. Abebe Girma"
required
value={ownerName}
onChange={(e) => setOwnerName(e.currentTarget.value)}
/>
<TextInput
label="National ID / TIN"
placeholder="e.g. ET-9812345"
required
value={ownerNationalIdOrTin}
onChange={(e) => setOwnerNationalIdOrTin(e.currentTarget.value)}
/>
<TextInput
label="Owner Phone"
placeholder="+251 9XX XXX XXX"
required
value={ownerPhone}
onChange={(e) => setOwnerPhone(e.currentTarget.value)}
/>
<TextInput
label="Owner Address"
placeholder="City, Region"
value={ownerAddress}
onChange={(e) => setOwnerAddress(e.currentTarget.value)}
style={{ gridColumn: 'span 2' }}
/>
</SimpleGrid>
</Stack>
)}
{/* ── Step 3: Documents Upload ─────────────────────────────── */}
{active === 3 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{docSlots.map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</SimpleGrid>
</Stack>
)}
{/* ── Step 4: Review & Submit ──────────────────────────────── */}
{active === 4 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Please review all information before submitting. You will be notified by the authority on application status.
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Vessel Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Category" value={category ?? ''} />
<ReviewRow label="Vessel Name" value={vesselName} />
<ReviewRow label="Vessel Type" value={vesselType ?? ''} />
<ReviewRow label={capacityLabel} value={String(capacityValue)} />
<ReviewRow label="Vessel Length (m)" value={String(vesselLengthM)} />
<ReviewRow label="Flag State" value={flagState} />
<ReviewRow label="Registration Area" value={portOfRegistry} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Technical Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label={idLabel} value={imoOrHullNumber} />
<ReviewRow label="Manufacturer / Shipyard" value={manufacturerShipyard} />
<ReviewRow label="Year Built" value={String(yearBuilt)} />
<ReviewRow label="Engine Type" value={engineType ?? ''} />
<ReviewRow label="Engine Power (kW)" value={String(enginePowerKw)} />
<ReviewRow label="Number of Engines" value={String(numberOfEngines)} />
<ReviewRow label="Hull Material" value={hullMaterial ?? ''} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Owner Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Owner Name" value={ownerName} />
<ReviewRow label="National ID / TIN" value={ownerNationalIdOrTin} />
<ReviewRow label="Phone" value={ownerPhone} />
<ReviewRow label="Address" value={ownerAddress} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<Stack gap={6}>
{docSlots.map((slot) => (
<Group key={slot.key} gap="xs">
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `${files[slot.key]!.name}` : '(not uploaded)'}
</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
)}
{/* Navigation */}
<Group justify="space-between" mt="xl">
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={active === 0 ? () => navigate('/vessel-registration') : prev}
>
{active === 0 ? 'Cancel' : 'Back'}
</Button>
{active < STEPS.length - 1 ? (
<Button
rightSection={<IconArrowRight size={16} />}
disabled={!canNext()}
onClick={next}
>
Next
</Button>
) : (
<Button
color="teal"
leftSection={<IconAnchor size={16} />}
loading={submitting}
onClick={handleSubmit}
>
Submit Registration
</Button>
)}
</Group>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,341 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconCircleCheck,
IconAlertCircle,
IconFileDescription,
IconShieldCheck,
IconCertificate,
IconDownload,
IconInfoCircle,
IconClockHour4,
IconTransferIn,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { authStorage } from '@ema-platform/auth';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required';
type VesselCategory = 'Inland Waterway Vessel' | 'Sea-going Vessel (International)';
type RenewalStatus = 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable';
interface VesselRegistration {
id: string;
vesselName: string;
category: VesselCategory;
vesselType: string;
flagState: string;
portOfRegistry: string;
capacityLabel: 'Passenger Capacity' | 'Gross Tonnage (GT)';
capacityValue: number;
vesselLengthM: number;
imoOrHullNumber: string;
ownerName: string;
status: VesselRegStatus;
submittedDate: string;
approvalDate: string | null;
remarks: string;
renewalStatus: RenewalStatus;
expiryDate: string | null;
}
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
};
// Inland vessel certificates (1)
const INLAND_CERTIFICATES = [
{ label: 'Inland Vessel Registration Certificate', description: 'Official government registration document for inland waterway operation' },
];
// Sea-going vessel certificates (4)
const SEAGOING_CERTIFICATES = [
{ label: 'Certificate of Nationality', description: 'Certifies the vessel\'s nationality and right to fly the Ethiopian flag' },
{ label: 'Certificate of Ownership', description: 'Confirms legal ownership of the vessel' },
{ label: 'Certificate of Registration', description: 'Official registration document for international sea-going operation' },
{ label: 'Minimum Safe Manning Certificate', description: 'Specifies the minimum crew required for safe operation of the vessel' },
];
// ---------------------------------------------------------------------------
// Requirements list
// ---------------------------------------------------------------------------
function RequirementItem({ label }: { label: string }) {
return (
<Group gap="xs">
<ThemeIcon size={20} radius="xl" color="blue" variant="light">
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm">{label}</Text>
</Group>
);
}
// ---------------------------------------------------------------------------
// Certificate card (shown after approval)
// ---------------------------------------------------------------------------
function CertificateCard({ label, description }: { label: string; description: string }) {
return (
<Card withBorder radius="md" p="md">
<Group gap="sm" mb="xs" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="teal" variant="light">
<IconCertificate size={20} />
</ThemeIcon>
<div>
<Text fw={600} fz="sm">{label}</Text>
<Text fz="xs" c="dimmed">{description}</Text>
</div>
</Group>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
Download Certificate
</Button>
</Card>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function VesselRegistrationPage() {
const navigate = useNavigate();
const [registration, setRegistration] = useState<VesselRegistration | null>(null);
const [fetchTrigger] = useApiMutation<VesselRegistration>();
const fetched = useRef(false);
useEffect(() => {
const profileId = authStorage.getProfileId();
if (!profileId || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
.unwrap()
.then((data) => setRegistration(data))
.catch(() => {/* no registration yet */});
}, [fetchTrigger]);
const certs = registration?.category === 'Sea-going Vessel (International)'
? SEAGOING_CERTIFICATES
: INLAND_CERTIFICATES;
return (
<Stack gap="md">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconAnchor size={24} />
</ThemeIcon>
<div>
<Title order={3}>Vessel Registration</Title>
<Text fz="sm" c="dimmed">Register your vessel with the Ethiopian Maritime Authority</Text>
</div>
</Group>
{/* ── No registration yet ───────────────────────────────────────── */}
{!registration && (
<>
<Paper withBorder radius="lg" p="xl">
<Group gap="md" mb="lg" wrap="nowrap">
<ThemeIcon size={52} radius="xl" color="blue" variant="light">
<IconAnchor size={28} />
</ThemeIcon>
<div>
<Text fw={700} fz="lg">Register Your Vessel</Text>
<Text fz="sm" c="dimmed">
Obtain official registration for inland waterway or sea-going vessels
</Text>
</div>
</Group>
<Divider mb="md" />
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
<Stack gap={6} mb="xl">
<RequirementItem label="Proof of Ownership / Bill of Sale" />
<RequirementItem label="Builder's Certificate or Technical Specifications" />
<RequirementItem label="Valid Insurance Certificate (Hull & Machinery)" />
<RequirementItem label="Tax Clearance Certificate" />
<RequirementItem label="Vessel Photos (at least 2 clear images)" />
<RequirementItem label="IMO Certificate of Registry (sea-going re-registration only)" />
</Stack>
<Button
size="md"
leftSection={<IconAnchor size={18} />}
onClick={() => navigate('/vessel-registration/apply')}
>
Start Vessel Registration
</Button>
</Paper>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb={4}>
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
<Text fw={600} fz="sm" c="blue.7">About Vessel Registration</Text>
</Group>
<Text fz="sm" c="dimmed">
Registration is valid for <strong>5 years</strong> from the date of approval. After approval,
inland vessels receive an <strong>Inland Vessel Registration Certificate</strong>, while
sea-going vessels receive four certificates: Certificate of Nationality, Certificate of
Ownership, Certificate of Registration, and Minimum Safe Manning Certificate.
</Text>
</Paper>
</>
)}
{/* ── Registration exists ──────────────────────────────────────── */}
{registration && (
<>
{/* Renewal alert */}
{registration.renewalStatus === 'Due Soon' && (
<Alert
icon={<IconAlertCircle size={17} />}
color="orange"
title="Renewal Due Soon"
>
Your vessel registration expires on {registration.expiryDate}. Please initiate renewal to avoid expiry.
<Button size="xs" variant="white" color="orange" mt="xs">
Start Renewal
</Button>
</Alert>
)}
{registration.renewalStatus === 'Overdue' && (
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Registration Expired">
Your vessel registration expired on {registration.expiryDate}. Immediate renewal is required.
</Alert>
)}
{/* Status card */}
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Group gap="sm">
<ThemeIcon size={40} radius="md" color="blue" variant="light">
<IconAnchor size={22} />
</ThemeIcon>
<div>
<Text fw={700} fz="lg">{registration.vesselName}</Text>
<Text fz="xs" c="dimmed">{registration.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[registration.status] ?? 'gray'} size="lg" variant="light">
{registration.status}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{[
{ label: 'Category', value: registration.category },
{ label: 'Vessel Type', value: registration.vesselType },
{ label: 'Flag State', value: registration.flagState },
{ label: 'Port of Registry', value: registration.portOfRegistry },
{ label: registration.capacityLabel, value: String(registration.capacityValue) },
{ label: 'Submitted', value: registration.submittedDate },
].map((row) => (
<div key={row.label}>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{row.label}</Text>
<Text fz="sm" mt={2}>{row.value || '—'}</Text>
</div>
))}
</SimpleGrid>
{registration.remarks && (
<>
<Divider my="md" />
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
<Text fz="sm">{registration.remarks}</Text>
</>
)}
</Paper>
{/* Timeline / status info */}
{registration.status !== 'Approved' && (
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="sm">
<IconClockHour4 size={16} />
<Text fw={600} fz="sm">Application Status</Text>
</Group>
<Stack gap={6}>
{[
{ label: 'Submitted', done: true },
{ label: 'Under Review', done: registration.status !== 'Pending' },
// Always pending here: this whole block only renders while the
// registration is *not* approved, so the step cannot be done.
{ label: 'Approved', done: false },
].map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
</Group>
))}
</Stack>
</Paper>
)}
{/* Transfer ownership — only when approved */}
{registration.status === 'Approved' && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600} fz="sm">Transfer Ownership</Text>
<Text fz="xs" c="dimmed">Transfer this vessel to a new owner</Text>
</div>
<Button
leftSection={<IconTransferIn size={15} />}
color="violet"
variant="light"
size="sm"
onClick={() => navigate('/vessel-registration/transfer')}
>
Request Transfer
</Button>
</Group>
</Paper>
)}
{/* Certificates section — shown after approval */}
{registration.status === 'Approved' && (
<div>
<Group gap="xs" mb="sm">
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz="md">
{registration.category === 'Sea-going Vessel (International)'
? 'Issued Certificates (4)'
: 'Issued Certificate'}
</Text>
</Group>
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
Your vessel registration has been approved. You may download your certificate(s) below.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{certs.map((cert) => (
<CertificateCard key={cert.label} label={cert.label} description={cert.description} />
))}
</SimpleGrid>
</div>
)}
</>
)}
</Stack>
);
}

View File

@@ -1,152 +0,0 @@
import { Badge, Button, Group, Text, Tooltip } from '@mantine/core';
import {
IconAlertTriangle,
IconCertificate,
IconRefresh,
} from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { IssuedLicense, Vessel } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
const CATEGORY_LABELS: Record<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
};
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
};
export function vesselColumns(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
licenseById: Map<string, IssuedLicense>;
onDownloadCertificate: (vessel: Vessel) => void;
onRenew: (vessel: Vessel) => void;
onReportIncident: (vessel: Vessel) => void;
}): AdvancedColumn<Vessel>[] {
return [
{
header: 'Registration №',
cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}>
{row.original.registrationNumber}
</Text>
),
},
{
header: 'Vessel',
cell: ({ row }) => (
<>
<Text size="sm" fw={500}>
{row.original.name}
</Text>
<Text size="xs" c="dimmed">
{row.original.vesselType ?? '—'}
{row.original.imoNumber ? ` · IMO ${row.original.imoNumber}` : ''}
</Text>
</>
),
},
{
header: 'Category',
cell: ({ row }) =>
CATEGORY_LABELS[row.original.category] ?? row.original.category,
},
{
header: 'Certificate',
cell: ({ row }) => {
const license = handlers.licenseById.get(row.original.licenseId);
const expiring =
license?.daysUntilExpiry !== undefined &&
license.daysUntilExpiry <= 60;
return license ? (
<Group gap={6} wrap="nowrap">
<Badge
size="sm"
variant="light"
color={
license.status === 'ACTIVE'
? expiring
? 'yellow'
: 'green'
: 'red'
}
>
{license.status === 'ACTIVE' && expiring
? `Expires in ${license.daysUntilExpiry}d`
: license.status}
</Badge>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
);
},
},
{
header: 'Status',
cell: ({ row }) => (
<Badge size="sm" color={VESSEL_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => {
const vessel = row.original;
const renewable =
handlers.licenseById.get(vessel.licenseId)?.renewable ?? false;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
{handlers.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) && (
<Tooltip label="Download certificate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => handlers.onDownloadCertificate(vessel)}
>
Certificate
</Button>
</Tooltip>
)}
{renewable &&
vessel.status === 'REGISTERED' &&
handlers.can([PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]) && (
<Tooltip label="Renew the registration">
<Button
size="compact-xs"
variant="light"
leftSection={<IconRefresh size={14} />}
onClick={() => handlers.onRenew(vessel)}
>
Renew
</Button>
</Tooltip>
)}
{handlers.can([PORTAL_PERMISSIONS.REPORT_VESSEL_INCIDENT]) && (
<Tooltip label="Report accident / incident">
<Button
size="compact-xs"
variant="subtle"
color="orange"
leftSection={<IconAlertTriangle size={14} />}
onClick={() => handlers.onReportIncident(vessel)}
>
Incident
</Button>
</Tooltip>
)}
</Group>
);
},
},
];
}

View File

@@ -1,333 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Group,
Loader,
Modal,
Paper,
Progress,
Stack,
Text,
TextInput,
Textarea,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconArrowRight,
IconInfoCircle,
IconPlus,
IconShip,
} from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
extractErrorMessage,
useCreateApplicationMutation,
useCreateVesselIncidentMutation,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetMyVesselsQuery,
} from '@ema-platform/api';
import type { Vessel } from '@ema-platform/api';
import {
PORTAL_PERMISSIONS,
RequirePermission,
usePermissions,
} from '@ema-platform/auth';
import { vesselColumns } from './columns';
const REGISTRATION_TYPE_KEY = 'VESSEL_REGISTRATION';
/** US-VES-016: the owner reports an accident or incident on their vessel. */
function IncidentModal({
vessel,
onClose,
}: {
vessel: Vessel | null;
onClose: () => void;
}) {
const [occurredAt, setOccurredAt] = useState('');
const [location, setLocation] = useState('');
const [description, setDescription] = useState('');
const [createIncident, { isLoading }] = useCreateVesselIncidentMutation();
const submit = async () => {
if (!vessel) return;
try {
await createIncident({
vesselId: vessel.id,
body: {
occurredAt,
description,
...(location ? { location } : {}),
},
}).unwrap();
notify.success('Incident recorded');
onClose();
setOccurredAt('');
setLocation('');
setDescription('');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not record the incident'));
}
};
return (
<Modal
opened={Boolean(vessel)}
onClose={onClose}
title={`Report incident — ${vessel?.name ?? ''}`}
centered
>
<Stack>
<TextInput
type="date"
label="Date of occurrence"
required
value={occurredAt}
onChange={(e) => setOccurredAt(e.target.value)}
/>
<TextInput
label="Location"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
<Textarea
label="What happened"
required
minRows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
loading={isLoading}
disabled={!occurredAt || description.trim().length < 10}
onClick={submit}
>
Record incident
</Button>
</Group>
</Stack>
</Modal>
);
}
/**
* The vessel owner's home (US-VES-001…009, 016): registered vessels with
* their certificates and renewals, registrations still in flight, and the
* entry point into the config-driven registration wizard.
*/
export function VesselRegistrationPage() {
const navigate = useNavigate();
const { data: vessels, isLoading: loadingVessels, refetch } = useGetMyVesselsQuery();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const [createApplication] = useCreateApplicationMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const [incidentFor, setIncidentFor] = useState<Vessel | null>(null);
const table = useServerTable();
const { can } = usePermissions();
const pagedVessels = table.paginate(vessels ?? []);
const inFlight = (applications?.items ?? []).filter(
(app) =>
app.licenseType?.key === REGISTRATION_TYPE_KEY &&
!TERMINAL_STATUSES.includes(app.status),
);
const licenseById = new Map(
(licenses?.items ?? []).map((license) => [license.id, license]),
);
async function downloadCertificate(vessel: Vessel) {
try {
const result = await getCertificateUrl(vessel.licenseId).unwrap();
window.open(result.url, '_blank', 'noopener');
} catch (error) {
notify.error(
extractErrorMessage(error, 'Could not fetch the certificate'),
);
}
}
/** A renewal is an ordinary application of kind RENEWAL (US-VES-009). */
async function renew(vessel: Vessel) {
try {
const application = await createApplication({
licenseType: REGISTRATION_TYPE_KEY,
kind: 'RENEWAL',
previousLicenseId: vessel.licenseId,
}).unwrap();
navigate(
`/licensing/${REGISTRATION_TYPE_KEY}/applications/${application.id}`,
);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not start the renewal'));
}
}
if (loadingVessels || loadingApplications) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Vessel Registration</Title>
<RequirePermission
anyOf={[PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]}
hideOnly
>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
>
Register a vessel
</Button>
</RequirePermission>
</Group>
{/* ----------------------------------------------------- in-flight */}
{inFlight.length > 0 && (
<Stack gap="sm">
<Title order={4}>Registrations in progress</Title>
{inFlight.map((app) => {
const isDraft = app.status === 'DRAFT';
const needsAction = app.status === 'RESUBMIT_REQUIRED';
const vesselName =
(app.formData?.vesselDetails?.vesselName as string) ?? null;
return (
<Card key={app.id} withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<div>
<Group gap="xs">
<Text fw={600}>{app.applicationNumber}</Text>
{vesselName && (
<Text c="dimmed" size="sm">
{vesselName}
</Text>
)}
{app.kind === 'RENEWAL' && (
<Badge variant="light">Renewal</Badge>
)}
</Group>
<Progress
value={STATUS_PROGRESS[app.status]}
mt="xs"
w={260}
/>
</div>
<Group wrap="nowrap">
<Badge color={STATUS_COLORS[app.status]}>
{STATUS_LABELS[app.status]}
</Badge>
<Button
size="compact-sm"
variant={needsAction ? 'filled' : 'light'}
color={needsAction ? 'orange' : undefined}
rightSection={<IconArrowRight size={14} />}
onClick={() =>
navigate(
`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`,
)
}
>
{isDraft ? 'Continue' : needsAction ? 'Fix' : 'View'}
</Button>
</Group>
</Group>
</Card>
);
})}
</Stack>
)}
{/* ------------------------------------------------------- register */}
<Stack gap="sm">
<Title order={4}>My vessels</Title>
{(vessels ?? []).length === 0 ? (
<Paper withBorder radius="md" p="xl">
<Stack align="center" gap="sm">
<IconShip size={40} color="var(--mantine-color-blue-5)" />
<Text fw={600}>No registered vessels yet</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
Register an inland-waterway or sea-going vessel. Approval
issues the registration certificate and enters the vessel in
the national register.
</Text>
<RequirePermission
anyOf={[PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]}
hideOnly
>
<Button
mt="xs"
onClick={() =>
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)
}
>
Start registration
</Button>
</RequirePermission>
</Stack>
</Paper>
) : (
<AdvancedTable
tableName="My vessels"
columns={vesselColumns({
can,
licenseById,
onDownloadCertificate: downloadCertificate,
onRenew: renew,
onReportIncident: setIncidentFor,
})}
data={pagedVessels.rows}
itemCount={pagedVessels.itemCount}
pageIndex={pagedVessels.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
refresh={refetch}
/>
)}
</Stack>
{(vessels ?? []).some((v) => v.status === 'SUSPENDED') && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
A suspended vessel may not operate. Contact the Ethiopian Maritime
Authority about reinstatement.
</Alert>
)}
<Paper withBorder radius="md" p="md">
<Group gap="xs">
<IconAnchor size={18} color="var(--mantine-color-blue-5)" />
<Text size="sm" c="dimmed">
Amendment and duplicate-certificate services are coming in a
later release.
</Text>
</Group>
</Paper>
<IncidentModal vessel={incidentFor} onClose={() => setIncidentFor(null)} />
</Stack>
);
}
export default VesselRegistrationPage;

View File

@@ -55,6 +55,8 @@ export const am: Translations = {
seaRecords: 'የባህር መዝገቦቼ',
myApplication: 'ማመልከቻዬ',
certificates: 'የምስክር ወረቀቶች',
seamanBook: 'የመርከበኛ መጽሐፍ',
btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት',
endorsements: 'ማረጋገጫዎች',
vesselRegistrations: 'የመርከብ ምዝገባ',
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
@@ -233,6 +235,7 @@ export const am: Translations = {
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
seafarerBanner: 'የባህረኛ ምዝገባ ለማድረግ የመገለጫ መረጃ ያስፈልጋል።',
},
profileSections: {

View File

@@ -54,6 +54,8 @@ export const en = {
mtoLicense: 'MTO License',
waiver: 'Waiver',
certificates: 'Certificates',
seamanBook: 'Seaman Book',
btc: 'Basic Training Certificate',
endorsements: 'Endorsements',
vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Transfers',
@@ -233,6 +235,7 @@ export const en = {
seafarerReason:
'Seafarer registration is built from your profile — these details fill it in for you.',
seafarerRedirect: 'Finish your profile to register as a seafarer. Still needed: {{fields}}',
seafarerBanner: 'Profile details are needed for seafarer registration.',
},
profileSections: {

View File

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

View File

@@ -27,7 +27,6 @@ import { RequireSeafarerProfile } from "./features/profile/components/RequireSea
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
import { ProfilePage } from "./features/profile/pages/ProfilePage";
import { SupportPage } from "./features/support/pages/SupportPage";
import { SeafarerRegistrationPage } from "./features/seafarer/pages/SeafarerRegistrationPage";
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
import { ExamsPage } from "./features/exams/pages/ExamsPage";
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
@@ -36,6 +35,8 @@ import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
import { SeamanBookPage } from "./features/seaman-book/pages/SeamanBookPage";
import { SeamanBookApplicationPage } from "./features/seaman-book/pages/SeamanBookApplicationPage";
import { MedicalCertificatePage } from "./features/medical/pages/MedicalCertificatePage";
import { BasicSafetyTrainingPage } from "./features/basic-safety-training/pages/BasicSafetyTrainingPage";
import { NotificationsPage } from "./features/notifications/pages/NotificationsPage";
// Phase 2 — CoC / CoP
@@ -152,15 +153,13 @@ export const router = createBrowserRouter([
},
// Seafarer
// The standalone wizard is gone — registration is the config-driven
// licensing flow like every other licence type, gated by
// RequireSeafarerProfile + RequirePermission the same way
// /licensing/:typeCode/apply already is.
{
path: "/seafarer-registration",
element: (
<RequireSeafarerProfile>
<RequirePermission anyOf={[P.APPLY_SEAFARER_REGISTRATION]}>
<SeafarerRegistrationPage />
</RequirePermission>
</RequireSeafarerProfile>
),
element: <Navigate to="/licensing/SEAFARER_REGISTRATION/apply" replace />,
},
{
path: "/seafarer/records",
@@ -215,6 +214,22 @@ export const router = createBrowserRouter([
</RequirePermission>
),
},
{
path: "/medical",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_MEDICAL]}>
<MedicalCertificatePage />
</RequirePermission>
),
},
{
path: "/basic-safety-training",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<BasicSafetyTrainingPage />
</RequirePermission>
),
},
{ path: "/notifications", element: <NotificationsPage /> },
// Phase 2 — CoC / CoP

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,
@@ -12,6 +16,35 @@ import {
import type { AuthUser, CurrentProfile } from "@ema-platform/auth";
configureAuthStorage("ema-portal", true);
// See the backoffice store: cookies ignore the port, so the API layer is told
// which app it is rather than guessing from a shared jar.
configureSessionScope("ema-portal");
// Dev-only preview mode (VITE_USE_MOCKS=true): seed a fake session so
// ProtectedRoute (which only checks that a token exists) treats the user as
// logged in without a real backend to authenticate against. Only runs when
// no real session is already present, so a genuine login is never clobbered.
if (
(import.meta as { env?: Record<string, string> }).env?.["VITE_USE_MOCKS"] === "true" &&
!authStorage.getToken()
) {
authStorage.setToken("mock-dev-token");
authStorage.setRefreshToken("mock-dev-refresh-token");
authStorage.setUser<AuthUser>({
id: "user-mock-001",
email: "abebe.tesfaye@example.et",
username: "abebe.tesfaye",
phoneNumber: "+251911223344",
name: { am: "አበበ ተስፋዬ", en: "Abebe Tesfaye" },
status: "ACTIVE",
sharepointId: null,
hasSetPassword: true,
hasFinishedRegistration: true,
hasFinishedDMSOnboarding: true,
isPhoneNumberVerified: true,
userType: "PORTAL",
});
}
const preloadedAuth = (() => {
const token = authStorage.getToken();

View File

@@ -4,6 +4,10 @@ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
export default defineConfig({
root: __dirname,
// Env lives at the workspace root, shared with the backoffice — without this
// Vite looks in apps/portal and VITE_BASE_API_URL silently falls back to its
// built-in default.
envDir: '../../',
cacheDir: '../../node_modules/.vite/apps/portal',
server: { port: 4200, host: 'localhost' },
preview: { port: 4200, host: 'localhost' },

View File

@@ -5,7 +5,7 @@ import { resolveSessionContext } from "../session";
export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
] ?? "http://localhost:3001/api";
let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null;

View File

@@ -1,9 +1,17 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import { baseQueryWithReauth } from "./base-query-with-reauth";
import { mockBaseQuery } from "./mock-base-query";
import { tagTypes } from "./tagTypes";
// Dev-only preview mode: set VITE_USE_MOCKS=true (repo-root .env.local) to
// serve the four preview feature areas from static sample data instead of a
// live backend. See mock-base-query.ts for what is and isn't covered.
const useMocks =
(import.meta as { env?: Record<string, string> }).env?.["VITE_USE_MOCKS"] === "true";
export const baseApi = createApi({
reducerPath: "baseApi",
baseQuery: baseQueryWithReauth,
baseQuery: useMocks ? mockBaseQuery : baseQueryWithReauth,
tagTypes: ["Api", "backOfficeApi", "portalApi", ...tagTypes],
endpoints: () => ({}),
});

View File

@@ -0,0 +1,385 @@
import type { BaseQueryFn } from '@reduxjs/toolkit/query/react';
import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { baseQueryWithReauth } from './base-query-with-reauth';
import {
mockApplicationDetails,
mockApplications,
mockLicenses,
mockLicenseTypeRequirements,
mockLicenseTypes,
mockMyExamAppeals,
mockMyExamRegistrations,
mockMyExamResults,
mockNotifications,
mockOpenExams,
mockProfileMeResponse,
mockVessels,
} from './mock-data';
/**
* Dev-only mock `BaseQueryFn`, opt-in via `VITE_USE_MOCKS=true` (see
* `base-api/index.ts`).
*
* Design choice: this is a partial mock, not a full fake backend. Each
* handler below matches a specific method + URL pattern for the four preview
* feature areas (vessel registration, seafarer registration, CoC
* certificates, exams) plus the shared profile/permissions/licensing-config
* endpoints they depend on. Anything not matched here falls through to the
* real `baseQueryWithReauth`, so login, signup, and any endpoint outside the
* four areas keep talking to a real backend if one is reachable. This was
* chosen over a full-mock/404 approach so the rest of the app (profile
* editing, payments, other license types, backoffice) is unaffected and a
* developer can extend coverage incrementally by adding more handlers below.
*
* Extend by adding another entry to `handlers`. Each handler receives the
* parsed method/path/params/body and returns the response payload (or
* `undefined` to fall through to the next handler / the real backend).
*/
type MockRequest = {
method: string;
path: string;
params?: Record<string, unknown>;
body?: unknown;
};
type MockHandler = {
method: string;
/** Matches the URL path (query string stripped). */
pattern: RegExp;
respond: (req: MockRequest, match: RegExpMatchArray) => unknown;
};
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
function paginated<T>(items: T[]) {
return { total: items.length, items };
}
let mockAppSeq = 100;
const handlers: MockHandler[] = [
// --------------------------------------------------------------- profile
{
method: 'GET',
pattern: /^\/profiles\/me$/,
respond: () => clone(mockProfileMeResponse),
},
// ----------------------------------------------------------- license types
{
method: 'GET',
pattern: /^\/license-types$/,
respond: () => paginated(clone(mockLicenseTypes)),
},
{
method: 'GET',
pattern: /^\/license-types\/requirements\/([\w-]+)$/,
respond: (_req, match) => {
const key = match[1];
const found = mockLicenseTypeRequirements[key];
return found ? clone(found) : undefined;
},
},
{
method: 'GET',
pattern: /^\/license-categories$/,
respond: () =>
paginated([
{ key: 'MARITIME_PERSONNEL', name: { en: 'Maritime Personnel' }, description: { en: 'Seafarer and vessel services' }, sortOrder: 1 },
]),
},
{
method: 'GET',
pattern: /^\/profiles\/me\/operations$/,
respond: () => ({ items: [] }),
},
// ----------------------------------------------------------- applications
{
method: 'GET',
pattern: /^\/license-applications\/mine$/,
respond: () => paginated(clone(Object.values(mockApplications))),
},
{
method: 'POST',
pattern: /^\/license-applications$/,
respond: (req) => {
const body = (req.body ?? {}) as { licenseType?: string; kind?: string; previousLicenseId?: string };
const typeKey = body.licenseType ?? 'VESSEL_REGISTRATION';
const requirements = mockLicenseTypeRequirements[typeKey];
const id = `app-draft-${++mockAppSeq}`;
const now = new Date().toISOString();
const application = {
id,
applicationNumber: `${requirements?.licenseType.certificatePrefix ?? 'APP'}-2026-${String(mockAppSeq).padStart(6, '0')}`,
licenseTypeId: requirements?.licenseType.id ?? 'license-type-unknown',
licenseType: requirements?.licenseType ?? { id: 'license-type-unknown', key: typeKey, name: { en: typeKey } },
applicantUserId: 'user-mock-001',
kind: body.kind ?? 'NEW',
status: 'DRAFT',
assignedOfficerId: null,
claimedAt: null,
formData: {},
companyName: null,
tradeName: null,
tinNumber: null,
businessAddress: null,
capitalAmountDeclared: null,
capitalAmountVerified: null,
adjustmentRound: 0,
submittedAt: null,
decidedAt: null,
rejectionReason: null,
feeAmount: String(requirements?.fee ?? ''),
feeCurrency: requirements?.feeCurrency ?? 'ETB',
issuedLicenseId: null,
createdAt: now,
};
mockApplications[id] = application;
mockApplicationDetails[id] = {
application,
staff: [],
attachments: [],
history: [
{
id: `hist-${id}-1`,
fromStatus: null,
toStatus: 'DRAFT',
event: 'created',
actorUserId: 'user-mock-001',
actorName: 'Abebe Tesfaye',
actorRole: 'APPLICANT',
remark: null,
metadata: null,
createdAt: now,
},
],
remarks: [],
openRemarks: [],
availableEvents: [],
};
return clone(application);
},
},
{
method: 'GET',
pattern: /^\/license-applications\/([\w-]+)$/,
respond: (_req, match) => {
const detail = mockApplicationDetails[match[1]];
return detail ? clone(detail) : undefined;
},
},
{
method: 'PATCH',
pattern: /^\/license-applications\/([\w-]+)\/sections\/([\w-]+)$/,
respond: (req, match) => {
const [, id, sectionKey] = match;
const application = mockApplications[id];
if (!application) return undefined;
const body = (req.body ?? {}) as { values?: Record<string, unknown> };
application.formData = { ...application.formData, [sectionKey]: body.values ?? {} };
return clone(application);
},
},
{
method: 'POST',
pattern: /^\/license-applications\/([\w-]+)\/submit$/,
respond: (_req, match) => {
const application = mockApplications[match[1]];
if (!application) return undefined;
application.status = 'UNDER_REVIEW';
application.submittedAt = new Date().toISOString();
return clone(application);
},
},
{
method: 'POST',
pattern: /^\/license-applications\/([\w-]+)\/resubmit$/,
respond: (_req, match) => {
const application = mockApplications[match[1]];
if (!application) return undefined;
application.status = 'UNDER_REVIEW';
return clone(application);
},
},
{
method: 'PATCH',
pattern: /^\/license-applications\/([\w-]+)\/remarks\/([\w-]+)\/resolve$/,
respond: () => ({ ok: true }),
},
{
method: 'POST',
pattern: /^\/license-applications\/([\w-]+)\/staff$/,
respond: (req) => {
const body = (req.body ?? {}) as { roleKey?: string; fullName?: string; position?: string; yearsOfExperience?: number };
return {
id: `staff-mock-${Date.now()}`,
roleKey: body.roleKey ?? '',
fullName: body.fullName ?? '',
position: body.position ?? null,
roleCategory: null,
yearsOfExperience: body.yearsOfExperience ?? null,
documents: [],
};
},
},
{
method: 'DELETE',
pattern: /^\/license-applications\/([\w-]+)\/staff\/([\w-]+)$/,
respond: () => ({ ok: true }),
},
// -------------------------------------------------------------- licenses
{
method: 'GET',
pattern: /^\/licenses\/mine$/,
respond: () => paginated(clone(mockLicenses)),
},
{
method: 'GET',
pattern: /^\/licenses\/([\w-]+)\/certificate$/,
respond: (_req, match) => ({
url: `https://example-cdn.ema.gov.et/certificates/${match[1]}.pdf`,
}),
},
// ------------------------------------------------------------ attachments
{
method: 'GET',
pattern: /^\/attachments$/,
respond: () => [],
},
// ---------------------------------------------------------- notifications
{
method: 'GET',
pattern: /^\/notifications\/unseen$/,
respond: () => clone(mockNotifications),
},
{
method: 'GET',
pattern: /^\/notifications$/,
respond: () => clone(mockNotifications),
},
{
method: 'PATCH',
pattern: /^\/notifications\/([\w-]+)\/read$/,
respond: () => ({ ok: true }),
},
// ---------------------------------------------------------------- vessels
{
method: 'GET',
pattern: /^\/vessels\/mine$/,
respond: () => clone(mockVessels),
},
{
method: 'POST',
pattern: /^\/vessels\/([\w-]+)\/incidents$/,
respond: (req, match) => ({
id: `incident-mock-${Date.now()}`,
vesselId: match[1],
occurredAt: (req.body as { occurredAt?: string })?.occurredAt ?? new Date().toISOString(),
location: (req.body as { location?: string })?.location ?? null,
description: (req.body as { description?: string })?.description ?? '',
severity: null,
reportedById: 'user-mock-001',
reportedByOfficer: false,
createdAt: new Date().toISOString(),
}),
},
// ------------------------------------------------------------------ exams
{
method: 'GET',
pattern: /^\/exams\/open$/,
respond: () => clone(mockOpenExams),
},
{
method: 'GET',
pattern: /^\/exams\/registrations\/mine$/,
respond: () => clone(mockMyExamRegistrations),
},
{
method: 'POST',
pattern: /^\/exams\/([\w-]+)\/register$/,
respond: () => ({ admissionNumber: `ADM-2026-${Math.floor(Math.random() * 900000 + 100000)}` }),
},
{
method: 'GET',
pattern: /^\/results\/mine$/,
respond: () => clone(mockMyExamResults),
},
{
method: 'GET',
pattern: /^\/results\/appeals\/mine$/,
respond: () => clone(mockMyExamAppeals),
},
{
method: 'POST',
pattern: /^\/results\/([\w-]+)\/appeal$/,
respond: () => ({ appealNumber: `APL-2026-${Math.floor(Math.random() * 900000 + 100000)}` }),
},
// -------------------------------------------------------- seafarer records
{
method: 'GET',
pattern: /^\/sea-service-records\/mine$/,
respond: () => [],
},
{
method: 'GET',
pattern: /^\/medical-certificates\/mine$/,
respond: () => [],
},
{
method: 'GET',
pattern: /^\/sea-service-records\/mine\/sea-time$/,
respond: () => ({ totalDays: 620, verifiedRecords: 4 }),
},
];
function parseArgs(args: string | FetchArgs): { method: string; path: string; params?: Record<string, unknown>; body?: unknown } {
if (typeof args === 'string') {
const [path] = args.split('?');
return { method: 'GET', path };
}
const [path] = (args.url ?? '').split('?');
return {
method: (args.method ?? 'GET').toUpperCase(),
path,
params: args.params as Record<string, unknown> | undefined,
body: args.body,
};
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const mockBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> = async (
args,
api,
extraOptions,
) => {
const req = parseArgs(args);
for (const handler of handlers) {
if (handler.method !== req.method) continue;
const match = req.path.match(handler.pattern);
if (!match) continue;
const data = handler.respond(req, match);
if (data === undefined) continue;
await delay(150 + Math.floor(Math.random() * 150));
return { data };
}
// Unmatched — fall through to the real backend (auth/login, and any
// endpoint outside the four mocked preview areas).
return baseQueryWithReauth(args, api, extraOptions);
};

View File

@@ -0,0 +1,740 @@
/**
* Dev-only sample data for the mock base query (see `mock-base-query.ts`).
*
* Throwaway preview fixtures — realistic enough to demo the four portal
* feature areas (vessel registration, seafarer registration, CoC
* certificates, exams) without a live backend. Not used unless
* `VITE_USE_MOCKS=true`.
*/
// ------------------------------------------------------------------ profile
export const mockPermissions: string[] = [
'can:View:own-profile',
'can:edit:own-profile',
'can:resubmit:license-application',
'can:upload:own-documents',
'can:View:own-documents',
'can:replace:own-documents',
'can:initiate:own-payment',
'can:View:own-payments',
'can:View:own-notifications',
'can:apply:seafarer-registration',
'can:add:own-sea-service',
'can:edit:own-sea-service',
'can:View:own-sea-service',
'can:upload:own-medical-certificate',
'can:View:own-medical-certificate',
'can:apply:seafarer-certificate',
'can:apply:exam',
'can:View:own-exam',
'can:View:own-certificates',
'can:apply:vessel-registration',
'can:View:own-vessels',
'can:report:own-vessel-incident',
'can:create:license-application',
'can:View:my-license-applications',
'can:update:license-application',
'can:submit:license-application',
];
export const mockProfile = {
id: 'profile-mock-001',
userId: 'user-mock-001',
professionId: 'profession-mock-001',
addressId: 'address-mock-001',
type: 'INDIVIDUAL',
firstName: 'Abebe',
middleName: 'Kebede',
lastName: 'Tesfaye',
gender: 'MALE',
dob: '1990-04-12',
pob: 'Addis Ababa',
maritalStatus: 'MARRIED',
isComplete: true,
seafarerNumber: 'ET-SF-2026-00147',
seafarerStatus: 'ACTIVE',
seafarerDepartment: 'DECK',
seafarerStatusReason: null,
user: {
id: 'user-mock-001',
email: 'abebe.tesfaye@example.et',
username: 'abebe.tesfaye',
phoneNumber: '+251911223344',
name: { am: 'አበበ ተስፋዬ', en: 'Abebe Tesfaye' },
status: 'ACTIVE',
sharepointId: null,
hasSetPassword: true,
hasFinishedRegistration: true,
hasFinishedDMSOnboarding: true,
isPhoneNumberVerified: true,
userType: 'PORTAL',
},
address: {
id: 'address-mock-001',
idType: 'NID',
idNumber: '1234567890123',
nationality: 'Ethiopia',
regionId: 'region-addis',
cityId: 'city-addis',
subCityId: 'subcity-bole',
woredaId: 'woreda-03',
kebeleId: null,
streetAddress: 'Bole Road',
houseNumber: '14',
primaryPhoneNumber: '+251911223344',
secondaryPhoneNumber: null,
email: 'abebe.tesfaye@example.et',
website: null,
postalAddress: null,
emergencyContactName: 'Selamawit Tesfaye',
emergencyContactPhone: '+251911998877',
emergencyContactRelation: 'Spouse',
isActive: true,
},
profession: {
id: 'profession-mock-001',
departmentId: 'department-deck',
name: { en: 'Deck Officer' },
description: { en: 'Deck department officer' },
isActive: true,
},
};
export const mockProfileMeResponse = {
profile: mockProfile,
completeness: 100,
missing: [] as string[],
permissions: mockPermissions,
};
// ------------------------------------------------------------------ vessels
export const mockVessels = [
{
id: 'vessel-mock-001',
registrationNumber: 'ET-VES-2025-000041',
name: 'MV Abay Queen',
category: 'INLAND_WATERWAY',
vesselType: 'Passenger Ferry',
imoNumber: '9876543',
hullNumber: 'HN-4471',
flagState: 'Ethiopia',
portOfRegistry: 'Bahir Dar',
grossTonnage: 420,
passengerCapacity: 120,
lengthMeters: 38.5,
yearBuilt: 2018,
engineType: 'Diesel',
enginePowerKw: 950,
numberOfEngines: 2,
hullMaterial: 'Steel',
ownerUserId: 'user-mock-001',
ownerProfileId: 'profile-mock-001',
ownerName: 'Abebe Tesfaye',
applicationId: 'app-vessel-approved-001',
licenseId: 'license-vessel-001',
status: 'REGISTERED',
statusReason: null,
statusChangedAt: null,
registeredAt: '2025-11-03T09:00:00.000Z',
},
{
id: 'vessel-mock-002',
registrationNumber: 'ET-VES-2024-000019',
name: 'MV Tana Star',
category: 'INLAND_WATERWAY',
vesselType: 'Cargo Vessel',
imoNumber: '9765432',
hullNumber: 'HN-2290',
flagState: 'Ethiopia',
portOfRegistry: 'Bahir Dar',
grossTonnage: 610,
passengerCapacity: null,
lengthMeters: 45.2,
yearBuilt: 2015,
engineType: 'Diesel',
enginePowerKw: 1200,
numberOfEngines: 2,
hullMaterial: 'Steel',
ownerUserId: 'user-mock-001',
ownerProfileId: 'profile-mock-001',
ownerName: 'Abebe Tesfaye',
applicationId: 'app-vessel-approved-002',
licenseId: 'license-vessel-002',
status: 'REGISTERED',
statusReason: null,
statusChangedAt: null,
registeredAt: '2024-06-18T09:00:00.000Z',
},
];
// -------------------------------------------------------------- applications
const nowIso = () => new Date().toISOString();
export const mockApplications: Record<string, any> = {
'app-vessel-approved-001': {
id: 'app-vessel-approved-001',
applicationNumber: 'VES-2025-000041',
licenseTypeId: 'license-type-vessel-registration',
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
applicantUserId: 'user-mock-001',
kind: 'NEW',
status: 'CERTIFICATE_ISSUED',
assignedOfficerId: null,
claimedAt: null,
formData: { vesselDetails: { vesselName: 'MV Abay Queen' } },
companyName: null,
tradeName: null,
tinNumber: null,
businessAddress: null,
capitalAmountDeclared: null,
capitalAmountVerified: null,
adjustmentRound: 0,
submittedAt: '2025-10-20T08:00:00.000Z',
decidedAt: '2025-11-03T09:00:00.000Z',
rejectionReason: null,
feeAmount: '3500',
feeCurrency: 'ETB',
issuedLicenseId: 'license-vessel-001',
createdAt: '2025-10-15T08:00:00.000Z',
},
'app-vessel-approved-002': {
id: 'app-vessel-approved-002',
applicationNumber: 'VES-2024-000019',
licenseTypeId: 'license-type-vessel-registration',
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
applicantUserId: 'user-mock-001',
kind: 'NEW',
status: 'CERTIFICATE_ISSUED',
assignedOfficerId: null,
claimedAt: null,
formData: { vesselDetails: { vesselName: 'MV Tana Star' } },
companyName: null,
tradeName: null,
tinNumber: null,
businessAddress: null,
capitalAmountDeclared: null,
capitalAmountVerified: null,
adjustmentRound: 0,
submittedAt: '2024-06-01T08:00:00.000Z',
decidedAt: '2024-06-18T09:00:00.000Z',
rejectionReason: null,
feeAmount: '3500',
feeCurrency: 'ETB',
issuedLicenseId: 'license-vessel-002',
createdAt: '2024-05-20T08:00:00.000Z',
},
'app-vessel-pending-003': {
id: 'app-vessel-pending-003',
applicationNumber: 'VES-2026-000123',
licenseTypeId: 'license-type-vessel-registration',
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
applicantUserId: 'user-mock-001',
kind: 'NEW',
status: 'UNDER_REVIEW',
assignedOfficerId: 'officer-mock-001',
claimedAt: '2026-08-10T10:00:00.000Z',
formData: { vesselDetails: { vesselName: 'MV Zeway Pearl' } },
companyName: null,
tradeName: null,
tinNumber: null,
businessAddress: null,
capitalAmountDeclared: null,
capitalAmountVerified: null,
adjustmentRound: 0,
submittedAt: '2026-08-05T08:00:00.000Z',
decidedAt: null,
rejectionReason: null,
feeAmount: '3500',
feeCurrency: 'ETB',
issuedLicenseId: null,
createdAt: '2026-08-01T08:00:00.000Z',
},
'app-vessel-resubmit-004': {
id: 'app-vessel-resubmit-004',
applicationNumber: 'VES-2026-000098',
licenseTypeId: 'license-type-vessel-registration',
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
applicantUserId: 'user-mock-001',
kind: 'NEW',
status: 'RESUBMIT_REQUIRED',
assignedOfficerId: 'officer-mock-001',
claimedAt: '2026-07-20T10:00:00.000Z',
formData: { vesselDetails: { vesselName: 'MV Koka Voyager' } },
companyName: null,
tradeName: null,
tinNumber: null,
businessAddress: null,
capitalAmountDeclared: null,
capitalAmountVerified: null,
adjustmentRound: 1,
submittedAt: '2026-07-15T08:00:00.000Z',
decidedAt: null,
rejectionReason: null,
feeAmount: '3500',
feeCurrency: 'ETB',
issuedLicenseId: null,
createdAt: '2026-07-10T08:00:00.000Z',
},
'app-seafarer-registered-005': {
id: 'app-seafarer-registered-005',
applicationNumber: 'SEA-2025-000512',
licenseTypeId: 'license-type-seafarer-registration',
licenseType: { id: 'license-type-seafarer-registration', key: 'SEAFARER_REGISTRATION', name: { en: 'Seafarer Registration' } },
applicantUserId: 'user-mock-001',
kind: 'NEW',
status: 'COMPLETED',
assignedOfficerId: null,
claimedAt: null,
formData: { account: { applicantName: 'Abebe Tesfaye' } },
companyName: null,
tradeName: null,
tinNumber: null,
businessAddress: null,
capitalAmountDeclared: null,
capitalAmountVerified: null,
adjustmentRound: 0,
submittedAt: '2025-09-01T08:00:00.000Z',
decidedAt: '2025-09-20T08:00:00.000Z',
rejectionReason: null,
feeAmount: '500',
feeCurrency: 'ETB',
issuedLicenseId: null,
createdAt: '2025-08-25T08:00:00.000Z',
},
'app-coc-issued-006': {
id: 'app-coc-issued-006',
applicationNumber: 'COC-2025-000321',
licenseTypeId: 'license-type-coc',
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
applicantUserId: 'user-mock-001',
kind: 'NEW',
status: 'CERTIFICATE_ISSUED',
assignedOfficerId: null,
claimedAt: null,
formData: { account: { applicantName: 'Abebe Tesfaye' } },
companyName: null,
tradeName: null,
tinNumber: null,
businessAddress: null,
capitalAmountDeclared: null,
capitalAmountVerified: null,
adjustmentRound: 0,
submittedAt: '2025-12-01T08:00:00.000Z',
decidedAt: '2026-01-10T08:00:00.000Z',
rejectionReason: null,
feeAmount: '1200',
feeCurrency: 'ETB',
issuedLicenseId: 'license-coc-001',
createdAt: '2025-11-20T08:00:00.000Z',
},
'app-coc-review-007': {
id: 'app-coc-review-007',
applicationNumber: 'COC-2026-000045',
licenseTypeId: 'license-type-coc',
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
applicantUserId: 'user-mock-001',
kind: 'RENEWAL',
status: 'ELIGIBILITY_APPROVED',
assignedOfficerId: 'officer-mock-002',
claimedAt: '2026-08-01T10:00:00.000Z',
formData: { account: { applicantName: 'Abebe Tesfaye' } },
companyName: null,
tradeName: null,
tinNumber: null,
businessAddress: null,
capitalAmountDeclared: null,
capitalAmountVerified: null,
adjustmentRound: 0,
submittedAt: '2026-07-28T08:00:00.000Z',
decidedAt: null,
rejectionReason: null,
feeAmount: '1200',
feeCurrency: 'ETB',
issuedLicenseId: null,
createdAt: '2026-07-25T08:00:00.000Z',
},
};
export const mockApplicationDetails: Record<string, any> = Object.fromEntries(
Object.entries(mockApplications).map(([id, application]) => [
id,
{
application,
staff: [],
attachments: [],
history: [
{
id: `hist-${id}-1`,
fromStatus: null,
toStatus: 'DRAFT',
event: 'created',
actorUserId: 'user-mock-001',
actorName: 'Abebe Tesfaye',
actorRole: 'APPLICANT',
remark: null,
metadata: null,
createdAt: application.createdAt,
},
{
id: `hist-${id}-2`,
fromStatus: 'DRAFT',
toStatus: 'SUBMITTED',
event: 'submitted',
actorUserId: 'user-mock-001',
actorName: 'Abebe Tesfaye',
actorRole: 'APPLICANT',
remark: null,
metadata: null,
createdAt: application.submittedAt ?? application.createdAt,
},
],
remarks:
application.status === 'RESUBMIT_REQUIRED'
? [
{
id: `remark-${id}-1`,
roundNumber: 1,
targetType: 'DOCUMENT',
targetKey: 'vesselOwnershipProof',
remark: 'Ownership document is blurred — please re-upload a clearer scan.',
isResolved: false,
resolvedAt: null,
createdAt: application.submittedAt ?? application.createdAt,
},
]
: [],
openRemarks:
application.status === 'RESUBMIT_REQUIRED'
? [
{
id: `remark-${id}-1`,
roundNumber: 1,
targetType: 'DOCUMENT',
targetKey: 'vesselOwnershipProof',
remark: 'Ownership document is blurred — please re-upload a clearer scan.',
isResolved: false,
resolvedAt: null,
createdAt: application.submittedAt ?? application.createdAt,
},
]
: [],
availableEvents: [],
},
]),
);
// ----------------------------------------------------------------- licenses
export const mockLicenses = [
{
id: 'license-vessel-001',
certificateNumber: 'ET-VES-CERT-2025-041',
licenseTypeId: 'license-type-vessel-registration',
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
applicationId: 'app-vessel-approved-001',
companyName: null,
tinNumber: null,
issueDate: '2025-11-03',
expiryDate: '2027-11-03',
status: 'ACTIVE',
daysUntilExpiry: 445,
renewable: false,
verificationCode: 'VESQR001',
certificateFileKey: 'certs/vessel-001.pdf',
},
{
id: 'license-vessel-002',
certificateNumber: 'ET-VES-CERT-2024-019',
licenseTypeId: 'license-type-vessel-registration',
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
applicationId: 'app-vessel-approved-002',
companyName: null,
tinNumber: null,
issueDate: '2024-06-18',
expiryDate: '2026-06-18',
status: 'ACTIVE',
daysUntilExpiry: 308,
renewable: true,
verificationCode: 'VESQR002',
certificateFileKey: 'certs/vessel-002.pdf',
},
{
id: 'license-coc-001',
certificateNumber: 'ET-COC-2026-000321',
licenseTypeId: 'license-type-coc',
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
applicationId: 'app-coc-issued-006',
companyName: null,
tinNumber: null,
issueDate: '2026-01-10',
expiryDate: '2031-01-10',
status: 'ACTIVE',
daysUntilExpiry: 1610,
renewable: false,
verificationCode: 'COCQR001',
certificateFileKey: 'certs/coc-001.pdf',
},
];
// -------------------------------------------------------------------- exams
export const mockOpenExams = [
{
id: 'exam-open-001',
title: { en: 'Officer of the Watch — Written Examination' },
date: '2026-09-15T09:00:00.000Z',
venue: 'EMA Headquarters, Addis Ababa',
status: 'OPEN',
certification: { name: { en: 'Certificate of Competency (Deck)' } },
},
{
id: 'exam-open-002',
title: { en: 'Marine Engineering Practical Assessment' },
date: '2026-10-02T09:00:00.000Z',
venue: 'Bahir Dar Maritime Training Center',
status: 'OPEN',
certification: { name: { en: 'Certificate of Competency (Engine)' } },
},
];
export const mockMyExamRegistrations = [
{
id: 'reg-mock-001',
admissionNumber: 'ADM-2026-000876',
createdAt: '2026-08-01T08:00:00.000Z',
kind: 'NEW',
attemptNumber: 1,
attendanceStatus: 'REGISTERED',
exam: mockOpenExams[0],
},
];
export const mockMyExamResults = [
{
id: 'result-mock-001',
totalScore: 82,
status: 'PASSED',
publishedAt: '2026-03-20T08:00:00.000Z',
remark: { en: 'Strong performance in navigation and safety.' },
exam: {
id: 'exam-past-001',
title: { en: 'Officer of the Watch — Written Examination' },
date: '2026-03-10T09:00:00.000Z',
venue: 'EMA Headquarters, Addis Ababa',
status: 'CLOSED',
certification: { name: { en: 'Certificate of Competency (Deck)' } },
},
},
];
export const mockMyExamAppeals: any[] = [];
// ------------------------------------------------------------ notifications
export const mockNotifications = {
count: 2,
items: [
{
id: 'notif-mock-001',
subject: { en: 'Application under review' },
content: { en: 'Your vessel registration VES-2026-000123 is now under review.' },
isSeen: false,
itemId: 'app-vessel-pending-003',
itemType: 'LicenseApplication',
metadata: null,
createdAt: '2026-08-05T09:00:00.000Z',
},
{
id: 'notif-mock-002',
subject: { en: 'Corrections requested' },
content: { en: 'Please resubmit documents for VES-2026-000098.' },
isSeen: true,
itemId: 'app-vessel-resubmit-004',
itemType: 'LicenseApplication',
metadata: null,
createdAt: '2026-07-21T09:00:00.000Z',
},
],
};
// ------------------------------------------------------------ license types
export const mockLicenseTypeRequirements: Record<string, any> = {
VESSEL_REGISTRATION: {
licenseType: {
id: 'license-type-vessel-registration',
key: 'VESSEL_REGISTRATION',
name: { en: 'Vessel Registration' },
description: { en: 'Register a vessel with the Ethiopian Maritime Authority.' },
category: 'MARITIME_PERSONNEL',
certificatePrefix: 'VES',
feeNewApplication: 3500,
feeRenewal: 2000,
feeCurrency: 'ETB',
capitalThreshold: null,
validityMonths: 24,
slaHours: 240,
inspectionRequired: true,
issuesCertificate: true,
renewalEnabled: true,
requiresOperatorMode: false,
formSchema: {
sections: [
{
key: 'vesselDetails',
title: { en: 'Vessel Details' },
fields: [
{ key: 'vesselName', label: { en: 'Vessel Name' }, type: 'TEXT', required: true, sortOrder: 1 },
{ key: 'vesselType', label: { en: 'Vessel Type' }, type: 'TEXT', required: true, sortOrder: 2 },
{ key: 'grossTonnage', label: { en: 'Gross Tonnage' }, type: 'NUMBER', required: true, sortOrder: 3 },
],
sortOrder: 1,
},
{
key: 'ownerDetails',
title: { en: 'Owner Details' },
fields: [
{ key: 'nationality', label: { en: 'Nationality' }, type: 'TEXT', required: true, sortOrder: 1 },
{ key: 'idNumber', label: { en: 'National ID (Fayda) Number' }, type: 'TEXT', required: true, sortOrder: 2 },
],
sortOrder: 2,
},
],
},
isActive: true,
sortOrder: 1,
},
applicationKind: 'NEW',
fee: 3500,
feeCurrency: 'ETB',
documentRequirements: [
{
id: 'doc-vessel-ownership',
key: 'vesselOwnershipProof',
name: { en: 'Proof of Ownership' },
applicationKind: 'NEW',
mode: 'ALWAYS',
allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
maxSizeMb: 10,
requiresValidityDates: false,
sortOrder: 1,
},
],
staffRoleRequirements: [],
},
SEAFARER_REGISTRATION: {
licenseType: {
id: 'license-type-seafarer-registration',
key: 'SEAFARER_REGISTRATION',
name: { en: 'Seafarer Registration' },
description: { en: 'Register as a seafarer with the Ethiopian Maritime Authority.' },
category: 'MARITIME_PERSONNEL',
certificatePrefix: 'SEA',
feeNewApplication: 500,
feeRenewal: null,
feeCurrency: 'ETB',
capitalThreshold: null,
validityMonths: 0,
slaHours: 120,
inspectionRequired: false,
issuesCertificate: false,
renewalEnabled: false,
requiresOperatorMode: false,
formSchema: {
sections: [
{
key: 'personalDetails',
title: { en: 'Personal Details' },
fields: [
{ key: 'applicantName', label: { en: 'Full Name' }, type: 'TEXT', required: true, sortOrder: 1 },
{ key: 'department', label: { en: 'Department' }, type: 'SELECT', required: true, options: [
{ value: 'DECK', label: { en: 'Deck' } },
{ value: 'ENGINE', label: { en: 'Engine' } },
{ value: 'CATERING', label: { en: 'Catering' } },
], sortOrder: 2 },
],
sortOrder: 1,
},
],
},
isActive: true,
sortOrder: 2,
},
applicationKind: 'NEW',
fee: 500,
feeCurrency: 'ETB',
documentRequirements: [
{
id: 'doc-seafarer-photo',
key: 'passportPhoto',
name: { en: 'Passport-size Photograph' },
applicationKind: 'NEW',
mode: 'ALWAYS',
allowedMimeTypes: ['image/jpeg', 'image/png'],
maxSizeMb: 5,
requiresValidityDates: false,
sortOrder: 1,
},
],
staffRoleRequirements: [],
},
CERTIFICATE_OF_COMPETENCY: {
licenseType: {
id: 'license-type-coc',
key: 'CERTIFICATE_OF_COMPETENCY',
name: { en: 'Certificate of Competency' },
description: { en: 'Apply for a Certificate of Competency.' },
category: 'MARITIME_PERSONNEL',
certificatePrefix: 'COC',
feeNewApplication: 1200,
feeRenewal: 800,
feeCurrency: 'ETB',
capitalThreshold: null,
validityMonths: 60,
slaHours: 168,
inspectionRequired: false,
issuesCertificate: true,
renewalEnabled: true,
requiresOperatorMode: false,
formSchema: {
sections: [
{
key: 'applicantDetails',
title: { en: 'Applicant Details' },
fields: [
{ key: 'applicantName', label: { en: 'Full Name' }, type: 'TEXT', required: true, sortOrder: 1 },
{ key: 'rank', label: { en: 'Rank Applied For' }, type: 'TEXT', required: true, sortOrder: 2 },
],
sortOrder: 1,
},
],
},
isActive: true,
sortOrder: 3,
},
applicationKind: 'NEW',
fee: 1200,
feeCurrency: 'ETB',
documentRequirements: [
{
id: 'doc-coc-seatime',
key: 'seaTimeRecord',
name: { en: 'Sea Time Record' },
applicationKind: 'NEW',
mode: 'ALWAYS',
allowedMimeTypes: ['application/pdf'],
maxSizeMb: 10,
requiresValidityDates: false,
sortOrder: 1,
},
],
staffRoleRequirements: [],
},
};
export const mockLicenseTypes = Object.values(mockLicenseTypeRequirements).map(
(r) => r.licenseType,
);

View File

@@ -25,6 +25,8 @@ import type {
QueueFilter,
RemarkTargetType,
SavedQueueView,
TemplateFieldPlacement,
TemplateLogoPlacement,
TemplatePageOptions,
TemplateVariable,
} from './licensing.types';
@@ -485,6 +487,33 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
/** Places a candidate who has paid the examination fee into a sitting. */
scheduleExam: builder.mutation<
LicenseApplication,
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/schedule-exam`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
/**
* Raises the examination fee — after eligibility approval, or again when
* a failed candidate elects to resit.
*/
requestExamPayment: builder.mutation<LicenseApplication, string>({
query: (id) => ({
url: `/license-applications/${id}/request-exam-payment`,
method: 'POST',
}),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication')],
}),
// ------------------------------------------------- certificate designs
getLicenseTemplates: builder.query<LicenseTemplate[], string | void>({
query: (licenseTypeId) => ({
@@ -522,6 +551,10 @@ export const licensingApi = baseApi
name?: string;
hbsSource?: string;
pageOptions?: TemplatePageOptions;
backgroundUrl?: string;
logoUrl?: string;
logoPlacement?: TemplateLogoPlacement;
fieldPlacements?: TemplateFieldPlacement[];
}
>({
query: ({ id, ...body }) => ({
@@ -805,6 +838,8 @@ export const {
useApproveDocumentsMutation,
useFinalApproveMutation,
useRejectApplicationMutation,
useScheduleExamMutation,
useRequestExamPaymentMutation,
useConfirmPaymentMutation,
useScheduleInspectionMutation,
useGetInspectionsQuery,

View File

@@ -10,7 +10,7 @@ import type {
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
] ?? "http://localhost:3001/api";
/**
* Uploads a document straight to the API.
@@ -115,6 +115,15 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
CERTIFICATE_ISSUED: 100,
COMPLETED: 100,
REJECTED: 100,
// The exam leg sits between approval and the certificate fee, so these
// interleave with PAYMENT_PENDING (80) rather than running past it.
ELIGIBILITY_APPROVED: 60,
EXAM_PAYMENT_PENDING: 64,
EXAM_PAID: 68,
EXAM_SCHEDULED: 72,
EXAM_PASSED: 78,
// A resit returns to the fee step, so this is not further along than a pass.
EXAM_FAILED: 64,
};
/** Statuses where nothing moves until the applicant does something. */

View File

@@ -1,5 +1,9 @@
/** Shared licensing contract — mirrors the emaapi domain model. */
// Reused rather than redeclared: the department vocabulary belongs to the
// seafarer domain, and two copies would drift.
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
export type Bilingual = { en?: string; am?: string };
/**
@@ -21,7 +25,15 @@ export type LicenseStatus =
| "PAID"
| "PAYMENT_CONFIRMED"
| "CERTIFICATE_ISSUED"
| "COMPLETED";
| "COMPLETED"
// Examined certificates (CoC, some CoP): approval establishes eligibility,
// the candidate pays to sit, and the certificate fee falls due on a pass.
| "ELIGIBILITY_APPROVED"
| "EXAM_PAYMENT_PENDING"
| "EXAM_PAID"
| "EXAM_SCHEDULED"
| "EXAM_PASSED"
| "EXAM_FAILED";
export type ApplicationKind = "NEW" | "RENEWAL";
@@ -78,7 +90,9 @@ export type LicenseCategory =
| 'CARGO_FREIGHT'
| 'SHIPPING_AGENCY'
| 'INVESTMENT'
| 'MARITIME_PERSONNEL';
| 'MARITIME_PERSONNEL'
| 'VESSEL_SERVICES'
| 'WAIVER_SERVICES';
export interface LicenseCategoryDefinition {
key: LicenseCategory;
@@ -130,6 +144,58 @@ export interface LicenseType {
isActive: boolean;
/** Display order set by EMA; lower comes first. */
sortOrder: number;
// --------------------------------------------------- examined certificates
/** Approval establishes eligibility; the certificate is earned by exam. */
requiresExamination?: boolean;
/** Fee per sitting. Falls back to `feeNewApplication` when null. */
feeExamination?: string | number | null;
/** Fee to issue after a pass. Falls back to `feeNewApplication` when null. */
feeCertificate?: string | number | null;
// ---------------------------------------------------------- STCW mapping
certificateCategory?: CertificateCategory | null;
stcwControlled?: boolean;
/** Convention regulation, e.g. `III/2`. */
stcwRegulation?: string | null;
/** Code section, e.g. `A-III/2`. */
stcwCodeSection?: string | null;
stcwDepartment?: SeafarerDepartment | null;
competencyLevel?: CompetencyLevel | null;
stcwFunctions?: StcwFunctionRow[] | null;
stcwCapacities?: StcwCapacityRow[] | null;
/** Licence keys that must be held before this may be applied for. */
prerequisiteLicenseKeys?: string[] | null;
}
/** What kind of document a certificate type produces. */
export type CertificateCategory =
| "COC"
| "COP"
| "ENDORSEMENT"
| "GOC"
| "NATIONAL";
/**
* STCW responsibility level. Cadet is absent by design — under STCW a cadet is
* a seafarer in training before holding any certificate, not a level of
* competence.
*/
export type CompetencyLevel = "SUPPORT" | "OPERATIONAL" | "MANAGEMENT";
/** One row of a CoC's function table (STCW Code A-I/2). */
export interface StcwFunctionRow {
function: Bilingual;
level: CompetencyLevel;
limitation?: Bilingual;
sortOrder?: number;
}
/** One row of a CoC's capacity table — what the holder may serve as. */
export interface StcwCapacityRow {
capacity: Bilingual;
limitation?: Bilingual;
sortOrder?: number;
}
export interface DocumentRequirement {
@@ -372,6 +438,44 @@ export interface TemplatePageOptions {
printBackground?: boolean;
}
/** Corner the institute logo is anchored to. */
export type TemplateLogoCorner =
| 'TOP_LEFT'
| 'TOP_CENTER'
| 'TOP_RIGHT'
| 'BOTTOM_LEFT'
| 'BOTTOM_RIGHT';
/** Where the institute logo sits on the certificate. */
export interface TemplateLogoPlacement {
corner?: TemplateLogoCorner;
/** Width as a percentage of page width. */
widthPct?: number;
/** Inset from the anchored corner, as a percentage of page width. */
offsetPct?: number;
}
/**
* One positioned block on the designer canvas.
*
* Percentages rather than pixels, so a layout survives an orientation change:
* the canvas and the rendered PDF agree without either knowing the other's
* dimensions.
*/
export interface TemplateFieldPlacement {
id: string;
/** Variable rendered here, or null when the block carries literal `text`. */
variable: string | null;
text?: string;
xPct: number;
yPct: number;
widthPct: number;
fontSize?: number;
fontWeight?: 'normal' | 'bold';
align?: 'left' | 'center' | 'right';
color?: string;
}
/** A certificate design authored in the backoffice. */
export interface LicenseTemplate {
id: string;
@@ -380,6 +484,18 @@ export interface LicenseTemplate {
version: number;
hbsSource: string;
pageOptions: TemplatePageOptions | null;
/**
* Artwork printed under the rendered text. A background, not the whole
* certificate — per-certificate data stays in the template layer above it.
*/
backgroundUrl?: string | null;
logoUrl?: string | null;
logoPlacement?: TemplateLogoPlacement | null;
/**
* Blocks positioned on the visual canvas. Null for a design authored as raw
* Handlebars — which is how the two editors stay distinguishable.
*/
fieldPlacements?: TemplateFieldPlacement[] | null;
status: TemplateStatus;
publishedAt: string | null;
createdAt: string;

View File

@@ -7,15 +7,33 @@ export const SESSION_HEADER_KEYS = {
currentProjectId: 'x-current-project-id',
} as const;
const TOKEN_STORAGE_KEYS = [
'ema-backoffice-auth-token',
'ema-portal-auth-token',
'auth-token',
] as const;
/**
* Which app this bundle is, so it reads its own session and no one else's.
*
* Set by each app's store via `configureSessionScope`. Cookies ignore the
* port, so `localhost:4200` and `localhost:4201` share one jar: without a
* scope the backoffice would happily authenticate as whoever last signed into
* the portal, and render a staff console with an applicant's permissions.
*/
let scopedTokenKey: string | undefined;
export function configureSessionScope(prefix: string): void {
scopedTokenKey = `${prefix}-auth-token`;
}
/** Legacy, pre-prefix sessions. Read only when the scoped key is empty. */
const LEGACY_TOKEN_KEY = 'auth-token';
export function resolveTokenFromStorage(): string | undefined {
// Only this app's key, then the legacy unprefixed one. Never another app's:
// falling through to a sibling's token is how a backoffice tab ends up
// holding a portal session.
const keys = scopedTokenKey
? [scopedTokenKey, LEGACY_TOKEN_KEY]
: [LEGACY_TOKEN_KEY];
for (const key of keys) {
// cookie first, then localStorage (legacy pre-migration sessions)
for (const key of TOKEN_STORAGE_KEYS) {
const cookie = Cookies.get(key);
if (cookie) return cookie;
const stored = localStorage.getItem(key);

View File

@@ -7,7 +7,7 @@ import type { AuthUser } from "../types/auth.types";
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
] ?? "http://localhost:3001/api";
/**
* Restores the signed-in session before the router renders.

View File

@@ -10,6 +10,9 @@ interface ProtectedRouteProps {
export function ProtectedRoute({ children, loginPath = '/login' }: ProtectedRouteProps) {
const location = useLocation();
// `authStorage` is already scoped to this app; the bare key is only the
// legacy pre-prefix session. Never read a sibling app's token — that is how
// a backoffice tab ends up authenticated as a portal applicant.
const token = authStorage.getToken() ?? Cookies.get('auth-token');
if (!token) {

View File

@@ -91,6 +91,13 @@ export interface ProfileMeResponse {
* `usePermissions()`.
*/
permissions?: string[];
/**
* Whether the profile can apply for a CoC/CoP right now: seafarer
* registration approved, plus a verified sea service record and a
* verified medical certificate. Computed server-side so the "Apply"
* button and the API's own eligibility check can never disagree.
*/
eligibleForCoc: boolean;
}
const profileApi = baseApi
@@ -109,7 +116,7 @@ const profileApi = baseApi
unknown,
{ id: string; body: Record<string, unknown> }
>({
query: ({ id, body }) => ({ url: `/profiles/${id}`, method: 'PATCH', body }),
query: ({ id, body }) => ({ url: `/profiles/${id}`, method: 'PUT', body }),
invalidatesTags: ['CurrentProfile'],
}),
updateMyAddress: builder.mutation<
@@ -167,6 +174,7 @@ export function useCurrentProfile() {
refetch,
completeness: data?.completeness ?? 0,
missing,
eligibleForCoc: data?.eligibleForCoc ?? false,
/**
* True when nothing the requirement asks for is still blank. Unknown
* profile (still loading) reads as not-ready, so a caller never submits

View File

@@ -35,6 +35,8 @@ export interface CurrentProfileAddress {
id: string;
idType: string;
idNumber: string;
passportNumber: string | null;
passportExpiry: string | null;
nationality: string;
regionId: string | null;
cityId: string | null;
@@ -42,6 +44,7 @@ export interface CurrentProfileAddress {
woredaId: string | null;
kebeleId: string | null;
streetAddress: string | null;
currentAddress: string | null;
houseNumber: string | null;
primaryPhoneNumber: string;
secondaryPhoneNumber: string | null;

View File

@@ -72,5 +72,10 @@
"typescript": "~5.9.2",
"vite": "^7.0.0",
"vitest": "^4.0.0"
},
"allowScripts": {
"esbuild@0.28.1": true,
"nx@22.7.8": true,
"core-js@3.49.0": true
}
}