Files
emaui/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx
2026-06-20 09:44:41 +03:00

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>
);
}