mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 04:50:56 +00:00
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:
committed by
mengstabketemaw
parent
b86335588f
commit
0ba70daf00
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user