mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 15:18:12 +00:00
447 lines
16 KiB
TypeScript
447 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|