mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge remote-tracking branch 'origin/certficate' into feature/exam-attempt-domain
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 } }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,22 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDeviceFloppy,
|
||||
IconEye,
|
||||
IconCode,
|
||||
IconLayoutBoard,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconRosetteDiscountCheck,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -39,26 +27,26 @@ import {
|
||||
useGetLicenseTemplatesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetTemplateVariablesQuery,
|
||||
useLocalized,
|
||||
usePublishLicenseTemplateMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useUpdateLicenseTemplateMutation,
|
||||
type LicenseTemplate,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui';
|
||||
import { authStorage, usePermissions } from '@ema-platform/auth';
|
||||
import { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||
|
||||
/** Same resolution the shared RTK Query baseQuery uses. */
|
||||
const API_BASE_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
|
||||
const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
PUBLISHED: 'teal',
|
||||
ARCHIVED: 'dark',
|
||||
};
|
||||
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
|
||||
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
|
||||
import { DesignerToolbar } from '../components/DesignerToolbar';
|
||||
import { NewVersionModal } from '../components/NewVersionModal';
|
||||
import { TemplateActionBar } from '../components/TemplateActionBar';
|
||||
import { TemplateBackgroundPanel } from '../components/TemplateBackgroundPanel';
|
||||
import { TemplateCanvas } from '../components/TemplateCanvas';
|
||||
import { TemplateEditor } from '../components/TemplateEditor';
|
||||
import { TemplateVariableList } from '../components/TemplateVariableList';
|
||||
import { TemplateVersionList } from '../components/TemplateVersionList';
|
||||
import { pageOptionsFor } from '../config/designer';
|
||||
import { compileLayoutToHbs } from '../config/layout-compiler';
|
||||
import { useDesignerActions } from '../hooks/useDesignerActions';
|
||||
import { useTemplateDraft } from '../hooks/useTemplateDraft';
|
||||
import { useTemplatePreview } from '../hooks/useTemplatePreview';
|
||||
|
||||
/**
|
||||
* Where the authority designs the certificate its licensees receive.
|
||||
@@ -71,7 +59,6 @@ const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
*/
|
||||
export function CertificateDesignerPage() {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
||||
@@ -97,16 +84,16 @@ export function CertificateDesignerPage() {
|
||||
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
|
||||
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [source, setSource] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [landscape, setLandscape] = useState(true);
|
||||
const draft = useTemplateDraft(templates);
|
||||
const run = useDesignerActions();
|
||||
const openPreview = useTemplatePreview();
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [validityMonths, setValidityMonths] = useState<number>(12);
|
||||
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
|
||||
|
||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||
const [validityMonths, setValidityMonths] = useState<number>(12);
|
||||
|
||||
// Default to the first licence type so the page is never an empty shell.
|
||||
useEffect(() => {
|
||||
@@ -117,101 +104,14 @@ export function CertificateDesignerPage() {
|
||||
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
||||
}, [selectedType]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => templates.find((tpl) => tpl.id === selectedId) ?? null,
|
||||
[templates, selectedId],
|
||||
);
|
||||
|
||||
// Pick the live design by default — that is the one staff usually want.
|
||||
useEffect(() => {
|
||||
if (!templates.length) {
|
||||
setSelectedId(null);
|
||||
return;
|
||||
}
|
||||
if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return;
|
||||
const published = templates.find((tpl) => tpl.status === 'PUBLISHED');
|
||||
setSelectedId((published ?? templates[0]).id);
|
||||
}, [templates, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
setSource(selected.hbsSource);
|
||||
setName(selected.name);
|
||||
setLandscape(selected.pageOptions?.landscape ?? true);
|
||||
}, [selected]);
|
||||
|
||||
const isPublished = selected?.status === 'PUBLISHED';
|
||||
const dirty =
|
||||
Boolean(selected) &&
|
||||
(source !== selected?.hbsSource ||
|
||||
name !== selected?.name ||
|
||||
landscape !== (selected?.pageOptions?.landscape ?? true));
|
||||
|
||||
async function run(action: () => Promise<unknown>, success: string) {
|
||||
try {
|
||||
await action();
|
||||
notifications.show({ color: 'teal', title: success, message: '' });
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('designer.actionFailed', 'Action failed'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
function startNewVersion() {
|
||||
setNewName(
|
||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||
);
|
||||
setNewOpen(true);
|
||||
}
|
||||
|
||||
/** Inserts a placeholder where the caret is, rather than at the end. */
|
||||
function insertVariable(key: string) {
|
||||
const el = editorRef.current;
|
||||
const token = `{{${key}}}`;
|
||||
if (!el) {
|
||||
setSource((prev) => prev + token);
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart ?? source.length;
|
||||
const end = el.selectionEnd ?? start;
|
||||
setSource(source.slice(0, start) + token + source.slice(end));
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(start + token.length, start + token.length);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the editor's current contents, not the saved row, so unsaved edits
|
||||
* are what you see. Opened as a blob so it never leaves a file behind.
|
||||
*/
|
||||
async function preview() {
|
||||
try {
|
||||
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
|
||||
// and calls the API directly — which means spelling out the base URL and
|
||||
// the bearer token that the shared baseQuery would normally attach.
|
||||
const token = authStorage.getToken();
|
||||
const response = await fetch(`${API_BASE_URL}/license-templates/preview`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
hbsSource: source,
|
||||
licenseTypeId: typeId,
|
||||
pageOptions: { format: 'A4', landscape, printBackground: true },
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
window.open(url, '_blank', 'noopener');
|
||||
// Give the new tab time to read it before revoking.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('designer.previewFailed', 'Could not render the preview'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
const editingLocked = !canEdit || draft.isPublished;
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
@@ -223,74 +123,26 @@ export function CertificateDesignerPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Group align="flex-end" mb="md" gap="sm">
|
||||
<Select
|
||||
label={t('designer.licenceType', 'Licence type')}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: localized(type.name) || type.key,
|
||||
}))}
|
||||
value={typeId}
|
||||
onChange={(value) => {
|
||||
setTypeId(value);
|
||||
setSelectedId(null);
|
||||
}}
|
||||
w={280}
|
||||
/>
|
||||
|
||||
{/* Validity lives beside the design because it is the other half of
|
||||
what a certificate promises. */}
|
||||
<NumberInput
|
||||
label={t('designer.validityYears', 'Valid for (years)')}
|
||||
description={t('designer.validityHint', 'Applied when a licence is issued')}
|
||||
value={Number((validityMonths / 12).toFixed(2))}
|
||||
onChange={(value) => setValidityMonths(Math.round(Number(value || 0) * 12))}
|
||||
min={0.5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
decimalScale={1}
|
||||
w={190}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<Tooltip
|
||||
label={
|
||||
canEdit
|
||||
? t('designer.saveValidity', 'Save validity')
|
||||
: t('designer.noPermission', 'You do not have permission')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="light"
|
||||
loading={savingValidity}
|
||||
disabled={!canEdit || !typeId || validityMonths === selectedType?.validityMonths}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
|
||||
t('designer.validitySaved', 'Validity updated'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.saveValidity', 'Save validity')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
disabled={!canEdit || !typeId}
|
||||
onClick={() => {
|
||||
setNewName(
|
||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||
);
|
||||
setNewOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('designer.newVersion', 'New version')}
|
||||
</Button>
|
||||
</Group>
|
||||
<DesignerToolbar
|
||||
licenseTypes={licenseTypes?.items ?? []}
|
||||
typeId={typeId}
|
||||
onTypeChange={(value) => {
|
||||
setTypeId(value);
|
||||
draft.setSelectedId(null);
|
||||
}}
|
||||
validityMonths={validityMonths}
|
||||
onValidityChange={setValidityMonths}
|
||||
currentValidityMonths={selectedType?.validityMonths}
|
||||
canEdit={canEdit}
|
||||
savingValidity={savingValidity}
|
||||
onSaveValidity={() =>
|
||||
run(
|
||||
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
|
||||
t('designer.validitySaved', 'Validity updated'),
|
||||
)
|
||||
}
|
||||
onNewVersion={startNewVersion}
|
||||
/>
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
@@ -308,253 +160,223 @@ export function CertificateDesignerPage() {
|
||||
)}
|
||||
action={
|
||||
canEdit
|
||||
? {
|
||||
label: t('designer.newVersion', 'New version'),
|
||||
onClick: () => {
|
||||
setNewName(`${selectedType?.name?.en ?? 'Certificate'} v1`);
|
||||
setNewOpen(true);
|
||||
},
|
||||
}
|
||||
? { label: t('designer.newVersion', 'New version'), onClick: startNewVersion }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Group align="flex-start" gap="md" wrap="nowrap">
|
||||
{/* Versions */}
|
||||
<Stack gap="xs" w={240} style={{ flexShrink: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('designer.versions', 'Versions')}
|
||||
</Text>
|
||||
{templates.map((tpl) => (
|
||||
<Card
|
||||
key={tpl.id}
|
||||
withBorder
|
||||
padding="xs"
|
||||
onClick={() => setSelectedId(tpl.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor:
|
||||
tpl.id === selectedId ? 'var(--mantine-color-blue-5)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{tpl.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
v{tpl.version}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={STATUS_COLOR[tpl.status]}>
|
||||
{tpl.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
<TemplateVersionList
|
||||
templates={templates}
|
||||
selectedId={draft.selectedId}
|
||||
onSelect={draft.setSelectedId}
|
||||
/>
|
||||
|
||||
{/* Editor */}
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="sm" align="flex-end">
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
disabled={!canEdit || isPublished}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Switch
|
||||
label={t('designer.landscape', 'Landscape')}
|
||||
checked={landscape}
|
||||
onChange={(e) => setLandscape(e.currentTarget.checked)}
|
||||
disabled={!canEdit || isPublished}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isPublished && (
|
||||
<Paper withBorder p="xs" bg="var(--mantine-color-teal-light)">
|
||||
<Text size="xs">
|
||||
{t(
|
||||
'designer.publishedLocked',
|
||||
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
|
||||
)}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={editorRef}
|
||||
label={t('designer.source', 'Template (Handlebars + HTML)')}
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.currentTarget.value)}
|
||||
disabled={!canEdit || isPublished}
|
||||
autosize
|
||||
minRows={18}
|
||||
maxRows={30}
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: 12 } }}
|
||||
<TemplateBackgroundPanel
|
||||
backgroundUrl={draft.backgroundUrl}
|
||||
onBackgroundChange={draft.setBackgroundUrl}
|
||||
logoUrl={draft.logoUrl}
|
||||
onLogoChange={draft.setLogoUrl}
|
||||
logoPlacement={draft.logoPlacement}
|
||||
onLogoPlacementChange={draft.setLogoPlacement}
|
||||
landscape={draft.landscape}
|
||||
onLandscapeChange={draft.setLandscape}
|
||||
disabled={editingLocked}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
{/* A published design is immutable by rule -- certificates were
|
||||
issued from it -- so the editor is locked. Without this the
|
||||
screen looks broken rather than deliberately read-only, which
|
||||
is what "edit is not available" turns out to mean. */}
|
||||
{draft.isPublished && (
|
||||
<Alert
|
||||
variant="light"
|
||||
leftSection={<IconEye size={16} />}
|
||||
onClick={preview}
|
||||
disabled={!source.trim()}
|
||||
color="blue"
|
||||
icon={<IconLock size={18} />}
|
||||
title={t('designer.liveDesign', 'This is the live design')}
|
||||
>
|
||||
{t('designer.preview', 'Preview PDF')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconDeviceFloppy size={16} />}
|
||||
loading={saving}
|
||||
disabled={!canEdit || isPublished || !dirty}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
updateTemplate({
|
||||
id: selected!.id,
|
||||
name,
|
||||
hbsSource: source,
|
||||
pageOptions: { format: 'A4', landscape, printBackground: true },
|
||||
}).unwrap(),
|
||||
t('designer.saved', 'Draft saved'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.save', 'Save draft')}
|
||||
</Button>
|
||||
<Tooltip
|
||||
label={
|
||||
!canPublish
|
||||
? t('designer.noPublishPermission', 'You cannot publish designs')
|
||||
: dirty
|
||||
? t('designer.saveFirst', 'Save your changes first')
|
||||
: t('designer.publishHint', 'Makes this the live certificate design')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconRosetteDiscountCheck size={16} />}
|
||||
loading={publishing}
|
||||
disabled={!canPublish || isPublished || dirty || !selected}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => publishTemplate(selected!.id).unwrap(),
|
||||
t('designer.published', 'Design published'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.publish', 'Publish')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div style={{ flex: 1 }} />
|
||||
{selected && isPublished && canPublish && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() =>
|
||||
run(
|
||||
() => archiveTemplate(selected.id).unwrap(),
|
||||
t('designer.archived', 'Design withdrawn'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.archive', 'Withdraw')}
|
||||
</Button>
|
||||
)}
|
||||
{selected && !isPublished && canEdit && (
|
||||
<Tooltip label={t('designer.delete', 'Delete draft')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t('designer.delete', 'Delete draft')}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => deleteTemplate(selected.id).unwrap(),
|
||||
t('designer.deleted', 'Draft deleted'),
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap="xs" align="flex-start">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'designer.liveDesignBody',
|
||||
'Certificates have been issued from this version, so it cannot be changed. Create a new version to edit — it starts as a copy of this one, and only replaces it when you publish.',
|
||||
)}
|
||||
</Text>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={startNewVersion}
|
||||
>
|
||||
{t('designer.newVersionFromThis', 'New version from this design')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs value={mode} onChange={(value) => setMode(value as 'canvas' | 'source')}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="canvas" leftSection={<IconLayoutBoard size={15} />}>
|
||||
{t('designer.tabCanvas', 'Visual editor')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="source" leftSection={<IconCode size={15} />}>
|
||||
{t('designer.tabSource', 'HTML source')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="canvas" pt="sm">
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={draft.name}
|
||||
onChange={(e) => draft.setName(e.currentTarget.value)}
|
||||
disabled={editingLocked}
|
||||
/>
|
||||
<TemplateCanvas
|
||||
backgroundUrl={draft.backgroundUrl}
|
||||
logoUrl={draft.logoUrl}
|
||||
logoPlacement={draft.logoPlacement}
|
||||
landscape={draft.landscape}
|
||||
placements={draft.placements}
|
||||
selectedId={draft.selectedBlockId}
|
||||
onSelect={draft.setSelectedBlockId}
|
||||
onChange={draft.setPlacements}
|
||||
disabled={editingLocked}
|
||||
/>
|
||||
<BlockPropertiesPanel
|
||||
block={draft.selectedBlock}
|
||||
variables={variables}
|
||||
onChange={draft.updateBlock}
|
||||
onDelete={draft.deleteBlock}
|
||||
disabled={editingLocked}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="source" pt="sm">
|
||||
{draft.usesCanvas && (
|
||||
<Paper withBorder p="xs" mb="sm" bg="var(--mantine-color-yellow-light)">
|
||||
<Text size="xs">
|
||||
{t(
|
||||
'designer.canvasOwnsSource',
|
||||
'This design is laid out on the visual editor, which regenerates the HTML on every save. Edits made here will be overwritten — remove all blocks first to hand-write the template.',
|
||||
)}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
<TemplateEditor
|
||||
name={draft.name}
|
||||
onNameChange={draft.setName}
|
||||
source={draft.source}
|
||||
onSourceChange={draft.setSource}
|
||||
landscape={draft.landscape}
|
||||
onLandscapeChange={draft.setLandscape}
|
||||
editorRef={draft.editorRef}
|
||||
disabled={editingLocked}
|
||||
isPublished={draft.isPublished}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<TemplateActionBar
|
||||
// A canvas layout has no Handlebars source until it is saved --
|
||||
// the server compiles it -- so gating preview on the source alone
|
||||
// left the button dead for exactly the designs the canvas is for.
|
||||
hasSource={Boolean(draft.source.trim()) || draft.usesCanvas}
|
||||
hasSelection={Boolean(draft.selected)}
|
||||
isPublished={draft.isPublished}
|
||||
dirty={draft.dirty}
|
||||
canEdit={canEdit}
|
||||
canPublish={canPublish}
|
||||
saving={saving}
|
||||
publishing={publishing}
|
||||
onPreview={() =>
|
||||
openPreview({
|
||||
// A canvas layout is compiled here so the preview shows
|
||||
// unsaved block moves; the server compiles the stored copy.
|
||||
hbsSource: draft.usesCanvas
|
||||
? compileLayoutToHbs({
|
||||
backgroundUrl: draft.backgroundUrl,
|
||||
logoUrl: draft.logoUrl,
|
||||
logoPlacement: draft.logoPlacement,
|
||||
fieldPlacements: draft.placements,
|
||||
})
|
||||
: draft.source,
|
||||
licenseTypeId: typeId,
|
||||
landscape: draft.landscape,
|
||||
})
|
||||
}
|
||||
onSave={() =>
|
||||
run(
|
||||
() =>
|
||||
updateTemplate({
|
||||
id: draft.selected!.id,
|
||||
name: draft.name,
|
||||
// The server recompiles the HTML from the blocks when a
|
||||
// canvas layout is present, so sending the stale source
|
||||
// alongside it would only fight that.
|
||||
hbsSource: draft.usesCanvas ? undefined : draft.source,
|
||||
pageOptions: pageOptionsFor(draft.landscape),
|
||||
backgroundUrl: draft.backgroundUrl || undefined,
|
||||
logoUrl: draft.logoUrl || undefined,
|
||||
logoPlacement: draft.logoPlacement,
|
||||
fieldPlacements: draft.placements,
|
||||
}).unwrap(),
|
||||
t('designer.saved', 'Draft saved'),
|
||||
)
|
||||
}
|
||||
onPublish={() =>
|
||||
run(
|
||||
() => publishTemplate(draft.selected!.id).unwrap(),
|
||||
t('designer.published', 'Design published'),
|
||||
)
|
||||
}
|
||||
onArchive={() =>
|
||||
run(
|
||||
() => archiveTemplate(draft.selected!.id).unwrap(),
|
||||
t('designer.archived', 'Design withdrawn'),
|
||||
)
|
||||
}
|
||||
onDelete={() =>
|
||||
run(
|
||||
() => deleteTemplate(draft.selected!.id).unwrap(),
|
||||
t('designer.deleted', 'Draft deleted'),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Placeholders */}
|
||||
<Stack gap="xs" w={230} style={{ flexShrink: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('designer.variables', 'Placeholders')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('designer.variablesHint', 'Click to insert at the cursor.')}
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={480} type="hover">
|
||||
<Stack gap={4}>
|
||||
{variables.map((variable) => (
|
||||
<Tooltip key={variable.key} label={variable.label} position="left">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
justify="flex-start"
|
||||
disabled={!canEdit || isPublished}
|
||||
onClick={() => insertVariable(variable.key)}
|
||||
>
|
||||
<Code fz={10}>{`{{${variable.key}}}`}</Code>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
<TemplateVariableList
|
||||
variables={variables}
|
||||
disabled={editingLocked}
|
||||
canvasMode={mode === 'canvas'}
|
||||
onInsert={draft.insertVariable}
|
||||
onAddBlock={(key) => draft.addBlock(key)}
|
||||
onAddTextBlock={() => draft.addBlock(null, 'Text')}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
<NewVersionModal
|
||||
opened={newOpen}
|
||||
name={newName}
|
||||
onNameChange={setNewName}
|
||||
creating={creating}
|
||||
onClose={() => setNewOpen(false)}
|
||||
title={t('designer.newVersion', 'New version')}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.currentTarget.value)}
|
||||
withAsterisk
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'designer.newHint',
|
||||
'Starts from the live design, or the built-in layout if this type has none.',
|
||||
)}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setNewOpen(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
loading={creating}
|
||||
disabled={!newName.trim()}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
const created = await createTemplate({
|
||||
licenseTypeId: typeId as string,
|
||||
name: newName.trim(),
|
||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||
}).unwrap();
|
||||
setSelectedId(created.id);
|
||||
setNewOpen(false);
|
||||
}, t('designer.created', 'Draft created'))
|
||||
}
|
||||
>
|
||||
{t('designer.create', 'Create')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
onCreate={() =>
|
||||
run(async () => {
|
||||
const created = await createTemplate({
|
||||
licenseTypeId: typeId as string,
|
||||
name: newName.trim(),
|
||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||
}).unwrap();
|
||||
draft.setSelectedId(created.id);
|
||||
setNewOpen(false);
|
||||
}, t('designer.created', 'Draft created'))
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconRubberStamp,
|
||||
IconShieldCheck,
|
||||
IconShip,
|
||||
IconTruck,
|
||||
IconUsers,
|
||||
@@ -112,15 +113,43 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A seafarer certificate is judged on the person, not a company.
|
||||
*
|
||||
* Matched by prefix rather than listed: the catalogue holds over fifty
|
||||
* rank-specific CoC/CoP keys and grows whenever EMA configures another, and an
|
||||
* explicit map would silently fall back to the company presentation — showing
|
||||
* a reviewing officer capital, staff-role and inspection tabs that a
|
||||
* certificate application can never fill.
|
||||
*/
|
||||
const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_'];
|
||||
const CERTIFICATE_SECTIONS: DetailSection[] = ['overview', 'documents'];
|
||||
|
||||
function isSeafarerCertificate(key: string): boolean {
|
||||
return (
|
||||
CERTIFICATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) ||
|
||||
key === 'CERTIFICATE_OF_COMPETENCY' ||
|
||||
key === 'CERTIFICATE_OF_PROFICIENCY'
|
||||
);
|
||||
}
|
||||
|
||||
/** Falls back to a generic presentation so an unseeded type still renders. */
|
||||
export function presentationFor(key: string | undefined): LicenseTypePresentation {
|
||||
return (
|
||||
(key && PRESENTATION[key]) || {
|
||||
key: key ?? 'UNKNOWN',
|
||||
icon: IconFileDescription,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
}
|
||||
);
|
||||
if (key && PRESENTATION[key]) return PRESENTATION[key];
|
||||
|
||||
if (key && isSeafarerCertificate(key)) {
|
||||
return {
|
||||
key,
|
||||
icon: IconShieldCheck,
|
||||
detailSections: CERTIFICATE_SECTIONS,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: key ?? 'UNKNOWN',
|
||||
icon: IconFileDescription,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
};
|
||||
}
|
||||
|
||||
export const LICENSE_TYPE_KEYS = Object.keys(PRESENTATION);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,6 +1,9 @@
|
||||
import { landingAm } from "@ema-platform/ui";
|
||||
import type { Translations } from "./en";
|
||||
|
||||
export const am: Translations = {
|
||||
landing: landingAm,
|
||||
|
||||
app: {
|
||||
name: "ኢማ አስተዳደር",
|
||||
shortName: "ኢማ",
|
||||
@@ -76,6 +79,7 @@ export const am: Translations = {
|
||||
dashboard: "ዳሽቦርድ",
|
||||
userManagement: "የተጠቃሚ አስተዳደር",
|
||||
seamanBookQueue: "የመርከበኞች መጽሐፍ ወረፋ",
|
||||
btcQueue: "የBTC ወረፋ",
|
||||
cocQueue: "የCoC ወረፋ",
|
||||
copQueue: "የCoP ወረፋ",
|
||||
endorsementCocQueue: "የCoC ማረጋገጫ ወረፋ",
|
||||
@@ -951,6 +955,7 @@ export const am: Translations = {
|
||||
finalApprove: "አጽድቅ እና ስጥ",
|
||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||
reject: "አትቀበል",
|
||||
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
||||
confirmPayment: "ክፍያ አረጋግጥ",
|
||||
print: "ሰነድ አትም",
|
||||
copyLink: "አገናኝ ቅዳ",
|
||||
@@ -993,6 +998,7 @@ export const am: Translations = {
|
||||
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
|
||||
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
|
||||
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
|
||||
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
|
||||
},
|
||||
notifications: {
|
||||
fallback: "ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።",
|
||||
@@ -1301,4 +1307,68 @@ export const am: Translations = {
|
||||
view: "ይመልከቱ",
|
||||
noUrl: "URL የለም",
|
||||
},
|
||||
|
||||
authShell: {
|
||||
toggleTheme: "ገጽታ ቀይር",
|
||||
brandTitle: "የባህር ትራንስፖርት ፈቃድ አሰጣጥ ቀላል ሆኗል።",
|
||||
brandSubtitle: "ለመርከብ እና ለመርከበኛ ፈቃዶች ያመልክቱ፣ ሰነዶችን ይስቀሉ፣ እና እያንዳንዱን ማመልከቻ በአንድ ደህንነቱ በተጠበቀ መተግበሪያ ላይ ይከታተሉ።",
|
||||
feature1: "ማመልከቻዎችን በመስመር ላይ 24/7 ያስገቡ",
|
||||
feature2: "የቅጽበት ሁኔታ ክትትል እና ማንቂያ",
|
||||
feature3: "በእንግሊዝኛ እና በአማርኛ ይገኛል",
|
||||
copyright: "© 2026 የኢትዮጵያ የባህር ትራንስፖርት ባለስልጣን",
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
|
||||
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
|
||||
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
|
||||
subtitle: "መለያዎን ለመድረስ ይግቡ።",
|
||||
emailOrPhoneLabel: "ኢሜይል ወይም ስልክ",
|
||||
emailOrPhonePlaceholder: "you@example.com",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "የይለፍ ቃልዎ",
|
||||
rememberMe: "አስታውሰኝ",
|
||||
forgotPassword: "የይለፍ ቃል ረሱ?",
|
||||
signIn: "ግባ",
|
||||
noAccount: "መለያ የለዎትም? ",
|
||||
createOne: "አንድ ይፍጠሩ",
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
|
||||
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
|
||||
phoneRequired: "ስልክ ቁጥር ያስፈልጋል",
|
||||
confirmPasswordRequired: "የይለፍ ቃልዎን ያረጋግጡ",
|
||||
passwordsDontMatch: "የይለፍ ቃላት አይመሳሰሉም",
|
||||
brandTitle: "የ{{appName}} ማህበረሰብን ይቀላቀሉ።",
|
||||
brandSubtitle: "የ{{appName}} አገልግሎቶችን ለመድረስ መለያዎን ይፍጠሩ።",
|
||||
title: "መለያ ይፍጠሩ",
|
||||
subtitle: "ለመጀመር አንድ ደቂቃ ብቻ ይወስዳል።",
|
||||
nameEnLabel: "ስም (እንግሊዝኛ)",
|
||||
nameEnPlaceholder: "አበበ በቀለ",
|
||||
nameAmLabel: "ስም (አማርኛ)",
|
||||
nameAmPlaceholder: "ስም",
|
||||
emailLabel: "ኢሜይል አድራሻ",
|
||||
emailPlaceholder: "you@example.com",
|
||||
usernameLabel: "የተጠቃሚ ስም",
|
||||
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
|
||||
phoneLabel: "ስልክ ቁጥር",
|
||||
phonePlaceholder: "+251 911 234 567",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
|
||||
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
|
||||
confirmPasswordPlaceholder: "የይለፍ ቃል እንደገና ያስገቡ",
|
||||
agreeToThe: "እስማማለሁ ከ",
|
||||
termsAndPrivacy: "ውሎች እና የግላዊነት ፖሊሲ",
|
||||
createAccount: "መለያ ይፍጠሩ",
|
||||
haveAccount: "አስቀድሞ መለያ አለዎት? ",
|
||||
signIn: "ግባ",
|
||||
passwordRule: {
|
||||
minLength: "ቢያንስ {{min}} ቁምፊዎች",
|
||||
lowercase: "አንድ ትንሽ ፊደል",
|
||||
uppercase: "አንድ ትልቅ ፊደል",
|
||||
number: "አንድ ቁጥር",
|
||||
special: "አንድ ልዩ ምልክት",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { landingEn } from '@ema-platform/ui';
|
||||
|
||||
export const en = {
|
||||
landing: landingEn,
|
||||
|
||||
app: {
|
||||
name: 'EMA Admin',
|
||||
shortName: 'EMA',
|
||||
@@ -59,6 +63,7 @@ export const en = {
|
||||
dashboard: 'Dashboard',
|
||||
userManagement: 'User Management',
|
||||
seamanBookQueue: 'Seaman Book Queue',
|
||||
btcQueue: 'BTC Queue',
|
||||
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
|
||||
vesselRegistrationApplicationQueue: 'Vessel Registration Queue',
|
||||
vesselRegistrationQueue: 'Vessel Register',
|
||||
@@ -952,6 +957,7 @@ export const en = {
|
||||
finalApprove: 'Approve & issue',
|
||||
requestAdjustment: 'Request adjustment',
|
||||
reject: 'Reject',
|
||||
scheduleExam: 'Schedule exam',
|
||||
confirmPayment: 'Confirm payment',
|
||||
print: 'Print dossier',
|
||||
copyLink: 'Copy link',
|
||||
@@ -993,6 +999,7 @@ export const en = {
|
||||
resume: 'Returns application {{number}} to the stage it was held from.',
|
||||
escalate: 'Raises application {{number}} to a supervisor for a decision.',
|
||||
'confirm-payment': 'Confirms settlement for application {{number}}.',
|
||||
'schedule-exam': 'Assigns {{applicant}} to an exam session for application {{number}}.',
|
||||
},
|
||||
notifications: {
|
||||
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
|
||||
@@ -1297,6 +1304,70 @@ export const en = {
|
||||
view: 'View',
|
||||
noUrl: 'No URL',
|
||||
},
|
||||
|
||||
authShell: {
|
||||
toggleTheme: 'Toggle theme',
|
||||
brandTitle: 'Maritime licensing, made simple.',
|
||||
brandSubtitle: 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
|
||||
feature1: 'Submit applications online, 24/7',
|
||||
feature2: 'Real-time status tracking & alerts',
|
||||
feature3: 'Available in English & አማርኛ',
|
||||
copyright: '© 2026 Ethiopian Maritime Authority',
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: 'Enter a valid email or phone number (+2519xxxxxxxx)',
|
||||
passwordMinLength: 'Password must be at least 8 characters',
|
||||
welcome: 'Welcome to {{appName}}',
|
||||
subtitle: 'Sign in to access your account.',
|
||||
emailOrPhoneLabel: 'Email or phone',
|
||||
emailOrPhonePlaceholder: 'you@example.com',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'Your password',
|
||||
rememberMe: 'Remember me',
|
||||
forgotPassword: 'Forgot password?',
|
||||
signIn: 'Sign in',
|
||||
noAccount: "Don't have an account? ",
|
||||
createOne: 'Create one',
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: 'Username must be at least 3 characters',
|
||||
nameEnRequired: 'Name (English) is required',
|
||||
phoneRequired: 'Phone number is required',
|
||||
confirmPasswordRequired: 'Confirm your password',
|
||||
passwordsDontMatch: 'Passwords do not match',
|
||||
brandTitle: "Join {{appName}}'s community.",
|
||||
brandSubtitle: 'Create your account to access {{appName}} features.',
|
||||
title: 'Create account',
|
||||
subtitle: 'It only takes a minute to get started.',
|
||||
nameEnLabel: 'Name (English)',
|
||||
nameEnPlaceholder: 'Abebe Bekele',
|
||||
nameAmLabel: 'Name (Amharic)',
|
||||
nameAmPlaceholder: 'ስም',
|
||||
emailLabel: 'Email address',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
usernameLabel: 'Username',
|
||||
usernamePlaceholder: 'Choose a username',
|
||||
phoneLabel: 'Phone number',
|
||||
phonePlaceholder: '+251 911 234 567',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'At least 8 characters',
|
||||
confirmPasswordLabel: 'Confirm password',
|
||||
confirmPasswordPlaceholder: 'Re-enter password',
|
||||
agreeToThe: 'I agree to the ',
|
||||
termsAndPrivacy: 'Terms & Privacy Policy',
|
||||
createAccount: 'Create account',
|
||||
haveAccount: 'Already have an account? ',
|
||||
signIn: 'Sign in',
|
||||
passwordRule: {
|
||||
minLength: 'At least {{min}} characters',
|
||||
lowercase: 'One lowercase letter',
|
||||
uppercase: 'One uppercase letter',
|
||||
number: 'One number',
|
||||
special: 'One special character',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
@@ -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[]>(
|
||||
|
||||
@@ -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] },
|
||||
|
||||
13
apps/backoffice/src/app/router/LandingRoute.tsx
Normal file
13
apps/backoffice/src/app/router/LandingRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { LandingPage } from '@ema-platform/ui';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
/**
|
||||
* Public `/` — mounts the shared landing page. Backoffice has no /signup
|
||||
* (enableSignup: false) and no /verify route, so those props are omitted.
|
||||
*/
|
||||
export function LandingRoute() {
|
||||
const token = authStorage.getToken() ?? Cookies.get('auth-token');
|
||||
|
||||
return <LandingPage primaryHref={token ? '/dashboard' : '/login'} />;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { AuthLayout } from '../layouts/AuthLayout';
|
||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { LandingRoute } from './LandingRoute';
|
||||
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
||||
import UserManagementPage from '../features/user-management/UserManagementPage';
|
||||
import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||
@@ -61,7 +62,7 @@ const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{ path: '/um/*', element: <UserManagementPage /> },
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '/', element: <LandingRoute /> },
|
||||
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
@@ -69,7 +70,6 @@ const router = createBrowserRouter([
|
||||
{
|
||||
element: <BackofficeLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'vessel-registration-head-dashboard', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationHeadDashboardPage />) },
|
||||
{ path: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, <LogisticsHeadDashboardPage />) },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -39,7 +39,7 @@ window.__USER_MANAGEMENT_BRANDING__ = {
|
||||
organizationName: 'Ethiopian Maritime Authority',
|
||||
logoSrc: '/assets/emaLogo.jpg',
|
||||
logoAlt: 'EMA Logo',
|
||||
homePath: '/',
|
||||
homePath: '/dashboard',
|
||||
moduleBasePath: '/user-management',
|
||||
backToAppPath: '/dashboard',
|
||||
backToAppLabel: 'Back to dashboard',
|
||||
|
||||
@@ -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,
|
||||
|
||||
124
apps/portal/src/app/components/AmharicDatePicker.tsx
Normal file
124
apps/portal/src/app/components/AmharicDatePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/portal/src/app/components/BilingualInput.tsx
Normal file
76
apps/portal/src/app/components/BilingualInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
apps/portal/src/app/components/LandingRoute.tsx
Normal file
15
apps/portal/src/app/components/LandingRoute.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { LandingPage } from '@ema-platform/ui';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
/**
|
||||
* Public `/` — mounts the shared landing page with portal-specific routes.
|
||||
* Auth state is read the same way ProtectedRoute does (token cookie or
|
||||
* storage fallback) so the header can show "Go to dashboard" instead of
|
||||
* Login/Sign Up without gating the route itself.
|
||||
*/
|
||||
export function LandingRoute() {
|
||||
const token = authStorage.getToken() ?? Cookies.get('auth-token');
|
||||
|
||||
return <LandingPage primaryHref={token ? '/dashboard' : '/login'} signupHref="/signup" />;
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
</Container>
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'];
|
||||
|
||||
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>
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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();
|
||||
// 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',
|
||||
};
|
||||
|
||||
const registered =
|
||||
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
||||
function humanStatus(status: string): string {
|
||||
return status
|
||||
.toLowerCase()
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
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 ?? ''),
|
||||
);
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
// blank PDF
|
||||
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
|
||||
|
||||
if (loadingProfile || loadingApplications) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack maw={860} mx="auto">
|
||||
<Title order={2}>My Endorsements</Title>
|
||||
<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>
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600} mb={6}>
|
||||
Eligibility
|
||||
{/* 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>
|
||||
<List spacing={4} size="sm">
|
||||
<EligibilityItem
|
||||
ok={registered}
|
||||
label={
|
||||
registered
|
||||
? `Registered seafarer (${profile?.seafarerNumber})`
|
||||
: 'Active seafarer registration required'
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
</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
|
||||
</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 — endorsement applications are refused without it.
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
{/* 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>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</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.
|
||||
{/* 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>
|
||||
</Card>
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<Button color="teal" leftSection={<IconCircleCheck size={14} />} onClick={() => setSubmitted(true)}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default EndorsementPage;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function EndorsementPage() {
|
||||
const navigate = useNavigate();
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [previewId, setPreviewId] = useState<string | null>(null);
|
||||
|
||||
const { data } = useApiQuery<EndorsementsOverview>({
|
||||
url: '/endorsements/my',
|
||||
method: 'GET',
|
||||
});
|
||||
const endorsementApps = data?.applications ?? [];
|
||||
const issuedEndorsements = data?.issued ?? [];
|
||||
|
||||
if (applying) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => setApplying(false)}>Back</Button>
|
||||
<div>
|
||||
<Title order={3}>Apply for Endorsement</Title>
|
||||
<Text fz="sm" c="dimmed">STCW Reg I/10 — Flag State Endorsement of Foreign CoC</Text>
|
||||
</div>
|
||||
</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 10–15 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>
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`),
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>5–7 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { useVerifyCertificateQuery } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Public certificate verification (US-PORTAL-008) — the page every printed
|
||||
* QR code points at. Deliberately public and chrome-free: a bank clerk or
|
||||
* port official scanning a certificate has no portal account.
|
||||
*/
|
||||
export function VerifyCertificatePage() {
|
||||
const { code } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [manualCode, setManualCode] = useState('');
|
||||
const { data, isLoading, isError } = useVerifyCertificateQuery(code ?? '', {
|
||||
skip: !code,
|
||||
});
|
||||
|
||||
const rows: [string, string | null | undefined][] = data?.valid
|
||||
? [
|
||||
['Certificate number', data.certificateNumber],
|
||||
['Licence / registration', data.licenseType],
|
||||
['Holder', data.companyName],
|
||||
['Issued', data.issueDate],
|
||||
['Expires', data.expiryDate],
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Container size="xs" py="xl">
|
||||
<Stack align="center" gap="lg">
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck size={28} color="var(--mantine-color-blue-6)" />
|
||||
<Title order={3}>EMA Certificate Verification</Title>
|
||||
</Group>
|
||||
|
||||
{!code && (
|
||||
<Card withBorder radius="md" p="lg" w="100%">
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Scan the QR code on a certificate, or enter its verification
|
||||
code below.
|
||||
</Text>
|
||||
<Group>
|
||||
<TextInput
|
||||
flex={1}
|
||||
placeholder="Verification code"
|
||||
value={manualCode}
|
||||
onChange={(e) => setManualCode(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<IconSearch size={16} />}
|
||||
disabled={!manualCode.trim()}
|
||||
onClick={() => navigate(`/verify/${manualCode.trim()}`)}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{code && isLoading && (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{code && !isLoading && (isError || !data) && (
|
||||
<Card withBorder radius="md" p="lg" w="100%">
|
||||
<Text c="red" ta="center">
|
||||
Verification is temporarily unavailable. Please try again.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{code && data && (
|
||||
<Card withBorder radius="md" p="lg" w="100%">
|
||||
<Stack>
|
||||
{data.valid ? (
|
||||
<Group>
|
||||
<IconCircleCheck
|
||||
size={40}
|
||||
color="var(--mantine-color-green-6)"
|
||||
/>
|
||||
<div>
|
||||
<Text fw={700} size="lg" c="green">
|
||||
Valid certificate
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Issued by the Ethiopian Maritime Authority.
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
) : (
|
||||
<Group>
|
||||
<IconCircleX size={40} color="var(--mantine-color-red-6)" />
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text fw={700} size="lg" c="red">
|
||||
Not valid
|
||||
</Text>
|
||||
{data.status && <Badge color="red">{data.status}</Badge>}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data.reason === 'not_found'
|
||||
? 'No certificate matches this code.'
|
||||
: 'This certificate is no longer valid.'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<Table variant="vertical" layout="fixed">
|
||||
<Table.Tbody>
|
||||
{rows
|
||||
.filter(([, value]) => value)
|
||||
.map(([label, value]) => (
|
||||
<Table.Tr key={label}>
|
||||
<Table.Th w={170}>{label}</Table.Th>
|
||||
<Table.Td>{value}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={() => {
|
||||
setManualCode('');
|
||||
navigate('/verify');
|
||||
}}
|
||||
>
|
||||
Verify another certificate
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,6 +1,9 @@
|
||||
import { landingAm } from '@ema-platform/ui';
|
||||
import type { Translations } from './en';
|
||||
|
||||
export const am: Translations = {
|
||||
landing: landingAm,
|
||||
|
||||
app: {
|
||||
name: 'ኢባባ ፖርታል',
|
||||
authority: 'የኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን',
|
||||
@@ -52,6 +55,8 @@ export const am: Translations = {
|
||||
seaRecords: 'የባህር መዝገቦቼ',
|
||||
myApplication: 'ማመልከቻዬ',
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
seamanBook: 'የመርከበኛ መጽሐፍ',
|
||||
btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት',
|
||||
endorsements: 'ማረጋገጫዎች',
|
||||
vesselRegistrations: 'የመርከብ ምዝገባ',
|
||||
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||
@@ -230,6 +235,7 @@ export const am: Translations = {
|
||||
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
|
||||
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
|
||||
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
|
||||
seafarerBanner: 'የባህረኛ ምዝገባ ለማድረግ የመገለጫ መረጃ ያስፈልጋል።',
|
||||
},
|
||||
|
||||
profileSections: {
|
||||
@@ -386,4 +392,68 @@ export const am: Translations = {
|
||||
SUPERSEDED: "ተተክቷል",
|
||||
},
|
||||
},
|
||||
|
||||
authShell: {
|
||||
toggleTheme: "ገጽታ ቀይር",
|
||||
brandTitle: "የባህር ትራንስፖርት ፈቃድ አሰጣጥ ቀላል ሆኗል።",
|
||||
brandSubtitle: "ለመርከብ እና ለመርከበኛ ፈቃዶች ያመልክቱ፣ ሰነዶችን ይስቀሉ፣ እና እያንዳንዱን ማመልከቻ በአንድ ደህንነቱ በተጠበቀ መተግበሪያ ላይ ይከታተሉ።",
|
||||
feature1: "ማመልከቻዎችን በመስመር ላይ 24/7 ያስገቡ",
|
||||
feature2: "የቅጽበት ሁኔታ ክትትል እና ማንቂያ",
|
||||
feature3: "በእንግሊዝኛ እና በአማርኛ ይገኛል",
|
||||
copyright: "© 2026 የኢትዮጵያ የባህር ትራንስፖርት ባለስልጣን",
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
|
||||
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
|
||||
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
|
||||
subtitle: "መለያዎን ለመድረስ ይግቡ።",
|
||||
emailOrPhoneLabel: "ኢሜይል ወይም ስልክ",
|
||||
emailOrPhonePlaceholder: "you@example.com",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "የይለፍ ቃልዎ",
|
||||
rememberMe: "አስታውሰኝ",
|
||||
forgotPassword: "የይለፍ ቃል ረሱ?",
|
||||
signIn: "ግባ",
|
||||
noAccount: "መለያ የለዎትም? ",
|
||||
createOne: "አንድ ይፍጠሩ",
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
|
||||
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
|
||||
phoneRequired: "ስልክ ቁጥር ያስፈልጋል",
|
||||
confirmPasswordRequired: "የይለፍ ቃልዎን ያረጋግጡ",
|
||||
passwordsDontMatch: "የይለፍ ቃላት አይመሳሰሉም",
|
||||
brandTitle: "የ{{appName}} ማህበረሰብን ይቀላቀሉ።",
|
||||
brandSubtitle: "የ{{appName}} አገልግሎቶችን ለመድረስ መለያዎን ይፍጠሩ።",
|
||||
title: "መለያ ይፍጠሩ",
|
||||
subtitle: "ለመጀመር አንድ ደቂቃ ብቻ ይወስዳል።",
|
||||
nameEnLabel: "ስም (እንግሊዝኛ)",
|
||||
nameEnPlaceholder: "አበበ በቀለ",
|
||||
nameAmLabel: "ስም (አማርኛ)",
|
||||
nameAmPlaceholder: "ስም",
|
||||
emailLabel: "ኢሜይል አድራሻ",
|
||||
emailPlaceholder: "you@example.com",
|
||||
usernameLabel: "የተጠቃሚ ስም",
|
||||
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
|
||||
phoneLabel: "ስልክ ቁጥር",
|
||||
phonePlaceholder: "+251 911 234 567",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
|
||||
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
|
||||
confirmPasswordPlaceholder: "የይለፍ ቃል እንደገና ያስገቡ",
|
||||
agreeToThe: "እስማማለሁ ከ",
|
||||
termsAndPrivacy: "ውሎች እና የግላዊነት ፖሊሲ",
|
||||
createAccount: "መለያ ይፍጠሩ",
|
||||
haveAccount: "አስቀድሞ መለያ አለዎት? ",
|
||||
signIn: "ግባ",
|
||||
passwordRule: {
|
||||
minLength: "ቢያንስ {{min}} ቁምፊዎች",
|
||||
lowercase: "አንድ ትንሽ ፊደል",
|
||||
uppercase: "አንድ ትልቅ ፊደል",
|
||||
number: "አንድ ቁጥር",
|
||||
special: "አንድ ልዩ ምልክት",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { landingEn } from '@ema-platform/ui';
|
||||
|
||||
export const en = {
|
||||
landing: landingEn,
|
||||
|
||||
app: {
|
||||
name: 'EMA Portal',
|
||||
authority: 'Ethiopian Maritime Authority',
|
||||
@@ -50,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',
|
||||
@@ -229,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: {
|
||||
@@ -385,6 +392,70 @@ export const en = {
|
||||
SUPERSEDED: 'Superseded',
|
||||
},
|
||||
},
|
||||
|
||||
authShell: {
|
||||
toggleTheme: 'Toggle theme',
|
||||
brandTitle: 'Maritime licensing, made simple.',
|
||||
brandSubtitle: 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
|
||||
feature1: 'Submit applications online, 24/7',
|
||||
feature2: 'Real-time status tracking & alerts',
|
||||
feature3: 'Available in English & አማርኛ',
|
||||
copyright: '© 2026 Ethiopian Maritime Authority',
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: 'Enter a valid email or phone number (+2519xxxxxxxx)',
|
||||
passwordMinLength: 'Password must be at least 8 characters',
|
||||
welcome: 'Welcome to {{appName}}',
|
||||
subtitle: 'Sign in to access your account.',
|
||||
emailOrPhoneLabel: 'Email or phone',
|
||||
emailOrPhonePlaceholder: 'you@example.com',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'Your password',
|
||||
rememberMe: 'Remember me',
|
||||
forgotPassword: 'Forgot password?',
|
||||
signIn: 'Sign in',
|
||||
noAccount: "Don't have an account? ",
|
||||
createOne: 'Create one',
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: 'Username must be at least 3 characters',
|
||||
nameEnRequired: 'Name (English) is required',
|
||||
phoneRequired: 'Phone number is required',
|
||||
confirmPasswordRequired: 'Confirm your password',
|
||||
passwordsDontMatch: 'Passwords do not match',
|
||||
brandTitle: "Join {{appName}}'s community.",
|
||||
brandSubtitle: 'Create your account to access {{appName}} features.',
|
||||
title: 'Create account',
|
||||
subtitle: 'It only takes a minute to get started.',
|
||||
nameEnLabel: 'Name (English)',
|
||||
nameEnPlaceholder: 'Abebe Bekele',
|
||||
nameAmLabel: 'Name (Amharic)',
|
||||
nameAmPlaceholder: 'ስም',
|
||||
emailLabel: 'Email address',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
usernameLabel: 'Username',
|
||||
usernamePlaceholder: 'Choose a username',
|
||||
phoneLabel: 'Phone number',
|
||||
phonePlaceholder: '+251 911 234 567',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'At least 8 characters',
|
||||
confirmPasswordLabel: 'Confirm password',
|
||||
confirmPasswordPlaceholder: 'Re-enter password',
|
||||
agreeToThe: 'I agree to the ',
|
||||
termsAndPrivacy: 'Terms & Privacy Policy',
|
||||
createAccount: 'Create account',
|
||||
haveAccount: 'Already have an account? ',
|
||||
signIn: 'Sign in',
|
||||
passwordRule: {
|
||||
minLength: 'At least {{min}} characters',
|
||||
lowercase: 'One lowercase letter',
|
||||
uppercase: 'One uppercase letter',
|
||||
number: 'One number',
|
||||
special: 'One special character',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthBootstrap, AuthConfigProvider } from '@ema-platform/auth';
|
||||
import type { ReactNode } from 'react';
|
||||
import { store } from '../store';
|
||||
import { i18n } from '../i18n/config';
|
||||
import { MantineThemeProvider } from './MantineThemeProvider';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||
|
||||
@@ -26,9 +28,11 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
enableForgotPassword: true,
|
||||
}}
|
||||
>
|
||||
<MantineThemeProvider>
|
||||
<AuthBootstrap>{children}</AuthBootstrap>
|
||||
</MantineThemeProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<MantineThemeProvider>
|
||||
<AuthBootstrap>{children}</AuthBootstrap>
|
||||
</MantineThemeProvider>
|
||||
</I18nextProvider>
|
||||
</AuthConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { i18n } from "./i18n/config";
|
||||
import { PortalLayout } from "./layouts/PortalLayout";
|
||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||
import { LandingRoute } from "./components/LandingRoute";
|
||||
|
||||
// Auth (standalone pages, no portal chrome)
|
||||
import {
|
||||
@@ -28,9 +27,7 @@ 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 { VerifyCertificatePage } from "./features/verify/pages/VerifyCertificatePage";
|
||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
|
||||
|
||||
@@ -38,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
|
||||
@@ -59,15 +58,13 @@ import { WaiverPage } from "./features/waiver/pages/WaiverPage";
|
||||
import { VesselRegistrationStatusPage } from "./features/vessel-registration/pages/VesselRegistrationStatusPage";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
// Public landing page — institutional overview + role-based entry points.
|
||||
{ path: "/", element: <LandingRoute /> },
|
||||
|
||||
// Public auth pages
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/signup", element: <SignupPage /> },
|
||||
|
||||
// Public certificate verification — the target of every printed QR code.
|
||||
// No auth: a verifier scanning a certificate has no portal account.
|
||||
{ path: "/verify", element: <VerifyCertificatePage /> },
|
||||
{ path: "/verify/:code", element: <VerifyCertificatePage /> },
|
||||
|
||||
// Completes the forgot-password flow; the reset message links here. The
|
||||
// IAM package generates `/reset-password` links, `/set-password` is the
|
||||
// first-time-credential variant — one page serves both.
|
||||
@@ -101,17 +98,14 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{/* Asks what the applicant operates as before the rest of the
|
||||
portal, which is filtered by that answer. */}
|
||||
<RequireOperations>
|
||||
<PortalLayout />
|
||||
</RequireOperations>
|
||||
</I18nextProvider>
|
||||
{/* Asks what the applicant operates as before the rest of the
|
||||
portal, which is filtered by that answer. */}
|
||||
<RequireOperations>
|
||||
<PortalLayout />
|
||||
</RequireOperations>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ path: "/", element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: "/dashboard", element: <DashboardPage /> },
|
||||
{ path: "/onboarding/operations", element: <OperationsOnboardingPage /> },
|
||||
|
||||
@@ -159,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",
|
||||
@@ -222,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
|
||||
@@ -418,5 +426,7 @@ export const router = createBrowserRouter([
|
||||
element: <Navigate to="/vessel-ownership-transfer" replace />,
|
||||
},
|
||||
|
||||
{ path: "*", element: <Navigate to="/" replace /> },
|
||||
// A typo'd URL should not maroon a signed-in user on the marketing page —
|
||||
// ProtectedRoute sends anonymous visitors on to /login exactly as before.
|
||||
{ path: "*", element: <Navigate to="/dashboard" replace /> },
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolveSessionContext } from '../session';
|
||||
|
||||
export const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3001/api';
|
||||
'http://localhost:3000/api';
|
||||
|
||||
let _onTokenExpired: (() => Promise<string>) | null = null;
|
||||
let _onAuthFailure: (() => void) | null = null;
|
||||
|
||||
@@ -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: () => ({}),
|
||||
});
|
||||
|
||||
385
libs/api/src/lib/base-api/mock-base-query.ts
Normal file
385
libs/api/src/lib/base-api/mock-base-query.ts
Normal 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);
|
||||
};
|
||||
740
libs/api/src/lib/base-api/mock-data.ts
Normal file
740
libs/api/src/lib/base-api/mock-data.ts
Normal 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,
|
||||
);
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
QueueFilter,
|
||||
RemarkTargetType,
|
||||
SavedQueueView,
|
||||
TemplateFieldPlacement,
|
||||
TemplateLogoPlacement,
|
||||
TemplatePageOptions,
|
||||
TemplateVariable,
|
||||
} from './licensing.types';
|
||||
@@ -308,27 +310,6 @@ export const licensingApi = baseApi
|
||||
}),
|
||||
|
||||
// ------------------------------------------------------------ licences
|
||||
/**
|
||||
* Public QR-code verification (US-PORTAL-008). No auth required —
|
||||
* the endpoint returns only what a verifier needs to trust the
|
||||
* document, never the holder's contact details.
|
||||
*/
|
||||
verifyCertificate: builder.query<
|
||||
{
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
certificateNumber?: string;
|
||||
licenseType?: string | null;
|
||||
companyName?: string | null;
|
||||
issueDate?: string;
|
||||
expiryDate?: string;
|
||||
status?: string;
|
||||
},
|
||||
string
|
||||
>({
|
||||
query: (code) => ({ url: `/licenses/verify/${code}` }),
|
||||
}),
|
||||
|
||||
/** The licence register for enforcement officers. */
|
||||
getLicenses: builder.query<
|
||||
Paginated<IssuedLicense>,
|
||||
@@ -506,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) => ({
|
||||
@@ -543,6 +551,10 @@ export const licensingApi = baseApi
|
||||
name?: string;
|
||||
hbsSource?: string;
|
||||
pageOptions?: TemplatePageOptions;
|
||||
backgroundUrl?: string;
|
||||
logoUrl?: string;
|
||||
logoPlacement?: TemplateLogoPlacement;
|
||||
fieldPlacements?: TemplateFieldPlacement[];
|
||||
}
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
@@ -769,7 +781,6 @@ export const licensingApi = baseApi
|
||||
});
|
||||
|
||||
export const {
|
||||
useVerifyCertificateQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
@@ -827,6 +838,8 @@ export const {
|
||||
useApproveDocumentsMutation,
|
||||
useFinalApproveMutation,
|
||||
useRejectApplicationMutation,
|
||||
useScheduleExamMutation,
|
||||
useRequestExamPaymentMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleInspectionMutation,
|
||||
useGetInspectionsQuery,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3001/api';
|
||||
'http://localhost:3000/api';
|
||||
|
||||
/**
|
||||
* Uploads a document straight to the API.
|
||||
@@ -70,6 +70,12 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||
COMPLETED: 'Completed',
|
||||
ELIGIBILITY_APPROVED: 'Eligible to Sit',
|
||||
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
|
||||
EXAM_PAID: 'Awaiting Exam Date',
|
||||
EXAM_SCHEDULED: 'Exam Scheduled',
|
||||
EXAM_PASSED: 'Exam Passed',
|
||||
EXAM_FAILED: 'Exam Not Passed',
|
||||
};
|
||||
|
||||
/** Mantine colour per status — green progresses, orange needs the applicant. */
|
||||
@@ -89,6 +95,13 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
PAYMENT_CONFIRMED: 'teal',
|
||||
CERTIFICATE_ISSUED: 'green',
|
||||
COMPLETED: 'green',
|
||||
ELIGIBILITY_APPROVED: 'teal',
|
||||
EXAM_PAYMENT_PENDING: 'yellow',
|
||||
EXAM_PAID: 'lime',
|
||||
EXAM_SCHEDULED: 'cyan',
|
||||
EXAM_PASSED: 'teal',
|
||||
// Orange, not red: a failure is recoverable here — the candidate resits.
|
||||
EXAM_FAILED: 'orange',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -114,6 +127,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. */
|
||||
@@ -121,6 +143,10 @@ export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
|
||||
'DRAFT',
|
||||
'RESUBMIT_REQUIRED',
|
||||
'PAYMENT_PENDING',
|
||||
// Both wait on the candidate: one to pay for a sitting, one to decide to
|
||||
// sit again after a failure.
|
||||
'EXAM_PAYMENT_PENDING',
|
||||
'EXAM_FAILED',
|
||||
];
|
||||
|
||||
/** Statuses that are finished, whichever way they went. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
// cookie first, then localStorage (legacy pre-migration sessions)
|
||||
for (const key of TOKEN_STORAGE_KEYS) {
|
||||
// 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)
|
||||
const cookie = Cookies.get(key);
|
||||
if (cookie) return cookie;
|
||||
const stored = localStorage.getItem(key);
|
||||
|
||||
@@ -6,7 +6,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:3001/api';
|
||||
'http://localhost:3000/api';
|
||||
|
||||
/**
|
||||
* Restores the signed-in session before the router renders.
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type BoxProps,
|
||||
} from '@mantine/core';
|
||||
import { IconCheck, IconMoon, IconSun } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
|
||||
export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number }) {
|
||||
@@ -33,6 +34,7 @@ export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number
|
||||
}
|
||||
|
||||
function ThemeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const computed = useComputedColorScheme('light');
|
||||
const isDark = computed === 'dark';
|
||||
@@ -40,7 +42,7 @@ function ThemeToggle() {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
||||
aria-label="Toggle theme"
|
||||
aria-label={t('authShell.toggleTheme', 'Toggle theme')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -60,28 +62,32 @@ function ThemeToggle() {
|
||||
);
|
||||
}
|
||||
|
||||
const FEATURES = [
|
||||
'Submit applications online, 24/7',
|
||||
'Real-time status tracking & alerts',
|
||||
'Available in English & አማርኛ',
|
||||
];
|
||||
|
||||
interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
brandTitle?: string;
|
||||
brandSubtitle?: string;
|
||||
}
|
||||
|
||||
export function AuthShell({
|
||||
children,
|
||||
brandTitle = 'Maritime licensing, made simple.',
|
||||
brandSubtitle = 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
|
||||
}: AuthShellProps) {
|
||||
export function AuthShell({ children, brandTitle, brandSubtitle }: AuthShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const { logoUrl } = useAuthConfig();
|
||||
const heroGradient = theme.other.heroGradient as string;
|
||||
const computed = useComputedColorScheme('light');
|
||||
const isDark = computed === 'dark';
|
||||
const resolvedBrandTitle =
|
||||
brandTitle ?? t('authShell.brandTitle', 'Maritime licensing, made simple.');
|
||||
const resolvedBrandSubtitle =
|
||||
brandSubtitle ??
|
||||
t(
|
||||
'authShell.brandSubtitle',
|
||||
'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
|
||||
);
|
||||
const FEATURES = [
|
||||
t('authShell.feature1', 'Submit applications online, 24/7'),
|
||||
t('authShell.feature2', 'Real-time status tracking & alerts'),
|
||||
t('authShell.feature3', 'Available in English & አማርኛ'),
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -175,10 +181,10 @@ export function AuthShell({
|
||||
|
||||
<Stack gap={6}>
|
||||
<Title order={2} c="white" fz={rem(28)} lh={1.2} fw={700}>
|
||||
{brandTitle}
|
||||
{resolvedBrandTitle}
|
||||
</Title>
|
||||
<Text fz="sm" lh={1.6} style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||
{brandSubtitle}
|
||||
{resolvedBrandSubtitle}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -204,7 +210,7 @@ export function AuthShell({
|
||||
</Stack>
|
||||
|
||||
<Text fz="xs" style={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
© 2026 Ethiopian Maritime Authority
|
||||
{t('authShell.copyright', '© 2026 Ethiopian Maritime Authority')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useApiMutation } from "@ema-platform/api";
|
||||
import { notify, useErrorHandler } from "@ema-platform/ui";
|
||||
import { AuthShell } from "../components/AuthShell";
|
||||
@@ -34,40 +35,11 @@ import type {
|
||||
} from "../types/auth.types";
|
||||
import { useAuthConfig } from "../AuthConfig";
|
||||
import { authStorage } from "../utils/auth-storage";
|
||||
const emailOrPhone = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => {
|
||||
// Convert 09xxxxxxxx -> +2519xxxxxxxx
|
||||
if (/^09\d{8}$/.test(value)) {
|
||||
return `+251${value.substring(1)}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
.refine(
|
||||
(value) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const phoneRegex = /^\+2519\d{8}$/;
|
||||
|
||||
return emailRegex.test(value) || phoneRegex.test(value);
|
||||
},
|
||||
{
|
||||
message: "Enter a valid email or phone number (+2519xxxxxxxx)",
|
||||
},
|
||||
);
|
||||
const schema = z.object({
|
||||
email: emailOrPhone,
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: "Password must be at least 8 characters" }),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { appName, loginRedirectPath, enableSignup, enableForgotPassword } =
|
||||
useAuthConfig();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -78,6 +50,41 @@ export function LoginPage() {
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
|
||||
|
||||
// Built inside the component (not module scope) so validation messages
|
||||
// pick up the active language — same pattern as ProfilePage's forms.
|
||||
const schema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => {
|
||||
// Convert 09xxxxxxxx -> +2519xxxxxxxx
|
||||
if (/^09\d{8}$/.test(value)) {
|
||||
return `+251${value.substring(1)}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
.refine(
|
||||
(value) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const phoneRegex = /^\+2519\d{8}$/;
|
||||
|
||||
return emailRegex.test(value) || phoneRegex.test(value);
|
||||
},
|
||||
{
|
||||
message: t(
|
||||
"login.emailOrPhoneInvalid",
|
||||
"Enter a valid email or phone number (+2519xxxxxxxx)",
|
||||
),
|
||||
},
|
||||
),
|
||||
password: z.string().min(8, {
|
||||
message: t("login.passwordMinLength", "Password must be at least 8 characters"),
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -144,10 +151,10 @@ export function LoginPage() {
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} fz={30}>
|
||||
Welcome to {appName}
|
||||
{t("login.welcome", { appName, defaultValue: "Welcome to {{appName}}" })}
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
Sign in to access your account.
|
||||
{t("login.subtitle", "Sign in to access your account.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -165,16 +172,16 @@ export function LoginPage() {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or phone"
|
||||
placeholder="you@example.com"
|
||||
label={t("login.emailOrPhoneLabel", "Email or phone")}
|
||||
placeholder={t("login.emailOrPhonePlaceholder", "you@example.com")}
|
||||
size="md"
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
label={t("login.passwordLabel", "Password")}
|
||||
placeholder={t("login.passwordPlaceholder", "Your password")}
|
||||
size="md"
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={errors.password?.message}
|
||||
@@ -183,7 +190,7 @@ export function LoginPage() {
|
||||
|
||||
<Group justify="space-between">
|
||||
<Checkbox
|
||||
label="Remember me"
|
||||
label={t("login.rememberMe", "Remember me")}
|
||||
size="sm"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.currentTarget.checked)}
|
||||
@@ -195,7 +202,7 @@ export function LoginPage() {
|
||||
size="sm"
|
||||
fw={600}
|
||||
>
|
||||
Forgot password?
|
||||
{t("login.forgotPassword", "Forgot password?")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
@@ -207,15 +214,15 @@ export function LoginPage() {
|
||||
size="md"
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
>
|
||||
Sign in
|
||||
{t("login.signIn", "Sign in")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
{enableSignup && (
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
Don't have an account?{" "}
|
||||
{t("login.noAccount", "Don't have an account? ")}
|
||||
<Anchor component={Link} to="/signup" fw={700}>
|
||||
Create one
|
||||
{t("login.createOne", "Create one")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
@@ -32,24 +33,6 @@ import { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().min(3, { message: 'Username must be at least 3 characters' }),
|
||||
phoneNumber: z.string().min(1, { message: 'Phone number is required' }),
|
||||
userType: z.literal('individual'),
|
||||
nameEn: z.string().min(1, { message: 'Name (English) is required' }),
|
||||
nameAm: z.string().optional(),
|
||||
password: passwordSchema(8),
|
||||
confirmPassword: z.string().min(1, { message: 'Confirm your password' }),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface SignupPayload {
|
||||
email: string;
|
||||
username: string;
|
||||
@@ -66,6 +49,7 @@ interface SignupPayload {
|
||||
export function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { appName, loginRedirectPath } = useAuthConfig();
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
@@ -77,6 +61,37 @@ export function SignupPage() {
|
||||
}>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
|
||||
// Order matches passwordRules' default list: length, lowercase, uppercase,
|
||||
// number, special character. Shared between the zod schema (field error)
|
||||
// and the live checklist below, so both agree on the wording.
|
||||
const passwordRuleLabels = [
|
||||
t('signup.passwordRule.minLength', { min: 8, defaultValue: 'At least {{min}} characters' }),
|
||||
t('signup.passwordRule.lowercase', 'One lowercase letter'),
|
||||
t('signup.passwordRule.uppercase', 'One uppercase letter'),
|
||||
t('signup.passwordRule.number', 'One number'),
|
||||
t('signup.passwordRule.special', 'One special character'),
|
||||
];
|
||||
|
||||
// Built inside the component (not module scope) so validation messages
|
||||
// pick up the active language — same pattern as ProfilePage's forms.
|
||||
const schema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().min(3, { message: t('signup.usernameMinLength', 'Username must be at least 3 characters') }),
|
||||
phoneNumber: z.string().min(1, { message: t('signup.phoneRequired', 'Phone number is required') }),
|
||||
userType: z.literal('individual'),
|
||||
nameEn: z.string().min(1, { message: t('signup.nameEnRequired', 'Name (English) is required') }),
|
||||
nameAm: z.string().optional(),
|
||||
password: passwordSchema(8, passwordRuleLabels),
|
||||
confirmPassword: z.string().min(1, { message: t('signup.confirmPasswordRequired', 'Confirm your password') }),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: t('signup.passwordsDontMatch', 'Passwords do not match'),
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -133,16 +148,19 @@ export function SignupPage() {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle={`Join ${appName}'s community.`}
|
||||
brandSubtitle={`Create your account to access ${appName} features.`}
|
||||
brandTitle={t('signup.brandTitle', { appName, defaultValue: "Join {{appName}}'s community." })}
|
||||
brandSubtitle={t('signup.brandSubtitle', {
|
||||
appName,
|
||||
defaultValue: 'Create your account to access {{appName}} features.',
|
||||
})}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} fz={30}>
|
||||
Create account
|
||||
{t('signup.title', 'Create account')}
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
It only takes a minute to get started.
|
||||
{t('signup.subtitle', 'It only takes a minute to get started.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -156,15 +174,15 @@ export function SignupPage() {
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Name (English)"
|
||||
placeholder="Abebe Bekele"
|
||||
label={t('signup.nameEnLabel', 'Name (English)')}
|
||||
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameEn?.message}
|
||||
{...register('nameEn')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name (Amharic)"
|
||||
placeholder="ስም"
|
||||
label={t('signup.nameAmLabel', 'Name (Amharic)')}
|
||||
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameAm?.message}
|
||||
{...register('nameAm')}
|
||||
@@ -173,15 +191,15 @@ export function SignupPage() {
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Email address"
|
||||
placeholder="you@example.com"
|
||||
label={t('signup.emailLabel', 'Email address')}
|
||||
placeholder={t('signup.emailPlaceholder', 'you@example.com')}
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Choose a username"
|
||||
label={t('signup.usernameLabel', 'Username')}
|
||||
placeholder={t('signup.usernamePlaceholder', 'Choose a username')}
|
||||
leftSection={<IconAt size={18} />}
|
||||
error={errors.username?.message}
|
||||
{...register('username')}
|
||||
@@ -189,8 +207,8 @@ export function SignupPage() {
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Phone number"
|
||||
placeholder="+251 911 234 567"
|
||||
label={t('signup.phoneLabel', 'Phone number')}
|
||||
placeholder={t('signup.phonePlaceholder', '+251 911 234 567')}
|
||||
leftSection={<IconDeviceMobile size={18} />}
|
||||
error={errors.phoneNumber?.message}
|
||||
{...register('phoneNumber')}
|
||||
@@ -199,17 +217,21 @@ export function SignupPage() {
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<div>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="At least 8 characters"
|
||||
label={t('signup.passwordLabel', 'Password')}
|
||||
placeholder={t('signup.passwordPlaceholder', 'At least 8 characters')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
<PasswordRequirements password={watch('password') ?? ''} minLength={8} />
|
||||
<PasswordRequirements
|
||||
password={watch('password') ?? ''}
|
||||
minLength={8}
|
||||
labels={passwordRuleLabels}
|
||||
/>
|
||||
</div>
|
||||
<PasswordInput
|
||||
label="Confirm password"
|
||||
placeholder="Re-enter password"
|
||||
label={t('signup.confirmPasswordLabel', 'Confirm password')}
|
||||
placeholder={t('signup.confirmPasswordPlaceholder', 'Re-enter password')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register('confirmPassword')}
|
||||
@@ -222,13 +244,13 @@ export function SignupPage() {
|
||||
onChange={(e) => setAgreed(e.currentTarget.checked)}
|
||||
label={
|
||||
<Text size="sm">
|
||||
I agree to the{' '}
|
||||
{t('signup.agreeToThe', 'I agree to the ')}
|
||||
<Anchor
|
||||
size="sm"
|
||||
fw={600}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
Terms & Privacy Policy
|
||||
{t('signup.termsAndPrivacy', 'Terms & Privacy Policy')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
}
|
||||
@@ -242,15 +264,15 @@ export function SignupPage() {
|
||||
size="md"
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
>
|
||||
Create account
|
||||
{t('signup.createAccount', 'Create account')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
Already have an account?{' '}
|
||||
{t('signup.haveAccount', 'Already have an account? ')}
|
||||
<Anchor component={Link} to="/login" fw={700}>
|
||||
Sign in
|
||||
{t('signup.signIn', 'Sign in')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { authStorage } from "./auth-storage";
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.[
|
||||
"VITE_BASE_API_URL"
|
||||
] ?? "http://localhost:3001/api";
|
||||
] ?? "http://localhost:3000/api";
|
||||
|
||||
interface RefreshResponse {
|
||||
token: string;
|
||||
|
||||
@@ -21,3 +21,5 @@ export * from "./lib/input/phone";
|
||||
export * from "./lib/data/AdvancedTable";
|
||||
export * from "./lib/feedback/use-error-handler";
|
||||
export * from "./lib/data/useServerTable";
|
||||
export * from "./lib/landing/LandingPage";
|
||||
export * from "./lib/landing/landing-copy";
|
||||
|
||||
@@ -2,20 +2,32 @@ import { Stack, Text, Group } from '@mantine/core';
|
||||
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function passwordRules(minLength: number) {
|
||||
const DEFAULT_LABELS = (minLength: number) => [
|
||||
`At least ${minLength} characters`,
|
||||
'One lowercase letter',
|
||||
'One uppercase letter',
|
||||
'One number',
|
||||
'One special character',
|
||||
];
|
||||
|
||||
/** `labels`, when given, overrides the default English text in the same
|
||||
* order — the caller's translated strings, so this stays a plain function
|
||||
* usable from a zod schema with no i18n context of its own. */
|
||||
export function passwordRules(minLength: number, labels?: string[]) {
|
||||
const text = labels ?? DEFAULT_LABELS(minLength);
|
||||
return [
|
||||
{ label: `At least ${minLength} characters`, test: (p: string) => p.length >= minLength },
|
||||
{ label: 'One lowercase letter', test: (p: string) => /[a-z]/.test(p) },
|
||||
{ label: 'One uppercase letter', test: (p: string) => /[A-Z]/.test(p) },
|
||||
{ label: 'One number', test: (p: string) => /\d/.test(p) },
|
||||
{ label: 'One special character', test: (p: string) => /[^A-Za-z0-9]/.test(p) },
|
||||
{ label: text[0], test: (p: string) => p.length >= minLength },
|
||||
{ label: text[1], test: (p: string) => /[a-z]/.test(p) },
|
||||
{ label: text[2], test: (p: string) => /[A-Z]/.test(p) },
|
||||
{ label: text[3], test: (p: string) => /\d/.test(p) },
|
||||
{ label: text[4], test: (p: string) => /[^A-Za-z0-9]/.test(p) },
|
||||
];
|
||||
}
|
||||
|
||||
/** Zod field schema enforcing every rule; unmet rules surface as separate issues. */
|
||||
export const passwordSchema = (minLength: number) =>
|
||||
export const passwordSchema = (minLength: number, labels?: string[]) =>
|
||||
z.string().superRefine((val, ctx) => {
|
||||
for (const rule of passwordRules(minLength)) {
|
||||
for (const rule of passwordRules(minLength, labels)) {
|
||||
if (!rule.test(val)) {
|
||||
ctx.addIssue({ code: 'custom', message: rule.label });
|
||||
}
|
||||
@@ -34,10 +46,10 @@ interface PasswordRequirementsProps {
|
||||
/** Live checklist of password requirements, ticking off as the user types. Hidden until the user starts typing. */
|
||||
export function PasswordRequirements({ password, minLength, labels }: PasswordRequirementsProps) {
|
||||
if (!password) return null;
|
||||
const rules = passwordRules(minLength);
|
||||
const rules = passwordRules(minLength, labels);
|
||||
return (
|
||||
<Stack gap={6} mt={6}>
|
||||
{rules.map((rule, i) => {
|
||||
{rules.map((rule) => {
|
||||
const met = rule.test(password);
|
||||
return (
|
||||
<Group key={rule.label} gap={6} wrap="nowrap">
|
||||
@@ -47,7 +59,7 @@ export function PasswordRequirements({ password, minLength, labels }: PasswordRe
|
||||
<IconX size={14} color="var(--mantine-color-gray-5)" />
|
||||
)}
|
||||
<Text fz="xs" c={met ? 'teal' : 'dimmed'}>
|
||||
{labels?.[i] ?? rule.label}
|
||||
{rule.label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
718
libs/ui/src/lib/landing/LandingPage.tsx
Normal file
718
libs/ui/src/lib/landing/LandingPage.tsx
Normal file
@@ -0,0 +1,718 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Accordion,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconActivity,
|
||||
IconAnchor,
|
||||
IconAward,
|
||||
IconBriefcase,
|
||||
IconCertificate,
|
||||
IconClipboardCheck,
|
||||
IconGavel,
|
||||
IconLanguage,
|
||||
IconMail,
|
||||
IconMapPin,
|
||||
IconPhone,
|
||||
IconSchool,
|
||||
IconShip,
|
||||
IconShieldCheck,
|
||||
IconUserCheck,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import { LanguageSwitcher } from '../layout/LanguageSwitcher';
|
||||
import { ColorSchemeToggle } from '../layout/ColorSchemeToggle';
|
||||
import './landing.css';
|
||||
|
||||
export interface LandingPageProps {
|
||||
/** Primary CTA target. Each app passes '/dashboard' when a session token
|
||||
* exists, else '/login'. Authenticated header state is derived from this
|
||||
* (anything other than '/login' counts as signed in) rather than a
|
||||
* separate prop, since libs/ui cannot import @ema-platform/auth. */
|
||||
primaryHref: string;
|
||||
/** Renders the "Create account" CTA only when set. Portal passes '/signup'; backoffice omits it (enableSignup: false). */
|
||||
signupHref?: string;
|
||||
supportedLanguages?: readonly string[];
|
||||
}
|
||||
|
||||
const HEADER_HEIGHT = 72;
|
||||
|
||||
// Sections with a nav item, in page order — drives the scrollspy underline.
|
||||
const NAV_SECTION_IDS = ['home', 'about', 'services', 'system', 'contact'] as const;
|
||||
|
||||
/** Tracks which nav section is currently under the sticky header, so the
|
||||
* header can underline it. Native IntersectionObserver — no scroll listener. */
|
||||
function useActiveSection(ids: readonly string[]) {
|
||||
const [active, setActive] = useState<string>(ids[0]);
|
||||
|
||||
useEffect(() => {
|
||||
const elements = ids
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter((el): el is HTMLElement => el !== null);
|
||||
if (!elements.length) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.filter((entry) => entry.isIntersecting);
|
||||
if (visible.length === 0) return;
|
||||
const topMost = visible.reduce((a, b) => (a.boundingClientRect.top <= b.boundingClientRect.top ? a : b));
|
||||
setActive(topMost.target.id);
|
||||
},
|
||||
// Counts a section only once it has cleared the sticky header, and only
|
||||
// while it's still in the top 30% of the viewport.
|
||||
{ rootMargin: `-${HEADER_HEIGHT}px 0px -70% 0px`, threshold: 0 },
|
||||
);
|
||||
elements.forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [ids]);
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
export function LandingPage({
|
||||
primaryHref,
|
||||
signupHref,
|
||||
supportedLanguages = ['en', 'am'],
|
||||
}: LandingPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const isAuthed = primaryHref !== '/login';
|
||||
|
||||
return (
|
||||
<Box className="ema-landing" style={{ minHeight: '100dvh', background: 'var(--mantine-color-body)' }}>
|
||||
<Anchor
|
||||
href="#main"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 8,
|
||||
top: -60,
|
||||
zIndex: 1000,
|
||||
padding: '10px 16px',
|
||||
background: 'var(--mantine-color-body)',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 8,
|
||||
transition: 'top 120ms ease',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.top = '8px';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.top = '-60px';
|
||||
}}
|
||||
>
|
||||
{t('landing.meta.skipToContent')}
|
||||
</Anchor>
|
||||
|
||||
<Header
|
||||
isAuthed={isAuthed}
|
||||
primaryHref={primaryHref}
|
||||
signupHref={signupHref}
|
||||
supportedLanguages={supportedLanguages}
|
||||
/>
|
||||
|
||||
<main id="main">
|
||||
<Hero primaryHref={primaryHref} signupHref={signupHref} isAuthed={isAuthed} />
|
||||
<About />
|
||||
<QuickAccess />
|
||||
<Services />
|
||||
<Roles />
|
||||
<HowItWorks />
|
||||
<SystemHighlights />
|
||||
<Faq />
|
||||
<Contact />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function NavAnchor({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Anchor
|
||||
href={href}
|
||||
underline="never"
|
||||
aria-current={active ? 'true' : undefined}
|
||||
c={active ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'}
|
||||
fw={500}
|
||||
fz="sm"
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
paddingBottom: 4,
|
||||
borderBottom: active ? '2px solid var(--mantine-primary-color-filled)' : '2px solid transparent',
|
||||
transition: 'color 120ms ease, border-color 120ms ease',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
isAuthed,
|
||||
primaryHref,
|
||||
signupHref,
|
||||
supportedLanguages,
|
||||
}: {
|
||||
isAuthed: boolean;
|
||||
primaryHref: string;
|
||||
signupHref?: string;
|
||||
supportedLanguages: readonly string[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const activeSection = useActiveSection(NAV_SECTION_IDS);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="header"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
height: HEADER_HEIGHT,
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderBottom: '1px solid var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
<Container size="xl" h="100%">
|
||||
<Group h="100%" justify="space-between" wrap="nowrap" gap="md">
|
||||
<Anchor href="#home" underline="never" c="var(--mantine-color-text)">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Box component="img" src="/ema-logo.png" alt="" w={34} h={34} style={{ objectFit: 'contain' }} />
|
||||
<Text fw={700} fz="sm" visibleFrom="sm">
|
||||
{t('app.name')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Anchor>
|
||||
|
||||
<Group gap="lg" wrap="nowrap" visibleFrom="md" component="nav" aria-label={t('landing.nav.home')}>
|
||||
<NavAnchor href="#home" active={activeSection === 'home'}>
|
||||
{t('landing.nav.home')}
|
||||
</NavAnchor>
|
||||
<NavAnchor href="#about" active={activeSection === 'about'}>
|
||||
{t('landing.nav.about')}
|
||||
</NavAnchor>
|
||||
<NavAnchor href="#services" active={activeSection === 'services'}>
|
||||
{t('landing.nav.services')}
|
||||
</NavAnchor>
|
||||
<NavAnchor href="#system" active={activeSection === 'system'}>
|
||||
{t('landing.nav.system')}
|
||||
</NavAnchor>
|
||||
<NavAnchor href="#contact" active={activeSection === 'contact'}>
|
||||
{t('landing.nav.contact')}
|
||||
</NavAnchor>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<LanguageSwitcher supportedLanguages={supportedLanguages} />
|
||||
<ColorSchemeToggle />
|
||||
{isAuthed ? (
|
||||
<Button component={Link} to={primaryHref} size="sm" visibleFrom="xs">
|
||||
{t('landing.auth.dashboard')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{/* Login is the secondary action next to Sign Up when both
|
||||
exist (portal) — but when there's no signup (backoffice,
|
||||
enableSignup: false), it's the only action, so it takes
|
||||
the filled/primary treatment instead of reading like a
|
||||
leftover half of a pair. */}
|
||||
<Button
|
||||
component={Link}
|
||||
to={primaryHref}
|
||||
variant={signupHref ? 'subtle' : 'filled'}
|
||||
size="sm"
|
||||
visibleFrom="xs"
|
||||
>
|
||||
{t('landing.auth.login')}
|
||||
</Button>
|
||||
{signupHref && (
|
||||
<Button component={Link} to={signupHref} size="sm" visibleFrom="xs">
|
||||
{t('landing.auth.signup')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const HERO_BADGE_ICONS = {
|
||||
secure: IconShieldCheck,
|
||||
bilingual: IconLanguage,
|
||||
tracking: IconActivity,
|
||||
} as const;
|
||||
|
||||
function Hero({
|
||||
primaryHref,
|
||||
signupHref,
|
||||
isAuthed,
|
||||
}: {
|
||||
primaryHref: string;
|
||||
signupHref?: string;
|
||||
isAuthed: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const badgeKeys = Object.keys(HERO_BADGE_ICONS) as (keyof typeof HERO_BADGE_ICONS)[];
|
||||
|
||||
return (
|
||||
<Box
|
||||
id="home"
|
||||
component="section"
|
||||
pos="relative"
|
||||
style={{
|
||||
background: theme.other?.heroGradient as string,
|
||||
color: 'white',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Decorative glow circles — same device as AuthShell's hero panel. */}
|
||||
<Box
|
||||
pos="absolute"
|
||||
top={-120}
|
||||
right={-100}
|
||||
w={420}
|
||||
h={420}
|
||||
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.08)' }}
|
||||
/>
|
||||
<Box
|
||||
pos="absolute"
|
||||
bottom={-160}
|
||||
left={-120}
|
||||
w={380}
|
||||
h={380}
|
||||
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.06)' }}
|
||||
/>
|
||||
<Box
|
||||
pos="absolute"
|
||||
top="30%"
|
||||
left="8%"
|
||||
w={140}
|
||||
h={140}
|
||||
visibleFrom="md"
|
||||
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.05)' }}
|
||||
/>
|
||||
|
||||
<Container size="xl" py={{ base: 88, sm: 120, lg: 148 }} pos="relative">
|
||||
<Stack gap="lg" align="center" ta="center" className="ema-landing-fade">
|
||||
<Text fz="sm" fw={600} style={{ opacity: 0.85, letterSpacing: 0.5 }}>
|
||||
{t('landing.hero.eyebrow')}
|
||||
</Text>
|
||||
<Title order={1} fz={{ base: 34, sm: 48, lg: 56 }} lh={1.1} maw={860}>
|
||||
{t('landing.hero.title')}
|
||||
</Title>
|
||||
<Text fz={{ base: 'md', sm: 'xl' }} fw={500} maw={680} style={{ opacity: 0.95 }}>
|
||||
{t('landing.hero.subtitle')}
|
||||
</Text>
|
||||
<Text fz={{ base: 'sm', sm: 'md' }} maw={600} style={{ opacity: 0.82 }}>
|
||||
{t('landing.hero.description')}
|
||||
</Text>
|
||||
|
||||
<Group justify="center">
|
||||
<Button
|
||||
component={Link}
|
||||
to={isAuthed ? primaryHref : (signupHref ?? '/login')}
|
||||
size="lg"
|
||||
variant="white"
|
||||
color="dark"
|
||||
leftSection={<IconShip size={20} />}
|
||||
style={{ boxShadow: '0 8px 24px rgba(0,0,0,0.18)' }}
|
||||
>
|
||||
{isAuthed ? t('landing.auth.dashboard') : t('landing.cta.getStarted')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" mt="xl" justify="center" wrap="wrap">
|
||||
{badgeKeys.map((key) => {
|
||||
const Icon = HERO_BADGE_ICONS[key];
|
||||
return (
|
||||
<Group
|
||||
key={key}
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
px="sm"
|
||||
py={6}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: 'rgba(255,255,255,0.12)',
|
||||
border: '1px solid rgba(255,255,255,0.18)',
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<Text fz="xs" fw={600}>
|
||||
{t(`landing.hero.badges.${key}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||
return (
|
||||
<Stack gap={6} align="center" ta="center" mb="xl" maw={640} mx="auto">
|
||||
<Title order={2} fz={{ base: 24, sm: 30 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{subtitle && (
|
||||
<Text c="dimmed" fz="md">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function About() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box id="about" component="section" py={72}>
|
||||
<Container size="xl">
|
||||
<SectionHeading title={t('landing.about.title')} />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xl">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="xs">
|
||||
<Text fw={700} c="var(--mantine-primary-color-filled)" tt="uppercase" fz="xs" style={{ letterSpacing: 0.5 }}>
|
||||
{t('landing.about.visionLabel')}
|
||||
</Text>
|
||||
<Text>{t('landing.about.vision')}</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="xs">
|
||||
<Text fw={700} c="var(--mantine-primary-color-filled)" tt="uppercase" fz="xs" style={{ letterSpacing: 0.5 }}>
|
||||
{t('landing.about.missionLabel')}
|
||||
</Text>
|
||||
<Text>{t('landing.about.mission')}</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAccessCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
icon: typeof IconCertificate;
|
||||
title: string;
|
||||
description: string;
|
||||
href?: string;
|
||||
}) {
|
||||
const body = (
|
||||
<Stack gap="sm">
|
||||
<ThemeIcon size={44} radius="md" variant="light">
|
||||
<Icon size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>{title}</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
const cardStyle = { display: 'block', height: '100%', textDecoration: 'none', color: 'inherit' } as const;
|
||||
|
||||
return href ? (
|
||||
<Paper component={Link} to={href} withBorder radius="lg" p="lg" className="ema-landing-hover" style={cardStyle}>
|
||||
{body}
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper withBorder radius="lg" p="lg" className="ema-landing-hover" style={cardStyle}>
|
||||
{body}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAccess() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box id="quick-access" component="section" py={72} style={{ background: 'var(--mantine-color-default-hover)' }}>
|
||||
<Container size="xl">
|
||||
<SectionHeading title={t('landing.quickAccess.title')} subtitle={t('landing.quickAccess.subtitle')} />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<QuickAccessCard
|
||||
icon={IconShip}
|
||||
title={t('landing.quickAccess.vesselRegistration.title')}
|
||||
description={t('landing.quickAccess.vesselRegistration.description')}
|
||||
/>
|
||||
<QuickAccessCard
|
||||
icon={IconAnchor}
|
||||
title={t('landing.quickAccess.notices.title')}
|
||||
description={t('landing.quickAccess.notices.description')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const SERVICE_ICONS = {
|
||||
seafarerRegistration: IconUserCheck,
|
||||
vesselRegistration: IconShip,
|
||||
licensing: IconBriefcase,
|
||||
examinations: IconSchool,
|
||||
waivers: IconShieldCheck,
|
||||
} as const;
|
||||
|
||||
function Services() {
|
||||
const { t } = useTranslation();
|
||||
const items = Object.keys(SERVICE_ICONS) as (keyof typeof SERVICE_ICONS)[];
|
||||
return (
|
||||
<Box id="services" component="section" py={72}>
|
||||
<Container size="xl">
|
||||
<SectionHeading title={t('landing.services.title')} subtitle={t('landing.services.subtitle')} />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{items.map((key) => {
|
||||
const Icon = SERVICE_ICONS[key];
|
||||
return (
|
||||
<Paper key={key} withBorder radius="lg" p="lg">
|
||||
<Stack gap="sm">
|
||||
<ThemeIcon size={44} radius="md" variant="light">
|
||||
<Icon size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>{t(`landing.services.items.${key}.title`)}</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
{t(`landing.services.items.${key}.description`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_ICONS = {
|
||||
seafarers: IconShip,
|
||||
vesselOwners: IconAnchor,
|
||||
agents: IconBriefcase,
|
||||
reviewers: IconGavel,
|
||||
} as const;
|
||||
|
||||
function Roles() {
|
||||
const { t } = useTranslation();
|
||||
const items = Object.keys(ROLE_ICONS) as (keyof typeof ROLE_ICONS)[];
|
||||
return (
|
||||
<Box id="roles" component="section" py={72} style={{ background: 'var(--mantine-color-default-hover)' }}>
|
||||
<Container size="xl">
|
||||
<SectionHeading title={t('landing.roles.title')} subtitle={t('landing.roles.subtitle')} />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
{items.map((key) => {
|
||||
const Icon = ROLE_ICONS[key];
|
||||
return (
|
||||
<Paper key={key} withBorder radius="lg" p="lg" ta="center">
|
||||
<Stack gap="sm" align="center">
|
||||
<ThemeIcon size={48} radius="xl" variant="light">
|
||||
<Icon size={24} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>{t(`landing.roles.${key}.title`)}</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
{t(`landing.roles.${key}.description`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const STEP_KEYS = ['selectRole', 'createAccount', 'submitApplication', 'trackStatus', 'receiveApproval'] as const;
|
||||
const STEP_ICONS = [IconUsers, IconUserCheck, IconClipboardCheck, IconAward, IconCertificate];
|
||||
|
||||
function HowItWorks() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box id="how-it-works" component="section" py={72}>
|
||||
<Container size="xl">
|
||||
<SectionHeading title={t('landing.howItWorks.title')} subtitle={t('landing.howItWorks.subtitle')} />
|
||||
<SimpleGrid cols={{ base: 1, sm: 5 }} spacing="lg">
|
||||
{STEP_KEYS.map((key, i) => {
|
||||
const Icon = STEP_ICONS[i];
|
||||
return (
|
||||
<Stack key={key} gap="xs" align="center" ta="center">
|
||||
<ThemeIcon size={48} radius="xl" variant="filled" color="var(--mantine-primary-color-filled)">
|
||||
<Icon size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} fz="sm">
|
||||
{i + 1}. {t(`landing.howItWorks.steps.${key}`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const SYSTEM_ICONS = {
|
||||
secure: IconShieldCheck,
|
||||
bilingual: IconLanguage,
|
||||
tracking: IconActivity,
|
||||
singleAccount: IconUserCircle,
|
||||
} as const;
|
||||
|
||||
function SystemHighlights() {
|
||||
const { t } = useTranslation();
|
||||
const items = Object.keys(SYSTEM_ICONS) as (keyof typeof SYSTEM_ICONS)[];
|
||||
return (
|
||||
<Box id="system" component="section" py={72} style={{ background: 'var(--mantine-color-default-hover)' }}>
|
||||
<Container size="xl">
|
||||
<SectionHeading title={t('landing.system.title')} subtitle={t('landing.system.subtitle')} />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
{items.map((key) => {
|
||||
const Icon = SYSTEM_ICONS[key];
|
||||
return (
|
||||
<Paper key={key} withBorder radius="lg" p="lg" ta="center">
|
||||
<Stack gap="sm" align="center">
|
||||
<ThemeIcon size={48} radius="xl" variant="light">
|
||||
<Icon size={24} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>{t(`landing.system.items.${key}.title`)}</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
{t(`landing.system.items.${key}.description`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const FAQ_KEYS = ['whatIsPortal', 'whoCanUse', 'howToApply', 'isFree'] as const;
|
||||
|
||||
function Faq() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box component="section" py={72}>
|
||||
<Container size="sm">
|
||||
<SectionHeading title={t('landing.faq.title')} />
|
||||
<Accordion variant="separated" radius="lg">
|
||||
{FAQ_KEYS.map((key) => (
|
||||
<Accordion.Item key={key} value={key}>
|
||||
<Accordion.Control>{t(`landing.faq.items.${key}.question`)}</Accordion.Control>
|
||||
<Accordion.Panel>{t(`landing.faq.items.${key}.answer`)}</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Contact() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box id="contact" component="section" py={72} style={{ background: 'var(--mantine-color-default-hover)' }}>
|
||||
<Container size="xl">
|
||||
<SectionHeading title={t('landing.contact.title')} subtitle={t('landing.contact.subtitle')} />
|
||||
<Paper withBorder radius="lg" p="xl" maw={560} mx="auto" component="address" style={{ fontStyle: 'normal' }}>
|
||||
<Stack gap="lg">
|
||||
<Group gap="md" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={38} radius="md" variant="light">
|
||||
<IconMapPin size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} fz="sm">
|
||||
{t('landing.contact.addressLabel')}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
{t('landing.contact.address')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="md" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={38} radius="md" variant="light">
|
||||
<IconPhone size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} fz="sm">
|
||||
{t('landing.contact.phoneLabel')}
|
||||
</Text>
|
||||
<Anchor href="tel:+251115150299" fz="sm">
|
||||
+251 011 515 0299
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="md" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={38} radius="md" variant="light">
|
||||
<IconMail size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} fz="sm">
|
||||
{t('landing.contact.websiteLabel')}
|
||||
</Text>
|
||||
<Anchor href="https://etmaritime.com" target="_blank" rel="noreferrer" fz="sm">
|
||||
etmaritime.com
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box component="footer" py="xl" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
|
||||
<Container size="xl">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="xs">
|
||||
<Box component="img" src="/ema-logo.png" alt="" w={24} h={24} style={{ objectFit: 'contain' }} />
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('landing.hero.title')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">
|
||||
© 2026 {t('landing.hero.title')} — {t('landing.footer.rights')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
377
libs/ui/src/lib/landing/landing-copy.ts
Normal file
377
libs/ui/src/lib/landing/landing-copy.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Copy for the public landing page (`LandingPage.tsx`), shared by both apps.
|
||||
* Written once here; each app's `en.ts`/`am.ts` re-exports it under the
|
||||
* `landing` key so it flows through that app's own i18next instance.
|
||||
*
|
||||
* `landingAm: LandingCopy` enforces English/Amharic parity right here — a
|
||||
* missing Amharic key is a compile error, same contract as each app's own
|
||||
* `Translations = typeof en`.
|
||||
*/
|
||||
|
||||
export const landingEn = {
|
||||
meta: {
|
||||
skipToContent: 'Skip to content',
|
||||
},
|
||||
|
||||
nav: {
|
||||
home: 'Home',
|
||||
about: 'About EMA',
|
||||
services: 'Services',
|
||||
system: 'Our System',
|
||||
contact: 'Contact',
|
||||
},
|
||||
|
||||
auth: {
|
||||
login: 'Login',
|
||||
signup: 'Sign Up',
|
||||
dashboard: 'Go to Dashboard',
|
||||
},
|
||||
|
||||
hero: {
|
||||
eyebrow: 'Federal Democratic Republic of Ethiopia',
|
||||
title: 'Ethiopian Maritime Authority',
|
||||
subtitle: 'Digital Maritime Services for Seafarers, Vessel Owners and Logistics Operators',
|
||||
description:
|
||||
'Apply, upload documents and track every application from one secure account — built for seafarers, vessel owners and logistics operators across Ethiopia.',
|
||||
badges: {
|
||||
secure: 'Secure & Verified',
|
||||
bilingual: 'English & አማርኛ',
|
||||
tracking: 'Real-Time Tracking',
|
||||
},
|
||||
},
|
||||
|
||||
about: {
|
||||
title: 'About EMA',
|
||||
visionLabel: 'Vision',
|
||||
vision:
|
||||
'To make Ethiopia the leading logistics performer in Africa and one of the five supplying seafarer nations in the world by 2030.',
|
||||
missionLabel: 'Mission',
|
||||
mission:
|
||||
"Transform Ethiopia's logistics system and unlock the blue economy — strengthening legal frameworks, building infrastructure, and ensuring vessel safety and seafarer qualification.",
|
||||
},
|
||||
|
||||
cta: {
|
||||
getStarted: 'Get Started',
|
||||
},
|
||||
|
||||
quickAccess: {
|
||||
title: 'Quick Access',
|
||||
subtitle: 'The most requested services, one click away.',
|
||||
vesselRegistration: {
|
||||
title: 'Vessel Registration',
|
||||
description: 'Register a vessel or transfer ownership.',
|
||||
},
|
||||
notices: {
|
||||
title: 'Marine Notices',
|
||||
description: 'Official notices to seafarers, owners and operators.',
|
||||
},
|
||||
},
|
||||
|
||||
services: {
|
||||
title: 'Maritime Services',
|
||||
subtitle: 'Every licence, certificate and registration EMA issues, in one digital system.',
|
||||
items: {
|
||||
seafarerRegistration: {
|
||||
title: 'Seafarer Registration & Certification',
|
||||
description:
|
||||
"Register as a seafarer, apply for a Certificate of Competency or Proficiency, and manage your seaman's book online.",
|
||||
},
|
||||
vesselRegistration: {
|
||||
title: 'Vessel Registration',
|
||||
description:
|
||||
'Register inland or sea-going vessels and manage ownership transfers with full document tracking.',
|
||||
},
|
||||
licensing: {
|
||||
title: 'Operator Licensing',
|
||||
description:
|
||||
'Apply for freight forwarder, shipping agent, combined and multimodal transport operator licences.',
|
||||
},
|
||||
examinations: {
|
||||
title: 'Examinations',
|
||||
description: 'Sit competency examinations and track your results as part of certification.',
|
||||
},
|
||||
waivers: {
|
||||
title: 'Waivers',
|
||||
description: 'Apply for a maritime waiver where standard requirements do not apply.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
roles: {
|
||||
title: 'Built for Every User',
|
||||
subtitle: 'One portal, tailored to what you do.',
|
||||
seafarers: {
|
||||
title: 'Seafarers',
|
||||
description: 'Register, certify, and manage your sea service records.',
|
||||
},
|
||||
vesselOwners: {
|
||||
title: 'Vessel Owners',
|
||||
description: 'Register vessels and manage ownership and licensing.',
|
||||
},
|
||||
agents: {
|
||||
title: 'Agents & Logistics Operators',
|
||||
description: 'Apply for and renew operator licences for freight and shipping.',
|
||||
},
|
||||
reviewers: {
|
||||
title: 'EMA Reviewers',
|
||||
description: 'Review, verify and approve applications from the backoffice.',
|
||||
},
|
||||
},
|
||||
|
||||
howItWorks: {
|
||||
title: 'How It Works',
|
||||
subtitle: 'From application to approval, in five steps.',
|
||||
steps: {
|
||||
selectRole: 'Select role',
|
||||
createAccount: 'Create account',
|
||||
submitApplication: 'Submit application',
|
||||
trackStatus: 'Track status',
|
||||
receiveApproval: 'Receive approval',
|
||||
},
|
||||
},
|
||||
|
||||
system: {
|
||||
title: 'A Modern Digital System',
|
||||
subtitle: 'Built to make maritime services faster, safer and accessible from anywhere.',
|
||||
items: {
|
||||
secure: {
|
||||
title: 'Secure & Verified',
|
||||
description: 'Every application and certificate is digitally recorded and verifiable.',
|
||||
},
|
||||
bilingual: {
|
||||
title: 'Bilingual by Design',
|
||||
description: 'Use the system fully in English or Amharic — switch anytime.',
|
||||
},
|
||||
tracking: {
|
||||
title: 'Real-Time Tracking',
|
||||
description: 'Follow your application from submission to approval, step by step.',
|
||||
},
|
||||
singleAccount: {
|
||||
title: 'One Account, Every Service',
|
||||
description: 'Register once and access all EMA licensing and certification services.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
faq: {
|
||||
title: 'Frequently Asked Questions',
|
||||
items: {
|
||||
whatIsPortal: {
|
||||
question: 'What is the EMA portal?',
|
||||
answer:
|
||||
"The EMA portal is the Ethiopian Maritime Authority's official digital system for seafarer registration, vessel registration and licensing.",
|
||||
},
|
||||
whoCanUse: {
|
||||
question: 'Who can use this portal?',
|
||||
answer:
|
||||
'Seafarers, vessel owners, shipping agents, freight forwarders and logistics operators can all apply for and manage their licences here.',
|
||||
},
|
||||
howToApply: {
|
||||
question: 'How do I apply for a licence or certificate?',
|
||||
answer:
|
||||
'Create an account, select your role, and follow the application wizard for the licence or certificate you need. You can track its status at any time.',
|
||||
},
|
||||
isFree: {
|
||||
question: 'Is registration free?',
|
||||
answer:
|
||||
'Creating a portal account is free. Statutory fees apply to specific licences and certificates, and are shown before you submit an application.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
contact: {
|
||||
title: 'Contact EMA',
|
||||
subtitle: 'Reach the Ethiopian Maritime Authority head office.',
|
||||
addressLabel: 'Address',
|
||||
address: 'Meskel Square, behind Hyatt Regency Hotel, Sunshine Building No. 4, Addis Ababa, Ethiopia',
|
||||
phoneLabel: 'Phone',
|
||||
websiteLabel: 'Website',
|
||||
},
|
||||
|
||||
footer: {
|
||||
rights: 'All rights reserved.',
|
||||
},
|
||||
};
|
||||
|
||||
export type LandingCopy = typeof landingEn;
|
||||
|
||||
export const landingAm: LandingCopy = {
|
||||
meta: {
|
||||
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
|
||||
},
|
||||
|
||||
nav: {
|
||||
home: 'ዋና ገጽ',
|
||||
about: 'ስለ ባለስልጣኑ',
|
||||
services: 'አገልግሎቶች',
|
||||
system: 'ስርዓታችን',
|
||||
contact: 'እኛን ያግኙ',
|
||||
},
|
||||
|
||||
auth: {
|
||||
login: 'ግባ',
|
||||
signup: 'ይመዝገቡ',
|
||||
dashboard: 'ወደ ዳሽቦርድ ይሂዱ',
|
||||
},
|
||||
|
||||
hero: {
|
||||
eyebrow: 'የኢትዮጵያ ፌዴራላዊ ዲሞክራሲያዊ ሪፐብሊክ',
|
||||
title: 'የኢትዮጵያ ማሪታይም ባለስልጣን',
|
||||
subtitle: 'ለመርከበኞች፣ ለመርከብ ባለቤቶች እና ለሎጂስቲክስ ኦፕሬተሮች የተዘጋጁ ዲጂታል የባህር አገልግሎቶች',
|
||||
description:
|
||||
'ከመለያዎ ማመልከቻ ያስገቡ፣ ሰነድ ይስቀሉ እንዲሁም ሁኔታ ይከታተሉ — ለመርከበኞች፣ ለመርከብ ባለቤቶችና ለሎጂስቲክስ ኦፕሬተሮች የተዘጋጀ ነው።',
|
||||
badges: {
|
||||
secure: 'ደህንነቱ የተጠበቀ',
|
||||
bilingual: 'አማርኛና እንግሊዝኛ',
|
||||
tracking: 'የቀጥታ ክትትል',
|
||||
},
|
||||
},
|
||||
|
||||
about: {
|
||||
title: 'ስለ ባለስልጣኑ',
|
||||
visionLabel: 'ራዕይ',
|
||||
vision:
|
||||
'በ2030 ኢትዮጵያ በአፍሪካ ግንባር ቀደም የሎጂስቲክስ አገልግሎት ሰጪ እንድትሆን፣ እንዲሁም ከዓለም አምስት መርከበኞችን ወደ ውጭ ከሚልኩ ሀገራት አንዷ እንድትሆን ማድረግ።',
|
||||
missionLabel: 'ተልዕኮ',
|
||||
mission:
|
||||
'የኢትዮጵያን የሎጂስቲክስ ስርዓት መለወጥ እንዲሁም ከባህር ኢኮኖሚ ተጠቃሚ መሆን — በህግ ማዕቀፎች ማጠናከር፣ መሠረተ ልማት በመገንባትና የመርከብ ደህንነትንና የመርከበኛ ብቃትን በማረጋገጥ።',
|
||||
},
|
||||
|
||||
cta: {
|
||||
getStarted: 'ይጀምሩ',
|
||||
},
|
||||
|
||||
quickAccess: {
|
||||
title: 'ፈጣን መዳረሻ',
|
||||
subtitle: 'በብዛት የሚፈለጉ አገልግሎቶች፣ በአንድ ቦታ።',
|
||||
vesselRegistration: {
|
||||
title: 'የመርከብ ምዝገባ',
|
||||
description: 'መርከብ ይመዝገቡ ወይም ባለቤትነት ያስተላልፉ።',
|
||||
},
|
||||
notices: {
|
||||
title: 'የባህር ማስታወቂያዎች',
|
||||
description: 'ለመርከበኞች፣ ለመርከብ ባለቤቶችና ለኦፕሬተሮች የተሰጡ ማስታወቂያዎች።',
|
||||
},
|
||||
},
|
||||
|
||||
services: {
|
||||
title: 'የባህር አገልግሎቶች',
|
||||
subtitle: 'ባለስልጣኑ የሚሰጣቸው ሁሉም ፈቃድ፣ ምስክር ወረቀትና ምዝገባ በአንድ ዲጂታል ስርዓት ውስጥ።',
|
||||
items: {
|
||||
seafarerRegistration: {
|
||||
title: 'የመርከበኞች ምዝገባና ማረጋገጫ',
|
||||
description:
|
||||
'እንደ መርከበኛ ይመዝገቡ፣ ለብቃት ወይም ችሎታ ማረጋገጫ ምስክር ወረቀት ያመልክቱ፣ እንዲሁም የመርከበኛ መዝገብ መጽሐፍዎን በመስመር ላይ ያስተዳድሩ።',
|
||||
},
|
||||
vesselRegistration: {
|
||||
title: 'የመርከብ ምዝገባ',
|
||||
description: 'የውስጥ ውሃ ወይም የባህር ማዶ መርከቦችን ይመዝገቡ፣ የባለቤትነት ዝውውርንም ሙሉ በሙሉ በሰነድ ክትትል ያስተዳድሩ።',
|
||||
},
|
||||
licensing: {
|
||||
title: 'የኦፕሬተር ፈቃድ',
|
||||
description: 'ለጭነት አስተላላፊ፣ ለመርከብ ወኪል፣ ለተቀናጀ እና ለብዙ-ዘዴ ትራንስፖርት ኦፕሬተር ፈቃድ ያመልክቱ።',
|
||||
},
|
||||
examinations: {
|
||||
title: 'ፈተናዎች',
|
||||
description: 'የብቃት ፈተናዎችን ይውሰዱ እንዲሁም ውጤቶችዎን እንደ ማረጋገጫ ሂደት አካል ይከታተሉ።',
|
||||
},
|
||||
waivers: {
|
||||
title: 'ነፃ ፈቃዶች',
|
||||
description: 'መደበኛ መስፈርቶች በማይሟሉበት ጊዜ ለባህር ትራንስፖርት ነፃ ፈቃድ ያመልክቱ።',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
roles: {
|
||||
title: 'ለሁሉም ተጠቃሚ የተዘጋጀ',
|
||||
subtitle: 'አንድ ፖርታል፣ ለሚናዎ የተዘጋጀ።',
|
||||
seafarers: {
|
||||
title: 'መርከበኞች',
|
||||
description: 'ይመዝገቡ፣ ይረጋገጡ እንዲሁም የባህር አገልግሎት መዝገብዎን ያስተዳድሩ።',
|
||||
},
|
||||
vesselOwners: {
|
||||
title: 'የመርከብ ባለቤቶች',
|
||||
description: 'መርከብ ይመዝገቡ እንዲሁም ባለቤትነትና ፈቃድ ያስተዳድሩ።',
|
||||
},
|
||||
agents: {
|
||||
title: 'ወኪሎችና ሎጂስቲክስ ኦፕሬተሮች',
|
||||
description: 'ለጭነትና ለመላኪያ ፈቃድ ያመልክቱ እንዲሁም ያድሱ።',
|
||||
},
|
||||
reviewers: {
|
||||
title: 'የባለስልጣኑ ገምጋሚዎች',
|
||||
description: 'ማመልከቻዎችን ይገመግሙ፣ ያረጋግጡ እንዲሁም ይፍቀዱ።',
|
||||
},
|
||||
},
|
||||
|
||||
howItWorks: {
|
||||
title: 'እንዴት እንደሚሰራ',
|
||||
subtitle: 'ከማመልከቻ እስከ ፈቃድ፣ በአምስት ደረጃዎች።',
|
||||
steps: {
|
||||
selectRole: 'ሚና ይምረጡ',
|
||||
createAccount: 'መለያ ይፍጠሩ',
|
||||
submitApplication: 'ማመልከቻ ያስገቡ',
|
||||
trackStatus: 'ሁኔታ ይከታተሉ',
|
||||
receiveApproval: 'ፍቃድ ይቀበሉ',
|
||||
},
|
||||
},
|
||||
|
||||
system: {
|
||||
title: 'ዘመናዊ ዲጂታል ስርዓት',
|
||||
subtitle: 'የባህር አገልግሎቶችን ፈጣን፣ ደህንነቱ የተጠበቀና ከየትኛውም ቦታ ተደራሽ ለማድረግ የተዘጋጀ።',
|
||||
items: {
|
||||
secure: {
|
||||
title: 'ደህንነቱ የተጠበቀ እና የተረጋገጠ',
|
||||
description: 'እያንዳንዱ ማመልከቻና ምስክር ወረቀት በዲጂታል መልኩ ተመዝግቦ ሊረጋገጥ የሚችል ነው።',
|
||||
},
|
||||
bilingual: {
|
||||
title: 'በሁለት ቋንቋ የተዘጋጀ',
|
||||
description: 'ስርዓቱን ሙሉ በሙሉ በአማርኛ ወይም በእንግሊዝኛ ይጠቀሙ — በማንኛውም ጊዜ ይቀይሩ።',
|
||||
},
|
||||
tracking: {
|
||||
title: 'የቀጥታ ሁኔታ ክትትል',
|
||||
description: 'ማመልከቻዎን ከማስገባት እስከ ማጽደቅ ደረጃ በደረጃ ይከታተሉ።',
|
||||
},
|
||||
singleAccount: {
|
||||
title: 'አንድ መለያ፣ ሁሉም አገልግሎት',
|
||||
description: 'አንድ ጊዜ ይመዝገቡና ሁሉንም የEMA ፈቃድና የምስክር ወረቀት አገልግሎቶች ይድረሱ።',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
faq: {
|
||||
title: 'ተደጋጋሚ ጥያቄዎች',
|
||||
items: {
|
||||
whatIsPortal: {
|
||||
question: 'EMA ፖርታል ምንድን ነው?',
|
||||
answer:
|
||||
'EMA ፖርታል የኢትዮጵያ ማሪታይም ባለስልጣን ለመርከበኛ ምዝገባ፣ ለመርከብ ምዝገባና ፈቃድ የሚጠቀምበት ዲጂታል ስርዓት ነው።',
|
||||
},
|
||||
whoCanUse: {
|
||||
question: 'ይህን ፖርታል ማን ሊጠቀም ይችላል?',
|
||||
answer: 'መርከበኞች፣ የመርከብ ባለቤቶች፣ ወኪሎችና ሎጂስቲክስ ኦፕሬተሮች ሁሉም እዚህ ፈቃዳቸውን ማመልከትና ማስተዳደር ይችላሉ።',
|
||||
},
|
||||
howToApply: {
|
||||
question: 'ለፈቃድ ወይም ለምስክር ወረቀት እንዴት አመለክታለሁ?',
|
||||
answer:
|
||||
'መለያ ይፍጠሩ፣ ሚናዎን ይምረጡ፣ እንዲሁም የሚያስፈልግዎትን ፈቃድ ወይም ምስክር ወረቀት ደረጃዎች ይከተሉ። ሁኔታውን በማንኛውም ጊዜ መከታተል ይችላሉ።',
|
||||
},
|
||||
isFree: {
|
||||
question: 'ምዝገባ ነፃ ነው?',
|
||||
answer: 'የፖርታል መለያ መክፈት ነፃ ነው። ለተወሰኑ ፈቃዶችና ምስክር ወረቀቶች የመንግስት ክፍያ ይኖራል፣ ማመልከቻ ከማስገባትዎ በፊት ይታያል።',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
contact: {
|
||||
title: 'ባለስልጣኑን ያግኙ',
|
||||
subtitle: 'የኢትዮጵያ ማሪታይም ባለስልጣን ዋና መሥሪያ ቤትን ያግኙ።',
|
||||
addressLabel: 'አድራሻ',
|
||||
address: 'መስቀል አደባባይ፣ ከHyatt Regency ሆቴል ጀርባ፣ Sunshine ህንፃ ቁጥር 4፣ አዲስ አበባ፣ ኢትዮጵያ',
|
||||
phoneLabel: 'ስልክ',
|
||||
websiteLabel: 'ድረ ገጽ',
|
||||
},
|
||||
|
||||
footer: {
|
||||
rights: 'ሁሉም መብቶች የተጠበቁ ናቸው።',
|
||||
},
|
||||
};
|
||||
59
libs/ui/src/lib/landing/landing.css
Normal file
59
libs/ui/src/lib/landing/landing.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/* Public landing page — scoped under .ema-landing so nothing leaks into the
|
||||
rest of either app. Neither app's global CSS (portal's .ema-page-enter /
|
||||
.ema-hover-lift, portal's --ema-surface-*) is available here, so this file
|
||||
is self-contained. */
|
||||
|
||||
/* `scroll-behavior` only affects the element that actually scrolls — for a
|
||||
full page that's `html`, not this div, so it has to live here. Scoped with
|
||||
:has() so it only applies while the landing page is mounted. */
|
||||
html:has(.ema-landing) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.ema-landing section[id] {
|
||||
/* Offsets anchor jumps by the sticky header height so the heading isn't
|
||||
hidden underneath it. Keep in sync with the header's fixed height. */
|
||||
scroll-margin-top: 72px;
|
||||
}
|
||||
|
||||
.ema-landing .ema-landing-fade {
|
||||
animation: ema-landing-fade-up 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes ema-landing-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ema-landing .ema-landing-hover {
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
.ema-landing .ema-landing-hover:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
/* Amharic runs 20-40% longer than English and falls back to a different font
|
||||
(Inter has no Ethiopic glyphs), so it needs more vertical room. */
|
||||
.ema-landing:lang(am) {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html:has(.ema-landing) {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
.ema-landing .ema-landing-fade,
|
||||
.ema-landing .ema-landing-hover {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,10 @@ export function ColorSchemeToggle() {
|
||||
border: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
||||
e.currentTarget.style.background = 'var(--mantine-primary-color-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-primary-color-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-primary-color-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
|
||||
@@ -38,10 +38,10 @@ export function LanguageSwitcher({ supportedLanguages, variant = 'icon' }: Langu
|
||||
border: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
||||
e.currentTarget.style.background = 'var(--mantine-primary-color-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-primary-color-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-primary-color-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user