mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 09:28:21 +00:00
ui componenet based on the requirements
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
FileButton,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBook2,
|
||||
IconCalendar,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconShield,
|
||||
IconShieldCheck,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
interface BSTItem {
|
||||
key: string;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
description: string;
|
||||
modelCourse: string;
|
||||
refreshYears: number;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface BSTRecord {
|
||||
key: string;
|
||||
issuer: string;
|
||||
issueDate: string;
|
||||
expiryDate: string;
|
||||
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
|
||||
certNumber: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
const BST_ITEMS: BSTItem[] = [
|
||||
{
|
||||
key: 'pst',
|
||||
label: 'Personal Survival Techniques',
|
||||
shortLabel: 'PST',
|
||||
description: 'Covers lifeboat/life-raft operation, survival at sea, and distress signals.',
|
||||
modelCourse: 'IMO 1.19',
|
||||
refreshYears: 5,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'fpff',
|
||||
label: 'Fire Prevention & Fire Fighting',
|
||||
shortLabel: 'FPFF',
|
||||
description: 'Covers fire prevention, detection, and fire-fighting on board vessels.',
|
||||
modelCourse: 'IMO 1.20',
|
||||
refreshYears: 5,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'efa',
|
||||
label: 'Elementary First Aid',
|
||||
shortLabel: 'EFA',
|
||||
description: 'Basic first-aid procedures, CPR, and medical emergency response.',
|
||||
modelCourse: 'IMO 1.13',
|
||||
refreshYears: 0,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'pssr',
|
||||
label: 'Personal Safety & Social Responsibility',
|
||||
shortLabel: 'PSSR',
|
||||
description: 'Shipboard safety culture, regulations, and working relationships.',
|
||||
modelCourse: 'IMO 1.21',
|
||||
refreshYears: 0,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'shp',
|
||||
label: 'Sexual Harassment Prevention Training',
|
||||
shortLabel: 'SHPT',
|
||||
description: 'Awareness and prevention of harassment in the maritime workplace.',
|
||||
modelCourse: 'EMA National',
|
||||
refreshYears: 0,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_RECORDS: Record<string, BSTRecord> = {
|
||||
pst: {
|
||||
key: 'pst',
|
||||
issuer: 'Bahirdar Maritime School',
|
||||
issueDate: '2023-04-10',
|
||||
expiryDate: '2028-04-09',
|
||||
status: 'Valid',
|
||||
certNumber: 'PST-2023-BMS-0421',
|
||||
fileName: 'pst_certificate.pdf',
|
||||
},
|
||||
fpff: {
|
||||
key: 'fpff',
|
||||
issuer: 'Bahirdar Maritime School',
|
||||
issueDate: '2023-04-10',
|
||||
expiryDate: '2028-04-09',
|
||||
status: 'Valid',
|
||||
certNumber: 'FPFF-2023-BMS-0421',
|
||||
fileName: 'fpff_certificate.pdf',
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Valid: 'teal',
|
||||
Expiring: 'orange',
|
||||
Expired: 'red',
|
||||
'Pending Verification': 'yellow',
|
||||
};
|
||||
|
||||
function daysUntil(dateStr: string) {
|
||||
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function UploadModal({
|
||||
item,
|
||||
opened,
|
||||
onClose,
|
||||
onUploaded,
|
||||
}: {
|
||||
item: BSTItem | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onUploaded: (key: string, 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) {
|
||||
notify.error('Please fill all required fields and upload the certificate.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
setSubmitting(false);
|
||||
onUploaded(item!.key, {
|
||||
key: item!.key,
|
||||
issuer,
|
||||
issueDate,
|
||||
expiryDate: expiryDate || '',
|
||||
status: 'Pending Verification',
|
||||
certNumber,
|
||||
fileName: file.name,
|
||||
});
|
||||
notify.success(`${item!.shortLabel} certificate submitted for verification.`);
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={() => { reset(); onClose(); }} title={`Upload ${item?.label}`} size="md" centered>
|
||||
{item && (
|
||||
<Stack gap="sm">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
|
||||
<Text fz="xs">{item.description} — Model Course: <strong>{item.modelCourse}</strong></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. PST-2024-001" required value={certNumber} onChange={(e) => setCertNumber(e.currentTarget.value)} size="sm" />
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Issue Date" type="date" required value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} size="sm" />
|
||||
{item.refreshYears > 0 && (
|
||||
<TextInput label={`Expiry Date (${item.refreshYears}-yr refresh)`} type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.currentTarget.value)} size="sm" />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<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 [records, setRecords] = useState<Record<string, BSTRecord>>(MOCK_RECORDS);
|
||||
const [modalItem, setModalItem] = useState<BSTItem | null>(null);
|
||||
|
||||
const doneCount = BST_ITEMS.filter((i) => records[i.key]).length;
|
||||
const allDone = doneCount === BST_ITEMS.length;
|
||||
|
||||
const handleUploaded = (key: string, record: BSTRecord) => {
|
||||
setRecords((prev) => ({ ...prev, [key]: record }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Title order={3}>Basic Safety Training</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
All seafarers must complete 5 mandatory BST certificates before joining a vessel (STCW Chapter VI).
|
||||
</Text>
|
||||
</div>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={allDone ? 'teal' : 'orange'}
|
||||
leftSection={allDone ? <IconShieldCheck size={14} /> : <IconAlertTriangle size={14} />}
|
||||
>
|
||||
{doneCount} / {BST_ITEMS.length} Complete
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Overall progress */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={700}>Overall Completion</Text>
|
||||
<Text fz="sm" c={allDone ? 'teal' : 'orange'} fw={600}>{Math.round((doneCount / BST_ITEMS.length) * 100)}%</Text>
|
||||
</Group>
|
||||
<Progress value={(doneCount / BST_ITEMS.length) * 100} color={allDone ? 'teal' : 'orange'} size="md" radius="xl" mb="md" />
|
||||
{!allDone && (
|
||||
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={15} />} p="sm">
|
||||
<Text fz="sm">
|
||||
You need all 5 certificates to apply for a Seaman Book. Missing: <strong>{BST_ITEMS.filter((i) => !records[i.key]).map((i) => i.shortLabel).join(', ')}</strong>
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{allDone && (
|
||||
<Alert variant="light" color="teal" icon={<IconShieldCheck size={15} />} p="sm">
|
||||
<Text fz="sm">
|
||||
All 5 BST training certificates are complete. You can now apply for your <strong>Seaman Book & BTC</strong> —
|
||||
the Basic Training Certificate (BTC) is issued by EMA alongside your Seaman Book after your application is approved.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Certificate cards */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{BST_ITEMS.map((item) => {
|
||||
const rec = records[item.key];
|
||||
const days = rec?.expiryDate ? daysUntil(rec.expiryDate) : null;
|
||||
const needsRefresh = item.refreshYears > 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.key}
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: rec
|
||||
? rec.status === 'Valid' ? 'var(--mantine-color-teal-4)' : 'var(--mantine-color-orange-4)'
|
||||
: 'var(--mantine-color-red-3)',
|
||||
borderStyle: rec ? 'solid' : 'dashed',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="sm" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={40}
|
||||
radius="md"
|
||||
variant={rec ? 'filled' : 'light'}
|
||||
color={rec ? STATUS_COLOR[rec.status] : 'red'}
|
||||
>
|
||||
{rec ? <IconShieldCheck size={20} /> : <IconShield size={20} />}
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{item.shortLabel}</Text>
|
||||
<Text fz="xs" c="dimmed">{item.modelCourse}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{rec ? (
|
||||
<Badge color={STATUS_COLOR[rec.status]} variant="light" size="sm">{rec.status}</Badge>
|
||||
) : (
|
||||
<Badge color="red" variant="light" size="sm">Missing</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Text fz="xs" c="dimmed" mb="sm" lh={1.4}>{item.label}</Text>
|
||||
|
||||
{rec ? (
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Certificate No.</Text>
|
||||
<Text fz="xs" fw={600}>{rec.certNumber}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Issuer</Text>
|
||||
<Text fz="xs" ta="right" maw={140} truncate>{rec.issuer}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Issued</Text>
|
||||
<Text fz="xs">{formatDate(rec.issueDate)}</Text>
|
||||
</Group>
|
||||
{needsRefresh && rec.expiryDate && (
|
||||
<>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Expires</Text>
|
||||
<Text fz="xs" fw={600} c={days !== null && days <= 180 ? 'orange' : undefined}>
|
||||
{formatDate(rec.expiryDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
{days !== null && (
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, (days / (item.refreshYears * 365)) * 100))}
|
||||
color={days <= 90 ? 'red' : days <= 180 ? 'orange' : 'teal'}
|
||||
size="xs"
|
||||
radius="xl"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{needsRefresh && (
|
||||
<Badge size="xs" variant="dot" color="blue" mt={2}>
|
||||
<IconRefresh size={10} /> {item.refreshYears}-year refresh required
|
||||
</Badge>
|
||||
)}
|
||||
<Group gap="xs" mt="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconDownload size={12} />} flex={1}>
|
||||
Download
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" color="orange" leftSection={<IconUpload size={12} />} flex={1} onClick={() => setModalItem(item)}>
|
||||
Update
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<Text fz="xs" c="dimmed" lh={1.4}>{item.description}</Text>
|
||||
{needsRefresh && (
|
||||
<Badge size="xs" variant="dot" color="blue">
|
||||
Requires {item.refreshYears}-year refresh
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconUpload size={13} />}
|
||||
onClick={() => setModalItem(item)}
|
||||
fullWidth
|
||||
mt="xs"
|
||||
>
|
||||
Upload Certificate
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Info */}
|
||||
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconBook2 size={18} color="var(--mantine-color-blue-6)" />
|
||||
<Text fw={700} fz="sm">About Basic Safety Training (STCW VI/1)</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Box>
|
||||
<Text fz="xs" c="dimmed" lh={1.6}>
|
||||
Basic Safety Training is mandatory for <strong>all seafarers</strong> regardless of department (Deck, Engine, or Catering).
|
||||
PST and FPFF certificates require evidence of maintained competence every <strong>5 years</strong>.
|
||||
EFA and PSSR do not have a mandatory 5-year repeat under STCW.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz="xs" c="dimmed" lh={1.6}>
|
||||
Certificates must be from <strong>EMA-approved training institutions</strong> (e.g. Bahirdar Maritime School, Babugaya Maritime School).
|
||||
EMA officers will verify authenticity before approving your Seaman Book application.
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Upload modal */}
|
||||
<UploadModal
|
||||
item={modalItem}
|
||||
opened={!!modalItem}
|
||||
onClose={() => setModalItem(null)}
|
||||
onUploaded={handleUploaded}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconClock,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
const MOCK_COC_APPS = [
|
||||
{
|
||||
id: 'COC-APP-2025-001',
|
||||
type: 'CoC — STCW II/1 Officer in Charge of Navigational Watch',
|
||||
submitted: '2025-03-10',
|
||||
examDate: '2025-04-15',
|
||||
examVenue: 'EMA HQ — Addis Ababa',
|
||||
status: 'Examination Scheduled',
|
||||
statusColor: 'indigo',
|
||||
statusNote: 'TRB inspected and approved by EMA officer. Attend your scheduled examination.',
|
||||
},
|
||||
{
|
||||
id: 'COC-APP-2025-005',
|
||||
type: 'CoC — STCW II/5 Able Seafarer Deck (AB)',
|
||||
submitted: '2025-05-01',
|
||||
examDate: null,
|
||||
examVenue: null,
|
||||
status: 'TRB Inspection',
|
||||
statusColor: 'yellow',
|
||||
statusNote: 'Your TRB is being physically inspected by an EMA officer. You may be contacted to bring the original document.',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_CERTIFICATES = [
|
||||
{
|
||||
id: 'COC-2023-0042',
|
||||
type: 'CoC — STCW II/1',
|
||||
issued: '2023-06-20',
|
||||
expiry: '2028-06-20',
|
||||
status: 'Valid',
|
||||
statusColor: 'teal',
|
||||
},
|
||||
];
|
||||
|
||||
// blank PDF for demo
|
||||
const BLANK_PDF =
|
||||
'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
|
||||
|
||||
export function CertificatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewTitle, setPreviewTitle] = useState('');
|
||||
|
||||
const openPreview = (title: string) => {
|
||||
setPreviewTitle(title);
|
||||
setPreviewUrl(BLANK_PDF);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Title order={3}>Certificates (CoC / CoP)</Title>
|
||||
<Text fz="sm" c="dimmed">Certificate of Competency and Certificate of Proficiency under STCW</Text>
|
||||
</div>
|
||||
<Button
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/certificates/apply')}
|
||||
>
|
||||
Apply for CoC / CoP
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Info banner */}
|
||||
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<ThemeIcon size={48} radius="md" color="blue" variant="light"><IconShieldCheck size={24} /></ThemeIcon>
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Text fw={700} fz="sm">What is a CoC / CoP?</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
||||
{[
|
||||
{ icon: IconBook2, color: 'blue', title: 'Certificate of Competency (CoC)', desc: 'Authorizes the holder to serve as an officer or master on board a ship under STCW.' },
|
||||
{ icon: IconCertificate, color: 'teal', title: 'Certificate of Proficiency (CoP)', desc: 'Certifies completion of specific STCW training for specialized duties on board.' },
|
||||
{ icon: IconClock, color: 'orange', title: 'Validity', desc: 'CoC/CoP certificates are valid for 5 years and must be revalidated before expiry.' },
|
||||
].map(({ icon: Icon, color, title, desc }) => (
|
||||
<Card key={title} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
|
||||
<Text fz="xs" fw={700}>{title}</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Active applications */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Applications</Text>
|
||||
{MOCK_COC_APPS.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No active CoC/CoP applications. Click "Apply for CoC / CoP" to start.
|
||||
</Alert>
|
||||
) : (
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['App ID', 'Certificate Type', 'Submitted', 'Exam Date / Status Note', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{MOCK_COC_APPS.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td><Text fz="xs" fw={600} c="blue.7">{app.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" fw={500} maw={220} style={{ lineHeight: 1.3 }}>{app.type}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{app.submitted}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
{app.examDate
|
||||
? <><Text fz="xs" fw={500}>{app.examDate}</Text><Text fz="xs" c="dimmed">{app.examVenue}</Text></>
|
||||
: <Text fz="xs" c="dimmed" maw={200} lh={1.3}>{app.statusNote}</Text>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={app.statusColor} variant="light" size="sm">{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" c="blue" style={{ cursor: 'pointer' }}>Details</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Issued certificates */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Certificates</Text>
|
||||
{MOCK_CERTIFICATES.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No certificates issued yet.
|
||||
</Alert>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{MOCK_CERTIFICATES.map((cert) => (
|
||||
<Card key={cert.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShieldCheck size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{cert.type}</Text>
|
||||
<Text fz="xs" c="dimmed">{cert.id}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={cert.statusColor} variant="light">{cert.status}</Badge>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{cert.issued}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{cert.expiry}</Text></div>
|
||||
</SimpleGrid>
|
||||
<Group mt="sm" gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconEye size={12} />} onClick={() => openPreview(cert.type)}>View</Button>
|
||||
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Preview modal */}
|
||||
<Modal
|
||||
opened={!!previewUrl}
|
||||
onClose={() => setPreviewUrl(null)}
|
||||
title={<Text fw={700} fz="sm">{previewTitle}</Text>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<iframe
|
||||
src={previewUrl ?? ''}
|
||||
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }}
|
||||
title={previewTitle}
|
||||
/>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -10,23 +13,59 @@ import {
|
||||
Title,
|
||||
UnstyledButton,
|
||||
useMantineTheme,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBook2,
|
||||
IconChevronRight,
|
||||
IconClipboardList,
|
||||
IconFileCheck,
|
||||
IconHeart,
|
||||
IconLifebuoy,
|
||||
IconShip,
|
||||
IconShieldCheck,
|
||||
IconUserPlus,
|
||||
IconBell,
|
||||
} from '@tabler/icons-react';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock profile completeness — replace with real store/API data
|
||||
// ---------------------------------------------------------------------------
|
||||
const PROFILE_STEPS = [
|
||||
{ label: 'Personal Information', done: true },
|
||||
{ label: 'Document Upload', done: true },
|
||||
{ label: 'Medical Certificate', done: false },
|
||||
{ label: 'Basic Safety Training', done: false },
|
||||
{ label: 'Seaman Book', done: false },
|
||||
];
|
||||
|
||||
const BST_ITEMS = [
|
||||
{ label: 'Personal Survival Techniques (PST)', done: true, expiry: '2028-04-10' },
|
||||
{ label: 'Fire Prevention & Fire Fighting (FPFF)', done: true, expiry: '2028-04-10' },
|
||||
{ label: 'Elementary First Aid (EFA)', done: false, expiry: null },
|
||||
{ label: 'Personal Safety & Social Responsibility (PSSR)', done: false, expiry: null },
|
||||
{ label: 'Sexual Harassment Prevention', done: false, expiry: null },
|
||||
];
|
||||
|
||||
const ALERTS = [
|
||||
{ id: 1, type: 'warning', message: 'Medical certificate expires in 45 days. Please renew before it lapses.', route: '/medical-certificate' },
|
||||
{ id: 2, type: 'info', message: '3 Basic Safety Training certificates are missing. Complete them to apply for a Seaman Book.', route: '/basic-safety-training' },
|
||||
];
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const doneSteps = PROFILE_STEPS.filter((s) => s.done).length;
|
||||
const completeness = Math.round((doneSteps / PROFILE_STEPS.length) * 100);
|
||||
const bstDone = BST_ITEMS.filter((b) => b.done).length;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* ---- Hero banner ------------------------------------------- */}
|
||||
{/* Hero */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
p="xl"
|
||||
@@ -36,11 +75,11 @@ export function DashboardPage() {
|
||||
<Stack gap="md" maw={560}>
|
||||
<Stack gap={6}>
|
||||
<Title order={2} c="white" fz={26}>
|
||||
Welcome to the EMA Portal
|
||||
Welcome to the EMA Seafarer Portal
|
||||
</Title>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
|
||||
Manage your seafarer profile, submit applications, and track your
|
||||
maritime credentials all in one place.
|
||||
Manage your seafarer profile, track certificates, apply for your Seaman Book
|
||||
and monitor your maritime credentials — all in one place.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -55,54 +94,144 @@ export function DashboardPage() {
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* ---- Quick actions ----------------------------------------- */}
|
||||
{/* Alerts */}
|
||||
{ALERTS.map((alert) => (
|
||||
<Alert
|
||||
key={alert.id}
|
||||
variant="light"
|
||||
color={alert.type === 'warning' ? 'orange' : 'blue'}
|
||||
icon={alert.type === 'warning' ? <IconAlertCircle size={17} /> : <IconBell size={17} />}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(alert.route)}
|
||||
>
|
||||
{alert.message}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
{/* Status cards */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatusCard label="Profile Complete" value={`${completeness}%`} icon={IconUserPlus} color="blue" />
|
||||
<StatusCard label="BST Certificates" value={`${bstDone} / ${BST_ITEMS.length}`} icon={IconShieldCheck} color="teal" />
|
||||
<StatusCard label="Medical Status" value="Expiring" icon={IconHeart} color="orange" />
|
||||
<StatusCard label="Seaman Book" value="Not Applied" icon={IconBook2} color="gray" />
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
{/* Profile completeness */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={4} mb="md">
|
||||
Quick actions
|
||||
</Title>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700}>Registration Checklist</Text>
|
||||
<Badge variant="light" color={completeness === 100 ? 'teal' : 'blue'}>
|
||||
{completeness}% complete
|
||||
</Badge>
|
||||
</Group>
|
||||
<Progress value={completeness} color={completeness === 100 ? 'teal' : 'blue'} mb="md" radius="xl" size="sm" />
|
||||
<Stack gap="xs">
|
||||
<QuickAction
|
||||
icon={IconUserPlus}
|
||||
color="emaPrimary"
|
||||
label="Register a new seafarer"
|
||||
onClick={() => navigate('/seafarer-registration')}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={IconLifebuoy}
|
||||
color="orange"
|
||||
label="Contact support"
|
||||
onClick={() => navigate('/support')}
|
||||
/>
|
||||
{PROFILE_STEPS.map((step) => (
|
||||
<Group key={step.label} gap="xs">
|
||||
<ThemeIcon
|
||||
size={20}
|
||||
radius="xl"
|
||||
variant={step.done ? 'filled' : 'light'}
|
||||
color={step.done ? 'teal' : 'gray'}
|
||||
>
|
||||
<IconFileCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={step.done ? undefined : 'dimmed'} td={step.done ? undefined : undefined}>
|
||||
{step.label}
|
||||
</Text>
|
||||
{step.done && <Badge size="xs" color="teal" variant="light" ml="auto">Done</Badge>}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* BST tracker */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700}>Basic Safety Training</Text>
|
||||
<Badge variant="light" color={bstDone === BST_ITEMS.length ? 'teal' : 'orange'}>
|
||||
{bstDone} / {BST_ITEMS.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{BST_ITEMS.map((item) => (
|
||||
<Group key={item.label} gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size={20} radius="xl" variant={item.done ? 'filled' : 'light'} color={item.done ? 'teal' : 'gray'} style={{ flexShrink: 0 }}>
|
||||
<IconShieldCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" flex={1} c={item.done ? undefined : 'dimmed'} style={{ lineHeight: 1.3 }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.done && item.expiry && (
|
||||
<Text fz="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>exp {item.expiry}</Text>
|
||||
)}
|
||||
{!item.done && (
|
||||
<Badge size="xs" color="red" variant="light">Missing</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Quick actions */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Text fw={700} mb="md">Quick Actions</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="sm">
|
||||
<QuickAction icon={IconUserPlus} color="emaPrimary" label="New Seafarer Registration" sub="Register a new seafarer profile" onClick={() => navigate('/seafarer-registration')} />
|
||||
<QuickAction icon={IconBook2} color="blue" label="Apply for Seaman Book" sub="Submit your Seaman Book application" onClick={() => navigate('/seaman-book')} />
|
||||
<QuickAction icon={IconShieldCheck} color="teal" label="Certificates (CoC / CoP)" sub="Apply for STCW certificates" onClick={() => navigate('/certificates')} />
|
||||
<QuickAction icon={IconHeart} color="red" label="Medical Certificate" sub="Upload or renew your medical certificate" onClick={() => navigate('/documents')} />
|
||||
<QuickAction icon={IconClipboardList} color="violet" label="Document Vault" sub="Manage all your uploaded documents" onClick={() => navigate('/documents')} />
|
||||
<QuickAction icon={IconLifebuoy} color="orange" label="Help & Support" sub="Get assistance from EMA staff" onClick={() => navigate('/support')} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({ label, value, icon: Icon, color }: { label: string; value: string; icon: Icon; color: string }) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text fz="xl" fw={700} lh={1}>{value}</Text>
|
||||
<Text fz="xs" c="dimmed" mt={4}>{label}</Text>
|
||||
</div>
|
||||
<ThemeIcon variant="light" color={color} size={46} radius="md">
|
||||
<Icon size={22} stroke={1.6} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({
|
||||
icon: ActionIconCmp,
|
||||
color,
|
||||
label,
|
||||
sub,
|
||||
onClick,
|
||||
}: {
|
||||
icon: Icon;
|
||||
color: string;
|
||||
label: string;
|
||||
sub: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton onClick={onClick}>
|
||||
<Card padding="xs" radius="md" bg="var(--mantine-color-default-hover)">
|
||||
<UnstyledButton onClick={onClick} style={{ width: '100%' }}>
|
||||
<Card padding="sm" radius="md" bg="var(--mantine-color-default-hover)" style={{ height: '100%' }}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={38} radius="md">
|
||||
<ActionIconCmp size={19} />
|
||||
<ThemeIcon variant="light" color={color} size={42} radius="md" style={{ flexShrink: 0 }}>
|
||||
<ActionIconCmp size={20} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" fw={500} flex={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="sm" fw={600} lh={1.3}>{label}</Text>
|
||||
<Text fz="xs" c="dimmed" mt={2} lh={1.3}>{sub}</Text>
|
||||
</div>
|
||||
<IconChevronRight size={16} style={{ opacity: 0.4, flexShrink: 0 }} />
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconCircleCheck,
|
||||
IconCloudDownload,
|
||||
IconDotsVertical,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconFileCheck,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconId,
|
||||
IconPhoto,
|
||||
IconSchool,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EMA-issued / system-generated documents
|
||||
// ---------------------------------------------------------------------------
|
||||
interface IssuedDoc {
|
||||
key: string;
|
||||
label: string;
|
||||
category: 'certificate' | 'book';
|
||||
color: string;
|
||||
icon: typeof IconBook2;
|
||||
issuedDate: string;
|
||||
expiryDate: string | null;
|
||||
status: 'issued' | 'pending' | 'expired';
|
||||
description: string;
|
||||
}
|
||||
|
||||
const ISSUED_DOCS: IssuedDoc[] = [
|
||||
{
|
||||
key: 'seaman-book',
|
||||
label: 'Seaman Book',
|
||||
category: 'book',
|
||||
color: 'blue',
|
||||
icon: IconBook2,
|
||||
issuedDate: '2024-06-01',
|
||||
expiryDate: '2029-06-01',
|
||||
status: 'issued',
|
||||
description: 'Official EMA-issued seafarer identification document. Valid for 5 years.',
|
||||
},
|
||||
{
|
||||
key: 'btc',
|
||||
label: 'Basic Training Certificate (BTC)',
|
||||
category: 'certificate',
|
||||
color: 'teal',
|
||||
icon: IconCertificate,
|
||||
issuedDate: '2024-06-01',
|
||||
expiryDate: '2029-06-01',
|
||||
status: 'issued',
|
||||
description: 'EMA-issued BTC certifying completion of all 5 basic safety training courses.',
|
||||
},
|
||||
{
|
||||
key: 'medical',
|
||||
label: 'Medical Fitness Certificate',
|
||||
category: 'certificate',
|
||||
color: 'pink',
|
||||
icon: IconHeart,
|
||||
issuedDate: '2024-03-20',
|
||||
expiryDate: '2026-03-20',
|
||||
status: 'issued',
|
||||
description: 'Medical fitness certificate from an EMA-approved medical centre.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BTC sub-certificates (the 5 training certs that qualify you for BTC)
|
||||
// ---------------------------------------------------------------------------
|
||||
interface BtcCert {
|
||||
key: string;
|
||||
short: string;
|
||||
label: string;
|
||||
certNumber: string;
|
||||
issuer: string;
|
||||
issueDate: string;
|
||||
expiryDate: string | null;
|
||||
}
|
||||
|
||||
const BTC_CERTS: BtcCert[] = [
|
||||
{ key: 'pst', short: 'PST', label: 'Personal Survival Techniques', certNumber: 'PST-2024-001', issuer: 'Bahirdar Maritime School', issueDate: '2024-01-15', expiryDate: '2029-01-15' },
|
||||
{ key: 'fpff', short: 'FPFF', label: 'Fire Prevention & Fire Fighting', certNumber: 'FPFF-2024-002', issuer: 'Bahirdar Maritime School', issueDate: '2024-01-16', expiryDate: '2029-01-16' },
|
||||
{ key: 'efa', short: 'EFA', label: 'Elementary First Aid', certNumber: 'EFA-2024-003', issuer: 'EMA Training Centre', issueDate: '2024-02-01', expiryDate: null },
|
||||
{ key: 'pssr', short: 'PSSR', label: 'Personal Safety & Social Responsibility', certNumber: 'PSSR-2024-004', issuer: 'EMA Training Centre', issueDate: '2024-02-02', expiryDate: null },
|
||||
{ key: 'shpt', short: 'SHPT', label: 'Sexual Harassment Prevention Training', certNumber: 'SHPT-2024-005', issuer: 'EMA Training Centre', issueDate: '2024-02-03', expiryDate: null },
|
||||
];
|
||||
|
||||
const STATUS_COLOR = { issued: 'teal', pending: 'yellow', expired: 'red' } as const;
|
||||
const STATUS_LABEL = { issued: 'Issued', pending: 'Pending', expired: 'Expired' } as const;
|
||||
|
||||
// Demo PDF for preview
|
||||
const DEMO_PDF =
|
||||
'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uploaded documents (user-provided supporting docs)
|
||||
// ---------------------------------------------------------------------------
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
category: 'Identity' | 'Education' | 'Photo';
|
||||
accept: string;
|
||||
}
|
||||
|
||||
interface UploadedDoc {
|
||||
key: string;
|
||||
file: File;
|
||||
uploadedAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const DOC_SLOTS: DocSlot[] = [
|
||||
{ key: 'nationalId', label: 'National ID / Fayda', description: 'Front and back of your national identity card', required: true, icon: IconId, category: 'Identity', accept: 'application/pdf,image/jpeg,image/png' },
|
||||
{ key: 'passport', label: 'Passport', description: 'Bio-data page of a valid passport', required: false, icon: IconFileDescription, category: 'Identity', accept: 'application/pdf,image/jpeg,image/png' },
|
||||
{ key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5 cm', required: true, icon: IconPhoto, category: 'Photo', accept: 'image/jpeg,image/png' },
|
||||
{ key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification certificate', required: false, icon: IconSchool, category: 'Education', accept: 'application/pdf,image/jpeg,image/png' },
|
||||
{ key: 'transcript', label: 'Academic Transcript', description: 'Official academic transcript from institution', required: false, icon: IconSchool, category: 'Education', accept: 'application/pdf,image/jpeg,image/png' },
|
||||
];
|
||||
|
||||
const UPLOAD_CATEGORIES = ['All', 'Identity', 'Photo', 'Education'] as const;
|
||||
type UploadCategory = typeof UPLOAD_CATEGORIES[number];
|
||||
|
||||
const CAT_COLOR: Record<string, string> = { Identity: 'blue', Photo: 'violet', Education: 'teal' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
export function DocumentVaultPage() {
|
||||
const [docs, setDocs] = useState<Record<string, UploadedDoc | null>>(() =>
|
||||
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||
);
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState<UploadCategory>('All');
|
||||
const [previewDoc, setPreviewDoc] = useState<{ name: string; url: string; isImage: boolean } | null>(null);
|
||||
const resetRefs = useRef<Record<string, (() => void) | null>>({});
|
||||
|
||||
const handleUpload = (key: string) => (file: File | null) => {
|
||||
if (!file) return;
|
||||
if (file.size > 5 * 1024 * 1024) { notify.error('File exceeds 5MB limit.'); return; }
|
||||
const url = URL.createObjectURL(file);
|
||||
setDocs((prev) => ({ ...prev, [key]: { key, file, uploadedAt: new Date().toLocaleDateString('en-GB'), url } }));
|
||||
notify.success(`${file.name} uploaded.`);
|
||||
};
|
||||
|
||||
const handleRemove = (key: string) => {
|
||||
const doc = docs[key];
|
||||
if (doc) URL.revokeObjectURL(doc.url);
|
||||
setDocs((prev) => ({ ...prev, [key]: null }));
|
||||
resetRefs.current[key]?.();
|
||||
notify.info('Document removed.');
|
||||
};
|
||||
|
||||
const filtered = DOC_SLOTS.filter((slot) => {
|
||||
const matchCat = category === 'All' || slot.category === category;
|
||||
const matchSearch = !search || slot.label.toLowerCase().includes(search.toLowerCase());
|
||||
return matchCat && matchSearch;
|
||||
});
|
||||
|
||||
const uploadedCount = Object.values(docs).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>My Documents</Title>
|
||||
<Text fz="sm" c="dimmed">All your EMA-issued certificates and uploaded supporting documents</Text>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="issued" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="issued" leftSection={<IconFileCheck size={16} />}>
|
||||
Certificates & Issued Documents
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="uploaded" leftSection={<IconUpload size={16} />}>
|
||||
Uploaded Documents
|
||||
<Badge size="xs" variant="light" color="blue" ml={6}>{uploadedCount} / {DOC_SLOTS.length}</Badge>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ── Certificates & Issued Documents ──────────────────────────── */}
|
||||
<Tabs.Panel value="issued">
|
||||
<Stack gap="xl">
|
||||
{/* EMA-issued documents */}
|
||||
<div>
|
||||
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="gray.6">EMA Issued Documents</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{ISSUED_DOCS.map((doc) => {
|
||||
const DocIcon = doc.icon;
|
||||
return (
|
||||
<Card key={doc.key} withBorder radius="lg" p="md"
|
||||
style={{
|
||||
borderColor: doc.status === 'issued'
|
||||
? `var(--mantine-color-${doc.color}-3)`
|
||||
: doc.status === 'expired'
|
||||
? 'var(--mantine-color-red-3)'
|
||||
: 'var(--mantine-color-yellow-3)',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<ThemeIcon size="xl" variant="light" color={doc.color} radius="lg">
|
||||
<DocIcon size={22} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={700} fz="sm" lh={1.2}>{doc.label}</Text>
|
||||
<Badge size="xs" variant="light" color={STATUS_COLOR[doc.status]} mt={3}>
|
||||
{STATUS_LABEL[doc.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" mb="sm" lh={1.4}>{doc.description}</Text>
|
||||
<Stack gap={3} mb="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Issued</Text>
|
||||
<Text fz="xs" fw={500}>{doc.issuedDate}</Text>
|
||||
</Group>
|
||||
{doc.expiryDate && (
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Expires</Text>
|
||||
<Text fz="xs" fw={500}>{doc.expiryDate}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider mb="sm" />
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" color={doc.color} leftSection={<IconEye size={13} />} style={{ flex: 1 }}
|
||||
onClick={() => setPreviewDoc({ name: doc.label, url: DEMO_PDF, isImage: false })}>
|
||||
View
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconCloudDownload size={13} />}
|
||||
component="a" href={DEMO_PDF} download={`${doc.label}.pdf`}>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
{/* BTC training certs — all 5 listed */}
|
||||
<div>
|
||||
<Group gap="sm" mb="sm" align="center">
|
||||
<ThemeIcon size="md" variant="light" color="teal" radius="md">
|
||||
<IconShieldCheck size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm" tt="uppercase" c="gray.6">BTC Training Certificates (5/5)</Text>
|
||||
<Text fz="xs" c="dimmed">Submitted training certificates that qualified you for the BTC</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{BTC_CERTS.map((cert) => (
|
||||
<Card key={cert.key} withBorder radius="md" p="md"
|
||||
style={{ borderColor: 'var(--mantine-color-teal-3)' }}>
|
||||
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||
<ThemeIcon size="lg" variant="light" color="teal" radius="md">
|
||||
<IconShieldCheck size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} align="center">
|
||||
<Text fw={700} fz="sm">{cert.short}</Text>
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-teal-6)" />
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" lh={1.3}>{cert.label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap={3} mb="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Cert No.</Text>
|
||||
<Text fz="xs" fw={500}>{cert.certNumber}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Issuer</Text>
|
||||
<Text fz="xs" fw={500} style={{ textAlign: 'right', maxWidth: rem(140) }}>{cert.issuer}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Issued</Text>
|
||||
<Text fz="xs" fw={500}>{cert.issueDate}</Text>
|
||||
</Group>
|
||||
{cert.expiryDate && (
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Expires</Text>
|
||||
<Text fz="xs" fw={500}>{cert.expiryDate}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconEye size={13} />} fullWidth
|
||||
onClick={() => setPreviewDoc({ name: `${cert.short} Certificate`, url: DEMO_PDF, isImage: false })}>
|
||||
View Certificate
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Uploaded supporting documents ─────────────────────────────── */}
|
||||
<Tabs.Panel value="uploaded">
|
||||
<Stack gap="md">
|
||||
{/* Category summary */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm">
|
||||
{(['Identity', 'Photo', 'Education'] as const).map((cat) => {
|
||||
const slots = DOC_SLOTS.filter((s) => s.category === cat);
|
||||
const done = slots.filter((s) => docs[s.key]).length;
|
||||
return (
|
||||
<Card key={cat} withBorder radius="md" p="sm" style={{ cursor: 'pointer' }} onClick={() => setCategory(cat)}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={CAT_COLOR[cat]} size={36} radius="md">
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed">{cat}</Text>
|
||||
<Text fz="sm" fw={700}>{done}/{slots.length}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Filters */}
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search documents…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ minWidth: rem(220) }}
|
||||
rightSection={search ? (
|
||||
<ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon>
|
||||
) : null}
|
||||
/>
|
||||
<Group gap={6}>
|
||||
{UPLOAD_CATEGORIES.map((cat) => (
|
||||
<Button key={cat} size="xs"
|
||||
variant={category === cat ? 'filled' : 'light'}
|
||||
color={cat === 'All' ? 'gray' : CAT_COLOR[cat] ?? 'gray'}
|
||||
onClick={() => setCategory(cat)}>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Document cards */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{filtered.map((slot) => {
|
||||
const doc = docs[slot.key];
|
||||
const SlotIcon = slot.icon;
|
||||
const resetRef = { current: null as (() => void) | null };
|
||||
return (
|
||||
<Card key={slot.key} withBorder radius="md" p="md"
|
||||
style={{
|
||||
borderStyle: doc ? 'solid' : 'dashed',
|
||||
borderColor: doc
|
||||
? 'var(--mantine-color-teal-5)'
|
||||
: slot.required
|
||||
? 'var(--mantine-color-orange-4)'
|
||||
: '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: doc ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<SlotIcon size={20} color={doc ? '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" lh={1.3}>{slot.label}</Text>
|
||||
{slot.required && !doc && <Text span c="red" fz="xs">*</Text>}
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" lh={1.3}>{slot.description}</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={CAT_COLOR[slot.category]}>{slot.category}</Badge>
|
||||
</Group>
|
||||
{doc ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{doc.file.name}</Text>
|
||||
<Text fz="xs" c="dimmed">{doc.uploadedAt}</Text>
|
||||
<Menu position="bottom-end" shadow="sm" width={140} withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="xs">
|
||||
<IconDotsVertical size={13} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEye size={13} />}
|
||||
onClick={() => setPreviewDoc({ name: doc.file.name, url: doc.url, isImage: doc.file.type.startsWith('image/') })}>
|
||||
Preview
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconDownload size={13} />} component="a" href={doc.url} download={doc.file.name}>
|
||||
Download
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconTrash size={13} />} color="red" onClick={() => handleRemove(slot.key)}>
|
||||
Remove
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={handleUpload(slot.key)} accept={slot.accept}>
|
||||
{(props) => {
|
||||
resetRefs.current[slot.key] = resetRef.current;
|
||||
return (
|
||||
<Button size="xs" variant="light" leftSection={<IconUpload size={13} />} fullWidth {...props}>
|
||||
Upload Document
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<Paper withBorder radius="md" p="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
|
||||
<IconFileDescription size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No documents match your search.</Text>
|
||||
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setSearch(''); setCategory('All'); }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* Preview modal */}
|
||||
<Modal
|
||||
opened={!!previewDoc}
|
||||
onClose={() => setPreviewDoc(null)}
|
||||
title={<Text fw={700}>{previewDoc?.name}</Text>}
|
||||
size="xl"
|
||||
centered
|
||||
styles={{ body: { padding: 0, minHeight: rem(500) } }}
|
||||
>
|
||||
{previewDoc && (
|
||||
previewDoc.isImage
|
||||
? <img src={previewDoc.url} alt={previewDoc.name} style={{ width: '100%', borderRadius: rem(8) }} />
|
||||
: <iframe src={previewDoc.url} title={previewDoc.name} style={{ width: '100%', height: rem(500), border: 'none' }} />
|
||||
)}
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
List,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconRubberStamp,
|
||||
IconShieldCheck,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data — existing endorsement applications
|
||||
// ---------------------------------------------------------------------------
|
||||
const MOCK_ENDORSEMENTS = [
|
||||
{
|
||||
id: 'END-APP-2025-001',
|
||||
cocType: 'Officer in Charge of a Navigational Watch (STCW II/1)',
|
||||
foreignCocNo: 'PHL-COC-2022-0045',
|
||||
issuingCountry: 'Philippines',
|
||||
submitted: '2025-04-05',
|
||||
status: 'Document Verification',
|
||||
statusColor: 'blue',
|
||||
statusNote: 'EMA is verifying your documents. You will be notified when verification is complete.',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_ISSUED = [
|
||||
{
|
||||
id: 'EMA-END-2024-012',
|
||||
cocType: 'Chief Mate — STCW II/2',
|
||||
foreignCocNo: 'GRC-COC-2019-0033',
|
||||
issuingCountry: 'Greece',
|
||||
endorsementNo: 'EMA-END-2024-012',
|
||||
issued: '2024-08-10',
|
||||
expiry: '2029-06-15',
|
||||
status: 'Valid',
|
||||
statusColor: 'teal',
|
||||
},
|
||||
];
|
||||
|
||||
// blank PDF
|
||||
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Application wizard
|
||||
// ---------------------------------------------------------------------------
|
||||
interface Docs {
|
||||
foreignCoc: File | null;
|
||||
translation: File | null;
|
||||
medical: File | null;
|
||||
seamanBook: File | null;
|
||||
photo: File | null;
|
||||
}
|
||||
|
||||
function ApplicationWizard({ onDone }: { onDone: () => void }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [cocNo, setCocNo] = useState('');
|
||||
const [issuer, setIssuer] = useState('');
|
||||
const [country, setCountry] = useState('');
|
||||
const [cocType, setCocType] = useState('');
|
||||
const [issueDate, setIssueDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
const [docs, setDocs] = useState<Docs>({ foreignCoc: null, translation: null, medical: null, seamanBook: null, photo: null });
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const step0Ok = !!cocNo && !!issuer && !!country && !!cocType && !!issueDate && !!expiryDate;
|
||||
const step1Ok = !!docs.foreignCoc && !!docs.medical && !!docs.seamanBook && !!docs.photo;
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<Stack gap="lg" align="center" py="xl">
|
||||
<ThemeIcon size={72} radius="xl" color="teal" variant="light"><IconCircleCheck size={40} /></ThemeIcon>
|
||||
<Title order={3} ta="center">Application Submitted</Title>
|
||||
<Text c="dimmed" ta="center" maw={400}>
|
||||
Your endorsement application has been submitted. EMA officers will verify your documents
|
||||
and notify you of the outcome. Reference: <strong>END-APP-2025-NEW</strong>
|
||||
</Text>
|
||||
<Button onClick={onDone}>Back to Endorsements</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stepper active={step} size="sm">
|
||||
<Stepper.Step label="Foreign CoC Details" description="Certificate information" />
|
||||
<Stepper.Step label="Upload Documents" description="Required documents" />
|
||||
<Stepper.Step label="Payment" description="Pay endorsement fee" />
|
||||
<Stepper.Step label="Review & Submit" description="Final check" />
|
||||
</Stepper>
|
||||
|
||||
{/* Step 0 — Foreign CoC details */}
|
||||
{step === 0 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="lg">
|
||||
<Text fz="sm">
|
||||
<strong>STCW Regulation I/10</strong> — EMA will endorse your foreign CoC so it is
|
||||
recognised for service on Ethiopian-flagged vessels. The endorsement is valid
|
||||
for the same period as your foreign CoC.
|
||||
</Text>
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="Foreign CoC Number" placeholder="e.g. PHL-COC-2022-0045" value={cocNo} onChange={(e) => setCocNo(e.currentTarget.value)} required />
|
||||
<TextInput label="Issuing Country" placeholder="e.g. Philippines" value={country} onChange={(e) => setCountry(e.currentTarget.value)} required />
|
||||
<TextInput label="Issuing Authority / Administration" placeholder="e.g. Maritime Industry Authority (MARINA)" value={issuer} onChange={(e) => setIssuer(e.currentTarget.value)} required />
|
||||
<TextInput label="Certificate Type" placeholder="e.g. Officer in Charge of a Navigational Watch" value={cocType} onChange={(e) => setCocType(e.currentTarget.value)} required />
|
||||
<TextInput label="Issue Date" type="date" value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} required />
|
||||
<TextInput label="Expiry Date" type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.currentTarget.value)} required />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 1 — Documents */}
|
||||
{step === 1 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="yellow" icon={<IconAlertCircle size={15} />}>
|
||||
<Text fz="sm">
|
||||
A <strong>certified translation</strong> is required if your foreign CoC is not in English.
|
||||
All documents must be clear, legible, and complete.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">Required Documents</Text>
|
||||
<Stack gap="md">
|
||||
{[
|
||||
{ key: 'foreignCoc' as keyof Docs, label: 'Foreign CoC (Original or Certified Copy)', required: true },
|
||||
{ key: 'translation' as keyof Docs, label: 'Certified Translation (only if CoC is not in English)', required: false },
|
||||
{ key: 'medical' as keyof Docs, label: 'Valid Medical Fitness Certificate (STCW Reg I/2)', required: true },
|
||||
{ key: 'seamanBook' as keyof Docs, label: 'Ethiopian Seaman Book', required: true },
|
||||
{ key: 'photo' as keyof Docs, label: 'Passport-Size Photo', required: true },
|
||||
].map((slot) => (
|
||||
<FileInput
|
||||
key={slot.key}
|
||||
label={<Group gap={4}><Text fz="sm" fw={500}>{slot.label}</Text>{slot.required && <Badge size="xs" color="red" variant="light">Required</Badge>}</Group>}
|
||||
placeholder="Click to upload"
|
||||
leftSection={<IconUpload size={14} />}
|
||||
value={docs[slot.key]}
|
||||
onChange={(f) => setDocs((prev) => ({ ...prev, [slot.key]: f }))}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
clearable
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Upload checklist */}
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0">
|
||||
<Text fz="xs" fw={700} mb="sm" tt="uppercase" c="dimmed">Upload Checklist</Text>
|
||||
<Stack gap={4}>
|
||||
{[
|
||||
{ label: 'Foreign CoC', done: !!docs.foreignCoc },
|
||||
{ label: 'Medical Cert', done: !!docs.medical },
|
||||
{ label: 'Seaman Book', done: !!docs.seamanBook },
|
||||
{ label: 'Photo', done: !!docs.photo },
|
||||
].map((item) => (
|
||||
<Group key={item.label} gap="xs">
|
||||
<ThemeIcon size={18} radius="xl" color={item.done ? 'teal' : 'gray'} variant={item.done ? 'filled' : 'light'}>
|
||||
{item.done ? <IconCheck size={11} /> : <IconFileDescription size={11} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="xs" c={item.done ? undefined : 'dimmed'}>{item.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Payment */}
|
||||
{step === 2 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">Endorsement Fee</Text>
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
|
||||
{[
|
||||
{ label: 'Application Processing Fee', amount: 300 },
|
||||
{ label: 'Document Verification Fee', amount: 200 },
|
||||
{ label: 'Endorsement Issuance Fee', amount: 500 },
|
||||
].map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb="xs">
|
||||
<Text fz="sm">{label}</Text>
|
||||
<Text fz="sm" fw={600}>ETB {amount}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="xs" />
|
||||
<Group justify="space-between">
|
||||
<Text fw={800}>Total</Text>
|
||||
<Text fw={800} fz="lg" c="blue">ETB 1,000</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
|
||||
<Text fz="sm">
|
||||
Transfer the fee to <strong>CBE Account: 1000-XXXXX-EMA</strong> and upload the receipt below.
|
||||
</Text>
|
||||
</Alert>
|
||||
<FileInput label="Payment Receipt" placeholder="Upload bank transfer receipt" leftSection={<IconUpload size={14} />} mt="md" accept=".pdf,.jpg,.jpeg,.png" />
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Review */}
|
||||
{step === 3 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="lg">Review Your Application</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="lg">
|
||||
{[
|
||||
['CoC Number', cocNo],
|
||||
['Country', country],
|
||||
['Issuer', issuer],
|
||||
['CoC Type', cocType],
|
||||
['Issue Date', issueDate],
|
||||
['Expiry Date', expiryDate],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb="xs">Uploaded Documents</Text>
|
||||
<List spacing="xs" size="sm">
|
||||
{[
|
||||
{ label: 'Foreign CoC', file: docs.foreignCoc },
|
||||
{ label: 'Medical Certificate', file: docs.medical },
|
||||
{ label: 'Seaman Book', file: docs.seamanBook },
|
||||
{ label: 'Photo', file: docs.photo },
|
||||
{ label: 'Translation', file: docs.translation },
|
||||
].map(({ label, file }) => file && (
|
||||
<List.Item key={label} icon={<ThemeIcon size={18} radius="xl" color="teal" variant="filled"><IconCheck size={11} /></ThemeIcon>}>
|
||||
<Text fz="sm">{label}: <Text span c="blue.7">{file.name}</Text></Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />} mt="lg">
|
||||
<Text fz="xs">
|
||||
By submitting you confirm that all information is accurate and the documents are genuine.
|
||||
Providing false information is an offence under the Maritime Code.
|
||||
</Text>
|
||||
</Alert>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={14} />} onClick={() => setStep(s => s - 1)} disabled={step === 0}>
|
||||
Back
|
||||
</Button>
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
disabled={(step === 0 && !step0Ok) || (step === 1 && !step1Ok)}
|
||||
onClick={() => setStep(s => s + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconCircleCheck size={14} />} onClick={() => setSubmitted(true)}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function EndorsementPage() {
|
||||
const navigate = useNavigate();
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [previewId, setPreviewId] = useState<string | null>(null);
|
||||
|
||||
if (applying) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => setApplying(false)}>Back</Button>
|
||||
<div>
|
||||
<Title order={3}>Apply for Endorsement</Title>
|
||||
<Text fz="sm" c="dimmed">STCW Reg I/10 — Flag State Endorsement of Foreign CoC</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<ApplicationWizard onDone={() => setApplying(false)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Title order={3}>Endorsements</Title>
|
||||
<Text fz="sm" c="dimmed">STCW Reg I/10 — Flag-state endorsement of foreign-issued Certificates of Competency</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconRubberStamp size={15} />} rightSection={<IconArrowRight size={15} />} onClick={() => setApplying(true)}>
|
||||
Apply for Endorsement
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Info panel */}
|
||||
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<ThemeIcon size={48} radius="md" color="blue" variant="light"><IconRubberStamp size={24} /></ThemeIcon>
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Text fw={700} fz="sm">What is an Endorsement?</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
||||
{[
|
||||
{ icon: IconShieldCheck, color: 'blue', title: 'Flag-State Recognition', desc: 'Under STCW Reg I/10, Ethiopia (flag state) must endorse foreign CoC certificates before a seafarer can serve on Ethiopian-flagged vessels.' },
|
||||
{ icon: IconCircleCheck, color: 'teal', title: 'Co-Terminous Validity', desc: 'The endorsement is valid for the same period as your foreign CoC. It must be revalidated whenever the foreign CoC is revalidated.' },
|
||||
{ icon: IconClock, color: 'orange', title: 'Processing Time', desc: 'Typical processing time is 10–15 working days after all documents are verified.' },
|
||||
].map(({ icon: Icon, color, title, desc }) => (
|
||||
<Card key={title} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
|
||||
<Text fz="xs" fw={700}>{title}</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Active applications */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Endorsement Applications</Text>
|
||||
{MOCK_ENDORSEMENTS.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No active endorsement applications.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{MOCK_ENDORSEMENTS.map((app) => (
|
||||
<Paper key={app.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={700}>{app.cocType}</Text>
|
||||
<Text fz="xs" c="dimmed">CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {app.submitted}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge color={app.statusColor} variant="light">{app.status}</Badge>
|
||||
<Text fz="xs" c="blue.7" fw={600}>{app.id}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Alert variant="light" color={app.statusColor} icon={<IconInfoCircle size={13} />} p="xs" mt="sm">
|
||||
<Text fz="xs">{app.statusNote}</Text>
|
||||
</Alert>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Issued endorsements */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Endorsements</Text>
|
||||
{MOCK_ISSUED.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No endorsements issued yet.
|
||||
</Alert>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{MOCK_ISSUED.map((end) => (
|
||||
<Card key={end.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{end.cocType}</Text>
|
||||
<Text fz="xs" c="dimmed">{end.endorsementNo}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={end.statusColor} variant="light">{end.status}</Badge>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs" mb="sm">
|
||||
<div><Text fz="xs" c="dimmed">Foreign CoC No.</Text><Text fz="sm" fw={500}>{end.foreignCocNo}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Issuing Country</Text><Text fz="sm" fw={500}>{end.issuingCountry}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{end.issued}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{end.expiry}</Text></div>
|
||||
</SimpleGrid>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconEye size={12} />} onClick={() => setPreviewId(end.id)}>View</Button>
|
||||
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Preview modal */}
|
||||
<Modal
|
||||
opened={!!previewId}
|
||||
onClose={() => setPreviewId(null)}
|
||||
title={<Text fw={700} fz="sm">Endorsement Certificate</Text>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<iframe src={BLANK_PDF} style={{ width: '100%', height: '70vh', border: 'none', borderRadius: rem(8) }} title="Endorsement" />
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
FileButton,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconCalendar,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconInfoCircle,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock current certificate — replace with real API data
|
||||
// ---------------------------------------------------------------------------
|
||||
const MOCK_CURRENT: MedicalCert | null = {
|
||||
id: 'MC-2024-001',
|
||||
issuedBy: 'EMA Approved Medical Center — Addis Ababa',
|
||||
issuedDate: '2024-03-15',
|
||||
expiryDate: '2026-03-14',
|
||||
status: 'Expiring',
|
||||
restrictions: 'None',
|
||||
fileName: 'medical_cert_2024.pdf',
|
||||
};
|
||||
|
||||
const MOCK_HISTORY: MedicalCert[] = [
|
||||
{ id: 'MC-2022-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2022-03-10', expiryDate: '2024-03-09', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2022.pdf' },
|
||||
{ id: 'MC-2020-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2020-02-20', expiryDate: '2022-02-19', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2020.pdf' },
|
||||
];
|
||||
|
||||
interface MedicalCert {
|
||||
id: string;
|
||||
issuedBy: string;
|
||||
issuedDate: string;
|
||||
expiryDate: string;
|
||||
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending';
|
||||
restrictions: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Valid: 'teal', Expiring: 'orange', Expired: 'red', Pending: 'yellow',
|
||||
};
|
||||
|
||||
function daysUntil(dateStr: string): number {
|
||||
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
export function MedicalCertificatePage() {
|
||||
const [current] = useState<MedicalCert | null>(MOCK_CURRENT);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [doctorName, setDoctorName] = useState('');
|
||||
const [issuedDate, setIssuedDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const resetRef = useRef<() => void>(null);
|
||||
|
||||
const days = current ? daysUntil(current.expiryDate) : 0;
|
||||
const progressVal = current
|
||||
? Math.max(0, Math.min(100, (days / 730) * 100))
|
||||
: 0;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!uploadedFile || !issuedDate || !expiryDate) {
|
||||
notify.error('Please fill all fields and upload the certificate file.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
setSubmitting(false);
|
||||
notify.success('Medical certificate submitted for verification. EMA will review within 2 working days.');
|
||||
setUploadedFile(null);
|
||||
setDoctorName('');
|
||||
setIssuedDate('');
|
||||
setExpiryDate('');
|
||||
resetRef.current?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Title order={3}>Medical Certificate</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
STCW requires a valid medical certificate for all seafarers. Valid for 2 years (1 year if under 18).
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Validity alert */}
|
||||
{current && days <= 90 && days > 0 && (
|
||||
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={17} />}>
|
||||
Your medical certificate expires in <strong>{days} days</strong> ({formatDate(current.expiryDate)}).
|
||||
Please visit an EMA-approved medical centre and upload your renewed certificate below.
|
||||
</Alert>
|
||||
)}
|
||||
{current && days <= 0 && (
|
||||
<Alert variant="light" color="red" icon={<IconAlertCircle size={17} />}>
|
||||
Your medical certificate <strong>has expired</strong>. You cannot join a vessel until a valid certificate is uploaded and verified.
|
||||
</Alert>
|
||||
)}
|
||||
{!current && (
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
No medical certificate on record. Upload your certificate below to complete your profile and apply for a Seaman Book.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{/* Current certificate */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="red" size={36} radius="md">
|
||||
<IconHeart size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Current Certificate</Text>
|
||||
</Group>
|
||||
|
||||
{current ? (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
|
||||
<Badge color={STATUS_COLOR[current.status]} variant="light">{current.status}</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Certificate ID</Text>
|
||||
<Text fz="sm">{current.id}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issued By</Text>
|
||||
<Text fz="sm" ta="right" maw={200}>{current.issuedBy}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issue Date</Text>
|
||||
<Text fz="sm">{formatDate(current.issuedDate)}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Expiry Date</Text>
|
||||
<Text fz="sm" fw={700} c={days <= 90 ? 'orange' : days <= 0 ? 'red' : undefined}>
|
||||
{formatDate(current.expiryDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Restrictions</Text>
|
||||
<Text fz="sm">{current.restrictions}</Text>
|
||||
</Group>
|
||||
|
||||
{/* Validity bar */}
|
||||
<Box mt="xs">
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fz="xs" c="dimmed">Validity remaining</Text>
|
||||
<Text fz="xs" fw={600} c={days <= 90 ? 'orange' : 'teal'}>{Math.max(0, days)} days</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={progressVal}
|
||||
color={days <= 30 ? 'red' : days <= 90 ? 'orange' : 'teal'}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconDownload size={13} />}
|
||||
mt="xs"
|
||||
>
|
||||
Download Certificate
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box ta="center" py="xl">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
|
||||
<IconFileDescription size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No certificate on record</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Upload new certificate */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconUpload size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>{current ? 'Upload Renewal' : 'Upload Certificate'}</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Issuing Doctor / Medical Centre"
|
||||
placeholder="e.g. Dr. Alemu Bekele — EMA Medical Centre"
|
||||
value={doctorName}
|
||||
onChange={(e) => setDoctorName(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput
|
||||
label="Issue Date"
|
||||
type="date"
|
||||
value={issuedDate}
|
||||
onChange={(e) => setIssuedDate(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput
|
||||
label="Expiry Date"
|
||||
type="date"
|
||||
value={expiryDate}
|
||||
onChange={(e) => setExpiryDate(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text fz="sm" fw={500} mb={4}>Certificate File <Text span c="red">*</Text></Text>
|
||||
{uploadedFile ? (
|
||||
<Card withBorder radius="sm" p="xs">
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{uploadedFile.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { setUploadedFile(null); resetRef.current?.(); }}>
|
||||
<IconTrash size={13} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={setUploadedFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
|
||||
Choose File (PDF / JPG / PNG, max 5MB)
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
|
||||
<Text fz="xs">
|
||||
Your certificate will be reviewed by an EMA Medical Officer within <strong>2 working days</strong>.
|
||||
Notifications will be sent by email and SMS.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
leftSection={<IconCheck size={15} />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!uploadedFile || !issuedDate || !expiryDate}
|
||||
>
|
||||
Submit for Verification
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Notification schedule */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconCalendar size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Expiry Notification Schedule</Text>
|
||||
</Group>
|
||||
<Timeline active={days <= 30 ? 2 : days <= 60 ? 1 : days <= 90 ? 0 : -1} bulletSize={24} lineWidth={2}>
|
||||
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="90 Days Before Expiry">
|
||||
<Text fz="xs" c="dimmed">First reminder — time to book your medical examination</Text>
|
||||
</Timeline.Item>
|
||||
<Timeline.Item bullet={<IconAlertTriangle size={13} />} title="60 Days Before Expiry">
|
||||
<Text fz="xs" c="dimmed">Second reminder — urgent renewal required</Text>
|
||||
</Timeline.Item>
|
||||
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="30 Days Before Expiry">
|
||||
<Text fz="xs" c="dimmed">Final reminder — certificate expires very soon</Text>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
</Paper>
|
||||
|
||||
{/* History */}
|
||||
{MOCK_HISTORY.length > 0 && (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Text fw={700} mb="md">Certificate History</Text>
|
||||
<Stack gap="xs">
|
||||
{MOCK_HISTORY.map((cert) => (
|
||||
<Card key={cert.id} withBorder radius="sm" p="sm">
|
||||
<Group justify="space-between" wrap="wrap" gap="xs">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="gray" size={32} radius="md">
|
||||
<IconFileDescription size={16} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{cert.id}</Text>
|
||||
<Text fz="xs" c="dimmed">{formatDate(cert.issuedDate)} → {formatDate(cert.expiryDate)}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge color={STATUS_COLOR[cert.status]} variant="light" size="sm">{cert.status}</Badge>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconBell,
|
||||
IconBellOff,
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconInfoCircle,
|
||||
IconShield,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
type NotifType = 'warning' | 'info' | 'success' | 'error';
|
||||
type NotifCategory = 'Medical' | 'Seaman Book' | 'BST' | 'Certificate' | 'Application' | 'System';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: NotifType;
|
||||
category: NotifCategory;
|
||||
title: string;
|
||||
message: string;
|
||||
date: string;
|
||||
read: boolean;
|
||||
actionLabel?: string;
|
||||
actionRoute?: string;
|
||||
}
|
||||
|
||||
const MOCK_NOTIFICATIONS: Notification[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'warning',
|
||||
category: 'Medical',
|
||||
title: 'Medical Certificate Expiring Soon',
|
||||
message: 'Your medical certificate expires on 14 March 2026 — 45 days remaining. Visit an EMA-approved medical centre to renew before it lapses.',
|
||||
date: '2026-01-28',
|
||||
read: false,
|
||||
actionLabel: 'View Medical Certificate',
|
||||
actionRoute: '/medical-certificate',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'info',
|
||||
category: 'BST',
|
||||
title: 'Basic Safety Training Incomplete',
|
||||
message: '3 of your 5 Basic Safety Training certificates are missing (EFA, PSSR, SHPT). All 5 are required before you can apply for a Seaman Book.',
|
||||
date: '2026-01-25',
|
||||
read: false,
|
||||
actionLabel: 'Manage BST Certificates',
|
||||
actionRoute: '/basic-safety-training',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'success',
|
||||
category: 'Application',
|
||||
title: 'Seaman Book Application Under Review',
|
||||
message: 'Your Seaman Book application (SB-APP-2024-001) has been received and is currently under review by an EMA Registration Officer.',
|
||||
date: '2024-05-10',
|
||||
read: true,
|
||||
actionLabel: 'Track Application',
|
||||
actionRoute: '/seaman-book',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'success',
|
||||
category: 'BST',
|
||||
title: 'PST Certificate Verified',
|
||||
message: 'Your Personal Survival Techniques (PST) certificate has been verified and approved. Certificate No: PST-2023-BMS-0421.',
|
||||
date: '2023-04-15',
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'success',
|
||||
category: 'BST',
|
||||
title: 'FPFF Certificate Verified',
|
||||
message: 'Your Fire Prevention & Fire Fighting (FPFF) certificate has been verified and approved. Certificate No: FPFF-2023-BMS-0421.',
|
||||
date: '2023-04-15',
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'info',
|
||||
category: 'System',
|
||||
title: 'Profile Setup Incomplete',
|
||||
message: 'Your seafarer profile is 60% complete. Please upload your National ID, passport size photo and remaining documents to complete your registration.',
|
||||
date: '2024-01-05',
|
||||
read: true,
|
||||
actionLabel: 'Go to Document Vault',
|
||||
actionRoute: '/documents',
|
||||
},
|
||||
];
|
||||
|
||||
const TYPE_CONFIG: Record<NotifType, { color: string; icon: typeof IconBell }> = {
|
||||
warning: { color: 'orange', icon: IconAlertTriangle },
|
||||
info: { color: 'blue', icon: IconInfoCircle },
|
||||
success: { color: 'teal', icon: IconCircleCheck },
|
||||
error: { color: 'red', icon: IconAlertCircle },
|
||||
};
|
||||
|
||||
const CATEGORY_ICON: Record<NotifCategory, typeof IconBell> = {
|
||||
Medical: IconHeart,
|
||||
'Seaman Book': IconBook2,
|
||||
BST: IconShield,
|
||||
Certificate: IconFileDescription,
|
||||
Application: IconFileDescription,
|
||||
System: IconBell,
|
||||
};
|
||||
|
||||
const CATEGORIES = ['All', 'Medical', 'Seaman Book', 'BST', 'Certificate', 'Application', 'System'] as const;
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
export function NotificationsPage() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS);
|
||||
const [filter, setFilter] = useState<string>('All');
|
||||
const [readFilter, setReadFilter] = useState<string | null>(null);
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
|
||||
const filtered = notifications.filter((n) => {
|
||||
const matchCat = filter === 'All' || n.category === filter;
|
||||
const matchRead =
|
||||
!readFilter ||
|
||||
(readFilter === 'Unread' && !n.read) ||
|
||||
(readFilter === 'Read' && n.read);
|
||||
return matchCat && matchRead;
|
||||
});
|
||||
|
||||
const markRead = (id: string) => {
|
||||
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
|
||||
};
|
||||
|
||||
const markAllRead = () => {
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
notify.success('All notifications marked as read.');
|
||||
};
|
||||
|
||||
const deleteNotif = (id: string) => {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
notify.info('Notification removed.');
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Title order={3}>Notifications</Title>
|
||||
{unreadCount > 0 && (
|
||||
<Badge color="red" variant="filled" circle size="lg">{unreadCount}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">Stay up to date on your certificates, applications and deadlines</Text>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button size="sm" variant="light" leftSection={<IconCheck size={14} />} onClick={markAllRead}>
|
||||
Mark all as read
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'All', count: notifications.length, color: 'gray', icon: IconBell },
|
||||
{ label: 'Unread', count: notifications.filter((n) => !n.read).length, color: 'blue', icon: IconBell },
|
||||
{ label: 'Warnings', count: notifications.filter((n) => n.type === 'warning').length, color: 'orange', icon: IconAlertTriangle },
|
||||
{ label: 'Actions Needed', count: notifications.filter((n) => !n.read && n.type !== 'success').length, color: 'red', icon: IconAlertCircle },
|
||||
].map(({ label, count, color, icon: Icon }) => (
|
||||
<Card key={label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={36} radius="md">
|
||||
<Icon size={17} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="lg" fw={700} lh={1}>{count}</Text>
|
||||
<Text fz="xs" c="dimmed">{label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Filters */}
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Group gap={6} style={{ flex: 1 }}>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<Button
|
||||
key={cat}
|
||||
size="xs"
|
||||
variant={filter === cat ? 'filled' : 'light'}
|
||||
color="blue"
|
||||
onClick={() => setFilter(cat)}
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
<Select
|
||||
size="xs"
|
||||
placeholder="All Status"
|
||||
data={['Unread', 'Read']}
|
||||
value={readFilter}
|
||||
onChange={setReadFilter}
|
||||
clearable
|
||||
style={{ width: rem(120) }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Notification list */}
|
||||
{filtered.length === 0 ? (
|
||||
<Paper withBorder radius="md" p="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
|
||||
<IconBellOff size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No notifications found</Text>
|
||||
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setFilter('All'); setReadFilter(null); }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{filtered.map((n) => {
|
||||
const { color, icon: TypeIcon } = TYPE_CONFIG[n.type];
|
||||
const CatIcon = CATEGORY_ICON[n.category];
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={n.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderLeft: `3px solid var(--mantine-color-${color}-5)`,
|
||||
background: n.read ? undefined : 'var(--mantine-color-blue-light)',
|
||||
opacity: n.read ? 0.85 : 1,
|
||||
}}
|
||||
onClick={() => markRead(n.id)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ flex: 1 }}>
|
||||
<ThemeIcon variant="light" color={color} size={38} radius="md" style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
<TypeIcon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text fw={n.read ? 500 : 700} fz="sm">{n.title}</Text>
|
||||
{!n.read && <Badge size="xs" color="blue" variant="filled">New</Badge>}
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<CatIcon size={10} />}>
|
||||
{n.category}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed" lh={1.5}>{n.message}</Text>
|
||||
<Group gap="sm" mt={4}>
|
||||
<Text fz="xs" c="dimmed">{formatDate(n.date)}</Text>
|
||||
{n.actionLabel && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={color}
|
||||
component="a"
|
||||
href={n.actionRoute}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{n.actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="xs" style={{ flexShrink: 0 }}>
|
||||
{!n.read && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
size="sm"
|
||||
title="Mark as read"
|
||||
onClick={(e) => { e.stopPropagation(); markRead(n.id); }}
|
||||
>
|
||||
<IconCheck size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
title="Delete"
|
||||
onClick={(e) => { e.stopPropagation(); deleteNotif(n.id); }}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCreditCard,
|
||||
IconHeart,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steps
|
||||
// ---------------------------------------------------------------------------
|
||||
const STEPS = [
|
||||
{ label: 'BST Certificates' },
|
||||
{ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
const FEES = [
|
||||
{ label: 'Seaman Book — Application Fee', amount: 500 },
|
||||
{ label: 'Seaman Book — Document Verification Fee',amount: 200 },
|
||||
{ label: 'Basic Training Certificate (BTC) — Application Fee', amount: 300 },
|
||||
{ label: 'BTC — Document Verification Fee', amount: 100 },
|
||||
{ label: 'BSID — Application Fee', amount: 100 },
|
||||
{ label: 'BSID — Card Production Fee', amount: 150 },
|
||||
];
|
||||
const TOTAL = FEES.reduce((s, f) => s + f.amount, 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap">
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box style={{
|
||||
width: rem(40), height: rem(40), borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : isCurrent ? 'var(--mantine-color-blue-7)' : 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34,139,230,0.2)' : 'none',
|
||||
flexShrink: 0, transition: 'all 0.2s ease',
|
||||
}}>
|
||||
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box style={{
|
||||
flex: 1, height: rem(2),
|
||||
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeamanBookApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [active, setActive] = useState(0);
|
||||
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 } }));
|
||||
|
||||
// Medical
|
||||
const [medCertNumber, setMedCertNumber] = useState('');
|
||||
const [medIssuer, setMedIssuer] = useState('');
|
||||
const [medIssueDate, setMedIssueDate] = useState('');
|
||||
const [medExpiryDate, setMedExpiryDate] = useState('');
|
||||
const [medFile, setMedFile] = useState<File | null>(null);
|
||||
const medResetRef = useRef<() => void>(null);
|
||||
|
||||
// Payment
|
||||
const [paymentMethod, setPaymentMethod] = useState<'cbe' | 'telebirr' | null>(null);
|
||||
const [paymentRef, setPaymentRef] = useState('');
|
||||
const [paymentDate, setPaymentDate] = useState('');
|
||||
const [paymentFile, setPaymentFile] = useState<File | null>(null);
|
||||
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 medComplete = !!medFile && !!medCertNumber.trim() && !!medIssuer.trim() && !!medIssueDate && !!medExpiryDate;
|
||||
const payComplete = !!paymentMethod && !!paymentRef.trim() && !!paymentDate;
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return bstComplete;
|
||||
if (active === 1) return medComplete;
|
||||
if (active === 2) return payComplete;
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 1400));
|
||||
notify.success('Application submitted! Reference: SB-BTC-2025-001');
|
||||
navigate('/seaman-book');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Seaman Book, BTC & BSID Application</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
One application covers your Seaman Book, Basic Training Certificate (BTC), and BSID — Step {active + 1} of {STEPS.length}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* What you will receive banner */}
|
||||
<Paper withBorder radius="md" p="md" bg="blue.0" style={{ borderColor: 'var(--mantine-color-blue-2)' }}>
|
||||
<Group gap="lg" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
<IconBook2 size={18} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
<Text fz="sm" fw={600} c="blue.8">Seaman Book</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">+</Text>
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" stroke={1.5} />
|
||||
<Text fz="sm" fw={600} c="teal.8">Basic Training Certificate (BTC)</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">+</Text>
|
||||
<Group gap="xs">
|
||||
<IconId size={18} color="var(--mantine-color-violet-6)" stroke={1.5} />
|
||||
<Text fz="sm" fw={600} c="violet.8">BSID (Biometric Seafarer ID)</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="lg">{STEPS[active]?.label}</Text>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* ── Step 1: BST ─────────────────────────────────────────────── */}
|
||||
{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.
|
||||
</Alert>
|
||||
<SectionHead title="5 Mandatory BST Training Certificates" />
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Medical ─────────────────────────────────────────── */}
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
Upload your valid medical certificate from an EMA-approved medical centre. Required for both Seaman Book and BTC issuance.
|
||||
</Alert>
|
||||
<SectionHead title="Medical Certificate Details" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Certificate Number" placeholder="e.g. MC-2024-001" required value={medCertNumber} onChange={(e) => setMedCertNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Issuing Medical Centre" placeholder="e.g. EMA Medical Centre" required value={medIssuer} onChange={(e) => setMedIssuer(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Issue Date" type="date" required value={medIssueDate} onChange={(e) => setMedIssueDate(e.currentTarget.value)} />
|
||||
<TextInput label="Expiry Date" type="date" required value={medExpiryDate} onChange={(e) => setMedExpiryDate(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Upload Certificate" />
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: medFile ? 'solid' : 'dashed',
|
||||
borderColor: medFile ? '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: medFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<IconHeart size={20} color={medFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">Medical Certificate <Text span c="red">*</Text></Text>
|
||||
<Text fz="xs" c="dimmed">PDF, JPG or PNG — max 5MB</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{medFile ? (
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{medFile.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { setMedFile(null); medResetRef.current?.(); }}>
|
||||
<IconTrash size={13} />
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={medResetRef} onChange={setMedFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
|
||||
Choose File
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
|
||||
Only certificates from <strong>EMA-approved medical centres</strong> are accepted.
|
||||
</Alert>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Payment ─────────────────────────────────────────── */}
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
{/* Fee breakdown — SB + BTC shown separately */}
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0">
|
||||
<Text fw={700} fz="sm" mb="xs">Fee Breakdown</Text>
|
||||
<Text fz="xs" c="dimmed" mb="md">Your payment covers both the Seaman Book and Basic Training Certificate (BTC).</Text>
|
||||
|
||||
{/* SB fees */}
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="blue.7" mb={6}>Seaman Book</Text>
|
||||
{FEES.filter(f => f.label.startsWith('Seaman Book')).map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb={4}>
|
||||
<Text fz="sm">{label.replace('Seaman Book — ', '')}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
<Divider my="xs" />
|
||||
|
||||
{/* BTC fees */}
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="teal.7" mb={6}>Basic Training Certificate (BTC)</Text>
|
||||
{FEES.filter(f => f.label.startsWith('Basic Training') || f.label.startsWith('BTC')).map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb={4}>
|
||||
<Text fz="sm">{label.replace('Basic Training Certificate (BTC) — ', '').replace('BTC — ', '')}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
<Divider my="xs" />
|
||||
|
||||
{/* BSID fees */}
|
||||
<Divider my="xs" />
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="violet.7" mb={6}>BSID (Biometric Seafarer ID)</Text>
|
||||
{FEES.filter(f => f.label.startsWith('BSID')).map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb={4}>
|
||||
<Text fz="sm">{label.replace('BSID — ', '')}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
<Divider mt="xs" mb="sm" />
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" fw={800}>Total Amount Due</Text>
|
||||
<Text fz="md" fw={800} c="blue">ETB {TOTAL.toFixed(2)}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<SectionHead title="Select Payment Method" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" style={{ maxWidth: rem(600) }}>
|
||||
{/* CBE */}
|
||||
<Card withBorder radius="md" p="md" onClick={() => { setPaymentMethod('cbe'); setPaymentRef(''); }}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: paymentMethod === 'cbe' ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-default-border)',
|
||||
borderWidth: paymentMethod === 'cbe' ? 2 : 1,
|
||||
background: paymentMethod === 'cbe' ? 'var(--mantine-color-blue-light)' : undefined,
|
||||
}}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8), flexShrink: 0,
|
||||
background: 'var(--mantine-color-blue-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<IconCreditCard size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">CBE Bank Transfer</Text>
|
||||
<Text fz="xs" c="dimmed">Commercial Bank of Ethiopia</Text>
|
||||
</div>
|
||||
{paymentMethod === 'cbe' && <IconCircleCheck size={18} color="var(--mantine-color-blue-6)" style={{ marginLeft: 'auto' }} />}
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* Telebirr */}
|
||||
<Card withBorder radius="md" p="md" onClick={() => { setPaymentMethod('telebirr'); setPaymentRef(''); }}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: paymentMethod === 'telebirr' ? 'var(--mantine-color-violet-6)' : 'var(--mantine-color-default-border)',
|
||||
borderWidth: paymentMethod === 'telebirr' ? 2 : 1,
|
||||
background: paymentMethod === 'telebirr' ? 'var(--mantine-color-violet-light)' : undefined,
|
||||
}}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8), flexShrink: 0,
|
||||
background: 'var(--mantine-color-violet-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<IconCreditCard size={22} color="var(--mantine-color-violet-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">Telebirr</Text>
|
||||
<Text fz="xs" c="dimmed">Ethio Telecom Mobile Money</Text>
|
||||
</div>
|
||||
{paymentMethod === 'telebirr' && <IconCircleCheck size={18} color="var(--mantine-color-violet-6)" style={{ marginLeft: 'auto' }} />}
|
||||
</Group>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{paymentMethod === 'cbe' && (
|
||||
<>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
|
||||
Transfer <strong>ETB {TOTAL.toFixed(2)}</strong> to CBE Account <strong>1000123456789</strong> (EMA Maritime Authority). Use your full name as description.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="CBE Transaction Reference" placeholder="e.g. CBE-TXN-20240510-001" required value={paymentRef} onChange={(e) => setPaymentRef(e.currentTarget.value)} />
|
||||
<TextInput label="Payment Date" type="date" required value={paymentDate} onChange={(e) => setPaymentDate(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'telebirr' && (
|
||||
<>
|
||||
<Alert variant="light" color="violet" icon={<IconInfoCircle size={15} />}>
|
||||
Send <strong>ETB {TOTAL.toFixed(2)}</strong> to Telebirr <strong>+251 11 551 0000</strong> (EMA Maritime Authority). Screenshot and upload your confirmation.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="Telebirr Transaction ID" placeholder="e.g. TLB-2024-001234" required value={paymentRef} onChange={(e) => setPaymentRef(e.currentTarget.value)} />
|
||||
<TextInput label="Payment Date" type="date" required value={paymentDate} onChange={(e) => setPaymentDate(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{paymentMethod && (
|
||||
<>
|
||||
<SectionHead title="Upload Receipt (optional)" />
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: paymentFile ? 'solid' : 'dashed',
|
||||
borderColor: paymentFile ? '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: paymentFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<IconCreditCard size={20} color={paymentFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{paymentMethod === 'telebirr' ? 'Telebirr Screenshot' : 'Bank Receipt'}</Text>
|
||||
<Text fz="xs" c="dimmed">PDF, JPG or PNG — max 5MB</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{paymentFile ? (
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{paymentFile.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { setPaymentFile(null); payResetRef.current?.(); }}>
|
||||
<IconTrash size={13} />
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={payResetRef} onChange={setPaymentFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
|
||||
Upload Receipt
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Review ──────────────────────────────────────────── */}
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
Submitting this application will initiate processing for both your <strong>Seaman Book</strong> and <strong>Basic Training Certificate (BTC)</strong>.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">BST Certificates</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>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Medical Certificate</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Certificate No." value={medCertNumber} />
|
||||
<ReviewRow label="Issuing Centre" value={medIssuer} />
|
||||
<ReviewRow label="Issue Date" value={medIssueDate} />
|
||||
<ReviewRow label="Expiry Date" value={medExpiryDate} />
|
||||
<ReviewRow label="Document" value={medFile?.name ?? '—'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Payment</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Payment Method" value={paymentMethod === 'cbe' ? 'CBE Bank Transfer' : 'Telebirr'} />
|
||||
<ReviewRow label="Transaction Reference" value={paymentRef} />
|
||||
<ReviewRow label="Payment Date" value={paymentDate} />
|
||||
<ReviewRow label="Total Paid" value={`ETB ${TOTAL.toFixed(2)}`} />
|
||||
<ReviewRow label="Receipt" value={paymentFile?.name ?? 'Not uploaded'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" onClick={() => navigate('/seaman-book')}>Cancel</Button>
|
||||
<Group gap="sm">
|
||||
{active > 0 && (
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={prev}>Previous</Button>
|
||||
)}
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button rightSection={<IconArrowRight size={16} />} onClick={next} disabled={!canNext()}>
|
||||
Next Step
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="blue" leftSection={<IconBook2 size={16} />} onClick={handleSubmit} loading={submitting}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconInfoCircle,
|
||||
IconPrinter,
|
||||
IconShield,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data — replace with real API
|
||||
// ---------------------------------------------------------------------------
|
||||
const ELIGIBILITY = {
|
||||
hasProfile: true,
|
||||
hasNationalId: true,
|
||||
hasMedicalCert: true,
|
||||
medicalExpiry: '2026-03-14',
|
||||
bstComplete: true,
|
||||
bstItems: [
|
||||
{ label: 'Personal Survival Techniques (PST)', done: true },
|
||||
{ label: 'Fire Prevention & Fire Fighting (FPFF)', done: true },
|
||||
{ label: 'Elementary First Aid (EFA)', done: true },
|
||||
{ label: 'Personal Safety & Social Responsibility (PSSR)', done: true },
|
||||
{ label: 'Sexual Harassment Prevention', done: true },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_APPLICATION: SeamanBookApp | null = null;
|
||||
|
||||
interface SeamanBookApp {
|
||||
id: string;
|
||||
submittedAt: string;
|
||||
status: string;
|
||||
remarks: string;
|
||||
timeline: { date: string | null; event: string; done: boolean }[];
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
'Under Review': 'yellow',
|
||||
'Approved': 'teal',
|
||||
'Rejected': 'red',
|
||||
'Correction Required': 'orange',
|
||||
'Ready for Collection': 'blue',
|
||||
};
|
||||
|
||||
function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={22} radius="xl" variant={ok ? 'filled' : 'light'} color={ok ? 'teal' : 'red'}>
|
||||
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={ok ? undefined : 'dimmed'}>{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeamanBookPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(!!MOCK_APPLICATION);
|
||||
|
||||
const bstDone = ELIGIBILITY.bstItems.filter((b) => b.done).length;
|
||||
const isEligible =
|
||||
ELIGIBILITY.hasProfile &&
|
||||
ELIGIBILITY.hasNationalId &&
|
||||
ELIGIBILITY.hasMedicalCert &&
|
||||
ELIGIBILITY.bstComplete;
|
||||
|
||||
const handleApply = async () => {
|
||||
setSubmitting(true);
|
||||
await new Promise((r) => setTimeout(r, 1400));
|
||||
setSubmitting(false);
|
||||
setSubmitted(true);
|
||||
notify.success('Seaman Book application submitted successfully! Reference: SB-APP-2024-002');
|
||||
};
|
||||
|
||||
const activeStep = MOCK_APPLICATION
|
||||
? MOCK_APPLICATION.timeline.filter((t) => t.done).length - 1
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Title order={3}>My Application — Seaman Book & BTC</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
A Seaman Book is your official maritime identity document. It records your sea service and must be
|
||||
held before joining any vessel.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Active application status */}
|
||||
{MOCK_APPLICATION && (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconBook2 size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Application {MOCK_APPLICATION.id}</Text>
|
||||
<Text fz="xs" c="dimmed">Submitted {MOCK_APPLICATION.submittedAt}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[MOCK_APPLICATION.status] ?? 'gray'} variant="light" size="lg">
|
||||
{MOCK_APPLICATION.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{MOCK_APPLICATION.remarks && (
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="md" p="sm">
|
||||
<Text fz="sm">{MOCK_APPLICATION.remarks}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Progress stepper */}
|
||||
<Stepper active={activeStep} size="sm" color="teal">
|
||||
{MOCK_APPLICATION.timeline.map((step, i) => (
|
||||
<Stepper.Step
|
||||
key={i}
|
||||
label={step.event}
|
||||
description={step.date ?? 'Pending'}
|
||||
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
|
||||
/>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{MOCK_APPLICATION.status === 'Ready for Collection' && (
|
||||
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
|
||||
Your Seaman Book is ready. Please visit the EMA office to collect it. Bring your National ID.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* No active application — eligibility + apply */}
|
||||
{!submitted && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{/* Eligibility checklist */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color={isEligible ? 'teal' : 'orange'} size={36} radius="md">
|
||||
<IconShield size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Eligibility Requirements</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<EligibilityItem label="Profile completed (name, DOB, nationality)" ok={ELIGIBILITY.hasProfile} />
|
||||
<EligibilityItem label="National ID / Fayda uploaded" ok={ELIGIBILITY.hasNationalId} />
|
||||
<EligibilityItem label="Valid medical certificate uploaded" ok={ELIGIBILITY.hasMedicalCert} />
|
||||
|
||||
<Divider label="Basic Safety Training (all 5 required)" labelPosition="left" my={4} />
|
||||
{ELIGIBILITY.bstItems.map((item) => (
|
||||
<EligibilityItem key={item.label} label={item.label} ok={item.done} />
|
||||
))}
|
||||
|
||||
{!isEligible && (
|
||||
<Alert variant="light" color="orange" icon={<IconAlertCircle size={15} />} mt="xs" p="sm">
|
||||
<Text fz="xs">
|
||||
Complete all requirements above before applying. Missing BST: {5 - bstDone} certificate(s).
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isEligible && (
|
||||
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />} mt="xs" p="sm">
|
||||
<Text fz="xs">You meet all requirements. You may proceed with your application.</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Application form */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>New Application</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c="dimmed" lh={1.6}>
|
||||
Upon submitting your application, EMA Registration Officers will verify your profile,
|
||||
documents, medical certificate, and Basic Safety Training certificates. You will be
|
||||
notified at each stage by email and SMS.
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text fw={600} fz="sm">What will be verified:</Text>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
'Full seafarer profile',
|
||||
'National ID / Fayda authenticity',
|
||||
'Medical certificate validity',
|
||||
'All 5 Basic Safety Training certificates',
|
||||
'Passport size photo',
|
||||
].map((item) => (
|
||||
<Group key={item} gap="xs">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="sm">{item}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<Card withBorder radius="sm" p="sm">
|
||||
<Group gap="xs">
|
||||
<IconClock size={15} color="var(--mantine-color-blue-6)" />
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed">Processing time</Text>
|
||||
<Text fz="sm" fw={600}>5–7 working days</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder radius="sm" p="sm">
|
||||
<Group gap="xs">
|
||||
<IconHeart size={15} color="var(--mantine-color-red-6)" />
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed">Medical validity</Text>
|
||||
<Text fz="sm" fw={600}>2 years (STCW)</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
|
||||
<Text fz="xs">
|
||||
Application fee will be communicated during the review process. Payment can be made online or at the EMA office.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
leftSection={<IconBook2 size={16} />}
|
||||
onClick={() => navigate('/seaman-book/apply')}
|
||||
loading={submitting}
|
||||
disabled={!isEligible}
|
||||
size="md"
|
||||
>
|
||||
Start Application
|
||||
</Button>
|
||||
|
||||
{!isEligible && (
|
||||
<Text fz="xs" c="dimmed" ta="center">
|
||||
Complete all eligibility requirements to enable this button.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Info box */}
|
||||
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconInfoCircle size={18} color="var(--mantine-color-blue-6)" />
|
||||
<Text fw={700} fz="sm">About the Seaman Book</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{[
|
||||
{ icon: IconBook2, title: 'Official Identity', desc: 'Internationally recognized maritime identity document required before joining any vessel.' },
|
||||
{ icon: IconFileDescription, title: 'Service Record', desc: 'Records all your sea service, vessel assignments, and employment history.' },
|
||||
{ icon: IconShield, title: 'STCW Compliance', desc: 'Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.' },
|
||||
].map(({ icon: Icon, title, desc }) => (
|
||||
<Box key={title}>
|
||||
<Group gap="xs" mb={4}>
|
||||
<Icon size={16} color="var(--mantine-color-blue-6)" />
|
||||
<Text fz="sm" fw={600}>{title}</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" lh={1.5}>{desc}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user