feat(portal): restore Mengestab's client-approved seafarer and vessel UI

Copied verbatim from the pre-override branch so the approved screens are
recoverable at this exact commit before any wiring changes them.

Brings back the richer flows the client signed off: a four-step seafarer
registration wizard with bilingual inputs and an Ethiopic date picker,
the vessel-owner portal (its own register/login/dashboard), ownership
transfer, and the seaman book, certificate, medical and endorsement
screens.

Six of these pages already call an API; ten are mockups carrying
hardcoded data. Both are committed as-is here -- the wiring that follows
is a separate commit so the diff shows exactly what changed from what
the client approved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fitse-yotor
2026-08-16 23:19:08 +03:00
parent f294f67ced
commit eabaae36a0
28 changed files with 7142 additions and 2839 deletions

View File

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

View File

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

View File

@@ -1,21 +1,437 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { useRef, useState } from 'react';
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',
};
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: 'Sexual Harassment Prevention', short: 'SHPT', course: 'EMA National' },
];
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);
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) => (
<List.Item key={c.short}>
<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>
</Group>
</List.Item>
))}
</List>
</Paper>
{/* Info */}
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="xs">
<IconCalendar size={16} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">Validity & Renewal</Text>
</Group>
<Box>
<Text fz="xs" c="dimmed" lh={1.6}>
BST certificates are typically valid for <strong>5 years</strong>. PST and FPFF components
require evidence of maintained competence at the 5-year point (STCW Reg. VI/1).
EFA and PSSR do not have a mandatory 5-year revalidation under STCW but your
institution's combined certificate carries a unified expiry date.
Certificates must be from <strong>EMA-approved training institutions</strong>.
</Text>
</Box>
</Paper>
<UploadModal
opened={modalOpen}
onClose={() => setModalOpen(false)}
onUploaded={(rec) => setRecord(rec)}
/>
</Stack>
);
}

View File

@@ -0,0 +1,272 @@
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,
rem,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconArrowRight,
IconBook2,
IconCertificate,
IconClock,
IconDownload,
IconEye,
IconInfoCircle,
IconShieldCheck,
} from '@tabler/icons-react';
import { authStorage } from '@ema-platform/auth';
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const MOCK_COC_APPS = [
{
id: 'COC-APP-2025-001',
type: 'CoC — STCW II/1 Officer in Charge of Navigational Watch',
submitted: '2025-03-10',
examDate: '2025-04-15',
examVenue: 'EMA HQ — Addis Ababa',
status: 'Examination Scheduled',
statusColor: 'indigo',
statusNote: 'TRB inspected and approved by EMA officer. Attend your scheduled examination.',
},
{
id: 'COC-APP-2025-005',
type: 'CoC — STCW II/5 Able Seafarer Deck (AB)',
submitted: '2025-05-01',
examDate: null,
examVenue: null,
status: 'TRB Inspection',
statusColor: 'yellow',
statusNote: 'Your TRB is being physically inspected by an EMA officer. You may be contacted to bring the original document.',
},
];
const MOCK_CERTIFICATES = [
{
id: 'COC-2023-0042',
type: 'CoC — STCW II/1',
issued: '2023-06-20',
expiry: '2028-06-20',
status: 'Valid',
statusColor: 'teal',
},
];
const API_BASE =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/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 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>
<Button
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/certificates/apply')}
>
Apply for CoC / CoP
</Button>
</Group>
{/* 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>
{MOCK_COC_APPS.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>
{MOCK_COC_APPS.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">{app.submitted}</Text></Table.Td>
<Table.Td>
{app.examDate
? <><Text fz="xs" fw={500}>{app.examDate}</Text><Text fz="xs" c="dimmed">{app.examVenue}</Text></>
: <Text fz="xs" c="dimmed" maw={200} lh={1.3}>{app.statusNote}</Text>}
</Table.Td>
<Table.Td>
<Badge color={app.statusColor} variant="light" size="sm">{app.status}</Badge>
</Table.Td>
<Table.Td>
<Text fz="xs" c="blue" style={{ cursor: 'pointer' }}>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>
{MOCK_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">
{MOCK_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={cert.statusColor} variant="light">{cert.status}</Badge>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{cert.issued}</Text></div>
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{cert.expiry}</Text></div>
</SimpleGrid>
<Group mt="sm" gap="xs">
<Button size="xs" variant="light" leftSection={loading ? <Loader size={12} /> : <IconEye size={12} />} onClick={() => openPreview(profileId, cert.type)}>View</Button>
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />} onClick={() => handleDownload(profileId, cert.type)}>Download</Button>
</Group>
</Card>
))}
</SimpleGrid>
)}
</Paper>
{/* Preview modal */}
<Modal
opened={!!previewUrl}
onClose={() => setPreviewUrl(null)}
title={<Text fw={700} fz="sm">{previewTitle}</Text>}
size="95vw"
radius="lg"
fullScreen
>
<iframe
src={previewUrl ?? ''}
style={{ width: '100%', height: '90vh', border: 'none', borderRadius: 8 }}
title={previewTitle}
/>
</Modal>
</Stack>
);
}

View File

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

View File

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

View File

@@ -1,255 +1,436 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
FileInput,
Group,
List,
Loader,
Modal,
Paper,
SimpleGrid,
Stack,
Table,
Stepper,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconArrowLeft,
IconArrowRight,
IconCertificate,
IconCheck,
IconCircleCheck,
IconCircleX,
IconClock,
IconDownload,
IconEye,
IconFileDescription,
IconInfoCircle,
IconRubberStamp,
IconShieldCheck,
IconUpload,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import {
STATUS_COLORS,
STATUS_LABELS,
TERMINAL_STATUSES,
extractErrorMessage,
useLocalized,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
} from '@ema-platform/api';
import { useCurrentProfile } from '@ema-platform/auth';
import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
// ---------------------------------------------------------------------------
// Mock data — existing endorsement applications
// ---------------------------------------------------------------------------
const MOCK_ENDORSEMENTS = [
{
id: 'END-APP-2025-001',
cocType: 'Officer in Charge of a Navigational Watch (STCW II/1)',
foreignCocNo: 'PHL-COC-2022-0045',
issuingCountry: 'Philippines',
submitted: '2025-04-05',
status: 'Document Verification',
statusColor: 'blue',
statusNote: 'EMA is verifying your documents. You will be notified when verification is complete.',
},
];
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>
);
const MOCK_ISSUED = [
{
id: 'EMA-END-2024-012',
cocType: 'Chief Mate — STCW II/2',
foreignCocNo: 'GRC-COC-2019-0033',
issuingCountry: 'Greece',
endorsementNo: 'EMA-END-2024-012',
issued: '2024-08-10',
expiry: '2029-06-15',
status: 'Valid',
statusColor: 'teal',
},
];
// blank PDF
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
// ---------------------------------------------------------------------------
// Application wizard
// ---------------------------------------------------------------------------
interface Docs {
foreignCoc: File | null;
translation: File | null;
medical: File | null;
seamanBook: File | null;
photo: File | null;
}
/**
* 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();
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 registered =
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
const step0Ok = !!cocNo && !!issuer && !!country && !!cocType && !!issueDate && !!expiryDate;
const step1Ok = !!docs.foreignCoc && !!docs.medical && !!docs.seamanBook && !!docs.photo;
const endorsementApplications = (applications?.items ?? []).filter((app) =>
ENDORSEMENT_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
);
const inFlight = endorsementApplications.filter(
(app) => !TERMINAL_STATUSES.includes(app.status),
);
const issued = (licenses?.items ?? []).filter((license) =>
ENDORSEMENT_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
);
async function download(licenseId: string) {
try {
const result = await getCertificateUrl(licenseId).unwrap();
window.open(result.url, '_blank', 'noopener');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not fetch endorsement'));
}
}
if (loadingProfile || loadingApplications) {
if (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);
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 1015 working days after all documents are verified.' },
].map(({ icon: Icon, color, title, desc }) => (
<Card key={title} withBorder radius="md" p="sm">
<Group gap="xs" mb={4}>
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
<Text fz="xs" fw={700}>{title}</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
</Card>
))}
</SimpleGrid>
</Stack>
</Group>
</Paper>
{/* Active applications */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Endorsement Applications</Text>
{MOCK_ENDORSEMENTS.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No active endorsement applications.
</Alert>
) : (
<Stack gap="sm">
{MOCK_ENDORSEMENTS.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 {app.submitted}</Text>
</div>
</Group>
<Group gap="xs">
<Badge color={app.statusColor} variant="light">{app.status}</Badge>
<Text fz="xs" c="blue.7" fw={600}>{app.id}</Text>
</Group>
</Group>
<Alert variant="light" color={app.statusColor} icon={<IconInfoCircle size={13} />} p="xs" mt="sm">
<Text fz="xs">{app.statusNote}</Text>
</Alert>
</Paper>
))}
</Stack>
)}
</Paper>
{/* Issued endorsements */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Endorsements</Text>
{MOCK_ISSUED.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No endorsements issued yet.
</Alert>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{MOCK_ISSUED.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={end.statusColor} variant="light">{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>
);
}

View File

@@ -1,21 +1,335 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { useRef, useState } from 'react';
import {
Alert,
Badge,
Box,
Button,
Card,
FileButton,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Timeline,
Title,
rem,
} 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>
);
// ---------------------------------------------------------------------------
// Mock current certificate — replace with real API data
// ---------------------------------------------------------------------------
const MOCK_CURRENT: MedicalCert | null = {
id: 'MC-2024-001',
issuedBy: 'EMA Approved Medical Center — Addis Ababa',
issuedDate: '2024-03-15',
expiryDate: '2026-03-14',
status: 'Expiring',
restrictions: 'None',
fileName: 'medical_cert_2024.pdf',
};
const MOCK_HISTORY: MedicalCert[] = [
{ id: 'MC-2022-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2022-03-10', expiryDate: '2024-03-09', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2022.pdf' },
{ id: 'MC-2020-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2020-02-20', expiryDate: '2022-02-19', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2020.pdf' },
];
interface MedicalCert {
id: string;
issuedBy: string;
issuedDate: string;
expiryDate: string;
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending';
restrictions: string;
fileName: string;
}
export default MedicalCertificatePage;
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() {
const [current] = useState<MedicalCert | null>(MOCK_CURRENT);
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);
const days = current ? 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 */}
{MOCK_HISTORY.length > 0 && (
<Paper withBorder radius="lg" p="lg">
<Text fw={700} mb="md">Certificate History</Text>
<Stack gap="xs">
{MOCK_HISTORY.map((cert) => (
<Card key={cert.id} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="wrap" gap="xs">
<Group gap="sm">
<ThemeIcon variant="light" color="gray" size={32} radius="md">
<IconFileDescription size={16} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>{cert.id}</Text>
<Text fz="xs" c="dimmed">{formatDate(cert.issuedDate)} {formatDate(cert.expiryDate)}</Text>
</div>
</Group>
<Group gap="xs">
<Badge color={STATUS_COLOR[cert.status]} variant="light" size="sm">{cert.status}</Badge>
<Button size="xs" variant="subtle" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
</Group>
</Card>
))}
</Stack>
</Paper>
)}
</Stack>
);
}

View File

@@ -1,111 +0,0 @@
import { ActionIcon, Group, Tooltip } from '@mantine/core';
import { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
export function seaServiceActionsColumn(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
onEvidence: (record: SeaServiceRecord) => void;
onEdit: (record: SeaServiceRecord) => void;
onDelete: (record: SeaServiceRecord) => void;
}): AdvancedColumn<SeaServiceRecord> {
return {
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => {
const record = row.original;
const locked = record.status !== 'SUBMITTED';
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Evidence">
<ActionIcon
variant="subtle"
onClick={() => handlers.onEvidence(record)}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
{handlers.can([PORTAL_PERMISSIONS.EDIT_SEA_SERVICE]) && (
<>
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => handlers.onEdit(record)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => handlers.onDelete(record)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
);
},
};
}
export function medicalActionsColumn(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
onEvidence: (certificate: MedicalCertificate) => void;
onEdit: (certificate: MedicalCertificate) => void;
onDelete: (certificate: MedicalCertificate) => void;
}): AdvancedColumn<MedicalCertificate> {
return {
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => {
const certificate = row.original;
const locked = certificate.status !== 'SUBMITTED';
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Scan / evidence">
<ActionIcon
variant="subtle"
onClick={() => handlers.onEvidence(certificate)}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
{handlers.can([PORTAL_PERMISSIONS.UPLOAD_MEDICAL]) && (
<>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => handlers.onEdit(certificate)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => handlers.onDelete(certificate)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
);
},
};
}

View File

@@ -1,117 +0,0 @@
import { Badge, Group, Text, Tooltip } from '@mantine/core';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
};
export const FITNESS_OPTIONS = [
{ value: 'FIT', label: 'Fit' },
{ value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' },
{ value: 'UNFIT', label: 'Unfit' },
];
export function seaServiceColumns(
showDate: (date: string) => string,
): AdvancedColumn<SeaServiceRecord>[] {
return [
{
header: 'Vessel',
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.vesselName}
</Text>
{row.original.imoNumber && (
<Text size="xs" c="dimmed">
IMO {row.original.imoNumber}
</Text>
)}
</>
),
},
{ header: 'Rank', accessorKey: 'rank' },
{
header: 'From',
accessorKey: 'engagementDate',
cell: ({ row }) => showDate(row.original.engagementDate),
},
{
header: 'To',
accessorKey: 'dischargeDate',
cell: ({ row }) => showDate(row.original.dischargeDate),
},
{
header: 'Status',
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
</Tooltip>
),
},
];
}
export function medicalColumns(
showDate: (date: string) => string,
): AdvancedColumn<MedicalCertificate>[] {
const today = new Date().toISOString().slice(0, 10);
return [
{
header: 'Issuer',
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.issuerName}
</Text>
{row.original.certificateNumber && (
<Text size="xs" c="dimmed">
{row.original.certificateNumber}
</Text>
)}
</>
),
},
{
header: 'Issued',
accessorKey: 'issueDate',
cell: ({ row }) => showDate(row.original.issueDate),
},
{
header: 'Expires',
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
{showDate(row.original.expiryDate)}
{row.original.expiryDate < today && <Badge color="red">Expired</Badge>}
</Group>
),
},
{
header: 'Fitness',
cell: ({ row }) =>
FITNESS_OPTIONS.find((o) => o.value === row.original.fitnessStatus)
?.label ?? row.original.fitnessStatus,
},
{
header: 'Status',
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
</Tooltip>
),
},
];
}

View File

@@ -1,637 +0,0 @@
import {
Alert,
Anchor,
Badge,
Button,
Card,
FileButton,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Tabs,
Text,
TextInput,
Textarea,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconFileUpload,
IconInfoCircle,
IconPaperclip,
IconPlus,
IconStethoscope,
} from '@tabler/icons-react';
import { useState } from 'react';
import { AdvancedTable, AmharicDatePicker, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
uploadDocument,
useCreateMedicalCertificateMutation,
useCreateSeaServiceRecordMutation,
useDeleteMedicalCertificateMutation,
useDeleteSeaServiceRecordMutation,
useGetAttachmentsQuery,
useGetMyMedicalCertificatesQuery,
useGetMySeaServiceRecordsQuery,
useGetMySeaTimeQuery,
useUpdateMedicalCertificateMutation,
useUpdateSeaServiceRecordMutation,
} from '@ema-platform/api';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
import {
PORTAL_PERMISSIONS,
RequirePermission,
usePermissions,
} from '@ema-platform/auth';
import { seaServiceColumns, medicalColumns, FITNESS_OPTIONS } from './columns';
import { seaServiceActionsColumn, medicalActionsColumn } from './actions';
/**
* Evidence viewer/uploader shared by both record kinds.
*
* Files upload against the record itself (`SEA_SERVICE_RECORD` /
* `MEDICAL_CERTIFICATE` owner types), so the officer verifying it later opens
* exactly what the seafarer attached.
*/
function EvidenceModal({
ownerType,
ownerId,
onClose,
}: {
ownerType: 'SEA_SERVICE_RECORD' | 'MEDICAL_CERTIFICATE';
ownerId: string | null;
onClose: () => void;
}) {
const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery(
{ ownerType, ownerId: ownerId ?? '' },
{ skip: !ownerId },
);
const [uploading, setUploading] = useState(false);
const upload = async (file: File | null) => {
if (!file || !ownerId) return;
setUploading(true);
const result = await uploadDocument({
ownerType,
ownerId,
documentKey: 'evidence',
file,
});
setUploading(false);
if (result.ok) {
notify.success('Evidence uploaded');
refetch();
} else {
notify.error(result.error);
}
};
const files = (attachments ?? []).flatMap((a) => a.files);
return (
<Modal opened={Boolean(ownerId)} onClose={onClose} title="Evidence" centered>
<Stack>
{isLoading ? (
<Loader size="sm" />
) : files.length === 0 ? (
<Text size="sm" c="dimmed">
No evidence uploaded yet.
</Text>
) : (
files.map((file) => (
<Group key={file.id} gap="xs">
<IconPaperclip size={16} />
{file.url ? (
<Anchor href={file.url} target="_blank" size="sm">
{file.originalName}
</Anchor>
) : (
<Text size="sm">{file.originalName}</Text>
)}
</Group>
))
)}
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
<FileButton onChange={upload} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
loading={uploading}
leftSection={<IconFileUpload size={16} />}
>
Upload evidence
</Button>
)}
</FileButton>
</RequirePermission>
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------- sea service
const EMPTY_SEA_SERVICE = {
vesselName: '',
imoNumber: '',
vesselType: '',
flagState: '',
rank: '',
engagementDate: '',
dischargeDate: '',
dutiesDescription: '',
};
function SeaServiceTab() {
const showDate = useDateDisplayer();
const { can } = usePermissions();
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
const { data: seaTime } = useGetMySeaTimeQuery();
const [createRecord, { isLoading: creating }] =
useCreateSeaServiceRecordMutation();
const [updateRecord, { isLoading: updating }] =
useUpdateSeaServiceRecordMutation();
const [deleteRecord] = useDeleteSeaServiceRecordMutation();
const [editing, setEditing] = useState<SeaServiceRecord | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_SEA_SERVICE);
const [grossTonnage, setGrossTonnage] = useState<number | ''>('');
const openCreate = () => {
setEditing(null);
setForm(EMPTY_SEA_SERVICE);
setGrossTonnage('');
setModalOpen(true);
};
const openEdit = (record: SeaServiceRecord) => {
setEditing(record);
setForm({
vesselName: record.vesselName,
imoNumber: record.imoNumber ?? '',
vesselType: record.vesselType ?? '',
flagState: record.flagState ?? '',
rank: record.rank,
engagementDate: record.engagementDate,
dischargeDate: record.dischargeDate,
dutiesDescription: record.dutiesDescription ?? '',
});
setGrossTonnage(record.grossTonnage ? Number(record.grossTonnage) : '');
setModalOpen(true);
};
const save = async () => {
const body = {
vesselName: form.vesselName,
rank: form.rank,
engagementDate: form.engagementDate,
dischargeDate: form.dischargeDate,
...(form.imoNumber ? { imoNumber: form.imoNumber } : {}),
...(form.vesselType ? { vesselType: form.vesselType } : {}),
...(form.flagState ? { flagState: form.flagState } : {}),
...(form.dutiesDescription
? { dutiesDescription: form.dutiesDescription }
: {}),
...(grossTonnage !== '' ? { grossTonnage: Number(grossTonnage) } : {}),
};
try {
if (editing) {
await updateRecord({ id: editing.id, body }).unwrap();
notify.success('Sea-service record updated');
} else {
await createRecord(body).unwrap();
notify.success('Sea-service record added');
}
setModalOpen(false);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the record'));
}
};
const remove = async (record: SeaServiceRecord) => {
try {
await deleteRecord(record.id).unwrap();
notify.success('Record withdrawn');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not delete the record'));
}
};
const valid =
form.vesselName.trim().length > 1 &&
form.rank.trim().length > 1 &&
form.engagementDate &&
form.dischargeDate &&
form.engagementDate < form.dischargeDate;
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
const page = paginate(records ?? []);
const columns = [
...seaServiceColumns(showDate),
seaServiceActionsColumn({
can,
onEvidence: (record) => setEvidenceFor(record.id),
onEdit: openEdit,
onDelete: remove,
}),
];
return (
<Stack>
<Group justify="space-between">
<Group gap="sm">
<Text size="sm" c="dimmed">
Every engagement aboard a vessel, with its evidence. Verified
records feed certificate eligibility.
</Text>
{seaTime && seaTime.verifiedRecords > 0 && (
<Badge variant="light" color="teal">
Approved sea time: {seaTime.totalDays} days
</Badge>
)}
</Group>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.ADD_SEA_SERVICE]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add sea service
</Button>
</RequirePermission>
</Group>
{(records ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md">
<Text c="dimmed" ta="center">
No sea-service records yet.
</Text>
</Paper>
) : (
<Card withBorder padding={0}>
<AdvancedTable
columns={columns}
data={page.rows}
tableName="Sea service"
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
isLoading={isLoading}
refresh={refetch}
/>
</Card>
)}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Edit sea service' : 'Add sea service'}
centered
size="lg"
>
<Stack>
<Group grow>
<TextInput
label="Vessel name"
required
value={form.vesselName}
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
/>
<TextInput
label="IMO number"
value={form.imoNumber}
onChange={(e) => setForm({ ...form, imoNumber: e.target.value })}
/>
</Group>
<Group grow>
<TextInput
label="Vessel type"
value={form.vesselType}
onChange={(e) => setForm({ ...form, vesselType: e.target.value })}
/>
<TextInput
label="Flag state"
value={form.flagState}
onChange={(e) => setForm({ ...form, flagState: e.target.value })}
/>
<NumberInput
label="Gross tonnage"
min={0}
value={grossTonnage}
onChange={(v) => setGrossTonnage(typeof v === 'number' ? v : '')}
/>
</Group>
<TextInput
label="Rank / capacity"
required
value={form.rank}
onChange={(e) => setForm({ ...form, rank: e.target.value })}
/>
<Group grow>
<AmharicDatePicker
label="Engagement date"
required
value={form.engagementDate}
onChange={(val) =>
setForm({ ...form, engagementDate: val })
}
dateFormat="date"
/>
<AmharicDatePicker
label="Discharge date"
required
value={form.dischargeDate}
onChange={(val) =>
setForm({ ...form, dischargeDate: val })
}
dateFormat="date"
/>
</Group>
<Textarea
label="Duties"
value={form.dutiesDescription}
onChange={(e) =>
setForm({ ...form, dutiesDescription: e.target.value })
}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
>
{editing ? 'Save changes' : 'Add record'}
</Button>
</Group>
</Stack>
</Modal>
<EvidenceModal
ownerType="SEA_SERVICE_RECORD"
ownerId={evidenceFor}
onClose={() => setEvidenceFor(null)}
/>
</Stack>
);
}
// -------------------------------------------------------------------- medical
const EMPTY_MEDICAL = {
issuerName: '',
certificateNumber: '',
issueDate: '',
expiryDate: '',
fitnessStatus: 'FIT',
restrictions: '',
};
function MedicalTab() {
const showDate = useDateDisplayer();
const { can } = usePermissions();
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
const [createCertificate, { isLoading: creating }] =
useCreateMedicalCertificateMutation();
const [updateCertificate, { isLoading: updating }] =
useUpdateMedicalCertificateMutation();
const [deleteCertificate] = useDeleteMedicalCertificateMutation();
const [editing, setEditing] = useState<MedicalCertificate | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_MEDICAL);
const openCreate = () => {
setEditing(null);
setForm(EMPTY_MEDICAL);
setModalOpen(true);
};
const openEdit = (certificate: MedicalCertificate) => {
setEditing(certificate);
setForm({
issuerName: certificate.issuerName,
certificateNumber: certificate.certificateNumber ?? '',
issueDate: certificate.issueDate,
expiryDate: certificate.expiryDate,
fitnessStatus: certificate.fitnessStatus,
restrictions: certificate.restrictions ?? '',
});
setModalOpen(true);
};
const save = async () => {
const body = {
issuerName: form.issuerName,
issueDate: form.issueDate,
expiryDate: form.expiryDate,
fitnessStatus: form.fitnessStatus as MedicalCertificate['fitnessStatus'],
...(form.certificateNumber
? { certificateNumber: form.certificateNumber }
: {}),
...(form.restrictions ? { restrictions: form.restrictions } : {}),
};
try {
if (editing) {
await updateCertificate({ id: editing.id, body }).unwrap();
notify.success('Medical certificate updated');
} else {
await createCertificate(body).unwrap();
notify.success('Medical certificate added');
}
setModalOpen(false);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the certificate'));
}
};
const remove = async (certificate: MedicalCertificate) => {
try {
await deleteCertificate(certificate.id).unwrap();
notify.success('Certificate withdrawn');
} catch (error) {
notify.error(
extractErrorMessage(error, 'Could not delete the certificate'),
);
}
};
const valid =
form.issuerName.trim().length > 1 &&
form.issueDate &&
form.expiryDate &&
form.issueDate < form.expiryDate;
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
const page = paginate(certificates ?? []);
const columns = [
...medicalColumns(showDate),
medicalActionsColumn({
can,
onEvidence: (certificate) => setEvidenceFor(certificate.id),
onEdit: openEdit,
onDelete: remove,
}),
];
return (
<Stack>
<Group justify="space-between">
<Text size="sm" c="dimmed">
STCW medical fitness certificates. An expired certificate blocks new
applications that require one.
</Text>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_MEDICAL]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add certificate
</Button>
</RequirePermission>
</Group>
{(certificates ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md">
<Text c="dimmed" ta="center">
No medical certificates yet.
</Text>
</Paper>
) : (
<Card withBorder padding={0}>
<AdvancedTable
columns={columns}
data={page.rows}
tableName="Medical certificates"
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
isLoading={isLoading}
refresh={refetch}
/>
</Card>
)}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Edit medical certificate' : 'Add medical certificate'}
centered
size="lg"
>
<Stack>
<Group grow>
<TextInput
label="Issuing clinic / physician"
required
value={form.issuerName}
onChange={(e) => setForm({ ...form, issuerName: e.target.value })}
/>
<TextInput
label="Certificate number"
value={form.certificateNumber}
onChange={(e) =>
setForm({ ...form, certificateNumber: e.target.value })
}
/>
</Group>
<Group grow>
<AmharicDatePicker
label="Issue date"
required
value={form.issueDate}
onChange={(val) => setForm({ ...form, issueDate: val })}
dateFormat="date"
/>
<AmharicDatePicker
label="Expiry date"
required
value={form.expiryDate}
onChange={(val) => setForm({ ...form, expiryDate: val })}
dateFormat="date"
/>
</Group>
<Select
label="Fitness outcome"
data={FITNESS_OPTIONS}
value={form.fitnessStatus}
onChange={(v) => setForm({ ...form, fitnessStatus: v ?? 'FIT' })}
/>
{form.fitnessStatus === 'FIT_WITH_RESTRICTIONS' && (
<Textarea
label="Restrictions"
value={form.restrictions}
onChange={(e) =>
setForm({ ...form, restrictions: e.target.value })
}
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
>
{editing ? 'Save changes' : 'Add certificate'}
</Button>
</Group>
</Stack>
</Modal>
<EvidenceModal
ownerType="MEDICAL_CERTIFICATE"
ownerId={evidenceFor}
onClose={() => setEvidenceFor(null)}
/>
</Stack>
);
}
/**
* The seafarer's evidence shelf (US-SSM-001/006): sea-service history and
* medical certificates, each with uploaded evidence, editable until an
* officer verifies them.
*/
export function MySeaRecordsPage() {
return (
<Stack>
<Title order={2}>My Sea Records</Title>
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={16} />}
>
Records you add here are submitted for EMA verification. Once verified
they are frozen and count toward certificate eligibility.
</Alert>
<Tabs defaultValue="sea-service" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
Sea Service
</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
Medical Certificates
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="sea-service" pt="md">
<SeaServiceTab />
</Tabs.Panel>
<Tabs.Panel value="medical" pt="md">
<MedicalTab />
</Tabs.Panel>
</Tabs>
</Stack>
);
}

View File

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

View File

@@ -1,268 +1,577 @@
import { useRef, useState } from 'react';
import { useApiMutation } from '@ema-platform/api';
import { useAppSelector } from '../../../store/hooks';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
List,
Loader,
Paper,
Progress,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconAddressBook,
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,
IconCamera,
IconCheck,
IconCircleCheck,
IconClipboardList,
IconFileDescription,
IconId,
IconInfoCircle,
IconSchool,
IconUser,
} 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';
import { notify } from '@ema-platform/ui';
import { BilingualInput } from '../../../components/BilingualInput';
import type { BilingualValue } from '../../../components/BilingualInput';
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
import { LocationPicker } from '../../location/components/LocationPicker';
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
const DEPARTMENT_LABELS: Record<string, string> = {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const NATIONALITIES = [
'Ethiopian', 'Eritrean', 'Djiboutian', 'Kenyan', 'Somali', 'Sudanese', 'Other',
];
const MARITAL_STATUSES = ['Single', 'Married', 'Divorced', 'Widowed'];
const GENDERS = ['Male', 'Female'];
const RELATIONSHIPS = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other'];
const SEAFARER_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
PENDING: 'yellow',
SUSPENDED: 'orange',
INACTIVE: 'gray',
};
const STEPS = [
{ label: 'Personal Information' },
{ label: 'Contact Details' },
{ label: 'Documents Upload' },
{ label: 'Review & Submit' },
];
/**
* 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();
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
}
// 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]);
const DOC_SLOTS: DocSlot[] = [
{ key: 'nationalId', label: 'National ID (Front & Back)', description: 'Both sides of your national identity card', required: true, icon: IconId },
{ key: 'passport', label: 'Passport Copy', description: 'Bio-data page of valid passport', required: false, icon: IconFileDescription },
{ key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification', required: false, icon: IconSchool },
{ key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5cm', required: true, icon: IconCamera },
];
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
// ---------------------------------------------------------------------------
// Step indicator
// ---------------------------------------------------------------------------
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>
<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>
</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>
{status === 'SUSPENDED' && profile.seafarerStatusReason && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
Your profile is suspended: {profile.seafarerStatusReason}
</Alert>
)}
</Stack>
</Card>
<Paper withBorder radius="md" p="lg">
<Group justify="space-between">
<div>
<Text fw={600}>Sea service &amp; medical records</Text>
<Text size="sm" c="dimmed">
Keep your sea-service history and medical certificates up to
date certificate and seaman-book applications draw on them.
</Text>
</div>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/seafarer/records')}
>
My records
);
})}
</Group>
</Box>
);
}
// ---------------------------------------------------------------------------
// Section heading
// ---------------------------------------------------------------------------
function SectionHead({ title }: { title: string }) {
return (
<>
<Divider mt="md" mb="xs" />
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
</>
);
}
// ---------------------------------------------------------------------------
// Review row
// ---------------------------------------------------------------------------
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>
);
}
// ---------------------------------------------------------------------------
// Document upload card
// ---------------------------------------------------------------------------
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 SeafarerRegistrationPage() {
const navigate = useNavigate();
const user = useAppSelector((state) => state.auth.user);
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
const [profileTrigger] = useApiMutation<{ id: string }>();
const [addressTrigger] = useApiMutation<unknown>();
// Step 1 — Personal Information
const [firstName, setFirstName] = useState<BilingualValue>({ en: '', am: '' });
const [middleName, setMiddleName] = useState<BilingualValue>({ en: '', am: '' });
const [lastName, setLastName] = useState<BilingualValue>({ en: '', am: '' });
const [gender, setGender] = useState<string | null>(null);
const [dob, setDob] = useState<Date | null>(null);
const [placeOfBirth, setPlaceOfBirth] = useState('');
const [nationality, setNationality] = useState<string | null>('Ethiopian');
const [maritalStatus, setMaritalStatus] = useState<string | null>(null);
const [nationalIdNumber, setNationalIdNumber] = useState('');
const [passportNumber, setPassportNumber] = useState('');
const [passportExpiry, setPassportExpiry] = useState('');
// Step 2 — Contact Details
const [mobile, setMobile] = useState('');
const [email, setEmail] = useState('');
const [locationId, setLocationId] = useState<string | null>(null);
const [permanentAddress, setPermanentAddress] = useState('');
const [currentAddress, setCurrentAddress] = useState('');
const [emergencyName, setEmergencyName] = useState('');
const [emergencyRel, setEmergencyRel] = useState<string | null>(null);
const [emergencyPhone, setEmergencyPhone] = useState('');
// Step 3 — Documents
const [files, setFiles] = useState<Record<string, File | null>>({
nationalId: null, passport: null, graduation: null, photo: null,
});
const setFile = (key: string) => (f: File | null) =>
setFiles((prev) => ({ ...prev, [key]: f }));
const canNext = () => {
if (active === 0) return !!firstName.en.trim() && !!lastName.en.trim() && !!gender && !!dob && !!placeOfBirth && !!nationality && !!nationalIdNumber.trim();
if (active === 1) return !!mobile.trim() && !!email.trim() && !!locationId;
if (active === 2) return !!files.nationalId && !!files.photo;
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 {
const profileResult = await profileTrigger({
url: '/profiles',
method: 'POST',
body: {
userId: user?.id,
type: 'SEAFARER',
firstName: firstName.en,
middleName: middleName.en || undefined,
lastName: lastName.en,
gender: gender?.toUpperCase() ?? 'MALE',
dob: dob?.toISOString().split('T')[0] ?? '',
pob: placeOfBirth || undefined,
maritalStatus: maritalStatus?.toUpperCase() ?? 'SINGLE',
},
}).unwrap();
await addressTrigger({
url: `/addresss/profile/${profileResult.id}`,
method: 'POST',
body: {
idType: 'NID',
idNumber: nationalIdNumber,
nationality: nationality ?? 'Ethiopian',
primaryPhoneNumber: mobile,
email: email || undefined,
streetAddress: permanentAddress || undefined,
emergencyContactName: emergencyName || undefined,
emergencyContactPhone: emergencyPhone || undefined,
emergencyContactRelation: emergencyRel || undefined,
},
}).unwrap();
notify.success(`Registration submitted! Profile ID: ${profileResult.id.slice(0, 8).toUpperCase()}`);
navigate('/seafarer-registry');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
const stepLabel = STEPS[active]?.label ?? '';
const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck];
const StepIcon = stepIcons[active];
return (
<Stack gap="md">
{/* Page header */}
<div>
<Title order={3}>New Seafarer Registration</Title>
<Text fz="sm" c="dimmed">Register a new seafarer profile Step {active + 1} of {STEPS.length}</Text>
</div>
{/* Step indicator */}
<StepIndicator active={active} completed={completed} />
{/* Card */}
<Paper withBorder radius="lg" p="xl">
{/* Card header */}
<Group justify="space-between" mb="lg">
<Group gap="xs">
<StepIcon size={20} stroke={1.6} />
<Text fw={700} fz="lg">{stepLabel}</Text>
</Group>
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
</Group>
{/* ── Step 1: Personal Information ───────────────────────────── */}
{active === 0 && (
<Stack gap="md">
<SectionHead title="Identity Details" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<BilingualInput label="First Name" required value={firstName} onChange={setFirstName} />
<BilingualInput label="Middle Name" value={middleName} onChange={setMiddleName} />
<BilingualInput label="Last Name" required value={lastName} onChange={setLastName} />
<Select label="Gender" placeholder="Select" required data={GENDERS} value={gender} onChange={setGender} />
<AmharicDatePicker label="Date of Birth" required value={dob} onChange={setDob} />
<TextInput label="Place of Birth" placeholder="City, Region" required value={placeOfBirth} onChange={(e) => setPlaceOfBirth(e.currentTarget.value)} />
<Select label="Nationality" required data={NATIONALITIES} value={nationality} onChange={setNationality} searchable />
<Select label="Marital Status" placeholder="Select" data={MARITAL_STATUSES} value={maritalStatus} onChange={setMaritalStatus} />
</SimpleGrid>
<SectionHead title="Identity Documents" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="National ID Number" placeholder="ET-000000" required value={nationalIdNumber} onChange={(e) => setNationalIdNumber(e.currentTarget.value)} />
<TextInput label="Passport Number" placeholder="EP000000" value={passportNumber} onChange={(e) => setPassportNumber(e.currentTarget.value)} />
<TextInput label="Passport Expiry Date" type="date" value={passportExpiry} onChange={(e) => setPassportExpiry(e.currentTarget.value)} />
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
A unique Seafarer ID will be automatically generated upon approval of this registration.
</Alert>
</Stack>
)}
{/* ── Step 2: Contact Details ─────────────────────────────────── */}
{active === 1 && (
<Stack gap="md">
<SectionHead title="Contact Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Mobile Number" placeholder="+251 9XX XXX XXX" required value={mobile} onChange={(e) => setMobile(e.currentTarget.value)} />
<TextInput label="Email Address" placeholder="email@example.com" type="email" required value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Location" />
<LocationPicker
value={locationId ?? undefined}
onChange={setLocationId}
required
/>
<SectionHead title="Address" />
<Textarea label="Permanent Address" placeholder="Full permanent address" autosize minRows={2} value={permanentAddress} onChange={(e) => setPermanentAddress(e.currentTarget.value)} />
<Textarea
label={<><Text span fz="sm" fw={500}>Current Address</Text><Text span fz="xs" c="dimmed" ml={6}>(If different from permanent)</Text></>}
placeholder="Full current address"
autosize
minRows={2}
value={currentAddress}
onChange={(e) => setCurrentAddress(e.currentTarget.value)}
/>
<SectionHead title="Emergency Contact" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Contact Name" placeholder="Full name" value={emergencyName} onChange={(e) => setEmergencyName(e.currentTarget.value)} />
<Select label="Relationship" placeholder="Select" data={RELATIONSHIPS} value={emergencyRel} onChange={setEmergencyRel} />
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" value={emergencyPhone} onChange={(e) => setEmergencyPhone(e.currentTarget.value)} />
</SimpleGrid>
</Stack>
)}
{/* ── Step 3: Documents Upload ────────────────────────────────── */}
{active === 2 && (
<Stack gap="md">
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
Please upload clear, readable copies of all required documents. Accepted formats: PDF, JPG, PNG (max 5MB each). Items marked with * are mandatory.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<DocCard
key={slot.key}
slot={slot}
file={files[slot.key]}
onFile={setFile(slot.key)}
/>
))}
</SimpleGrid>
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fw={600} fz="sm" mb="sm">Upload Progress</Text>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap={6} align="center">
{files[slot.key] ? (
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
) : (
<Box style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)' }} />
)}
<Text fz="xs" c={files[slot.key] ? 'teal.7' : 'dimmed'} fw={files[slot.key] ? 600 : 400}>
{slot.key === 'nationalId' ? 'National ID' : slot.key === 'passport' ? 'Passport' : slot.key === 'graduation' ? 'Certificate' : 'Photo'}
</Text>
</Group>
))}
</SimpleGrid>
</Paper>
</Stack>
)}
{/* ── Step 4: Review & Submit ─────────────────────────────────── */}
{active === 3 && (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Personal Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="First Name" value={`${firstName.en}${firstName.am ? ` / ${firstName.am}` : ''}`} />
<ReviewRow label="Middle Name" value={`${middleName.en}${middleName.am ? ` / ${middleName.am}` : ''}`} />
<ReviewRow label="Last Name" value={`${lastName.en}${lastName.am ? ` / ${lastName.am}` : ''}`} />
<ReviewRow label="Gender" value={gender ?? ''} />
<ReviewRow label="Date of Birth" value={`${dob?.toLocaleDateString('en-US') ?? ''}${dob ? ` (${toEthiopicDateLabel(dob)})` : ''}`} />
<ReviewRow label="Place of Birth" value={placeOfBirth} />
<ReviewRow label="Nationality" value={nationality ?? ''} />
<ReviewRow label="Marital Status" value={maritalStatus ?? ''} />
<ReviewRow label="National ID No." value={nationalIdNumber} />
<ReviewRow label="Passport No." value={passportNumber} />
<ReviewRow label="Passport Expiry" value={passportExpiry} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Contact Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Mobile" value={mobile} />
<ReviewRow label="Email" value={email} />
<ReviewRow label="Location" value={locationId ?? ''} />
<ReviewRow label="Permanent Address" value={permanentAddress} />
<ReviewRow label="Current Address" value={currentAddress} />
</SimpleGrid>
{emergencyName && (
<>
<Divider mt="md" mb="sm" />
<Text fw={600} fz="sm" mb="sm">Emergency Contact</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Name" value={emergencyName} />
<ReviewRow label="Relationship" value={emergencyRel ?? ''} />
<ReviewRow label="Phone" value={emergencyPhone} />
</SimpleGrid>
</>
)}
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap="xs" align="center">
{files[slot.key] ? (
<IconCircleCheck size={18} color="var(--mantine-color-teal-6)" />
) : (
<Box style={{ width: 18, height: 18, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)', flexShrink: 0 }} />
)}
<div>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label}
{slot.required && !files[slot.key] && <Text span c="red" ml={3}>*</Text>}
</Text>
{files[slot.key] && (
<Text fz="xs" c="dimmed" truncate maw={160}>{files[slot.key]!.name}</Text>
)}
</div>
</Group>
))}
</SimpleGrid>
</Paper>
</Stack>
)}
{/* Navigation buttons */}
<Group justify="space-between" mt="xl">
<Button variant="default" onClick={() => navigate('/applications')}>
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={<IconCircleCheck size={16} />}
onClick={handleSubmit}
loading={submitting}
>
Submit Registration
</Button>
)}
</Group>
</Group>
</Paper>
</Stack>
);
}
// -------------------------------------------------------------- in flight
if (registration && !TERMINAL_STATUSES.includes(registration.status)) {
const isDraft = registration.status === 'DRAFT';
const needsAction = registration.status === 'RESUBMIT_REQUIRED';
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
<Card withBorder radius="md" p="xl">
<Stack>
<Group justify="space-between">
<div>
<Text fw={700}>{registration.applicationNumber}</Text>
<Text size="sm" c="dimmed">
Submitted registrations are reviewed by an EMA registration
officer; you will be notified of every decision.
</Text>
</div>
<Badge color={STATUS_COLORS[registration.status]} size="lg">
{STATUS_LABELS[registration.status]}
</Badge>
</Group>
<Progress value={STATUS_PROGRESS[registration.status]} />
{needsAction && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
The registration officer asked for corrections. Open the
application to see exactly what needs fixing.
</Alert>
)}
<Group>
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() =>
navigate(
isDraft || needsAction
? `/licensing/${REGISTRATION_TYPE_KEY}/apply`
: `/licensing/${REGISTRATION_TYPE_KEY}/applications/${registration.id}`,
)
}
>
{isDraft
? 'Continue registration'
: needsAction
? 'Fix and resubmit'
: 'View application'}
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}
// ------------------------------------------------------- not yet started
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
{registration?.status === 'REJECTED' && (
<Alert color="red" title="Previous registration rejected">
{registration.rejectionReason ??
'Your previous registration was rejected. You may register again.'}
</Alert>
)}
<Card withBorder radius="md" p="xl">
<Stack>
<Group>
<IconAnchor size={32} color="var(--mantine-color-blue-6)" />
<div>
<Text fw={700} size="lg">
Register as a seafarer
</Text>
<Text c="dimmed" size="sm">
Approval creates your official seafarer profile with a unique
seafarer number the identity every maritime service builds on.
</Text>
</div>
</Group>
<Text fw={600} size="sm" mt="sm">
You will need:
</Text>
<List
size="sm"
spacing={4}
icon={<IconClipboardList size={16} color="var(--mantine-color-blue-5)" />}
>
<List.Item>A passport-size photograph</List.Item>
<List.Item>Your National ID (Fayda) or Kebele ID</List.Item>
<List.Item>Your educational certificate</List.Item>
<List.Item>
A medical fitness certificate and passport, if you already hold
them
</List.Item>
</List>
<Group mt="md">
<RequirePermission
anyOf={[PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]}
hideOnly
>
<Button
size="md"
rightSection={<IconArrowRight size={18} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
>
Start registration
</Button>
</RequirePermission>
</Group>
</Stack>
</Card>
</Stack>
);
}

View File

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

View File

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

View File

@@ -1,12 +1,310 @@
import { Navigate } from 'react-router-dom';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Stepper,
Text,
ThemeIcon,
Timeline,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconBook2,
IconCheck,
IconCircleCheck,
IconClock,
IconFileDescription,
IconHeart,
IconInfoCircle,
IconPrinter,
IconShield,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
/**
* The seaman book rides the config-driven licensing flow, like every other
* issued document, so this route forwards to it rather than duplicating the
* wizard. Kept as a route because the nav and older links point here.
*/
export function SeamanBookPage() {
return <Navigate to="/licensing/SEAMAN_BOOK/apply" replace />;
// ---------------------------------------------------------------------------
// Mock data — replace with real API
// ---------------------------------------------------------------------------
const ELIGIBILITY = {
hasProfile: true,
hasNationalId: true,
hasMedicalCert: true,
medicalExpiry: '2026-03-14',
bstComplete: true,
bstItems: [
{ label: 'Personal Survival Techniques (PST)', done: true },
{ label: 'Fire Prevention & Fire Fighting (FPFF)', done: true },
{ label: 'Elementary First Aid (EFA)', done: true },
{ label: 'Personal Safety & Social Responsibility (PSSR)', done: true },
{ label: 'Sexual Harassment Prevention', done: true },
],
};
const MOCK_APPLICATION: SeamanBookApp | null = null;
interface SeamanBookApp {
id: string;
submittedAt: string;
status: string;
remarks: string;
timeline: { date: string | null; event: string; done: boolean }[];
}
export default SeamanBookPage;
const STATUS_COLOR: Record<string, string> = {
'Under Review': 'yellow',
'Approved': 'teal',
'Rejected': 'red',
'Correction Required': 'orange',
'Ready for Collection': 'blue',
};
function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
return (
<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>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SeamanBookPage() {
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(!!MOCK_APPLICATION);
const bstDone = ELIGIBILITY.bstItems.filter((b) => b.done).length;
const isEligible =
ELIGIBILITY.hasProfile &&
ELIGIBILITY.hasNationalId &&
ELIGIBILITY.hasMedicalCert &&
ELIGIBILITY.bstComplete;
const handleApply = async () => {
setSubmitting(true);
await new Promise((r) => setTimeout(r, 1400));
setSubmitting(false);
setSubmitted(true);
notify.success('Seaman Book application submitted successfully! Reference: SB-APP-2024-002');
};
const activeStep = MOCK_APPLICATION
? MOCK_APPLICATION.timeline.filter((t) => t.done).length - 1
: -1;
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 */}
{MOCK_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 {MOCK_APPLICATION.id}</Text>
<Text fz="xs" c="dimmed">Submitted {MOCK_APPLICATION.submittedAt}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[MOCK_APPLICATION.status] ?? 'gray'} variant="light" size="lg">
{MOCK_APPLICATION.status}
</Badge>
</Group>
{MOCK_APPLICATION.remarks && (
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="md" p="sm">
<Text fz="sm">{MOCK_APPLICATION.remarks}</Text>
</Alert>
)}
{/* Progress stepper */}
<Stepper active={activeStep} size="sm" color="teal">
{MOCK_APPLICATION.timeline.map((step, i) => (
<Stepper.Step
key={i}
label={step.event}
description={step.date ?? 'Pending'}
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
/>
))}
</Stepper>
{MOCK_APPLICATION.status === 'Ready for Collection' && (
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
Your Seaman Book is ready. Please visit the EMA office to collect it. Bring 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={ELIGIBILITY.hasProfile} />
<EligibilityItem label="National ID / Fayda uploaded" ok={ELIGIBILITY.hasNationalId} />
<EligibilityItem label="Valid medical certificate uploaded" ok={ELIGIBILITY.hasMedicalCert} />
<Divider label="Basic Safety Training (all 5 required)" labelPosition="left" my={4} />
{ELIGIBILITY.bstItems.map((item) => (
<EligibilityItem key={item.label} label={item.label} ok={item.done} />
))}
{!isEligible && (
<Alert variant="light" color="orange" icon={<IconAlertCircle size={15} />} mt="xs" p="sm">
<Text fz="xs">
Complete all requirements above before applying. Missing BST: {5 - bstDone} certificate(s).
</Text>
</Alert>
)}
{isEligible && (
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />} mt="xs" p="sm">
<Text fz="xs">You meet all requirements. You may proceed with your application.</Text>
</Alert>
)}
</Stack>
</Paper>
{/* Application form */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconFileDescription size={18} />
</ThemeIcon>
<Text fw={700}>New Application</Text>
</Group>
<Stack gap="sm">
<Text fz="sm" c="dimmed" lh={1.6}>
Upon submitting your application, EMA Registration Officers will verify your profile,
documents, medical certificate, and Basic Safety Training certificates. You will be
notified at each stage by email and SMS.
</Text>
<Divider />
<Text fw={600} fz="sm">What will be verified:</Text>
<Stack gap={6}>
{[
'Full seafarer profile',
'National ID / Fayda authenticity',
'Medical certificate validity',
'All 5 Basic Safety Training certificates',
'Passport size photo',
].map((item) => (
<Group key={item} gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="sm">{item}</Text>
</Group>
))}
</Stack>
<Divider />
<SimpleGrid cols={2} spacing="xs">
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconClock size={15} color="var(--mantine-color-blue-6)" />
<div>
<Text fz="xs" c="dimmed">Processing time</Text>
<Text fz="sm" fw={600}>57 working days</Text>
</div>
</Group>
</Card>
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconHeart size={15} color="var(--mantine-color-red-6)" />
<div>
<Text fz="xs" c="dimmed">Medical validity</Text>
<Text fz="sm" fw={600}>2 years (STCW)</Text>
</div>
</Group>
</Card>
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Text fz="xs">
Application fee will be communicated during the review process. Payment can be made online or at the EMA office.
</Text>
</Alert>
<Button
leftSection={<IconBook2 size={16} />}
onClick={() => navigate('/seaman-book/apply')}
loading={submitting}
disabled={!isEligible}
size="md"
>
Start Application
</Button>
{!isEligible && (
<Text fz="xs" c="dimmed" ta="center">
Complete all eligibility requirements to enable this button.
</Text>
)}
</Stack>
</Paper>
</SimpleGrid>
)}
{/* Info box */}
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="sm">
<IconInfoCircle size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">About the Seaman Book</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{ icon: IconBook2, title: 'Official Identity', desc: 'Internationally recognized maritime identity document required before joining any vessel.' },
{ icon: IconFileDescription, title: 'Service Record', desc: 'Records all your sea service, vessel assignments, and employment history.' },
{ icon: IconShield, title: 'STCW Compliance', desc: 'Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.' },
].map(({ icon: Icon, title, desc }) => (
<Box key={title}>
<Group gap="xs" mb={4}>
<Icon size={16} color="var(--mantine-color-blue-6)" />
<Text fz="sm" fw={600}>{title}</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.5}>{desc}</Text>
</Box>
))}
</SimpleGrid>
</Paper>
</Stack>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,286 +0,0 @@
// ponytail: in-memory mock array, resets on reload — swap for RTK Query endpoint when backend lands.
export type VesselCategory = 'Inland Waterway' | 'Sea-going';
export const VESSEL_TYPES: Record<VesselCategory, string[]> = {
'Inland Waterway': ['Passenger Boat', 'Cargo Barge', 'Ferry', 'Tugboat', 'Fishing Boat'],
'Sea-going': ['Bulk Carrier', 'Container Ship', 'Tanker', 'General Cargo', 'Passenger Ship'],
};
export const ENGINE_TYPES = ['Diesel', 'Inboard', 'Outboard', 'Electric', 'Steam'] as const;
export const HULL_MATERIALS = ['Steel', 'Aluminum', 'Fiberglass', 'Wood', 'Composite'] as const;
export interface RequiredDocSlot {
key: string;
label: string;
minCount?: number;
}
export const REQUIRED_DOCS: Record<VesselCategory, RequiredDocSlot[]> = {
'Inland Waterway': [{ key: 'photos', label: 'Vessel Photos', minCount: 2 }],
'Sea-going': [
{ key: 'photos', label: 'Vessel Photos' },
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale' },
{ key: 'particulars', label: 'Ship Particulars' },
{ key: 'insurance', label: 'Insurance Certificate' },
],
};
export const CERTIFICATES: Record<VesselCategory, string[]> = {
'Inland Waterway': ['Inland Vessel Registration Certificate'],
'Sea-going': [
'Certificate of Nationality',
'Certificate of Ownership',
'Certificate of Registration',
'Minimum Safe Manning Certificate',
],
};
export type RegistrationStatus = 'Pending' | 'Under Review' | 'Correction Required' | 'Approved' | 'Rejected';
export const STATUS_COLOR: Record<RegistrationStatus, string> = {
Pending: 'gray',
'Under Review': 'blue',
'Correction Required': 'orange',
Approved: 'teal',
Rejected: 'red',
};
export type RenewalState = 'OK' | 'Due Soon' | 'Overdue';
export interface RegistrationOwner {
name: string;
idOrTin: string;
phone: string;
email: string;
address: string;
}
export interface RegistrationCertificate {
name: string;
number: string;
issueDate: string;
downloads: number;
}
export interface TimelineStep {
date: string | null;
event: string;
done: boolean;
}
export interface VesselRegistration {
id: string;
category: VesselCategory;
status: RegistrationStatus;
submitted: string;
remarks?: string;
expiryDate?: string;
renewal?: RenewalState;
timeline: TimelineStep[];
certificates?: RegistrationCertificate[];
// Vessel details
vesselName: string;
vesselType: string;
registrationArea: string;
flagState: string;
passengerCapacity?: string;
grossTonnage?: string;
length: string;
breadth: string;
depth: string;
// Technical
imoNumber?: string;
hullNumber?: string;
shipyard: string;
yearBuilt: string;
engineType: string;
engineNumber: string;
enginePower: string;
hullMaterial: string;
// Ownership
owner: RegistrationOwner;
}
const SUBMITTED_STEP = (date: string): TimelineStep => ({ date, event: 'Application Submitted', done: true });
const PENDING_STEP = (event: string): TimelineStep => ({ date: null, event, done: false });
export const MOCK_REGISTRATIONS: VesselRegistration[] = [
{
id: 'VR-2025-0001',
category: 'Sea-going',
status: 'Under Review',
submitted: '2025-06-20',
timeline: [
SUBMITTED_STEP('2025-06-20'),
{ date: '2025-06-22', event: 'Document Verification', done: true },
PENDING_STEP('Inspection'),
PENDING_STEP('Approval'),
],
vesselName: 'MV Nile Star',
vesselType: 'Bulk Carrier',
registrationArea: 'Djibouti Corridor',
flagState: 'Ethiopia',
grossTonnage: '18500',
length: '190',
breadth: '28',
depth: '15',
imoNumber: 'IMO9876543',
shipyard: 'Hyundai Heavy Industries',
yearBuilt: '2016',
engineType: 'Diesel',
engineNumber: 'ENG-44210',
enginePower: '12000 kW',
hullMaterial: 'Steel',
owner: {
name: 'Nile Shipping PLC',
idOrTin: 'TIN-0012345678',
phone: '+251911223344',
email: 'ops@nileshipping.et',
address: 'Bole Sub-city, Addis Ababa',
},
},
{
id: 'VR-2025-0002',
category: 'Inland Waterway',
status: 'Correction Required',
submitted: '2025-06-10',
remarks: 'Vessel photos are blurry — please re-upload at least 2 clear photos showing the hull and registration markings.',
timeline: [
SUBMITTED_STEP('2025-06-10'),
{ date: '2025-06-12', event: 'Document Verification', done: true },
PENDING_STEP('Inspection'),
PENDING_STEP('Approval'),
],
vesselName: 'Tana Ferry 3',
vesselType: 'Ferry',
registrationArea: 'Lake Tana',
flagState: 'Ethiopia',
passengerCapacity: '40',
length: '18',
breadth: '5',
depth: '2',
hullNumber: 'HN-2211',
shipyard: 'Bahir Dar Boat Works',
yearBuilt: '2020',
engineType: 'Outboard',
engineNumber: 'ENG-9931',
enginePower: '150 hp',
hullMaterial: 'Fiberglass',
owner: {
name: 'Getachew Alemu',
idOrTin: 'ID-4455667788',
phone: '+251922334455',
email: 'getachew.alemu@example.com',
address: 'Bahir Dar, Amhara',
},
},
{
id: 'VR-2025-0003',
category: 'Sea-going',
status: 'Approved',
submitted: '2025-04-05',
expiryDate: '2026-08-15',
renewal: 'Due Soon',
timeline: [
SUBMITTED_STEP('2025-04-05'),
{ date: '2025-04-08', event: 'Document Verification', done: true },
{ date: '2025-04-20', event: 'Inspection', done: true },
{ date: '2025-04-28', event: 'Approval', done: true },
],
certificates: [
{ name: 'Certificate of Nationality', number: 'CN-2025-0091', issueDate: '2025-04-28', downloads: 0 },
{ name: 'Certificate of Ownership', number: 'CO-2025-0091', issueDate: '2025-04-28', downloads: 0 },
{ name: 'Certificate of Registration', number: 'CR-2025-0091', issueDate: '2025-04-28', downloads: 0 },
{ name: 'Minimum Safe Manning Certificate', number: 'MSM-2025-0091', issueDate: '2025-04-28', downloads: 0 },
],
vesselName: 'MV Abay Voyager',
vesselType: 'General Cargo',
registrationArea: 'Djibouti Corridor',
flagState: 'Ethiopia',
grossTonnage: '9600',
length: '140',
breadth: '21',
depth: '11',
imoNumber: 'IMO9123456',
shipyard: 'Damen Shipyards',
yearBuilt: '2012',
engineType: 'Diesel',
engineNumber: 'ENG-33012',
enginePower: '7200 kW',
hullMaterial: 'Steel',
owner: {
name: 'Abay Maritime PLC',
idOrTin: 'TIN-0098765432',
phone: '+251933445566',
email: 'contact@abaymaritime.et',
address: 'Kirkos Sub-city, Addis Ababa',
},
},
{
id: 'VR-2025-0004',
category: 'Inland Waterway',
status: 'Rejected',
submitted: '2025-03-02',
remarks: 'Hull number does not match the submitted proof of ownership. Application rejected — please reapply with matching documentation.',
timeline: [
SUBMITTED_STEP('2025-03-02'),
{ date: '2025-03-05', event: 'Document Verification', done: true },
{ date: '2025-03-14', event: 'Inspection', done: true },
{ date: '2025-03-18', event: 'Approval', done: false },
],
vesselName: 'Awash Cargo 1',
vesselType: 'Cargo Barge',
registrationArea: 'Awash River Basin',
flagState: 'Ethiopia',
passengerCapacity: '0',
length: '22',
breadth: '6',
depth: '3',
hullNumber: 'HN-1187',
shipyard: 'Awash River Works',
yearBuilt: '2018',
engineType: 'Inboard',
engineNumber: 'ENG-5567',
enginePower: '210 hp',
hullMaterial: 'Steel',
owner: {
name: 'Selam Tesfaye',
idOrTin: 'ID-2233445566',
phone: '+251944556677',
email: 'selam.tesfaye@example.com',
address: 'Adama, Oromia',
},
},
];
export function addRegistration(
reg: Omit<VesselRegistration, 'id' | 'status' | 'submitted' | 'timeline' | 'certificates'>
): VesselRegistration {
const submitted = new Date().toISOString().slice(0, 10);
const created: VesselRegistration = {
...reg,
id: `VR-2025-${String(MOCK_REGISTRATIONS.length + 1).padStart(4, '0')}`,
status: 'Pending',
submitted,
timeline: [
SUBMITTED_STEP(submitted),
PENDING_STEP('Document Verification'),
PENDING_STEP('Inspection'),
PENDING_STEP('Approval'),
],
};
MOCK_REGISTRATIONS.unshift(created);
return created;
}
// ponytail: in-memory counter, not persisted.
export function recordDownload(regId: string, certName: string): void {
const reg = MOCK_REGISTRATIONS.find((r) => r.id === regId);
const cert = reg?.certificates?.find((c) => c.name === certName);
if (cert) cert.downloads += 1;
}

View File

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

View File

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

View File

@@ -0,0 +1,339 @@
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' },
{ label: 'Approved', done: registration.status === 'Approved' },
].map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
</Group>
))}
</Stack>
</Paper>
)}
{/* Transfer ownership — only when approved */}
{registration.status === 'Approved' && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600} fz="sm">Transfer Ownership</Text>
<Text fz="xs" c="dimmed">Transfer this vessel to a new owner</Text>
</div>
<Button
leftSection={<IconTransferIn size={15} />}
color="violet"
variant="light"
size="sm"
onClick={() => navigate('/vessel-registration/transfer')}
>
Request Transfer
</Button>
</Group>
</Paper>
)}
{/* Certificates section — shown after approval */}
{registration.status === 'Approved' && (
<div>
<Group gap="xs" mb="sm">
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz="md">
{registration.category === 'Sea-going Vessel (International)'
? 'Issued Certificates (4)'
: 'Issued Certificate'}
</Text>
</Group>
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
Your vessel registration has been approved. You may download your certificate(s) below.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{certs.map((cert) => (
<CertificateCard key={cert.label} label={cert.label} description={cert.description} />
))}
</SimpleGrid>
</div>
)}
</>
)}
</Stack>
);
}

View File

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

View File

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

View File

@@ -1,158 +0,0 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Stepper,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconCircleCheck,
IconClock,
IconDownload,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { MOCK_REGISTRATIONS, recordDownload, STATUS_COLOR } from '../mock';
// ponytail: placeholder PDF blob; wire real cert endpoint when backend lands.
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
function downloadCertificate(filename: string) {
const a = document.createElement('a');
a.href = BLANK_PDF;
a.download = filename;
a.click();
}
export function VesselRegistrationStatusPage() {
const navigate = useNavigate();
const showDate = useDateDisplayer();
const { id } = useParams();
const [reg] = useState(() => MOCK_REGISTRATIONS.find((r) => r.id === id) ?? null);
const [, forceUpdate] = useState(0);
if (!reg) {
return (
<Stack gap="md">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
<Alert variant="light" color="red" icon={<IconInfoCircle size={15} />}>Registration not found.</Alert>
</Stack>
);
}
const activeStep = reg.timeline.filter((t) => t.done).length - 1;
const needsCorrection = reg.status === 'Correction Required' || reg.status === 'Rejected';
const handleDownload = (certName: string, certNumber: string) => {
downloadCertificate(`${certName.replace(/\s+/g, '-')}-${certNumber}.pdf`);
recordDownload(reg.id, certName);
forceUpdate((n) => n + 1);
notify.success(`${certName} downloaded.`);
};
return (
<Stack gap="md">
<Group gap="sm">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
<div>
<Title order={3}>{reg.vesselName}</Title>
<Text fz="sm" c="dimmed">{reg.id} {reg.category}</Text>
</div>
</Group>
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShip size={18} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">Registration Status</Text>
<Text fz="xs" c="dimmed">Submitted {reg.submitted}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[reg.status]} variant="light" size="lg">{reg.status}</Badge>
</Group>
{reg.renewal && reg.renewal !== 'OK' && (
<Alert
variant="light"
color={reg.renewal === 'Overdue' ? 'red' : 'orange'}
icon={<IconAlertTriangle size={15} />}
mb="md"
p="sm"
>
<Text fz="sm">
Registration {reg.renewal === 'Overdue' ? 'is overdue for renewal' : 'is due for renewal soon'}
{reg.expiryDate ? ` — expires ${showDate(reg.expiryDate)}` : ''}.
</Text>
</Alert>
)}
{reg.remarks && (
<Alert variant="light" color={needsCorrection ? 'orange' : 'blue'} icon={<IconInfoCircle size={15} />} mb="md" p="sm">
<Text fz="sm" fw={600} mb={2}>Officer Remarks</Text>
<Text fz="sm">{reg.remarks}</Text>
</Alert>
)}
<Stepper active={activeStep} size="sm" color="teal">
{reg.timeline.map((step, i) => (
<Stepper.Step
key={i}
label={step.event}
description={step.date ?? 'Pending'}
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
/>
))}
</Stepper>
{needsCorrection && (
<Group justify="flex-end" mt="md">
<Button color="orange" onClick={() => navigate('/vessel-registrations/apply')}>Resubmit Application</Button>
</Group>
)}
</Paper>
{reg.status === 'Approved' && reg.certificates && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Certificates</Text>
<Stack gap="sm">
{reg.certificates.map((cert) => (
<div key={cert.name}>
<Group justify="space-between" wrap="wrap" gap="sm">
<div>
<Text fw={600} fz="sm">{cert.name}</Text>
<Text fz="xs" c="dimmed">Certificate No. {cert.number} Issued {showDate(cert.issueDate)}</Text>
{cert.downloads > 0 && (
<Text fz="xs" c="dimmed">Downloaded {cert.downloads} time{cert.downloads === 1 ? '' : 's'}</Text>
)}
</div>
<Button
size="xs"
leftSection={<IconDownload size={14} />}
onClick={() => handleDownload(cert.name, cert.number)}
>
Download
</Button>
</Group>
<Divider mt="sm" />
</div>
))}
</Stack>
</Paper>
)}
</Stack>
);
}

View File

@@ -1,233 +0,0 @@
import { useNavigate } from 'react-router-dom';
import {
Badge,
Button,
Card,
Group,
Loader,
Paper,
Progress,
Stack,
Table,
Text,
Title,
Tooltip,
} from '@mantine/core';
import {
IconArrowRight,
IconArrowsExchange,
IconShip,
} from '@tabler/icons-react';
import {
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
useGetMyApplicationsQuery,
useGetMyVesselsQuery,
} from '@ema-platform/api';
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
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',
};
/**
* The vessel owner's ownership-transfer home: transfers in flight, and the
* registered vessels eligible to start one. Same config-driven wizard as
* registration underneath (VESSEL_OWNERSHIP_TRANSFER) — this page mirrors
* VesselRegistrationPage, just a different entry point and no
* certificate/renewal/incident actions, which don't apply here.
*/
export function VesselTransferPage() {
const navigate = useNavigate();
const { data: vessels, isLoading: loadingVessels } = useGetMyVesselsQuery();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const inFlight = (applications?.items ?? []).filter(
(app) =>
app.licenseType?.key === TRANSFER_TYPE_KEY &&
!TERMINAL_STATUSES.includes(app.status),
);
// A transfer moves ownership of a vessel already on the register — nothing
// to transfer without at least one REGISTERED vessel.
const hasTransferableVessel = (vessels ?? []).some(
(v) => v.status === 'REGISTERED',
);
if (loadingVessels || loadingApplications) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Ownership Transfer</Title>
<Tooltip
label="Register a vessel first — there's nothing to transfer yet"
disabled={hasTransferableVessel}
>
<Button
leftSection={<IconArrowsExchange size={16} />}
disabled={!hasTransferableVessel}
onClick={() => navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)}
>
Start transfer
</Button>
</Tooltip>
</Group>
{/* ----------------------------------------------------- in-flight */}
{inFlight.length > 0 && (
<Stack gap="sm">
<Title order={4}>Transfers in progress</Title>
{inFlight.map((app) => {
const isDraft = app.status === 'DRAFT';
const needsAction = app.status === 'RESUBMIT_REQUIRED';
return (
<Card key={app.id} withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={600}>{app.applicationNumber}</Text>
<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/${TRANSFER_TYPE_KEY}/applications/${app.id}`,
)
}
>
{isDraft ? 'Continue' : needsAction ? 'Fix' : 'View'}
</Button>
</Group>
</Group>
</Card>
);
})}
</Stack>
)}
{/* ------------------------------------------------------- vessels */}
<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}>
Ownership can only be transferred for a vessel already on the
register.
</Text>
<Button
mt="xs"
variant="light"
onClick={() => navigate('/vessel-registration')}
>
Go to Vessel Registration
</Button>
</Stack>
</Paper>
) : (
<Table.ScrollContainer minWidth={640}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Registration </Table.Th>
<Table.Th>Vessel</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(vessels ?? []).map((vessel) => {
const canTransfer = vessel.status === 'REGISTERED';
return (
<Table.Tr key={vessel.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{vessel.registrationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{vessel.name}
</Text>
<Text size="xs" c="dimmed">
{vessel.vesselType ?? '—'}
</Text>
</Table.Td>
<Table.Td>
{CATEGORY_LABELS[vessel.category] ?? vessel.category}
</Table.Td>
<Table.Td>
<Badge
size="sm"
color={VESSEL_STATUS_COLORS[vessel.status]}
>
{vessel.status}
</Badge>
</Table.Td>
<Table.Td>
<Group justify="flex-end">
{canTransfer ? (
<Tooltip label="Start an ownership transfer for this vessel">
<Button
size="compact-xs"
variant="light"
leftSection={<IconArrowsExchange size={14} />}
onClick={() =>
navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)
}
>
Transfer
</Button>
</Tooltip>
) : (
<Text size="xs" c="dimmed">
Not transferable
</Text>
)}
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
</Stack>
);
}
export default VesselTransferPage;