Files
emaui/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx
2026-08-21 11:16:22 +00:00

483 lines
15 KiB
TypeScript

import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared';
import { useRef, useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
FileButton,
Group,
List,
Modal,
Paper,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertTriangle,
IconBook2,
IconCalendar,
IconCheck,
IconCircleCheck,
IconDownload,
IconInfoCircle,
IconRefresh,
IconShieldCheck,
IconTrash,
IconUpload,
} from '@tabler/icons-react';
import { StatusBadge, notify } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface BSTRecord {
issuer: string;
issueDate: string;
expiryDate: string;
certNumber: string;
fileName: string;
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
}
const STATUS_TONE: Record<string, StatusTone> = {
Valid: 'success',
Expiring: 'pending',
Expired: 'danger',
'Pending Verification': 'warning',
};
/** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */
interface BstProgress {
modules: { key: string; label: string; licenseTypeKey: string; done: boolean }[];
completed: number;
total: number;
complete: boolean;
}
// `short` doubles as the key the API reports each module under, so the two
// stay matched without a second lookup table between them.
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: 'Security Awareness', short: 'SSA', course: 'STCW A-VI/6' },
];
function formatDate(dateStr: string) {
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({
opened,
onClose,
onUploaded,
}: {
opened: boolean;
onClose: () => void;
onUploaded: (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 || !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({
issuer,
issueDate,
expiryDate,
certNumber,
fileName: file.name,
status: 'Pending Verification',
});
notify.success('Basic Safety Training certificate submitted for verification.');
reset();
onClose();
};
return (
<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>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function BasicSafetyTrainingPage() {
const [record, setRecord] = useState<BSTRecord | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// Which of the five modules the seafarer actually holds. The certificate
// itself is the evidence, so this is read from the issued licences rather
// than tracked separately — two places to record it would disagree.
const { data: bst, isLoading: bstLoading } = useApiQuery<BstProgress>({
url: '/bst/my',
method: 'GET',
});
const doneByKey = new Map(
(bst?.modules ?? []).map((m) => [m.key, m.done]),
);
const days = record?.expiryDate ? daysUntil(record.expiryDate) : null;
const isExpiringSoon = days !== null && days <= 180 && days > 0;
const isExpired = days !== null && days <= 0;
const { t } = useTranslation();
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Title order={3}>Basic Safety Training Certificate</Title>
<Text fz="sm" c="dimmed">
STCW Chapter VI/1 mandatory for all seafarers before joining a vessel.
</Text>
</div>
{record && (
<StatusBadge
tone={STATUS_TONE[record.status]}
label={record.status}
size="lg"
variant="light"
leftSection={<IconShieldCheck size={14} />}
/>
)}
</Group>
{/* 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 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_TONE_COLOR[STATUS_TONE[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>
<StatusBadge tone={STATUS_TONE[record.status]} label={record.status} variant="light" />
</Group>
<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) => {
const done = doneByKey.get(c.short);
return (
<List.Item
key={c.short}
icon={
<ThemeIcon
size={18}
radius="xl"
color={done ? 'teal' : 'gray'}
variant={done ? 'light' : 'outline'}
>
<IconCheck size={11} />
</ThemeIcon>
}
>
<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>
{/* Only stated once known: an absent badge reads as "not
loaded", where a "Not held" badge would read as fact. */}
{!bstLoading && (
<Badge size="xs" variant="light" color={done ? 'teal' : 'gray'}>
{done ? 'Held' : 'Not held'}
</Badge>
)}
</Group>
</List.Item>
);
})}
</List>
</Paper>
{/* Info */}
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="xs">
<IconCalendar size={16} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">Validity & Renewal</Text>
</Group>
<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>
<UploadModal
opened={modalOpen}
onClose={() => setModalOpen(false)}
onUploaded={(rec) => setRecord(rec)}
/>
</Stack>
);
}