feat: simplify BST to single certificate and fetch dashboard from API

- BST page: replace 5 individual certificate cards with one combined
  BST certificate upload (institution issues one cert covering all components)
- Dashboard: fetch profile, seaman book, medical, and BST status from
  backend on load; show skeletons while loading, fall back to defaults
  on API failure; greet user by first name from auth store
- Add /basic-safety-training route to portal router

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fitse-yotor
2026-07-02 16:23:17 +03:00
committed by mengstabketemaw
parent 7f76c862fb
commit b86335588f
3 changed files with 658 additions and 408 deletions

View File

@@ -7,16 +7,14 @@ import {
Card,
FileButton,
Group,
List,
Modal,
Paper,
Progress,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
@@ -25,109 +23,26 @@ import {
IconCheck,
IconCircleCheck,
IconDownload,
IconFileDescription,
IconInfoCircle,
IconRefresh,
IconShield,
IconShieldCheck,
IconTrash,
IconUpload,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// Types
// ---------------------------------------------------------------------------
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;
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
}
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',
@@ -135,27 +50,37 @@ const STATUS_COLOR: Record<string, string> = {
'Pending Verification': 'yellow',
};
function daysUntil(dateStr: string) {
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
}
const BST_COMPONENTS = [
{ label: 'Personal Survival Techniques', short: 'PST', course: 'IMO 1.19' },
{ label: 'Fire Prevention & Fire Fighting', short: 'FPFF', course: 'IMO 1.20' },
{ label: 'Elementary First Aid', short: 'EFA', course: 'IMO 1.13' },
{ label: 'Personal Safety & Social Responsibility', short: 'PSSR', course: 'IMO 1.21' },
{ label: 'Sexual Harassment Prevention', short: 'SHPT', course: 'EMA National' },
];
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
return new Date(dateStr).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function daysUntil(dateStr: string) {
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
}
// ---------------------------------------------------------------------------
// Upload modal
// ---------------------------------------------------------------------------
function UploadModal({
item,
opened,
onClose,
onUploaded,
}: {
item: BSTItem | null;
opened: boolean;
onClose: () => void;
onUploaded: (key: string, record: BSTRecord) => void;
onUploaded: (record: BSTRecord) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [issuer, setIssuer] = useState('');
@@ -166,75 +91,153 @@ function UploadModal({
const resetRef = useRef<() => void>(null);
const reset = () => {
setFile(null); setIssuer(''); setCertNumber(''); setIssueDate(''); setExpiryDate('');
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.');
if (!file || !issuer || !certNumber || !issueDate || !expiryDate) {
notify.error('Please fill all required fields and upload the certificate file.');
return;
}
setSubmitting(true);
await new Promise((r) => setTimeout(r, 1000));
setSubmitting(false);
onUploaded(item!.key, {
key: item!.key,
onUploaded({
issuer,
issueDate,
expiryDate: expiryDate || '',
status: 'Pending Verification',
expiryDate,
certNumber,
fileName: file.name,
status: 'Pending Verification',
});
notify.success(`${item!.shortLabel} certificate submitted for verification.`);
notify.success('Basic Safety Training 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
opened={opened}
onClose={() => {
reset();
onClose();
}}
title="Upload Basic Safety Training Certificate"
size="md"
centered
>
<Stack gap="sm">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Text fz="xs">
Upload your combined BST certificate issued by an EMA-approved training institution.
The certificate must cover all 5 components (PST, FPFF, EFA, PSSR, SHPT).
</Text>
</Alert>
<TextInput
label="Issuing Institution"
placeholder="e.g. Bahirdar Maritime School"
required
value={issuer}
onChange={(e) => setIssuer(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Certificate Number"
placeholder="e.g. BST-2024-BMS-001"
required
value={certNumber}
onChange={(e) => setCertNumber(e.currentTarget.value)}
size="sm"
/>
<Group grow>
<TextInput
label="Issue Date"
type="date"
required
value={issueDate}
onChange={(e) => setIssueDate(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Expiry Date"
type="date"
required
value={expiryDate}
onChange={(e) => setExpiryDate(e.currentTarget.value)}
size="sm"
/>
</Group>
<div>
<Text fz="sm" fw={500} mb={4}>
Certificate File <Text span c="red">*</Text>
</Text>
{file ? (
<Card withBorder radius="sm" p="xs">
<Group gap="xs">
<IconCircleCheck size={14} color="var(--mantine-color-teal-6)" />
<Text fz="xs" flex={1} truncate>
{file.name}
</Text>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => {
setFile(null);
resetRef.current?.();
}}
>
<IconTrash size={12} />
</Button>
</Group>
</Card>
) : (
<FileButton
resetRef={resetRef}
onChange={setFile}
accept="application/pdf,image/jpeg,image/png"
>
{(props) => (
<Button
size="sm"
variant="default"
leftSection={<IconUpload size={13} />}
fullWidth
{...props}
>
Choose File (PDF / JPG / PNG)
</Button>
)}
</FileButton>
)}
</div>
<Group justify="flex-end" mt="xs">
<Button
variant="default"
onClick={() => {
reset();
onClose();
}}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
loading={submitting}
leftSection={<IconCheck size={14} />}
>
Submit
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -243,203 +246,191 @@ function UploadModal({
// Main page
// ---------------------------------------------------------------------------
export function BasicSafetyTrainingPage() {
const [records, setRecords] = useState<Record<string, BSTRecord>>(MOCK_RECORDS);
const [modalItem, setModalItem] = useState<BSTItem | null>(null);
const [record, setRecord] = useState<BSTRecord | null>(null);
const [modalOpen, setModalOpen] = useState(false);
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 }));
};
const days = record?.expiryDate ? daysUntil(record.expiryDate) : null;
const isExpiringSoon = days !== null && days <= 180 && days > 0;
const isExpired = days !== null && days <= 0;
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Title order={3}>Basic Safety Training</Title>
<Title order={3}>Basic Safety Training Certificate</Title>
<Text fz="sm" c="dimmed">
All seafarers must complete 5 mandatory BST certificates before joining a vessel (STCW Chapter VI).
STCW Chapter VI/1 mandatory for all seafarers before joining a vessel.
</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>
{record && (
<Badge
size="lg"
variant="light"
color={STATUS_COLOR[record.status]}
leftSection={<IconShieldCheck size={14} />}
>
{record.status}
</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>
{/* Expiry alert */}
{isExpired && (
<Alert variant="light" color="red" icon={<IconAlertTriangle size={15} />}>
<Text fz="sm">
Your BST certificate has <strong>expired</strong>. Upload a renewed certificate to remain eligible.
</Text>
</Alert>
)}
{isExpiringSoon && (
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={15} />}>
<Text fz="sm">
Your BST certificate expires in <strong>{days} days</strong>. Renew before it lapses.
</Text>
</Alert>
)}
{/* Certificate 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;
{/* Certificate card */}
{record ? (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="sm">
<ThemeIcon size={48} radius="md" color={STATUS_COLOR[record.status]} variant="light">
<IconShieldCheck size={24} />
</ThemeIcon>
<div>
<Text fw={700}>Basic Safety Training (BST)</Text>
<Text fz="xs" c="dimmed">Combined certificate all 5 STCW components</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[record.status]} variant="light">
{record.status}
</Badge>
</Group>
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>
<Stack gap="xs" mb="md">
<Group justify="space-between">
<Text fz="sm" c="dimmed">Certificate Number</Text>
<Text fz="sm" fw={600}>{record.certNumber}</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Issuing Institution</Text>
<Text fz="sm">{record.issuer}</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Issue Date</Text>
<Text fz="sm">{formatDate(record.issueDate)}</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Expiry Date</Text>
<Text
fz="sm"
fw={600}
c={isExpired ? 'red' : isExpiringSoon ? 'orange' : undefined}
>
{formatDate(record.expiryDate)}
{days !== null && days > 0 && (
<Text span fz="xs" c="dimmed" ml={6}>({days} days remaining)</Text>
)}
</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">File</Text>
<Text fz="sm">{record.fileName}</Text>
</Group>
</Stack>
<Group gap="xs">
<Button size="sm" variant="light" leftSection={<IconDownload size={14} />}>
Download
</Button>
<Button
size="sm"
variant="subtle"
color="orange"
leftSection={<IconRefresh size={14} />}
onClick={() => setModalOpen(true)}
>
Replace / Renew
</Button>
</Group>
</Paper>
) : (
<Paper withBorder radius="lg" p="xl" style={{ borderStyle: 'dashed' }}>
<Stack align="center" gap="md">
<ThemeIcon size={64} radius="xl" color="gray" variant="light">
<IconShieldCheck size={32} />
</ThemeIcon>
<div style={{ textAlign: 'center' }}>
<Text fw={700} fz="lg" mb={4}>No BST Certificate Uploaded</Text>
<Text fz="sm" c="dimmed" maw={420}>
You must upload a valid Basic Safety Training certificate issued by an
EMA-approved institution before applying for a Seaman Book.
</Text>
</div>
<Button
leftSection={<IconUpload size={16} />}
onClick={() => setModalOpen(true)}
size="md"
>
Upload BST Certificate
</Button>
</Stack>
</Paper>
)}
{/* Components covered */}
<Paper withBorder radius="lg" p="lg">
<Group gap="xs" mb="md">
<IconBook2 size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">Certificate Components (STCW VI/1)</Text>
</Group>
<Text fz="xs" c="dimmed" mb="sm">
A combined BST certificate from an EMA-approved institution covers all five components:
</Text>
<List
spacing="xs"
size="sm"
icon={
<ThemeIcon size={18} radius="xl" color="teal" variant="light">
<IconCheck size={11} />
</ThemeIcon>
}
>
{BST_COMPONENTS.map((c) => (
<List.Item key={c.short}>
<Group gap="xs" display="inline-flex">
<Text fz="sm" fw={600}>{c.short}</Text>
<Text fz="sm" c="dimmed"> {c.label}</Text>
<Badge size="xs" variant="outline" color="gray">{c.course}</Badge>
</Group>
<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>
</List.Item>
))}
</List>
</Paper>
{/* 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 gap="xs" mb="xs">
<IconCalendar size={16} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">Validity & Renewal</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>
<Box>
<Text fz="xs" c="dimmed" lh={1.6}>
BST certificates are typically valid for <strong>5 years</strong>. PST and FPFF components
require evidence of maintained competence at the 5-year point (STCW Reg. VI/1).
EFA and PSSR do not have a mandatory 5-year revalidation under STCW but your
institution's combined certificate carries a unified expiry date.
Certificates must be from <strong>EMA-approved training institutions</strong>.
</Text>
</Box>
</Paper>
{/* Upload modal */}
<UploadModal
item={modalItem}
opened={!!modalItem}
onClose={() => setModalItem(null)}
onUploaded={handleUploaded}
opened={modalOpen}
onClose={() => setModalOpen(false)}
onUploaded={(rec) => setRecord(rec)}
/>
</Stack>
);

View File

@@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import {
Alert,
Badge,
@@ -7,16 +8,17 @@ import {
Paper,
Progress,
SimpleGrid,
Skeleton,
Stack,
Text,
ThemeIcon,
Title,
UnstyledButton,
useMantineTheme,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconBell,
IconBook2,
IconChevronRight,
IconClipboardList,
@@ -26,42 +28,154 @@ import {
IconShip,
IconShieldCheck,
IconUserPlus,
IconBell,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { useAppSelector } from '../../../store/hooks';
// ---------------------------------------------------------------------------
// Mock profile completeness — replace with real store/API data
// Types
// ---------------------------------------------------------------------------
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 },
];
interface ProfileSummary {
id: string;
fullName: string;
completeness: number; // 0100
steps: { label: string; done: boolean }[];
}
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 },
];
interface SeamanBookSummary {
status: 'Not Applied' | 'Pending' | 'Under Review' | 'Approved' | 'Rejected';
applicationId?: string;
submittedAt?: string;
}
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' },
];
interface MedicalSummary {
status: 'Valid' | 'Expiring' | 'Expired' | 'Not Uploaded';
expiryDate?: string;
daysUntilExpiry?: number;
}
interface BSTSummary {
status: 'Complete' | 'Pending Verification' | 'Missing';
certNumber?: string;
expiryDate?: string;
}
interface DashboardData {
profile: ProfileSummary;
seamanBook: SeamanBookSummary;
medical: MedicalSummary;
bst: BSTSummary;
}
// ---------------------------------------------------------------------------
// Fallback data shown when API is unavailable
// ---------------------------------------------------------------------------
const FALLBACK: DashboardData = {
profile: {
id: '',
fullName: '',
completeness: 40,
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 },
],
},
seamanBook: { status: 'Not Applied' },
medical: { status: 'Not Uploaded' },
bst: { status: 'Missing' },
};
const STATUS_COLOR_SEAMAN: Record<string, string> = {
'Not Applied': 'gray',
Pending: 'yellow',
'Under Review': 'blue',
Approved: 'teal',
Rejected: 'red',
};
const STATUS_COLOR_MEDICAL: Record<string, string> = {
Valid: 'teal',
Expiring: 'orange',
Expired: 'red',
'Not Uploaded': 'gray',
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function DashboardPage() {
const navigate = useNavigate();
const theme = useMantineTheme();
const user = useAppSelector((s) => s.auth.user);
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;
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [fetchProfile] = useApiMutation<ProfileSummary>();
const [fetchSeamanBook] = useApiMutation<SeamanBookSummary>();
const [fetchMedical] = useApiMutation<MedicalSummary>();
const [fetchBST] = useApiMutation<BSTSummary>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
const load = async () => {
try {
const [profile, seamanBook, medical, bst] = await Promise.allSettled([
fetchProfile({ url: '/profiles/me', method: 'GET' }).unwrap(),
fetchSeamanBook({ url: '/seaman-book/my', method: 'GET' }).unwrap(),
fetchMedical({ url: '/medical/my', method: 'GET' }).unwrap(),
fetchBST({ url: '/bst/my', method: 'GET' }).unwrap(),
]);
setData({
profile: profile.status === 'fulfilled' ? profile.value : FALLBACK.profile,
seamanBook: seamanBook.status === 'fulfilled' ? seamanBook.value : FALLBACK.seamanBook,
medical: medical.status === 'fulfilled' ? medical.value : FALLBACK.medical,
bst: bst.status === 'fulfilled' ? bst.value : FALLBACK.bst,
});
} catch {
setData(FALLBACK);
} finally {
setLoading(false);
}
};
load();
}, [fetchProfile, fetchSeamanBook, fetchMedical, fetchBST]);
const d = data ?? FALLBACK;
const alerts: { type: 'warning' | 'info'; message: string; route: string }[] = [];
if (d.medical.status === 'Expiring' && d.medical.daysUntilExpiry != null) {
alerts.push({
type: 'warning',
message: `Medical certificate expires in ${d.medical.daysUntilExpiry} days. Please renew before it lapses.`,
route: '/documents',
});
}
if (d.medical.status === 'Expired') {
alerts.push({
type: 'warning',
message: 'Your medical certificate has expired. Upload a renewed certificate immediately.',
route: '/documents',
});
}
if (d.bst.status === 'Missing') {
alerts.push({
type: 'info',
message: 'No Basic Safety Training certificate uploaded. BST is required before applying for a Seaman Book.',
route: '/basic-safety-training',
});
}
return (
<Stack gap="lg">
@@ -69,13 +183,13 @@ export function DashboardPage() {
<Paper
radius="lg"
p="xl"
style={{ background: theme.other.heroGradient as string, overflow: 'hidden' }}
style={{ background: theme.other?.heroGradient as string ?? 'var(--mantine-color-blue-6)', overflow: 'hidden' }}
>
<Group justify="space-between" wrap="nowrap" align="center">
<Stack gap="md" maw={560}>
<Stack gap={6}>
<Title order={2} c="white" fz={26}>
Welcome to the EMA Seafarer Portal
Welcome{user?.name?.en ? `, ${user.name.en.split(' ')[0]}` : ''} to the EMA Seafarer Portal
</Title>
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
Manage your seafarer profile, track certificates, apply for your Seaman Book
@@ -95,9 +209,9 @@ export function DashboardPage() {
</Paper>
{/* Alerts */}
{ALERTS.map((alert) => (
{alerts.map((alert, i) => (
<Alert
key={alert.id}
key={i}
variant="light"
color={alert.type === 'warning' ? 'orange' : 'blue'}
icon={alert.type === 'warning' ? <IconAlertCircle size={17} /> : <IconBell size={17} />}
@@ -110,68 +224,153 @@ export function DashboardPage() {
{/* 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" />
{loading ? (
<>
<Skeleton height={80} radius="md" />
<Skeleton height={80} radius="md" />
<Skeleton height={80} radius="md" />
<Skeleton height={80} radius="md" />
</>
) : (
<>
<StatusCard
label="Profile Complete"
value={`${d.profile.completeness}%`}
icon={IconUserPlus}
color="blue"
/>
<StatusCard
label="BST Certificate"
value={d.bst.status}
icon={IconShieldCheck}
color={d.bst.status === 'Complete' ? 'teal' : d.bst.status === 'Pending Verification' ? 'yellow' : 'gray'}
/>
<StatusCard
label="Medical Status"
value={d.medical.status}
icon={IconHeart}
color={STATUS_COLOR_MEDICAL[d.medical.status] ?? 'gray'}
/>
<StatusCard
label="Seaman Book"
value={d.seamanBook.status}
icon={IconBook2}
color={STATUS_COLOR_SEAMAN[d.seamanBook.status] ?? 'gray'}
/>
</>
)}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
{/* Profile completeness */}
{/* Registration checklist */}
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
<Text fw={700}>Registration Checklist</Text>
<Badge variant="light" color={completeness === 100 ? 'teal' : 'blue'}>
{completeness}% complete
</Badge>
{loading ? (
<Skeleton height={22} width={80} radius="xl" />
) : (
<Badge
variant="light"
color={d.profile.completeness === 100 ? 'teal' : 'blue'}
>
{d.profile.completeness}% complete
</Badge>
)}
</Group>
<Progress value={completeness} color={completeness === 100 ? 'teal' : 'blue'} mb="md" radius="xl" size="sm" />
<Stack gap="xs">
{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>
{loading ? (
<Stack gap="xs">
{[1, 2, 3, 4, 5].map((n) => <Skeleton key={n} height={24} radius="sm" />)}
</Stack>
) : (
<>
<Progress
value={d.profile.completeness}
color={d.profile.completeness === 100 ? 'teal' : 'blue'}
mb="md"
radius="xl"
size="sm"
/>
<Stack gap="xs">
{d.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'}>
{step.label}
</Text>
{step.done && (
<Badge size="xs" color="teal" variant="light" ml="auto">
Done
</Badge>
)}
</Group>
))}
</Stack>
</>
)}
</Paper>
{/* BST tracker */}
{/* BST status */}
<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>
{!loading && (
<Badge
variant="light"
color={d.bst.status === 'Complete' ? 'teal' : d.bst.status === 'Pending Verification' ? 'yellow' : 'red'}
>
{d.bst.status}
</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}
{loading ? (
<Stack gap="xs">
{[1, 2, 3].map((n) => <Skeleton key={n} height={24} radius="sm" />)}
</Stack>
) : d.bst.status !== 'Missing' ? (
<Stack gap="xs">
{d.bst.certNumber && (
<Group justify="space-between">
<Text fz="sm" c="dimmed">Certificate No.</Text>
<Text fz="sm" fw={600}>{d.bst.certNumber}</Text>
</Group>
)}
{d.bst.expiryDate && (
<Group justify="space-between">
<Text fz="sm" c="dimmed">Expires</Text>
<Text fz="sm">
{new Date(d.bst.expiryDate).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
})}
</Text>
</Group>
)}
<Text fz="xs" c="dimmed" mt="xs">
Covers PST, FPFF, EFA, PSSR, and SHPT STCW Chapter VI/1
</Text>
</Stack>
) : (
<Stack gap="xs">
<Alert variant="light" color="orange" icon={<IconAlertCircle size={15} />} p="sm">
<Text fz="sm">
No BST certificate on file. Upload your certificate to become eligible for a Seaman Book.
</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>
</Alert>
<UnstyledButton onClick={() => navigate('/basic-safety-training')}>
<Text fz="sm" c="blue" fw={500}>
Upload BST Certificate
</Text>
</UnstyledButton>
</Stack>
)}
</Paper>
</SimpleGrid>
@@ -179,25 +378,75 @@ export function DashboardPage() {
<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')} />
<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 }) {
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>
<Text fz="lg" fw={700} lh={1} truncate maw={100}>
{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} />
@@ -208,7 +457,7 @@ function StatusCard({ label, value, icon: Icon, color }: { label: string; value:
}
function QuickAction({
icon: ActionIconCmp,
icon: ActionIcon,
color,
label,
sub,
@@ -225,11 +474,15 @@ function QuickAction({
<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={42} radius="md" style={{ flexShrink: 0 }}>
<ActionIconCmp size={20} />
<ActionIcon size={20} />
</ThemeIcon>
<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>
<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>

View File

@@ -25,6 +25,9 @@ import { SeamanBookPage } from './features/seaman-book/pages/SeamanBookPage';
import { SeamanBookApplicationPage } from './features/seaman-book/pages/SeamanBookApplicationPage';
import { NotificationsPage } from './features/notifications/pages/NotificationsPage';
// Basic Safety Training
import { BasicSafetyTrainingPage } from './features/basic-safety-training/pages/BasicSafetyTrainingPage';
// Phase 2 — CoC / CoP
import { CertificatesPage } from './features/certificates/pages/CertificatesPage';
import { CoCApplicationPage } from './features/certificates/pages/CoCApplicationPage';
@@ -77,6 +80,9 @@ export const router = createBrowserRouter([
{ path: '/seaman-book/apply', element: <SeamanBookApplicationPage /> },
{ path: '/notifications', element: <NotificationsPage /> },
// Basic Safety Training
{ path: '/basic-safety-training', element: <BasicSafetyTrainingPage /> },
// Phase 2 — CoC / CoP
{ path: '/certificates', element: <CertificatesPage /> },
{ path: '/certificates/apply', element: <CoCApplicationPage /> },