Files
emaui/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx
fitse-yotor eabaae36a0 feat(portal): restore Mengestab's client-approved seafarer and vessel UI
Copied verbatim from the pre-override branch so the approved screens are
recoverable at this exact commit before any wiring changes them.

Brings back the richer flows the client signed off: a four-step seafarer
registration wizard with bilingual inputs and an Ethiopic date picker,
the vessel-owner portal (its own register/login/dashboard), ownership
transfer, and the seaman book, certificate, medical and endorsement
screens.

Six of these pages already call an API; ten are mockups carrying
hardcoded data. Both are committed as-is here -- the wiring that follows
is a separate commit so the diff shows exactly what changed from what
the client approved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:19:08 +03:00

438 lines
14 KiB
TypeScript

import { useRef, useState } from 'react';
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 { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface BSTRecord {
issuer: string;
issueDate: string;
expiryDate: string;
certNumber: string;
fileName: string;
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
}
const STATUS_COLOR: Record<string, string> = {
Valid: 'teal',
Expiring: 'orange',
Expired: 'red',
'Pending Verification': 'yellow',
};
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',
});
}
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);
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 Certificate</Title>
<Text fz="sm" c="dimmed">
STCW Chapter VI/1 mandatory for all seafarers before joining a vessel.
</Text>
</div>
{record && (
<Badge
size="lg"
variant="light"
color={STATUS_COLOR[record.status]}
leftSection={<IconShieldCheck size={14} />}
>
{record.status}
</Badge>
)}
</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_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>
<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>
</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>
);
}