ui componenet based on the requirements

This commit is contained in:
mengstabketemaw
2026-06-20 09:44:41 +03:00
parent 1f27a36b90
commit 5d8e749cf7
28 changed files with 8652 additions and 41 deletions

View File

@@ -0,0 +1,446 @@
import { useRef, useState } from 'react';
import {
Alert,
Badge,
Box,
Button,
Card,
FileButton,
Group,
Modal,
Paper,
Progress,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconBook2,
IconCalendar,
IconCheck,
IconCircleCheck,
IconDownload,
IconFileDescription,
IconInfoCircle,
IconRefresh,
IconShield,
IconShieldCheck,
IconTrash,
IconUpload,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
interface BSTItem {
key: string;
label: string;
shortLabel: string;
description: string;
modelCourse: string;
refreshYears: number;
required: boolean;
}
interface BSTRecord {
key: string;
issuer: string;
issueDate: string;
expiryDate: string;
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
certNumber: string;
fileName: string;
}
const BST_ITEMS: BSTItem[] = [
{
key: 'pst',
label: 'Personal Survival Techniques',
shortLabel: 'PST',
description: 'Covers lifeboat/life-raft operation, survival at sea, and distress signals.',
modelCourse: 'IMO 1.19',
refreshYears: 5,
required: true,
},
{
key: 'fpff',
label: 'Fire Prevention & Fire Fighting',
shortLabel: 'FPFF',
description: 'Covers fire prevention, detection, and fire-fighting on board vessels.',
modelCourse: 'IMO 1.20',
refreshYears: 5,
required: true,
},
{
key: 'efa',
label: 'Elementary First Aid',
shortLabel: 'EFA',
description: 'Basic first-aid procedures, CPR, and medical emergency response.',
modelCourse: 'IMO 1.13',
refreshYears: 0,
required: true,
},
{
key: 'pssr',
label: 'Personal Safety & Social Responsibility',
shortLabel: 'PSSR',
description: 'Shipboard safety culture, regulations, and working relationships.',
modelCourse: 'IMO 1.21',
refreshYears: 0,
required: true,
},
{
key: 'shp',
label: 'Sexual Harassment Prevention Training',
shortLabel: 'SHPT',
description: 'Awareness and prevention of harassment in the maritime workplace.',
modelCourse: 'EMA National',
refreshYears: 0,
required: true,
},
];
const MOCK_RECORDS: Record<string, BSTRecord> = {
pst: {
key: 'pst',
issuer: 'Bahirdar Maritime School',
issueDate: '2023-04-10',
expiryDate: '2028-04-09',
status: 'Valid',
certNumber: 'PST-2023-BMS-0421',
fileName: 'pst_certificate.pdf',
},
fpff: {
key: 'fpff',
issuer: 'Bahirdar Maritime School',
issueDate: '2023-04-10',
expiryDate: '2028-04-09',
status: 'Valid',
certNumber: 'FPFF-2023-BMS-0421',
fileName: 'fpff_certificate.pdf',
},
};
const STATUS_COLOR: Record<string, string> = {
Valid: 'teal',
Expiring: 'orange',
Expired: 'red',
'Pending Verification': 'yellow',
};
function daysUntil(dateStr: string) {
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
}
// ---------------------------------------------------------------------------
// Upload modal
// ---------------------------------------------------------------------------
function UploadModal({
item,
opened,
onClose,
onUploaded,
}: {
item: BSTItem | null;
opened: boolean;
onClose: () => void;
onUploaded: (key: string, 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) {
notify.error('Please fill all required fields and upload the certificate.');
return;
}
setSubmitting(true);
await new Promise((r) => setTimeout(r, 1000));
setSubmitting(false);
onUploaded(item!.key, {
key: item!.key,
issuer,
issueDate,
expiryDate: expiryDate || '',
status: 'Pending Verification',
certNumber,
fileName: file.name,
});
notify.success(`${item!.shortLabel} certificate submitted for verification.`);
reset();
onClose();
};
return (
<Modal opened={opened} onClose={() => { reset(); onClose(); }} title={`Upload ${item?.label}`} size="md" centered>
{item && (
<Stack gap="sm">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Text fz="xs">{item.description} Model Course: <strong>{item.modelCourse}</strong></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. PST-2024-001" required value={certNumber} onChange={(e) => setCertNumber(e.currentTarget.value)} size="sm" />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" required value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} size="sm" />
{item.refreshYears > 0 && (
<TextInput label={`Expiry Date (${item.refreshYears}-yr refresh)`} 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>
{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>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function BasicSafetyTrainingPage() {
const [records, setRecords] = useState<Record<string, BSTRecord>>(MOCK_RECORDS);
const [modalItem, setModalItem] = useState<BSTItem | null>(null);
const doneCount = BST_ITEMS.filter((i) => records[i.key]).length;
const allDone = doneCount === BST_ITEMS.length;
const handleUploaded = (key: string, record: BSTRecord) => {
setRecords((prev) => ({ ...prev, [key]: record }));
};
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Title order={3}>Basic Safety Training</Title>
<Text fz="sm" c="dimmed">
All seafarers must complete 5 mandatory BST certificates before joining a vessel (STCW Chapter VI).
</Text>
</div>
<Badge
size="lg"
variant="light"
color={allDone ? 'teal' : 'orange'}
leftSection={allDone ? <IconShieldCheck size={14} /> : <IconAlertTriangle size={14} />}
>
{doneCount} / {BST_ITEMS.length} Complete
</Badge>
</Group>
{/* Overall progress */}
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="sm">
<Text fw={700}>Overall Completion</Text>
<Text fz="sm" c={allDone ? 'teal' : 'orange'} fw={600}>{Math.round((doneCount / BST_ITEMS.length) * 100)}%</Text>
</Group>
<Progress value={(doneCount / BST_ITEMS.length) * 100} color={allDone ? 'teal' : 'orange'} size="md" radius="xl" mb="md" />
{!allDone && (
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={15} />} p="sm">
<Text fz="sm">
You need all 5 certificates to apply for a Seaman Book. Missing: <strong>{BST_ITEMS.filter((i) => !records[i.key]).map((i) => i.shortLabel).join(', ')}</strong>
</Text>
</Alert>
)}
{allDone && (
<Alert variant="light" color="teal" icon={<IconShieldCheck size={15} />} p="sm">
<Text fz="sm">
All 5 BST training certificates are complete. You can now apply for your <strong>Seaman Book & BTC</strong>
the Basic Training Certificate (BTC) is issued by EMA alongside your Seaman Book after your application is approved.
</Text>
</Alert>
)}
</Paper>
{/* Certificate cards */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{BST_ITEMS.map((item) => {
const rec = records[item.key];
const days = rec?.expiryDate ? daysUntil(rec.expiryDate) : null;
const needsRefresh = item.refreshYears > 0;
return (
<Card
key={item.key}
withBorder
radius="md"
p="md"
style={{
borderColor: rec
? rec.status === 'Valid' ? 'var(--mantine-color-teal-4)' : 'var(--mantine-color-orange-4)'
: 'var(--mantine-color-red-3)',
borderStyle: rec ? 'solid' : 'dashed',
}}
>
<Group justify="space-between" mb="sm" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant={rec ? 'filled' : 'light'}
color={rec ? STATUS_COLOR[rec.status] : 'red'}
>
{rec ? <IconShieldCheck size={20} /> : <IconShield size={20} />}
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{item.shortLabel}</Text>
<Text fz="xs" c="dimmed">{item.modelCourse}</Text>
</div>
</Group>
{rec ? (
<Badge color={STATUS_COLOR[rec.status]} variant="light" size="sm">{rec.status}</Badge>
) : (
<Badge color="red" variant="light" size="sm">Missing</Badge>
)}
</Group>
<Text fz="xs" c="dimmed" mb="sm" lh={1.4}>{item.label}</Text>
{rec ? (
<Stack gap={6}>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Certificate No.</Text>
<Text fz="xs" fw={600}>{rec.certNumber}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Issuer</Text>
<Text fz="xs" ta="right" maw={140} truncate>{rec.issuer}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Issued</Text>
<Text fz="xs">{formatDate(rec.issueDate)}</Text>
</Group>
{needsRefresh && rec.expiryDate && (
<>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Expires</Text>
<Text fz="xs" fw={600} c={days !== null && days <= 180 ? 'orange' : undefined}>
{formatDate(rec.expiryDate)}
</Text>
</Group>
{days !== null && (
<Progress
value={Math.max(0, Math.min(100, (days / (item.refreshYears * 365)) * 100))}
color={days <= 90 ? 'red' : days <= 180 ? 'orange' : 'teal'}
size="xs"
radius="xl"
/>
)}
</>
)}
{needsRefresh && (
<Badge size="xs" variant="dot" color="blue" mt={2}>
<IconRefresh size={10} /> {item.refreshYears}-year refresh required
</Badge>
)}
<Group gap="xs" mt="xs">
<Button size="xs" variant="light" leftSection={<IconDownload size={12} />} flex={1}>
Download
</Button>
<Button size="xs" variant="subtle" color="orange" leftSection={<IconUpload size={12} />} flex={1} onClick={() => setModalItem(item)}>
Update
</Button>
</Group>
</Stack>
) : (
<Stack gap="xs">
<Text fz="xs" c="dimmed" lh={1.4}>{item.description}</Text>
{needsRefresh && (
<Badge size="xs" variant="dot" color="blue">
Requires {item.refreshYears}-year refresh
</Badge>
)}
<Button
size="xs"
leftSection={<IconUpload size={13} />}
onClick={() => setModalItem(item)}
fullWidth
mt="xs"
>
Upload Certificate
</Button>
</Stack>
)}
</Card>
);
})}
</SimpleGrid>
{/* Info */}
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="sm">
<IconBook2 size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">About Basic Safety Training (STCW VI/1)</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Box>
<Text fz="xs" c="dimmed" lh={1.6}>
Basic Safety Training is mandatory for <strong>all seafarers</strong> regardless of department (Deck, Engine, or Catering).
PST and FPFF certificates require evidence of maintained competence every <strong>5 years</strong>.
EFA and PSSR do not have a mandatory 5-year repeat under STCW.
</Text>
</Box>
<Box>
<Text fz="xs" c="dimmed" lh={1.6}>
Certificates must be from <strong>EMA-approved training institutions</strong> (e.g. Bahirdar Maritime School, Babugaya Maritime School).
EMA officers will verify authenticity before approving your Seaman Book application.
</Text>
</Box>
</SimpleGrid>
</Paper>
{/* Upload modal */}
<UploadModal
item={modalItem}
opened={!!modalItem}
onClose={() => setModalItem(null)}
onUploaded={handleUploaded}
/>
</Stack>
);
}

View File

@@ -0,0 +1,215 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Modal,
Paper,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconArrowRight,
IconBook2,
IconCertificate,
IconClock,
IconDownload,
IconEye,
IconInfoCircle,
IconShieldCheck,
} from '@tabler/icons-react';
// ---------------------------------------------------------------------------
// 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',
},
];
// blank PDF for demo
const BLANK_PDF =
'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
export function CertificatesPage() {
const navigate = useNavigate();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewTitle, setPreviewTitle] = useState('');
const openPreview = (title: string) => {
setPreviewTitle(title);
setPreviewUrl(BLANK_PDF);
};
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={<IconEye size={12} />} onClick={() => openPreview(cert.type)}>View</Button>
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
</Card>
))}
</SimpleGrid>
)}
</Paper>
{/* Preview modal */}
<Modal
opened={!!previewUrl}
onClose={() => setPreviewUrl(null)}
title={<Text fw={700} fz="sm">{previewTitle}</Text>}
size="xl"
radius="lg"
>
<iframe
src={previewUrl ?? ''}
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }}
title={previewTitle}
/>
</Modal>
</Stack>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,11 @@
import {
Alert,
Badge,
Card,
Center,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Text,
@@ -10,23 +13,59 @@ import {
Title,
UnstyledButton,
useMantineTheme,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconBook2,
IconChevronRight,
IconClipboardList,
IconFileCheck,
IconHeart,
IconLifebuoy,
IconShip,
IconShieldCheck,
IconUserPlus,
IconBell,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
// ---------------------------------------------------------------------------
// Mock profile completeness — replace with real store/API data
// ---------------------------------------------------------------------------
const PROFILE_STEPS = [
{ label: 'Personal Information', done: true },
{ label: 'Document Upload', done: true },
{ label: 'Medical Certificate', done: false },
{ label: 'Basic Safety Training', done: false },
{ label: 'Seaman Book', done: false },
];
const BST_ITEMS = [
{ label: 'Personal Survival Techniques (PST)', done: true, expiry: '2028-04-10' },
{ label: 'Fire Prevention & Fire Fighting (FPFF)', done: true, expiry: '2028-04-10' },
{ label: 'Elementary First Aid (EFA)', done: false, expiry: null },
{ label: 'Personal Safety & Social Responsibility (PSSR)', done: false, expiry: null },
{ label: 'Sexual Harassment Prevention', done: false, expiry: null },
];
const ALERTS = [
{ id: 1, type: 'warning', message: 'Medical certificate expires in 45 days. Please renew before it lapses.', route: '/medical-certificate' },
{ id: 2, type: 'info', message: '3 Basic Safety Training certificates are missing. Complete them to apply for a Seaman Book.', route: '/basic-safety-training' },
];
export function DashboardPage() {
const navigate = useNavigate();
const theme = useMantineTheme();
const doneSteps = PROFILE_STEPS.filter((s) => s.done).length;
const completeness = Math.round((doneSteps / PROFILE_STEPS.length) * 100);
const bstDone = BST_ITEMS.filter((b) => b.done).length;
return (
<Stack gap="lg">
{/* ---- Hero banner ------------------------------------------- */}
{/* Hero */}
<Paper
radius="lg"
p="xl"
@@ -36,11 +75,11 @@ export function DashboardPage() {
<Stack gap="md" maw={560}>
<Stack gap={6}>
<Title order={2} c="white" fz={26}>
Welcome to the EMA Portal
Welcome to the EMA Seafarer Portal
</Title>
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
Manage your seafarer profile, submit applications, and track your
maritime credentials all in one place.
Manage your seafarer profile, track certificates, apply for your Seaman Book
and monitor your maritime credentials all in one place.
</Text>
</Stack>
</Stack>
@@ -55,54 +94,144 @@ export function DashboardPage() {
</Group>
</Paper>
{/* ---- Quick actions ----------------------------------------- */}
{/* Alerts */}
{ALERTS.map((alert) => (
<Alert
key={alert.id}
variant="light"
color={alert.type === 'warning' ? 'orange' : 'blue'}
icon={alert.type === 'warning' ? <IconAlertCircle size={17} /> : <IconBell size={17} />}
style={{ cursor: 'pointer' }}
onClick={() => navigate(alert.route)}
>
{alert.message}
</Alert>
))}
{/* Status cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatusCard label="Profile Complete" value={`${completeness}%`} icon={IconUserPlus} color="blue" />
<StatusCard label="BST Certificates" value={`${bstDone} / ${BST_ITEMS.length}`} icon={IconShieldCheck} color="teal" />
<StatusCard label="Medical Status" value="Expiring" icon={IconHeart} color="orange" />
<StatusCard label="Seaman Book" value="Not Applied" icon={IconBook2} color="gray" />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
{/* Profile completeness */}
<Paper withBorder radius="lg" p="lg">
<Title order={4} mb="md">
Quick actions
</Title>
<Group justify="space-between" mb="md">
<Text fw={700}>Registration Checklist</Text>
<Badge variant="light" color={completeness === 100 ? 'teal' : 'blue'}>
{completeness}% complete
</Badge>
</Group>
<Progress value={completeness} color={completeness === 100 ? 'teal' : 'blue'} mb="md" radius="xl" size="sm" />
<Stack gap="xs">
<QuickAction
icon={IconUserPlus}
color="emaPrimary"
label="Register a new seafarer"
onClick={() => navigate('/seafarer-registration')}
/>
<QuickAction
icon={IconLifebuoy}
color="orange"
label="Contact support"
onClick={() => navigate('/support')}
/>
{PROFILE_STEPS.map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon
size={20}
radius="xl"
variant={step.done ? 'filled' : 'light'}
color={step.done ? 'teal' : 'gray'}
>
<IconFileCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'} td={step.done ? undefined : undefined}>
{step.label}
</Text>
{step.done && <Badge size="xs" color="teal" variant="light" ml="auto">Done</Badge>}
</Group>
))}
</Stack>
</Paper>
{/* BST tracker */}
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
<Text fw={700}>Basic Safety Training</Text>
<Badge variant="light" color={bstDone === BST_ITEMS.length ? 'teal' : 'orange'}>
{bstDone} / {BST_ITEMS.length}
</Badge>
</Group>
<Stack gap="xs">
{BST_ITEMS.map((item) => (
<Group key={item.label} gap="xs" wrap="nowrap">
<ThemeIcon size={20} radius="xl" variant={item.done ? 'filled' : 'light'} color={item.done ? 'teal' : 'gray'} style={{ flexShrink: 0 }}>
<IconShieldCheck size={12} />
</ThemeIcon>
<Text fz="sm" flex={1} c={item.done ? undefined : 'dimmed'} style={{ lineHeight: 1.3 }}>
{item.label}
</Text>
{item.done && item.expiry && (
<Text fz="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>exp {item.expiry}</Text>
)}
{!item.done && (
<Badge size="xs" color="red" variant="light">Missing</Badge>
)}
</Group>
))}
</Stack>
</Paper>
</SimpleGrid>
{/* Quick actions */}
<Paper withBorder radius="lg" p="lg">
<Text fw={700} mb="md">Quick Actions</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="sm">
<QuickAction icon={IconUserPlus} color="emaPrimary" label="New Seafarer Registration" sub="Register a new seafarer profile" onClick={() => navigate('/seafarer-registration')} />
<QuickAction icon={IconBook2} color="blue" label="Apply for Seaman Book" sub="Submit your Seaman Book application" onClick={() => navigate('/seaman-book')} />
<QuickAction icon={IconShieldCheck} color="teal" label="Certificates (CoC / CoP)" sub="Apply for STCW certificates" onClick={() => navigate('/certificates')} />
<QuickAction icon={IconHeart} color="red" label="Medical Certificate" sub="Upload or renew your medical certificate" onClick={() => navigate('/documents')} />
<QuickAction icon={IconClipboardList} color="violet" label="Document Vault" sub="Manage all your uploaded documents" onClick={() => navigate('/documents')} />
<QuickAction icon={IconLifebuoy} color="orange" label="Help & Support" sub="Get assistance from EMA staff" onClick={() => navigate('/support')} />
</SimpleGrid>
</Paper>
</Stack>
);
}
function StatusCard({ label, value, icon: Icon, color }: { label: string; value: string; icon: Icon; color: string }) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<div>
<Text fz="xl" fw={700} lh={1}>{value}</Text>
<Text fz="xs" 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>
);
}
function QuickAction({
icon: ActionIconCmp,
color,
label,
sub,
onClick,
}: {
icon: Icon;
color: string;
label: string;
sub: string;
onClick: () => void;
}) {
return (
<UnstyledButton onClick={onClick}>
<Card padding="xs" radius="md" bg="var(--mantine-color-default-hover)">
<UnstyledButton onClick={onClick} style={{ width: '100%' }}>
<Card padding="sm" radius="md" bg="var(--mantine-color-default-hover)" style={{ height: '100%' }}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={38} radius="md">
<ActionIconCmp size={19} />
<ThemeIcon variant="light" color={color} size={42} radius="md" style={{ flexShrink: 0 }}>
<ActionIconCmp size={20} />
</ThemeIcon>
<Text fz="sm" fw={500} flex={1}>
{label}
</Text>
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<Text fz="sm" fw={600} lh={1.3}>{label}</Text>
<Text fz="xs" c="dimmed" mt={2} lh={1.3}>{sub}</Text>
</div>
<IconChevronRight size={16} style={{ opacity: 0.4, flexShrink: 0 }} />
</Group>
</Card>
</UnstyledButton>

View File

@@ -0,0 +1,487 @@
import { useRef, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
Menu,
Modal,
Paper,
SimpleGrid,
Stack,
Tabs,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconBook2,
IconCertificate,
IconCircleCheck,
IconCloudDownload,
IconDotsVertical,
IconDownload,
IconEye,
IconFileCheck,
IconFileDescription,
IconHeart,
IconId,
IconPhoto,
IconSchool,
IconSearch,
IconShieldCheck,
IconTrash,
IconUpload,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// EMA-issued / system-generated documents
// ---------------------------------------------------------------------------
interface IssuedDoc {
key: string;
label: string;
category: 'certificate' | 'book';
color: string;
icon: typeof IconBook2;
issuedDate: string;
expiryDate: string | null;
status: 'issued' | 'pending' | 'expired';
description: string;
}
const ISSUED_DOCS: IssuedDoc[] = [
{
key: 'seaman-book',
label: 'Seaman Book',
category: 'book',
color: 'blue',
icon: IconBook2,
issuedDate: '2024-06-01',
expiryDate: '2029-06-01',
status: 'issued',
description: 'Official EMA-issued seafarer identification document. Valid for 5 years.',
},
{
key: 'btc',
label: 'Basic Training Certificate (BTC)',
category: 'certificate',
color: 'teal',
icon: IconCertificate,
issuedDate: '2024-06-01',
expiryDate: '2029-06-01',
status: 'issued',
description: 'EMA-issued BTC certifying completion of all 5 basic safety training courses.',
},
{
key: 'medical',
label: 'Medical Fitness Certificate',
category: 'certificate',
color: 'pink',
icon: IconHeart,
issuedDate: '2024-03-20',
expiryDate: '2026-03-20',
status: 'issued',
description: 'Medical fitness certificate from an EMA-approved medical centre.',
},
];
// ---------------------------------------------------------------------------
// BTC sub-certificates (the 5 training certs that qualify you for BTC)
// ---------------------------------------------------------------------------
interface BtcCert {
key: string;
short: string;
label: string;
certNumber: string;
issuer: string;
issueDate: string;
expiryDate: string | null;
}
const BTC_CERTS: BtcCert[] = [
{ key: 'pst', short: 'PST', label: 'Personal Survival Techniques', certNumber: 'PST-2024-001', issuer: 'Bahirdar Maritime School', issueDate: '2024-01-15', expiryDate: '2029-01-15' },
{ key: 'fpff', short: 'FPFF', label: 'Fire Prevention & Fire Fighting', certNumber: 'FPFF-2024-002', issuer: 'Bahirdar Maritime School', issueDate: '2024-01-16', expiryDate: '2029-01-16' },
{ key: 'efa', short: 'EFA', label: 'Elementary First Aid', certNumber: 'EFA-2024-003', issuer: 'EMA Training Centre', issueDate: '2024-02-01', expiryDate: null },
{ key: 'pssr', short: 'PSSR', label: 'Personal Safety & Social Responsibility', certNumber: 'PSSR-2024-004', issuer: 'EMA Training Centre', issueDate: '2024-02-02', expiryDate: null },
{ key: 'shpt', short: 'SHPT', label: 'Sexual Harassment Prevention Training', certNumber: 'SHPT-2024-005', issuer: 'EMA Training Centre', issueDate: '2024-02-03', expiryDate: null },
];
const STATUS_COLOR = { issued: 'teal', pending: 'yellow', expired: 'red' } as const;
const STATUS_LABEL = { issued: 'Issued', pending: 'Pending', expired: 'Expired' } as const;
// Demo PDF for preview
const DEMO_PDF =
'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
// ---------------------------------------------------------------------------
// Uploaded documents (user-provided supporting docs)
// ---------------------------------------------------------------------------
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
category: 'Identity' | 'Education' | 'Photo';
accept: string;
}
interface UploadedDoc {
key: string;
file: File;
uploadedAt: string;
url: string;
}
const DOC_SLOTS: DocSlot[] = [
{ key: 'nationalId', label: 'National ID / Fayda', description: 'Front and back of your national identity card', required: true, icon: IconId, category: 'Identity', accept: 'application/pdf,image/jpeg,image/png' },
{ key: 'passport', label: 'Passport', description: 'Bio-data page of a valid passport', required: false, icon: IconFileDescription, category: 'Identity', accept: 'application/pdf,image/jpeg,image/png' },
{ key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5 cm', required: true, icon: IconPhoto, category: 'Photo', accept: 'image/jpeg,image/png' },
{ key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification certificate', required: false, icon: IconSchool, category: 'Education', accept: 'application/pdf,image/jpeg,image/png' },
{ key: 'transcript', label: 'Academic Transcript', description: 'Official academic transcript from institution', required: false, icon: IconSchool, category: 'Education', accept: 'application/pdf,image/jpeg,image/png' },
];
const UPLOAD_CATEGORIES = ['All', 'Identity', 'Photo', 'Education'] as const;
type UploadCategory = typeof UPLOAD_CATEGORIES[number];
const CAT_COLOR: Record<string, string> = { Identity: 'blue', Photo: 'violet', Education: 'teal' };
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function DocumentVaultPage() {
const [docs, setDocs] = useState<Record<string, UploadedDoc | null>>(() =>
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
);
const [search, setSearch] = useState('');
const [category, setCategory] = useState<UploadCategory>('All');
const [previewDoc, setPreviewDoc] = useState<{ name: string; url: string; isImage: boolean } | null>(null);
const resetRefs = useRef<Record<string, (() => void) | null>>({});
const handleUpload = (key: string) => (file: File | null) => {
if (!file) return;
if (file.size > 5 * 1024 * 1024) { notify.error('File exceeds 5MB limit.'); return; }
const url = URL.createObjectURL(file);
setDocs((prev) => ({ ...prev, [key]: { key, file, uploadedAt: new Date().toLocaleDateString('en-GB'), url } }));
notify.success(`${file.name} uploaded.`);
};
const handleRemove = (key: string) => {
const doc = docs[key];
if (doc) URL.revokeObjectURL(doc.url);
setDocs((prev) => ({ ...prev, [key]: null }));
resetRefs.current[key]?.();
notify.info('Document removed.');
};
const filtered = DOC_SLOTS.filter((slot) => {
const matchCat = category === 'All' || slot.category === category;
const matchSearch = !search || slot.label.toLowerCase().includes(search.toLowerCase());
return matchCat && matchSearch;
});
const uploadedCount = Object.values(docs).filter(Boolean).length;
return (
<Stack gap="md">
<div>
<Title order={3}>My Documents</Title>
<Text fz="sm" c="dimmed">All your EMA-issued certificates and uploaded supporting documents</Text>
</div>
<Tabs defaultValue="issued" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="issued" leftSection={<IconFileCheck size={16} />}>
Certificates & Issued Documents
</Tabs.Tab>
<Tabs.Tab value="uploaded" leftSection={<IconUpload size={16} />}>
Uploaded Documents
<Badge size="xs" variant="light" color="blue" ml={6}>{uploadedCount} / {DOC_SLOTS.length}</Badge>
</Tabs.Tab>
</Tabs.List>
{/* ── Certificates & Issued Documents ──────────────────────────── */}
<Tabs.Panel value="issued">
<Stack gap="xl">
{/* EMA-issued documents */}
<div>
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="gray.6">EMA Issued Documents</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{ISSUED_DOCS.map((doc) => {
const DocIcon = doc.icon;
return (
<Card key={doc.key} withBorder radius="lg" p="md"
style={{
borderColor: doc.status === 'issued'
? `var(--mantine-color-${doc.color}-3)`
: doc.status === 'expired'
? 'var(--mantine-color-red-3)'
: 'var(--mantine-color-yellow-3)',
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<ThemeIcon size="xl" variant="light" color={doc.color} radius="lg">
<DocIcon size={22} stroke={1.5} />
</ThemeIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={700} fz="sm" lh={1.2}>{doc.label}</Text>
<Badge size="xs" variant="light" color={STATUS_COLOR[doc.status]} mt={3}>
{STATUS_LABEL[doc.status]}
</Badge>
</div>
</Group>
<Text fz="xs" c="dimmed" mb="sm" lh={1.4}>{doc.description}</Text>
<Stack gap={3} mb="sm">
<Group justify="space-between">
<Text fz="xs" c="dimmed">Issued</Text>
<Text fz="xs" fw={500}>{doc.issuedDate}</Text>
</Group>
{doc.expiryDate && (
<Group justify="space-between">
<Text fz="xs" c="dimmed">Expires</Text>
<Text fz="xs" fw={500}>{doc.expiryDate}</Text>
</Group>
)}
</Stack>
<Divider mb="sm" />
<Group gap="xs">
<Button size="xs" variant="light" color={doc.color} leftSection={<IconEye size={13} />} style={{ flex: 1 }}
onClick={() => setPreviewDoc({ name: doc.label, url: DEMO_PDF, isImage: false })}>
View
</Button>
<Button size="xs" variant="subtle" leftSection={<IconCloudDownload size={13} />}
component="a" href={DEMO_PDF} download={`${doc.label}.pdf`}>
Download
</Button>
</Group>
</Card>
);
})}
</SimpleGrid>
</div>
{/* BTC training certs — all 5 listed */}
<div>
<Group gap="sm" mb="sm" align="center">
<ThemeIcon size="md" variant="light" color="teal" radius="md">
<IconShieldCheck size={16} stroke={1.5} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm" tt="uppercase" c="gray.6">BTC Training Certificates (5/5)</Text>
<Text fz="xs" c="dimmed">Submitted training certificates that qualified you for the BTC</Text>
</div>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{BTC_CERTS.map((cert) => (
<Card key={cert.key} withBorder radius="md" p="md"
style={{ borderColor: 'var(--mantine-color-teal-3)' }}>
<Group gap="sm" mb="xs" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color="teal" radius="md">
<IconShieldCheck size={16} stroke={1.5} />
</ThemeIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} align="center">
<Text fw={700} fz="sm">{cert.short}</Text>
<IconCircleCheck size={14} color="var(--mantine-color-teal-6)" />
</Group>
<Text fz="xs" c="dimmed" lh={1.3}>{cert.label}</Text>
</div>
</Group>
<Stack gap={3} mb="xs">
<Group justify="space-between">
<Text fz="xs" c="dimmed">Cert No.</Text>
<Text fz="xs" fw={500}>{cert.certNumber}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Issuer</Text>
<Text fz="xs" fw={500} style={{ textAlign: 'right', maxWidth: rem(140) }}>{cert.issuer}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Issued</Text>
<Text fz="xs" fw={500}>{cert.issueDate}</Text>
</Group>
{cert.expiryDate && (
<Group justify="space-between">
<Text fz="xs" c="dimmed">Expires</Text>
<Text fz="xs" fw={500}>{cert.expiryDate}</Text>
</Group>
)}
</Stack>
<Button size="xs" variant="light" color="teal" leftSection={<IconEye size={13} />} fullWidth
onClick={() => setPreviewDoc({ name: `${cert.short} Certificate`, url: DEMO_PDF, isImage: false })}>
View Certificate
</Button>
</Card>
))}
</SimpleGrid>
</div>
</Stack>
</Tabs.Panel>
{/* ── Uploaded supporting documents ─────────────────────────────── */}
<Tabs.Panel value="uploaded">
<Stack gap="md">
{/* Category summary */}
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm">
{(['Identity', 'Photo', 'Education'] as const).map((cat) => {
const slots = DOC_SLOTS.filter((s) => s.category === cat);
const done = slots.filter((s) => docs[s.key]).length;
return (
<Card key={cat} withBorder radius="md" p="sm" style={{ cursor: 'pointer' }} onClick={() => setCategory(cat)}>
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color={CAT_COLOR[cat]} size={36} radius="md">
<IconFileDescription size={18} />
</ThemeIcon>
<div>
<Text fz="xs" c="dimmed">{cat}</Text>
<Text fz="sm" fw={700}>{done}/{slots.length}</Text>
</div>
</Group>
</Card>
);
})}
</SimpleGrid>
{/* Filters */}
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search documents…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
size="sm"
style={{ minWidth: rem(220) }}
rightSection={search ? (
<ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon>
) : null}
/>
<Group gap={6}>
{UPLOAD_CATEGORIES.map((cat) => (
<Button key={cat} size="xs"
variant={category === cat ? 'filled' : 'light'}
color={cat === 'All' ? 'gray' : CAT_COLOR[cat] ?? 'gray'}
onClick={() => setCategory(cat)}>
{cat}
</Button>
))}
</Group>
</Group>
{/* Document cards */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{filtered.map((slot) => {
const doc = docs[slot.key];
const SlotIcon = slot.icon;
const resetRef = { current: null as (() => void) | null };
return (
<Card key={slot.key} withBorder radius="md" p="md"
style={{
borderStyle: doc ? 'solid' : 'dashed',
borderColor: doc
? 'var(--mantine-color-teal-5)'
: slot.required
? 'var(--mantine-color-orange-4)'
: 'var(--mantine-color-default-border)',
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8), flexShrink: 0,
background: doc ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<SlotIcon size={20} color={doc ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div style={{ flex: 1, minWidth: 0 }}>
<Group gap={4}>
<Text fw={600} fz="sm" lh={1.3}>{slot.label}</Text>
{slot.required && !doc && <Text span c="red" fz="xs">*</Text>}
</Group>
<Text fz="xs" c="dimmed" lh={1.3}>{slot.description}</Text>
</div>
<Badge size="xs" variant="light" color={CAT_COLOR[slot.category]}>{slot.category}</Badge>
</Group>
{doc ? (
<Group gap="xs" align="center">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{doc.file.name}</Text>
<Text fz="xs" c="dimmed">{doc.uploadedAt}</Text>
<Menu position="bottom-end" shadow="sm" width={140} withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="xs">
<IconDotsVertical size={13} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEye size={13} />}
onClick={() => setPreviewDoc({ name: doc.file.name, url: doc.url, isImage: doc.file.type.startsWith('image/') })}>
Preview
</Menu.Item>
<Menu.Item leftSection={<IconDownload size={13} />} component="a" href={doc.url} download={doc.file.name}>
Download
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconTrash size={13} />} color="red" onClick={() => handleRemove(slot.key)}>
Remove
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={handleUpload(slot.key)} accept={slot.accept}>
{(props) => {
resetRefs.current[slot.key] = resetRef.current;
return (
<Button size="xs" variant="light" leftSection={<IconUpload size={13} />} fullWidth {...props}>
Upload Document
</Button>
);
}}
</FileButton>
)}
</Card>
);
})}
</SimpleGrid>
{filtered.length === 0 && (
<Paper withBorder radius="md" p="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
<IconFileDescription size={22} />
</ThemeIcon>
<Text fz="sm" c="dimmed">No documents match your search.</Text>
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setSearch(''); setCategory('All'); }}>
Clear filters
</Button>
</Paper>
)}
</Stack>
</Tabs.Panel>
</Tabs>
{/* Preview modal */}
<Modal
opened={!!previewDoc}
onClose={() => setPreviewDoc(null)}
title={<Text fw={700}>{previewDoc?.name}</Text>}
size="xl"
centered
styles={{ body: { padding: 0, minHeight: rem(500) } }}
>
{previewDoc && (
previewDoc.isImage
? <img src={previewDoc.url} alt={previewDoc.name} style={{ width: '100%', borderRadius: rem(8) }} />
: <iframe src={previewDoc.url} title={previewDoc.name} style={{ width: '100%', height: rem(500), border: 'none' }} />
)}
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,436 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
FileInput,
Group,
List,
Modal,
Paper,
SimpleGrid,
Stack,
Stepper,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconArrowLeft,
IconArrowRight,
IconCheck,
IconCircleCheck,
IconClock,
IconDownload,
IconEye,
IconFileDescription,
IconInfoCircle,
IconRubberStamp,
IconShieldCheck,
IconUpload,
} from '@tabler/icons-react';
// ---------------------------------------------------------------------------
// 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.',
},
];
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;
}
function ApplicationWizard({ onDone }: { onDone: () => void }) {
const [step, setStep] = useState(0);
const [cocNo, setCocNo] = useState('');
const [issuer, setIssuer] = useState('');
const [country, setCountry] = useState('');
const [cocType, setCocType] = useState('');
const [issueDate, setIssueDate] = useState('');
const [expiryDate, setExpiryDate] = useState('');
const [docs, setDocs] = useState<Docs>({ foreignCoc: null, translation: null, medical: null, seamanBook: null, photo: null });
const [submitted, setSubmitted] = useState(false);
const step0Ok = !!cocNo && !!issuer && !!country && !!cocType && !!issueDate && !!expiryDate;
const step1Ok = !!docs.foreignCoc && !!docs.medical && !!docs.seamanBook && !!docs.photo;
if (submitted) {
return (
<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 gap="lg">
<Stepper active={step} size="sm">
<Stepper.Step label="Foreign CoC Details" description="Certificate information" />
<Stepper.Step label="Upload Documents" description="Required documents" />
<Stepper.Step label="Payment" description="Pay endorsement fee" />
<Stepper.Step label="Review & Submit" description="Final check" />
</Stepper>
{/* Step 0 — Foreign CoC details */}
{step === 0 && (
<Paper withBorder radius="lg" p="xl">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="lg">
<Text fz="sm">
<strong>STCW Regulation I/10</strong> EMA will endorse your foreign CoC so it is
recognised for service on Ethiopian-flagged vessels. The endorsement is valid
for the same period as your foreign CoC.
</Text>
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="Foreign CoC Number" placeholder="e.g. PHL-COC-2022-0045" value={cocNo} onChange={(e) => setCocNo(e.currentTarget.value)} required />
<TextInput label="Issuing Country" placeholder="e.g. Philippines" value={country} onChange={(e) => setCountry(e.currentTarget.value)} required />
<TextInput label="Issuing Authority / Administration" placeholder="e.g. Maritime Industry Authority (MARINA)" value={issuer} onChange={(e) => setIssuer(e.currentTarget.value)} required />
<TextInput label="Certificate Type" placeholder="e.g. Officer in Charge of a Navigational Watch" value={cocType} onChange={(e) => setCocType(e.currentTarget.value)} required />
<TextInput label="Issue Date" type="date" value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} required />
<TextInput label="Expiry Date" type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.currentTarget.value)} required />
</SimpleGrid>
</Paper>
)}
{/* Step 1 — Documents */}
{step === 1 && (
<Stack gap="md">
<Alert variant="light" color="yellow" icon={<IconAlertCircle size={15} />}>
<Text fz="sm">
A <strong>certified translation</strong> is required if your foreign CoC is not in English.
All documents must be clear, legible, and complete.
</Text>
</Alert>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Required Documents</Text>
<Stack gap="md">
{[
{ key: 'foreignCoc' as keyof Docs, label: 'Foreign CoC (Original or Certified Copy)', required: true },
{ key: 'translation' as keyof Docs, label: 'Certified Translation (only if CoC is not in English)', required: false },
{ key: 'medical' as keyof Docs, label: 'Valid Medical Fitness Certificate (STCW Reg I/2)', required: true },
{ key: 'seamanBook' as keyof Docs, label: 'Ethiopian Seaman Book', required: true },
{ key: 'photo' as keyof Docs, label: 'Passport-Size Photo', required: true },
].map((slot) => (
<FileInput
key={slot.key}
label={<Group gap={4}><Text fz="sm" fw={500}>{slot.label}</Text>{slot.required && <Badge size="xs" color="red" variant="light">Required</Badge>}</Group>}
placeholder="Click to upload"
leftSection={<IconUpload size={14} />}
value={docs[slot.key]}
onChange={(f) => setDocs((prev) => ({ ...prev, [slot.key]: f }))}
accept=".pdf,.jpg,.jpeg,.png"
clearable
/>
))}
</Stack>
</Paper>
{/* Upload checklist */}
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fz="xs" fw={700} mb="sm" tt="uppercase" c="dimmed">Upload Checklist</Text>
<Stack gap={4}>
{[
{ label: 'Foreign CoC', done: !!docs.foreignCoc },
{ label: 'Medical Cert', done: !!docs.medical },
{ label: 'Seaman Book', done: !!docs.seamanBook },
{ label: 'Photo', done: !!docs.photo },
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={18} radius="xl" color={item.done ? 'teal' : 'gray'} variant={item.done ? 'filled' : 'light'}>
{item.done ? <IconCheck size={11} /> : <IconFileDescription size={11} />}
</ThemeIcon>
<Text fz="xs" c={item.done ? undefined : 'dimmed'}>{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
)}
{/* Step 2 — Payment */}
{step === 2 && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Endorsement Fee</Text>
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
{[
{ label: 'Application Processing Fee', amount: 300 },
{ label: 'Document Verification Fee', amount: 200 },
{ label: 'Endorsement Issuance Fee', amount: 500 },
].map(({ label, amount }) => (
<Group key={label} justify="space-between" mb="xs">
<Text fz="sm">{label}</Text>
<Text fz="sm" fw={600}>ETB {amount}</Text>
</Group>
))}
<Divider my="xs" />
<Group justify="space-between">
<Text fw={800}>Total</Text>
<Text fw={800} fz="lg" c="blue">ETB 1,000</Text>
</Group>
</Paper>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
<Text fz="sm">
Transfer the fee to <strong>CBE Account: 1000-XXXXX-EMA</strong> and upload the receipt below.
</Text>
</Alert>
<FileInput label="Payment Receipt" placeholder="Upload bank transfer receipt" leftSection={<IconUpload size={14} />} mt="md" accept=".pdf,.jpg,.jpeg,.png" />
</Paper>
)}
{/* Step 3 — Review */}
{step === 3 && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="lg">Review Your Application</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="lg">
{[
['CoC Number', cocNo],
['Country', country],
['Issuer', issuer],
['CoC Type', cocType],
['Issue Date', issueDate],
['Expiry Date', expiryDate],
].map(([label, value]) => (
<div key={label}>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
</div>
))}
</SimpleGrid>
<Divider mb="md" />
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb="xs">Uploaded Documents</Text>
<List spacing="xs" size="sm">
{[
{ label: 'Foreign CoC', file: docs.foreignCoc },
{ label: 'Medical Certificate', file: docs.medical },
{ label: 'Seaman Book', file: docs.seamanBook },
{ label: 'Photo', file: docs.photo },
{ label: 'Translation', file: docs.translation },
].map(({ label, file }) => file && (
<List.Item key={label} icon={<ThemeIcon size={18} radius="xl" color="teal" variant="filled"><IconCheck size={11} /></ThemeIcon>}>
<Text fz="sm">{label}: <Text span c="blue.7">{file.name}</Text></Text>
</List.Item>
))}
</List>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />} mt="lg">
<Text fz="xs">
By submitting you confirm that all information is accurate and the documents are genuine.
Providing false information is an offence under the Maritime Code.
</Text>
</Alert>
</Paper>
)}
{/* Navigation */}
<Group justify="space-between" mt="md">
<Button variant="default" leftSection={<IconArrowLeft size={14} />} onClick={() => setStep(s => s - 1)} disabled={step === 0}>
Back
</Button>
{step < 3 ? (
<Button
rightSection={<IconArrowRight size={14} />}
disabled={(step === 0 && !step0Ok) || (step === 1 && !step1Ok)}
onClick={() => setStep(s => s + 1)}
>
Next
</Button>
) : (
<Button color="teal" leftSection={<IconCircleCheck size={14} />} onClick={() => setSubmitted(true)}>
Submit Application
</Button>
)}
</Group>
</Stack>
);
}
// ---------------------------------------------------------------------------
// 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

@@ -0,0 +1,335 @@
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';
// ---------------------------------------------------------------------------
// 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;
}
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

@@ -0,0 +1,331 @@
import { useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconAlertTriangle,
IconBell,
IconBellOff,
IconBook2,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconHeart,
IconInfoCircle,
IconShield,
IconTrash,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & mock data
// ---------------------------------------------------------------------------
type NotifType = 'warning' | 'info' | 'success' | 'error';
type NotifCategory = 'Medical' | 'Seaman Book' | 'BST' | 'Certificate' | 'Application' | 'System';
interface Notification {
id: string;
type: NotifType;
category: NotifCategory;
title: string;
message: string;
date: string;
read: boolean;
actionLabel?: string;
actionRoute?: string;
}
const MOCK_NOTIFICATIONS: Notification[] = [
{
id: '1',
type: 'warning',
category: 'Medical',
title: 'Medical Certificate Expiring Soon',
message: 'Your medical certificate expires on 14 March 2026 — 45 days remaining. Visit an EMA-approved medical centre to renew before it lapses.',
date: '2026-01-28',
read: false,
actionLabel: 'View Medical Certificate',
actionRoute: '/medical-certificate',
},
{
id: '2',
type: 'info',
category: 'BST',
title: 'Basic Safety Training Incomplete',
message: '3 of your 5 Basic Safety Training certificates are missing (EFA, PSSR, SHPT). All 5 are required before you can apply for a Seaman Book.',
date: '2026-01-25',
read: false,
actionLabel: 'Manage BST Certificates',
actionRoute: '/basic-safety-training',
},
{
id: '3',
type: 'success',
category: 'Application',
title: 'Seaman Book Application Under Review',
message: 'Your Seaman Book application (SB-APP-2024-001) has been received and is currently under review by an EMA Registration Officer.',
date: '2024-05-10',
read: true,
actionLabel: 'Track Application',
actionRoute: '/seaman-book',
},
{
id: '4',
type: 'success',
category: 'BST',
title: 'PST Certificate Verified',
message: 'Your Personal Survival Techniques (PST) certificate has been verified and approved. Certificate No: PST-2023-BMS-0421.',
date: '2023-04-15',
read: true,
},
{
id: '5',
type: 'success',
category: 'BST',
title: 'FPFF Certificate Verified',
message: 'Your Fire Prevention & Fire Fighting (FPFF) certificate has been verified and approved. Certificate No: FPFF-2023-BMS-0421.',
date: '2023-04-15',
read: true,
},
{
id: '6',
type: 'info',
category: 'System',
title: 'Profile Setup Incomplete',
message: 'Your seafarer profile is 60% complete. Please upload your National ID, passport size photo and remaining documents to complete your registration.',
date: '2024-01-05',
read: true,
actionLabel: 'Go to Document Vault',
actionRoute: '/documents',
},
];
const TYPE_CONFIG: Record<NotifType, { color: string; icon: typeof IconBell }> = {
warning: { color: 'orange', icon: IconAlertTriangle },
info: { color: 'blue', icon: IconInfoCircle },
success: { color: 'teal', icon: IconCircleCheck },
error: { color: 'red', icon: IconAlertCircle },
};
const CATEGORY_ICON: Record<NotifCategory, typeof IconBell> = {
Medical: IconHeart,
'Seaman Book': IconBook2,
BST: IconShield,
Certificate: IconFileDescription,
Application: IconFileDescription,
System: IconBell,
};
const CATEGORIES = ['All', 'Medical', 'Seaman Book', 'BST', 'Certificate', 'Application', 'System'] as const;
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS);
const [filter, setFilter] = useState<string>('All');
const [readFilter, setReadFilter] = useState<string | null>(null);
const unreadCount = notifications.filter((n) => !n.read).length;
const filtered = notifications.filter((n) => {
const matchCat = filter === 'All' || n.category === filter;
const matchRead =
!readFilter ||
(readFilter === 'Unread' && !n.read) ||
(readFilter === 'Read' && n.read);
return matchCat && matchRead;
});
const markRead = (id: string) => {
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
};
const markAllRead = () => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
notify.success('All notifications marked as read.');
};
const deleteNotif = (id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
notify.info('Notification removed.');
};
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Group gap="xs">
<Title order={3}>Notifications</Title>
{unreadCount > 0 && (
<Badge color="red" variant="filled" circle size="lg">{unreadCount}</Badge>
)}
</Group>
<Text fz="sm" c="dimmed">Stay up to date on your certificates, applications and deadlines</Text>
</div>
{unreadCount > 0 && (
<Button size="sm" variant="light" leftSection={<IconCheck size={14} />} onClick={markAllRead}>
Mark all as read
</Button>
)}
</Group>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{[
{ label: 'All', count: notifications.length, color: 'gray', icon: IconBell },
{ label: 'Unread', count: notifications.filter((n) => !n.read).length, color: 'blue', icon: IconBell },
{ label: 'Warnings', count: notifications.filter((n) => n.type === 'warning').length, color: 'orange', icon: IconAlertTriangle },
{ label: 'Actions Needed', count: notifications.filter((n) => !n.read && n.type !== 'success').length, color: 'red', icon: IconAlertCircle },
].map(({ label, count, color, icon: Icon }) => (
<Card key={label} withBorder radius="md" p="sm">
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={36} radius="md">
<Icon size={17} />
</ThemeIcon>
<div>
<Text fz="lg" fw={700} lh={1}>{count}</Text>
<Text fz="xs" c="dimmed">{label}</Text>
</div>
</Group>
</Card>
))}
</SimpleGrid>
{/* Filters */}
<Group gap="sm" wrap="wrap">
<Group gap={6} style={{ flex: 1 }}>
{CATEGORIES.map((cat) => (
<Button
key={cat}
size="xs"
variant={filter === cat ? 'filled' : 'light'}
color="blue"
onClick={() => setFilter(cat)}
>
{cat}
</Button>
))}
</Group>
<Select
size="xs"
placeholder="All Status"
data={['Unread', 'Read']}
value={readFilter}
onChange={setReadFilter}
clearable
style={{ width: rem(120) }}
/>
</Group>
{/* Notification list */}
{filtered.length === 0 ? (
<Paper withBorder radius="md" p="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
<IconBellOff size={22} />
</ThemeIcon>
<Text fz="sm" c="dimmed">No notifications found</Text>
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setFilter('All'); setReadFilter(null); }}>
Clear filters
</Button>
</Paper>
) : (
<Stack gap="xs">
{filtered.map((n) => {
const { color, icon: TypeIcon } = TYPE_CONFIG[n.type];
const CatIcon = CATEGORY_ICON[n.category];
return (
<Card
key={n.id}
withBorder
radius="md"
p="md"
style={{
borderLeft: `3px solid var(--mantine-color-${color}-5)`,
background: n.read ? undefined : 'var(--mantine-color-blue-light)',
opacity: n.read ? 0.85 : 1,
}}
onClick={() => markRead(n.id)}
>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ flex: 1 }}>
<ThemeIcon variant="light" color={color} size={38} radius="md" style={{ flexShrink: 0, marginTop: 2 }}>
<TypeIcon size={18} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<Text fw={n.read ? 500 : 700} fz="sm">{n.title}</Text>
{!n.read && <Badge size="xs" color="blue" variant="filled">New</Badge>}
<Badge size="xs" variant="light" color="gray" leftSection={<CatIcon size={10} />}>
{n.category}
</Badge>
</Group>
<Text fz="sm" c="dimmed" lh={1.5}>{n.message}</Text>
<Group gap="sm" mt={4}>
<Text fz="xs" c="dimmed">{formatDate(n.date)}</Text>
{n.actionLabel && (
<Button
size="xs"
variant="light"
color={color}
component="a"
href={n.actionRoute}
onClick={(e) => e.stopPropagation()}
>
{n.actionLabel}
</Button>
)}
</Group>
</Stack>
</Group>
<Group gap="xs" style={{ flexShrink: 0 }}>
{!n.read && (
<ActionIcon
variant="subtle"
color="teal"
size="sm"
title="Mark as read"
onClick={(e) => { e.stopPropagation(); markRead(n.id); }}
>
<IconCheck size={14} />
</ActionIcon>
)}
<ActionIcon
variant="subtle"
color="red"
size="sm"
title="Delete"
onClick={(e) => { e.stopPropagation(); deleteNotif(n.id); }}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
</Card>
);
})}
</Stack>
)}
</Stack>
);
}

View File

@@ -0,0 +1,655 @@
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';
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
const STEPS = [
{ label: 'BST Certificates' },
{ label: 'Medical Certificate' },
{ label: 'Payment' },
{ label: 'Review & Submit' },
];
// ---------------------------------------------------------------------------
// BST slots
// ---------------------------------------------------------------------------
interface BSTSlot {
key: string;
label: string;
short: string;
refreshYears: number;
}
const BST_SLOTS: BSTSlot[] = [
{ key: 'pst', short: 'PST', label: 'Personal Survival Techniques (PST)', refreshYears: 5 },
{ key: 'fpff', short: 'FPFF', label: 'Fire Prevention & Fire Fighting (FPFF)', refreshYears: 5 },
{ key: 'efa', short: 'EFA', label: 'Elementary First Aid (EFA)', refreshYears: 0 },
{ key: 'pssr', short: 'PSSR', label: 'Personal Safety & Social Responsibility (PSSR)',refreshYears: 0 },
{ key: 'shp', short: 'SHPT', label: 'Sexual Harassment Prevention Training (SHPT)', refreshYears: 0 },
];
// ---------------------------------------------------------------------------
// 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>
);
}
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>
);
}
// ---------------------------------------------------------------------------
// BST upload card
// ---------------------------------------------------------------------------
function BSTCard({ slot, certNumber, onCertNumber, issuer, onIssuer, issueDate, onIssueDate, expiryDate, onExpiryDate, file, onFile, resetRef }: {
slot: BSTSlot;
certNumber: string; onCertNumber: (v: string) => void;
issuer: string; onIssuer: (v: string) => void;
issueDate: string; onIssueDate: (v: string) => void;
expiryDate: string; onExpiryDate: (v: string) => void;
file: File | null; onFile: (f: File | null) => void;
resetRef: React.MutableRefObject<(() => void) | null>;
}) {
const isComplete = !!file && !!certNumber.trim() && !!issuer.trim() && !!issueDate;
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: isComplete ? 'solid' : 'dashed',
borderColor: isComplete ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8), flexShrink: 0,
background: isComplete ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconShieldCheck size={20} color={isComplete ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div style={{ flex: 1, minWidth: 0 }}>
<Group gap={4}>
<Text fw={600} fz="sm">{slot.short}</Text>
<Text span c="red" fz="xs">*</Text>
{isComplete && <Badge size="xs" color="teal" variant="light" ml="auto">Done</Badge>}
</Group>
<Text fz="xs" c="dimmed" lh={1.3}>{slot.label}</Text>
</div>
</Group>
<Stack gap="xs">
<TextInput label="Certificate Number" placeholder="e.g. PST-2024-001" size="xs" value={certNumber} onChange={(e) => onCertNumber(e.currentTarget.value)} />
<TextInput label="Issuing Institution" placeholder="e.g. Bahirdar Maritime School" size="xs" value={issuer} onChange={(e) => onIssuer(e.currentTarget.value)} />
<SimpleGrid cols={slot.refreshYears > 0 ? 2 : 1} spacing="xs">
<TextInput label="Issue Date" type="date" size="xs" value={issueDate} onChange={(e) => onIssueDate(e.currentTarget.value)} />
{slot.refreshYears > 0 && (
<TextInput label={`Expiry (${slot.refreshYears}yr)`} type="date" size="xs" value={expiryDate} onChange={(e) => onExpiryDate(e.currentTarget.value)} />
)}
</SimpleGrid>
{file ? (
<Group gap="xs" align="center" mt={4}>
<IconCircleCheck size={14} 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?.(); }}>
<IconTrash size={12} />
</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="xs" variant="light" leftSection={<IconUpload size={12} />} fullWidth mt={4} {...props}>
Upload Certificate
</Button>
)}
</FileButton>
)}
</Stack>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeamanBookApplicationPage() {
const navigate = useNavigate();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// BST
const [bstData, setBstData] = useState<Record<string, { certNumber: string; issuer: string; issueDate: string; expiryDate: string; file: File | null }>>(() =>
Object.fromEntries(BST_SLOTS.map((s) => [s.key, { certNumber: '', issuer: '', issueDate: '', expiryDate: '', file: null }]))
);
const bstResetRefs = useRef<Record<string, (() => void) | null>>({});
const updateBst = (key: string, field: string, value: string | File | null) =>
setBstData((prev) => ({ ...prev, [key]: { ...prev[key], [field]: value } }));
// 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 bstComplete = BST_SLOTS.every((s) => {
const d = bstData[s.key];
return !!d.file && !!d.certNumber.trim() && !!d.issuer.trim() && !!d.issueDate;
});
const medComplete = !!medFile && !!medCertNumber.trim() && !!medIssuer.trim() && !!medIssueDate && !!medExpiryDate;
const payComplete = !!paymentMethod && !!paymentRef.trim() && !!paymentDate;
const canNext = () => {
if (active === 0) return bstComplete;
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: BST ─────────────────────────────────────────────── */}
{active === 0 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Upload all 5 Basic Safety Training certificates. These training certificates issued by approved institutions are different from the EMA-issued BTC they are the prerequisite for your BTC.
</Alert>
<SectionHead title="5 Mandatory BST Training Certificates" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{BST_SLOTS.map((slot) => {
const d = bstData[slot.key];
const rRef = { current: bstResetRefs.current[slot.key] ?? null };
return (
<BSTCard
key={slot.key}
slot={slot}
certNumber={d.certNumber}
onCertNumber={(v) => updateBst(slot.key, 'certNumber', v)}
issuer={d.issuer}
onIssuer={(v) => updateBst(slot.key, 'issuer', v)}
issueDate={d.issueDate}
onIssueDate={(v) => updateBst(slot.key, 'issueDate', v)}
expiryDate={d.expiryDate}
onExpiryDate={(v) => updateBst(slot.key, 'expiryDate', v)}
file={d.file}
onFile={(f) => updateBst(slot.key, 'file', f)}
resetRef={rRef}
/>
);
})}
</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: 5 }} spacing="sm">
{BST_SLOTS.map((slot) => {
const done = !!bstData[slot.key].file && !!bstData[slot.key].certNumber;
return (
<Group key={slot.key} gap={6} align="center">
{done ? <IconCircleCheck size={15} color="var(--mantine-color-teal-6)" /> : (
<Box style={{ width: 15, height: 15, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)' }} />
)}
<Text fz="xs" c={done ? 'teal.7' : 'dimmed'} fw={done ? 600 : 400}>{slot.short}</Text>
</Group>
);
})}
</SimpleGrid>
</Paper>
</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">BST Certificates</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{BST_SLOTS.map((slot) => {
const d = bstData[slot.key];
return (
<div key={slot.key}>
<Group gap="xs" mb={4}>
<IconCircleCheck size={15} color={d.file ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-gray-4)'} />
<Text fz="xs" fw={700}>{slot.short}</Text>
</Group>
<Text fz="xs" c="dimmed">{d.certNumber || '—'}</Text>
<Text fz="xs" c="dimmed">{d.issuer || '—'}</Text>
<Text fz="xs" c="dimmed">Issued: {d.issueDate || '—'}</Text>
{d.file && <Text fz="xs" c="teal.7" truncate>{d.file.name}</Text>}
</div>
);
})}
</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

@@ -0,0 +1,310 @@
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';
// ---------------------------------------------------------------------------
// 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 }[];
}
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>
);
}