mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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:
committed by
mengstabketemaw
parent
7f76c862fb
commit
b86335588f
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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; // 0–100
|
||||
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>
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user