feat: replace 5 BST certificate slots with single relevant certificate upload

Seaman Book application wizard step 1 now collects one combined
certificate (cert number, institution, issue date, expiry, file)
instead of 5 separate BST component uploads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fitse-yotor
2026-07-02 16:32:16 +03:00
committed by mengstabketemaw
parent b86335588f
commit 0ba70daf00

View File

@@ -38,29 +38,12 @@ import { notify } from '@ema-platform/ui';
// Steps
// ---------------------------------------------------------------------------
const STEPS = [
{ label: 'BST Certificates' },
{ label: 'Relevant Certificate' },
{ label: 'Medical Certificate' },
{ label: 'Payment' },
{ label: 'Review & Submit' },
];
// ---------------------------------------------------------------------------
// BST slots
// ---------------------------------------------------------------------------
interface BSTSlot {
key: string;
label: string;
short: string;
refreshYears: number;
}
const BST_SLOTS: BSTSlot[] = [
{ key: 'pst', short: 'PST', label: 'Personal Survival Techniques (PST)', refreshYears: 5 },
{ key: 'fpff', short: 'FPFF', label: 'Fire Prevention & Fire Fighting (FPFF)', refreshYears: 5 },
{ key: 'efa', short: 'EFA', label: 'Elementary First Aid (EFA)', refreshYears: 0 },
{ key: 'pssr', short: 'PSSR', label: 'Personal Safety & Social Responsibility (PSSR)',refreshYears: 0 },
{ key: 'shp', short: 'SHPT', label: 'Sexual Harassment Prevention Training (SHPT)', refreshYears: 0 },
];
// ---------------------------------------------------------------------------
// Fee table — Seaman Book + BTC shown separately, paid together
@@ -137,71 +120,6 @@ function ReviewRow({ label, value }: { label: string; value: string }) {
);
}
// ---------------------------------------------------------------------------
// BST upload card
// ---------------------------------------------------------------------------
function BSTCard({ slot, certNumber, onCertNumber, issuer, onIssuer, issueDate, onIssueDate, expiryDate, onExpiryDate, file, onFile, resetRef }: {
slot: BSTSlot;
certNumber: string; onCertNumber: (v: string) => void;
issuer: string; onIssuer: (v: string) => void;
issueDate: string; onIssueDate: (v: string) => void;
expiryDate: string; onExpiryDate: (v: string) => void;
file: File | null; onFile: (f: File | null) => void;
resetRef: React.MutableRefObject<(() => void) | null>;
}) {
const isComplete = !!file && !!certNumber.trim() && !!issuer.trim() && !!issueDate;
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: isComplete ? 'solid' : 'dashed',
borderColor: isComplete ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8), flexShrink: 0,
background: isComplete ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconShieldCheck size={20} color={isComplete ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div style={{ flex: 1, minWidth: 0 }}>
<Group gap={4}>
<Text fw={600} fz="sm">{slot.short}</Text>
<Text span c="red" fz="xs">*</Text>
{isComplete && <Badge size="xs" color="teal" variant="light" ml="auto">Done</Badge>}
</Group>
<Text fz="xs" c="dimmed" lh={1.3}>{slot.label}</Text>
</div>
</Group>
<Stack gap="xs">
<TextInput label="Certificate Number" placeholder="e.g. PST-2024-001" size="xs" value={certNumber} onChange={(e) => onCertNumber(e.currentTarget.value)} />
<TextInput label="Issuing Institution" placeholder="e.g. Bahirdar Maritime School" size="xs" value={issuer} onChange={(e) => onIssuer(e.currentTarget.value)} />
<SimpleGrid cols={slot.refreshYears > 0 ? 2 : 1} spacing="xs">
<TextInput label="Issue Date" type="date" size="xs" value={issueDate} onChange={(e) => onIssueDate(e.currentTarget.value)} />
{slot.refreshYears > 0 && (
<TextInput label={`Expiry (${slot.refreshYears}yr)`} type="date" size="xs" value={expiryDate} onChange={(e) => onExpiryDate(e.currentTarget.value)} />
)}
</SimpleGrid>
{file ? (
<Group gap="xs" align="center" mt={4}>
<IconCircleCheck size={14} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>
<IconTrash size={12} />
</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="xs" variant="light" leftSection={<IconUpload size={12} />} fullWidth mt={4} {...props}>
Upload Certificate
</Button>
)}
</FileButton>
)}
</Stack>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
@@ -212,13 +130,13 @@ export function SeamanBookApplicationPage() {
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// BST
const [bstData, setBstData] = useState<Record<string, { certNumber: string; issuer: string; issueDate: string; expiryDate: string; file: File | null }>>(() =>
Object.fromEntries(BST_SLOTS.map((s) => [s.key, { certNumber: '', issuer: '', issueDate: '', expiryDate: '', file: null }]))
);
const bstResetRefs = useRef<Record<string, (() => void) | null>>({});
const updateBst = (key: string, field: string, value: string | File | null) =>
setBstData((prev) => ({ ...prev, [key]: { ...prev[key], [field]: value } }));
// Relevant Certificate
const [relCertNumber, setRelCertNumber] = useState('');
const [relIssuer, setRelIssuer] = useState('');
const [relIssueDate, setRelIssueDate] = useState('');
const [relExpiryDate, setRelExpiryDate] = useState('');
const [relFile, setRelFile] = useState<File | null>(null);
const relResetRef = useRef<() => void>(null);
// Medical
const [medCertNumber, setMedCertNumber] = useState('');
@@ -236,15 +154,12 @@ export function SeamanBookApplicationPage() {
const payResetRef = useRef<() => void>(null);
// Validation
const bstComplete = BST_SLOTS.every((s) => {
const d = bstData[s.key];
return !!d.file && !!d.certNumber.trim() && !!d.issuer.trim() && !!d.issueDate;
});
const relComplete = !!relFile && !!relCertNumber.trim() && !!relIssuer.trim() && !!relIssueDate && !!relExpiryDate;
const medComplete = !!medFile && !!medCertNumber.trim() && !!medIssuer.trim() && !!medIssueDate && !!medExpiryDate;
const payComplete = !!paymentMethod && !!paymentRef.trim() && !!paymentDate;
const canNext = () => {
if (active === 0) return bstComplete;
if (active === 0) return relComplete;
if (active === 1) return medComplete;
if (active === 2) return payComplete;
return true;
@@ -306,52 +221,87 @@ export function SeamanBookApplicationPage() {
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
</Group>
{/* ── Step 1: BST ─────────────────────────────────────────────── */}
{/* ── Step 1: Relevant Certificate ────────────────────────────── */}
{active === 0 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Upload all 5 Basic Safety Training certificates. These training certificates issued by approved institutions are different from the EMA-issued BTC they are the prerequisite for your BTC.
Upload your relevant certificate issued by an EMA-approved training institution. This is the prerequisite for your Basic Training Certificate (BTC).
</Alert>
<SectionHead title="5 Mandatory BST Training Certificates" />
<SectionHead title="Relevant Certificate Details" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{BST_SLOTS.map((slot) => {
const d = bstData[slot.key];
const rRef = { current: bstResetRefs.current[slot.key] ?? null };
return (
<BSTCard
key={slot.key}
slot={slot}
certNumber={d.certNumber}
onCertNumber={(v) => updateBst(slot.key, 'certNumber', v)}
issuer={d.issuer}
onIssuer={(v) => updateBst(slot.key, 'issuer', v)}
issueDate={d.issueDate}
onIssueDate={(v) => updateBst(slot.key, 'issueDate', v)}
expiryDate={d.expiryDate}
onExpiryDate={(v) => updateBst(slot.key, 'expiryDate', v)}
file={d.file}
onFile={(f) => updateBst(slot.key, 'file', f)}
resetRef={rRef}
/>
);
})}
<TextInput
label="Certificate Number"
placeholder="e.g. CERT-2024-001"
required
value={relCertNumber}
onChange={(e) => setRelCertNumber(e.currentTarget.value)}
/>
<TextInput
label="Issuing Institution"
placeholder="e.g. Bahirdar Maritime School"
required
value={relIssuer}
onChange={(e) => setRelIssuer(e.currentTarget.value)}
/>
</SimpleGrid>
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fw={600} fz="sm" mb="sm">Upload Progress</Text>
<SimpleGrid cols={{ base: 2, sm: 5 }} spacing="sm">
{BST_SLOTS.map((slot) => {
const done = !!bstData[slot.key].file && !!bstData[slot.key].certNumber;
return (
<Group key={slot.key} gap={6} align="center">
{done ? <IconCircleCheck size={15} color="var(--mantine-color-teal-6)" /> : (
<Box style={{ width: 15, height: 15, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)' }} />
)}
<Text fz="xs" c={done ? 'teal.7' : 'dimmed'} fw={done ? 600 : 400}>{slot.short}</Text>
</Group>
);
})}
</SimpleGrid>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Issue Date"
type="date"
required
value={relIssueDate}
onChange={(e) => setRelIssueDate(e.currentTarget.value)}
/>
<TextInput
label="Expiry Date"
type="date"
required
value={relExpiryDate}
onChange={(e) => setRelExpiryDate(e.currentTarget.value)}
/>
</SimpleGrid>
<SectionHead title="Upload Certificate" />
<Card
withBorder
radius="md"
p="md"
style={{
borderStyle: relFile ? 'solid' : 'dashed',
borderColor: relFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
maxWidth: rem(420),
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8),
background: relFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconShieldCheck size={20} color={relFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">Relevant Certificate <Text span c="red">*</Text></Text>
<Text fz="xs" c="dimmed">PDF, JPG or PNG max 5MB</Text>
</div>
</Group>
{relFile ? (
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{relFile.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setRelFile(null); relResetRef.current?.(); }}>
<IconTrash size={13} />
</Button>
</Group>
) : (
<FileButton resetRef={relResetRef} onChange={setRelFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
Choose File
</Button>
)}
</FileButton>
)}
</Card>
</Stack>
)}
@@ -587,23 +537,13 @@ export function SeamanBookApplicationPage() {
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">BST Certificates</Text>
<Text fw={700} fz="sm" mb="sm">Relevant Certificate</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{BST_SLOTS.map((slot) => {
const d = bstData[slot.key];
return (
<div key={slot.key}>
<Group gap="xs" mb={4}>
<IconCircleCheck size={15} color={d.file ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-gray-4)'} />
<Text fz="xs" fw={700}>{slot.short}</Text>
</Group>
<Text fz="xs" c="dimmed">{d.certNumber || '—'}</Text>
<Text fz="xs" c="dimmed">{d.issuer || '—'}</Text>
<Text fz="xs" c="dimmed">Issued: {d.issueDate || '—'}</Text>
{d.file && <Text fz="xs" c="teal.7" truncate>{d.file.name}</Text>}
</div>
);
})}
<ReviewRow label="Certificate No." value={relCertNumber} />
<ReviewRow label="Issuing Institution" value={relIssuer} />
<ReviewRow label="Issue Date" value={relIssueDate} />
<ReviewRow label="Expiry Date" value={relExpiryDate} />
<ReviewRow label="Document" value={relFile?.name ?? '—'} />
</SimpleGrid>
</Paper>