mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
483 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|