mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-27 13:10:58 +00:00
ui componenet based on the requirements
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconChartBar,
|
||||
IconDownload,
|
||||
IconHeart,
|
||||
IconId,
|
||||
IconShieldCheck,
|
||||
IconTrendingUp,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
const KPI = [
|
||||
{ label: 'Total Seafarers', value: 1284, delta: '+12 this month', color: 'blue', icon: IconUsers },
|
||||
{ label: 'Seaman Books Issued', value: 1102, delta: '85.8% of total', color: 'teal', icon: IconBook2 },
|
||||
{ label: 'BTC Issued', value: 1098, delta: '85.5% of total', color: 'teal', icon: IconCertificate },
|
||||
{ label: 'BSID Issued', value: 874, delta: '68.1% of total', color: 'violet', icon: IconId },
|
||||
{ label: 'CoC / CoP Issued', value: 342, delta: '26.6% of total', color: 'orange', icon: IconShieldCheck },
|
||||
{ label: 'Medical Expiring (90d)',value: 41, delta: 'Action required', color: 'red', icon: IconHeart },
|
||||
];
|
||||
|
||||
const MONTHLY_APPS = [
|
||||
{ month: 'Jan', seamanBook: 18, coc: 4 },
|
||||
{ month: 'Feb', seamanBook: 22, coc: 6 },
|
||||
{ month: 'Mar', seamanBook: 28, coc: 9 },
|
||||
{ month: 'Apr', seamanBook: 31, coc: 12 },
|
||||
{ month: 'May', seamanBook: 26, coc: 8 },
|
||||
{ month: 'Jun', seamanBook: 35, coc: 14 },
|
||||
];
|
||||
|
||||
const CERT_DISTRIBUTION = [
|
||||
{ label: 'Seaman Book', count: 1102, pct: 86, color: 'blue' },
|
||||
{ label: 'Basic Training Cert', count: 1098, pct: 85, color: 'teal' },
|
||||
{ label: 'BSID', count: 874, pct: 68, color: 'violet' },
|
||||
{ label: 'CoC / CoP', count: 342, pct: 27, color: 'orange' },
|
||||
];
|
||||
|
||||
const REVENUE = [
|
||||
{ cert: 'Seaman Book', apps: 1102, feePerApp: 700, revenue: 771400 },
|
||||
{ cert: 'BTC', apps: 1098, feePerApp: 400, revenue: 439200 },
|
||||
{ cert: 'BSID', apps: 874, feePerApp: 250, revenue: 218500 },
|
||||
{ cert: 'CoC / CoP', apps: 342, feePerApp: 1400, revenue: 478800 },
|
||||
];
|
||||
const TOTAL_REVENUE = REVENUE.reduce((s, r) => s + r.revenue, 0);
|
||||
|
||||
const EXPIRY_WATCH = [
|
||||
{ cert: 'Seaman Book', expiring30: 8, expiring90: 23, total: 1102 },
|
||||
{ cert: 'BTC', expiring30: 6, expiring90: 19, total: 1098 },
|
||||
{ cert: 'Medical Cert', expiring30: 12, expiring90: 41, total: 1284 },
|
||||
{ cert: 'CoC / CoP', expiring30: 4, expiring90: 17, total: 342 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
function KpiCard({ label, value, delta, color, icon: Icon }: typeof KPI[0]) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap" mb="xs">
|
||||
<ThemeIcon variant="light" color={color} size={44} radius="md"><Icon size={22} stroke={1.6} /></ThemeIcon>
|
||||
<Text fz="xl" fw={800}>{value.toLocaleString()}</Text>
|
||||
</Group>
|
||||
<Text fz="sm" fw={600} lh={1.3}>{label}</Text>
|
||||
<Text fz="xs" c="dimmed" mt={2}>{delta}</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple bar chart using divs
|
||||
function BarChart() {
|
||||
const max = Math.max(...MONTHLY_APPS.map((m) => m.seamanBook + m.coc));
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{MONTHLY_APPS.map((m) => (
|
||||
<Group key={m.month} gap="sm" align="center" wrap="nowrap">
|
||||
<Text fz="xs" c="dimmed" w={28} style={{ flexShrink: 0 }}>{m.month}</Text>
|
||||
<div style={{ flex: 1, position: 'relative', height: 22 }}>
|
||||
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: `${(m.seamanBook / max) * 100}%`, background: 'var(--mantine-color-blue-4)', borderRadius: 4 }} />
|
||||
<div style={{ position: 'absolute', left: `${(m.seamanBook / max) * 100}%`, top: 0, height: '100%', width: `${(m.coc / max) * 100}%`, background: 'var(--mantine-color-orange-4)', borderRadius: '0 4px 4px 0' }} />
|
||||
</div>
|
||||
<Text fz="xs" fw={600} w={40} style={{ flexShrink: 0 }}>{m.seamanBook + m.coc}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Group gap="md" mt="xs">
|
||||
<Group gap={6}><div style={{ width: 12, height: 12, borderRadius: 2, background: 'var(--mantine-color-blue-4)' }} /><Text fz="xs" c="dimmed">Seaman Book</Text></Group>
|
||||
<Group gap={6}><div style={{ width: 12, height: 12, borderRadius: 2, background: 'var(--mantine-color-orange-4)' }} /><Text fz="xs" c="dimmed">CoC / CoP</Text></Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function AnalyticsPage() {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Analytics & Reports</Title>
|
||||
<Text fz="sm" c="dimmed">Overview of seafarer registrations, certificate issuance, and revenue — mock data for demonstration</Text>
|
||||
</div>
|
||||
<Button variant="default" size="sm" leftSection={<IconDownload size={14} />}>
|
||||
Export PDF
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* KPIs */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, lg: 6 }} spacing="sm">
|
||||
{KPI.map((k) => <KpiCard key={k.label} {...k} />)}
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
{/* Monthly applications bar chart */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={28} radius="md" color="blue" variant="light"><IconChartBar size={15} /></ThemeIcon>
|
||||
<Text fw={700} fz="sm">Monthly Applications (2025)</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<BarChart />
|
||||
</Paper>
|
||||
|
||||
{/* Certificate distribution */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="xs" mb="lg">
|
||||
<ThemeIcon size={28} radius="md" color="teal" variant="light"><IconTrendingUp size={15} /></ThemeIcon>
|
||||
<Text fw={700} fz="sm">Certificate Distribution</Text>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
{CERT_DISTRIBUTION.map((c) => (
|
||||
<div key={c.label}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap="xs">
|
||||
<Badge color={c.color} variant="light" size="sm">{c.label}</Badge>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Text fz="xs" fw={600}>{c.count.toLocaleString()}</Text>
|
||||
<Text fz="xs" c="dimmed">{c.pct}%</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Progress value={c.pct} color={c.color} radius="xl" size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Revenue breakdown */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700} fz="sm">Revenue Breakdown by Certificate Type</Text>
|
||||
<Badge size="lg" variant="light" color="blue">Total: ETB {TOTAL_REVENUE.toLocaleString()}</Badge>
|
||||
</Group>
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Certificate Type', 'Applications', 'Fee per App', 'Total Revenue', '% of Revenue'].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>
|
||||
{REVENUE.map((r) => (
|
||||
<Table.Tr key={r.cert}>
|
||||
<Table.Td><Text fz="sm" fw={600}>{r.cert}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{r.apps.toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">ETB {r.feePerApp.toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={700} c="blue">ETB {r.revenue.toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Progress value={(r.revenue / TOTAL_REVENUE) * 100} size="sm" radius="xl" style={{ flex: 1, minWidth: 60 }} />
|
||||
<Text fz="xs" c="dimmed">{((r.revenue / TOTAL_REVENUE) * 100).toFixed(1)}%</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td><Text fz="sm" fw={700}>Total</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={700}>{REVENUE.reduce((s, r) => s + r.apps, 0).toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td />
|
||||
<Table.Td><Text fz="sm" fw={800} c="blue">ETB {TOTAL_REVENUE.toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td />
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
{/* Expiry watchlist */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon size={28} radius="md" color="red" variant="light"><IconHeart size={15} /></ThemeIcon>
|
||||
<Text fw={700} fz="sm">Expiry Watchlist</Text>
|
||||
</Group>
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Certificate', 'Total Issued', 'Expiring in 30 days', 'Expiring in 90 days', '% at Risk'].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>
|
||||
{EXPIRY_WATCH.map((e) => (
|
||||
<Table.Tr key={e.cert}>
|
||||
<Table.Td><Text fz="sm" fw={600}>{e.cert}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{e.total.toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={e.expiring30 > 5 ? 'red' : 'orange'} variant="light" size="sm">{e.expiring30}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={e.expiring90 > 20 ? 'orange' : 'yellow'} variant="light" size="sm">{e.expiring90}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Progress
|
||||
value={(e.expiring90 / e.total) * 100}
|
||||
color={(e.expiring90 / e.total) > 0.1 ? 'red' : 'orange'}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
<Text fz="xs" c="dimmed">{((e.expiring90 / e.total) * 100).toFixed(1)}%</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCreditCard,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
const FEES = [
|
||||
{ group: 'Seaman Book', label: 'Application Fee', amount: 500 },
|
||||
{ group: 'Seaman Book', label: 'Document Verification Fee', amount: 200 },
|
||||
{ group: 'Basic Training Certificate (BTC)', label: 'Application Fee', amount: 300 },
|
||||
{ group: 'Basic Training Certificate (BTC)', label: 'Document Verification Fee',amount: 100 },
|
||||
{ group: 'BSID', label: 'Application Fee', amount: 100 },
|
||||
];
|
||||
const TOTAL = FEES.reduce((s, f) => s + f.amount, 0);
|
||||
|
||||
const MOCK_APPLICATION = {
|
||||
id: 'SB-APP-2025-001',
|
||||
submittedAt: '2025-05-10',
|
||||
status: 'pending_review' as const,
|
||||
applicant: {
|
||||
fullName: 'Abebe Girma Tadesse',
|
||||
dob: '1990-04-15',
|
||||
nationality: 'Ethiopian',
|
||||
idNumber: 'ID-ETH-2024-001',
|
||||
phone: '+251 91 234 5678',
|
||||
email: 'abebe.girma@email.com',
|
||||
address: 'Bole, Addis Ababa, Ethiopia',
|
||||
height: '178 cm',
|
||||
bloodType: 'O+',
|
||||
hairColor: 'Black',
|
||||
eyeColor: 'Dark Brown',
|
||||
seafarerNumber: 'SEAF-2024-00142',
|
||||
},
|
||||
bst: [
|
||||
{ key: 'pst', short: 'PST', label: 'Personal Survival Techniques', certNumber: 'PST-2024-001', issuer: 'Bahirdar Maritime Training School', issueDate: '2024-01-15', expiryDate: '2029-01-15', fileName: 'pst_cert.pdf', fileType: 'pdf' },
|
||||
{ key: 'fpff', short: 'FPFF', label: 'Fire Prevention & Fire Fighting', certNumber: 'FPFF-2024-002', issuer: 'Bahirdar Maritime Training School', issueDate: '2024-01-16', expiryDate: '2029-01-16', fileName: 'fpff_cert.pdf', fileType: 'pdf' },
|
||||
{ key: 'efa', short: 'EFA', label: 'Elementary First Aid', certNumber: 'EFA-2024-003', issuer: 'EMA Training Centre', issueDate: '2024-02-01', expiryDate: '', fileName: 'efa_cert.jpg', fileType: 'image' },
|
||||
{ key: 'pssr', short: 'PSSR', label: 'Personal Safety & Social Responsibility', certNumber: 'PSSR-2024-004', issuer: 'EMA Training Centre', issueDate: '2024-02-02', expiryDate: '', fileName: 'pssr_cert.jpg', fileType: 'image' },
|
||||
{ key: 'shpt', short: 'SHPT', label: 'Sexual Harassment Prevention Training', certNumber: 'SHPT-2024-005', issuer: 'EMA Training Centre', issueDate: '2024-02-03', expiryDate: '', fileName: 'shpt_cert.jpg', fileType: 'image' },
|
||||
],
|
||||
medical: {
|
||||
certNumber: 'MC-2024-009',
|
||||
issuer: 'EMA Medical Centre — Addis Ababa',
|
||||
issueDate: '2024-03-20',
|
||||
expiryDate: '2026-03-20',
|
||||
fileName: 'medical_cert.pdf',
|
||||
fileType: 'pdf',
|
||||
},
|
||||
payment: {
|
||||
method: 'CBE Bank Transfer',
|
||||
ref: 'CBE-TXN-20240510-001',
|
||||
date: '2025-05-10',
|
||||
status: 'confirmed' as 'confirmed' | 'pending' | 'rejected',
|
||||
receiptFileName: 'payment_receipt.pdf',
|
||||
receiptFileType: 'pdf',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending_review: 'yellow', approved: 'teal', correction_required: 'orange', rejected: 'red',
|
||||
};
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending_review: 'Pending Review', approved: 'Approved', correction_required: 'Correction Required', rejected: 'Rejected',
|
||||
};
|
||||
const PAYMENT_COLOR = { confirmed: 'teal', pending: 'yellow', rejected: 'red' } as const;
|
||||
|
||||
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
|
||||
|
||||
function getDocUrl(fileName: string) {
|
||||
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline document viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
function DocViewer({ fileName, fileType, label }: { fileName: string; fileType: string; label: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const url = getDocUrl(fileName);
|
||||
return (
|
||||
<>
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Group gap="sm" wrap="nowrap" mb="sm">
|
||||
<ThemeIcon size="md" variant="light" color={fileType === 'pdf' ? 'red' : 'blue'} radius="md">
|
||||
<IconFileDescription size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="sm" fw={600}>{label}</Text>
|
||||
<Text fz="xs" c="dimmed" truncate>{fileName}</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={fileType === 'pdf' ? 'red' : 'blue'}>{fileType.toUpperCase()}</Badge>
|
||||
</Group>
|
||||
<Box style={{ width: '100%', height: rem(180), borderRadius: rem(6), overflow: 'hidden', border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-gray-0)' }}>
|
||||
{fileType === 'pdf'
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Box>
|
||||
<Button size="xs" variant="subtle" fullWidth mt="xs" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
|
||||
View Full Document
|
||||
</Button>
|
||||
</Card>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} — {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
|
||||
{fileType === 'pdf'
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return <Text fw={600} fz="xs" tt="uppercase" c="gray.6" mb="sm">{children}</Text>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function ApplicationReviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const app = MOCK_APPLICATION;
|
||||
|
||||
const [status, setStatus] = useState<string>(app.status);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [noteModalOpen, setNoteModalOpen] = useState(false);
|
||||
const [noteAction, setNoteAction] = useState<'correction' | 'reject' | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const performAction = async (action: string, label: string, newStatus: string) => {
|
||||
setActionLoading(action);
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
setStatus(newStatus);
|
||||
setActionLoading(null);
|
||||
notify.success(`Application ${label}`);
|
||||
if (noteModalOpen) setNoteModalOpen(false);
|
||||
};
|
||||
|
||||
const openNote = (action: 'correction' | 'reject') => {
|
||||
setNoteAction(action);
|
||||
setNote('');
|
||||
setNoteModalOpen(true);
|
||||
};
|
||||
|
||||
// Group fees by category for display
|
||||
const feeGroups = FEES.reduce<Record<string, typeof FEES>>((acc, f) => {
|
||||
(acc[f.group] = acc[f.group] || []).push(f);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/seaman-book-queue')}>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Title order={3}>Application Review</Title>
|
||||
<Group gap={6} mt={2}>
|
||||
<Text fz="sm" c="dimmed">{app.id}</Text>
|
||||
<Text fz="sm" c="dimmed">·</Text>
|
||||
<Text fz="sm" c="dimmed">Submitted {app.submittedAt}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge size="lg" variant="light" color={STATUS_COLOR[status] ?? 'gray'}>
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Action bar */}
|
||||
{status === 'pending_review' && (
|
||||
<Paper withBorder radius="lg" p="md" bg="gray.0">
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Text fz="sm" c="dimmed" style={{ flex: 1 }}>Review all tabs, then take an action:</Text>
|
||||
<Button size="sm" color="orange" variant="light" leftSection={<IconAlertTriangle size={15} />} onClick={() => openNote('correction')}>
|
||||
Request Correction
|
||||
</Button>
|
||||
<Button size="sm" color="red" variant="light" leftSection={<IconX size={15} />} onClick={() => openNote('reject')}>
|
||||
Reject
|
||||
</Button>
|
||||
<Button size="sm" color="teal" leftSection={<IconCheck size={15} />} onClick={() => performAction('approve', 'approved', 'approved')} loading={actionLoading === 'approve'}>
|
||||
Approve Application
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{status !== 'pending_review' && (
|
||||
<Alert variant="light" color={STATUS_COLOR[status] ?? 'gray'}
|
||||
icon={status === 'approved' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}>
|
||||
This application has been <strong>{STATUS_LABEL[status] ?? status}</strong>.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="profile" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="profile" leftSection={<IconUser size={16} />}>Applicant Profile</Tabs.Tab>
|
||||
<Tabs.Tab value="bst" leftSection={<IconShieldCheck size={16} />}>BST Certificates</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconHeart size={16} />}>Medical</Tabs.Tab>
|
||||
<Tabs.Tab value="payment" leftSection={<IconCreditCard size={16} />}>Payment</Tabs.Tab>
|
||||
<Tabs.Tab value="issuance" leftSection={<IconCertificate size={16} />}>Issuance</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ── Applicant Profile ─────────────────────────────────────── */}
|
||||
<Tabs.Panel value="profile">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" radius="lg"><IconUser size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{app.applicant.fullName}</Text>
|
||||
<Text fz="sm" c="dimmed">Seafarer No: {app.applicant.seafarerNumber}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Personal Information</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
|
||||
<InfoRow label="Date of Birth" value={app.applicant.dob} />
|
||||
<InfoRow label="Nationality" value={app.applicant.nationality} />
|
||||
<InfoRow label="National ID" value={app.applicant.idNumber} />
|
||||
<InfoRow label="Phone" value={app.applicant.phone} />
|
||||
<InfoRow label="Email" value={app.applicant.email} />
|
||||
<InfoRow label="Address" value={app.applicant.address} />
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Physical Characteristics</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<InfoRow label="Height" value={app.applicant.height} />
|
||||
<InfoRow label="Blood Type" value={app.applicant.bloodType} />
|
||||
<InfoRow label="Hair Color" value={app.applicant.hairColor} />
|
||||
<InfoRow label="Eye Color" value={app.applicant.eyeColor} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── BST Certificates ──────────────────────────────────────── */}
|
||||
<Tabs.Panel value="bst">
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
Verify all 5 BST training certificates. PST and FPFF have 5-year renewal — check expiry dates are valid.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{app.bst.map((cert) => (
|
||||
<Paper key={cert.key} withBorder radius="lg" p="md">
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<ThemeIcon size="lg" variant="light" color="blue" radius="md">
|
||||
<IconShieldCheck size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{cert.short}</Text>
|
||||
<Text fz="xs" c="dimmed" lh={1.3}>{cert.label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap={4} mb="sm">
|
||||
<InfoRow label="Certificate No." value={cert.certNumber} />
|
||||
<InfoRow label="Issuer" value={cert.issuer} />
|
||||
<InfoRow label="Issue Date" value={cert.issueDate} />
|
||||
{cert.expiryDate && <InfoRow label="Expiry Date" value={cert.expiryDate} />}
|
||||
</Stack>
|
||||
<DocViewer fileName={cert.fileName} fileType={cert.fileType} label={`${cert.short} Certificate`} />
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Medical ───────────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="medical">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="pink" radius="lg"><IconHeart size={22} stroke={1.5} /></ThemeIcon>
|
||||
<Text fw={700} fz="lg">Medical Certificate</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
|
||||
<InfoRow label="Certificate No." value={app.medical.certNumber} />
|
||||
<InfoRow label="Issuing Centre" value={app.medical.issuer} />
|
||||
<InfoRow label="Issue Date" value={app.medical.issueDate} />
|
||||
<InfoRow label="Expiry Date" value={app.medical.expiryDate} />
|
||||
</SimpleGrid>
|
||||
<Box style={{ maxWidth: rem(420) }}>
|
||||
<DocViewer fileName={app.medical.fileName} fileType={app.medical.fileType} label="Medical Certificate" />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Payment ───────────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="payment">
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="green" radius="lg"><IconCreditCard size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Payment</Text>
|
||||
<Badge size="sm" variant="light" color={PAYMENT_COLOR[app.payment.status]} mt={2}>
|
||||
{app.payment.status.charAt(0).toUpperCase() + app.payment.status.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Fee breakdown grouped */}
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
|
||||
<Text fw={700} fz="sm" mb="md">Fee Breakdown</Text>
|
||||
{Object.entries(feeGroups).map(([group, fees], gi) => (
|
||||
<div key={group}>
|
||||
{gi > 0 && <Divider my="sm" />}
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="blue.7" mb={6}>{group}</Text>
|
||||
{fees.map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb={4}>
|
||||
<Text fz="sm">{label}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Divider mt="sm" mb="xs" />
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" fw={800}>Total</Text>
|
||||
<Text fz="md" fw={800} c="blue">ETB {TOTAL.toFixed(2)}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" mb="lg">
|
||||
<InfoRow label="Payment Method" value={app.payment.method} />
|
||||
<InfoRow label="Transaction Reference" value={app.payment.ref} />
|
||||
<InfoRow label="Payment Date" value={app.payment.date} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Box style={{ maxWidth: rem(420) }}>
|
||||
<DocViewer fileName={app.payment.receiptFileName} fileType={app.payment.receiptFileType} label="Payment Receipt" />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Issuance ──────────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="issuance">
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color={status === 'approved' ? 'teal' : 'yellow'} icon={<IconInfoCircle size={17} />}>
|
||||
{status === 'approved'
|
||||
? 'Application approved. The documents below will be generated and dispatched to the applicant.'
|
||||
: 'Approve the application to trigger document issuance.'}
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{[
|
||||
{ icon: IconBook2, color: 'blue', label: 'Seaman Book', desc: 'Official EMA seafarer identity book', validity: '5 years' },
|
||||
{ icon: IconCertificate,color: 'teal', label: 'Basic Training Certificate (BTC)',desc: 'EMA-issued BTC for all 5 BST courses', validity: '5 years' },
|
||||
{ icon: IconShieldCheck,color: 'violet',label: 'BSID Card', desc: 'Biometric Seafarer ID Card', validity: '5 years' },
|
||||
].map(({ icon: Icon, color, label, desc, validity }) => (
|
||||
<Card key={label} withBorder radius="lg" p="md"
|
||||
style={{ borderColor: status === 'approved' ? `var(--mantine-color-${color}-3)` : 'var(--mantine-color-gray-2)' }}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<ThemeIcon size="xl" variant="light" color={status === 'approved' ? color : 'gray'} radius="lg">
|
||||
<Icon size={22} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{label}</Text>
|
||||
<Badge size="xs" variant="light" color={status === 'approved' ? 'teal' : 'yellow'} mt={2}>
|
||||
{status === 'approved' ? 'Ready to Issue' : 'Pending Approval'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" mb="xs">{desc}</Text>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Validity</Text>
|
||||
<Text fz="xs" fw={500}>{validity}</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* Note modal */}
|
||||
<Modal
|
||||
opened={noteModalOpen}
|
||||
onClose={() => setNoteModalOpen(false)}
|
||||
title={<Group gap="xs"><IconBook2 size={18} /><Text fw={700}>{noteAction === 'correction' ? 'Request Correction' : 'Reject Application'}</Text></Group>}
|
||||
size="md" radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{noteAction === 'correction' ? 'Describe what the applicant needs to correct or resubmit.' : 'State the reason for rejection. The applicant will be notified.'}
|
||||
</Text>
|
||||
<Textarea label="Note to Applicant" placeholder="Enter your note..." rows={4} value={note} onChange={(e) => setNote(e.currentTarget.value)} required />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setNoteModalOpen(false)}>Cancel</Button>
|
||||
<Button color={noteAction === 'correction' ? 'orange' : 'red'} disabled={!note.trim()} loading={actionLoading === noteAction}
|
||||
onClick={() => performAction(noteAction!, noteAction === 'correction' ? 'sent back for correction' : 'rejected', noteAction === 'correction' ? 'correction_required' : 'rejected')}>
|
||||
{noteAction === 'correction' ? 'Send Back' : 'Reject'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
export type CoCAppStatus =
|
||||
| 'Submitted'
|
||||
| 'Document Review'
|
||||
| 'TRB Inspection'
|
||||
| 'TRB Resubmit Required'
|
||||
| 'TRB Rejected'
|
||||
| 'Examination Scheduled'
|
||||
| 'Examination Passed'
|
||||
| 'Examination Failed'
|
||||
| 'Certificate Issued';
|
||||
|
||||
export interface CoCApp {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
nationality: string;
|
||||
certType: string;
|
||||
stcwRef: string;
|
||||
level: 'Support' | 'Operational' | 'Management';
|
||||
dept: string;
|
||||
submitted: string;
|
||||
paymentStatus: 'Verified' | 'Pending';
|
||||
status: CoCAppStatus;
|
||||
examDate: string | null;
|
||||
examVenue: string | null;
|
||||
seaServiceFile: string;
|
||||
trbFile: string;
|
||||
competencyDocs: { area: string; uploaded: boolean; filename: string }[];
|
||||
trbInspectionNotes: string;
|
||||
examNotes: string;
|
||||
officerRemarks: string;
|
||||
}
|
||||
|
||||
export const STATUS_COLOR: Record<CoCAppStatus, string> = {
|
||||
'Submitted': 'gray',
|
||||
'Document Review': 'blue',
|
||||
'TRB Inspection': 'yellow',
|
||||
'TRB Resubmit Required': 'orange',
|
||||
'TRB Rejected': 'red',
|
||||
'Examination Scheduled': 'indigo',
|
||||
'Examination Passed': 'teal',
|
||||
'Examination Failed': 'red',
|
||||
'Certificate Issued': 'teal',
|
||||
};
|
||||
|
||||
export const MOCK_COC_APPS: CoCApp[] = [
|
||||
{
|
||||
id: 'COC-APP-2025-001',
|
||||
seafarerId: 'SF-2024-0001',
|
||||
name: 'Abebe Girma',
|
||||
email: 'abebe.g@email.com',
|
||||
mobile: '+251 911 234 567',
|
||||
nationality: 'Ethiopian',
|
||||
certType: 'Officer in Charge of a Navigational Watch (OICNW)',
|
||||
stcwRef: 'Reg. II/1 — Code A-II/1',
|
||||
level: 'Operational',
|
||||
dept: 'Deck',
|
||||
submitted: '2025-03-10',
|
||||
paymentStatus: 'Verified',
|
||||
status: 'TRB Inspection',
|
||||
examDate: null,
|
||||
examVenue: null,
|
||||
seaServiceFile: 'abebe_sea_service.pdf',
|
||||
trbFile: 'abebe_trb.pdf',
|
||||
competencyDocs: [
|
||||
{ area: 'Navigation at Operational Level', uploaded: true, filename: 'abebe_nav.pdf' },
|
||||
{ area: 'Cargo Handling & Stowage', uploaded: true, filename: 'abebe_cargo.pdf' },
|
||||
{ area: 'Control of Ship Operations', uploaded: true, filename: 'abebe_ops.pdf' },
|
||||
{ area: 'Radio Communication (GMDSS)', uploaded: true, filename: 'abebe_gmdss.pdf' },
|
||||
{ area: 'Leadership & Teamwork', uploaded: true, filename: 'abebe_brm.pdf' },
|
||||
],
|
||||
trbInspectionNotes: '',
|
||||
examNotes: '',
|
||||
officerRemarks: '',
|
||||
},
|
||||
{
|
||||
id: 'COC-APP-2025-002',
|
||||
seafarerId: 'SF-2024-0002',
|
||||
name: 'Sara Tadesse',
|
||||
email: 'sara.t@email.com',
|
||||
mobile: '+251 922 345 678',
|
||||
nationality: 'Ethiopian',
|
||||
certType: 'Able Seafarer Deck (AB)',
|
||||
stcwRef: 'Reg. II/5 — Code A-II/5',
|
||||
level: 'Support',
|
||||
dept: 'Deck',
|
||||
submitted: '2025-03-18',
|
||||
paymentStatus: 'Verified',
|
||||
status: 'Document Review',
|
||||
examDate: null,
|
||||
examVenue: null,
|
||||
seaServiceFile: 'sara_sea_service.pdf',
|
||||
trbFile: '',
|
||||
competencyDocs: [
|
||||
{ area: 'Navigation', uploaded: true, filename: 'sara_nav.pdf' },
|
||||
{ area: 'Cargo Operations', uploaded: true, filename: 'sara_cargo.pdf' },
|
||||
{ area: 'Ship Operations', uploaded: false, filename: '' },
|
||||
{ area: 'Safety & Emergency', uploaded: true, filename: 'sara_safety.pdf' },
|
||||
],
|
||||
trbInspectionNotes: '',
|
||||
examNotes: '',
|
||||
officerRemarks: 'Missing Ship Operations certificate. Requested resubmission.',
|
||||
},
|
||||
{
|
||||
id: 'COC-APP-2025-003',
|
||||
seafarerId: 'SF-2024-0003',
|
||||
name: 'Dawit Bekele',
|
||||
email: 'dawit.b@email.com',
|
||||
mobile: '+251 933 456 789',
|
||||
nationality: 'Ethiopian',
|
||||
certType: 'Officer in Charge of an Engineering Watch (OICEW)',
|
||||
stcwRef: 'Reg. III/1 — Code A-III/1',
|
||||
level: 'Operational',
|
||||
dept: 'Engine',
|
||||
submitted: '2025-03-25',
|
||||
paymentStatus: 'Verified',
|
||||
status: 'Examination Scheduled',
|
||||
examDate: '2025-05-10',
|
||||
examVenue: 'EMA HQ — Addis Ababa',
|
||||
seaServiceFile: 'dawit_sea_service.pdf',
|
||||
trbFile: 'dawit_trb.pdf',
|
||||
competencyDocs: [
|
||||
{ area: 'Marine Engineering at Operational Level', uploaded: true, filename: 'dawit_eng.pdf' },
|
||||
{ area: 'Electrical & Control Systems', uploaded: true, filename: 'dawit_elec.pdf' },
|
||||
{ area: 'Maintenance & Repair', uploaded: true, filename: 'dawit_maint.pdf' },
|
||||
{ area: 'Controlling Ship Operations', uploaded: true, filename: 'dawit_ops.pdf' },
|
||||
{ area: 'Leadership & Teamwork', uploaded: true, filename: 'dawit_brm.pdf' },
|
||||
],
|
||||
trbInspectionNotes: 'TRB physically inspected on 2025-04-20. All competency entries signed off by approved assessor.',
|
||||
examNotes: '',
|
||||
officerRemarks: '',
|
||||
},
|
||||
{
|
||||
id: 'COC-APP-2025-004',
|
||||
seafarerId: 'SF-2023-0088',
|
||||
name: 'Tekle Haile',
|
||||
email: 'tekle.h@email.com',
|
||||
mobile: '+251 944 567 890',
|
||||
nationality: 'Ethiopian',
|
||||
certType: 'Chief Mate — Ships 500–3,000 GT',
|
||||
stcwRef: 'Reg. II/2 — Code A-II/2',
|
||||
level: 'Management',
|
||||
dept: 'Deck',
|
||||
submitted: '2025-04-01',
|
||||
paymentStatus: 'Verified',
|
||||
status: 'Examination Passed',
|
||||
examDate: '2025-05-20',
|
||||
examVenue: 'EMA HQ — Addis Ababa',
|
||||
seaServiceFile: 'tekle_sea_service.pdf',
|
||||
trbFile: 'tekle_trb.pdf',
|
||||
competencyDocs: [
|
||||
{ area: 'Navigation at Management Level', uploaded: true, filename: 'tekle_nav.pdf' },
|
||||
{ area: 'Cargo Handling at Management Level', uploaded: true, filename: 'tekle_cargo.pdf' },
|
||||
{ area: 'Control of Ship Operations', uploaded: true, filename: 'tekle_ops.pdf' },
|
||||
{ area: 'Leadership & Management', uploaded: true, filename: 'tekle_lead.pdf' },
|
||||
],
|
||||
trbInspectionNotes: 'TRB inspected on 2025-04-28. All competency records complete and verified by EMA examiner.',
|
||||
examNotes: 'Oral examination conducted 2025-05-20. Candidate demonstrated full command of management-level navigation competencies. Result: PASS.',
|
||||
officerRemarks: '',
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
'All', 'Submitted', 'Document Review', 'TRB Inspection',
|
||||
'TRB Resubmit Required', 'TRB Rejected', 'Examination Scheduled',
|
||||
'Examination Passed', 'Examination Failed', 'Certificate Issued',
|
||||
];
|
||||
|
||||
export function CoCQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('All');
|
||||
|
||||
const filtered = MOCK_COC_APPS.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.seafarerId.toLowerCase().includes(q);
|
||||
const matchStatus = statusFilter === 'All' || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: MOCK_COC_APPS.length,
|
||||
trbPending: MOCK_COC_APPS.filter((a) => a.status === 'TRB Inspection' || a.status === 'TRB Resubmit Required').length,
|
||||
examSched: MOCK_COC_APPS.filter((a) => a.status === 'Examination Scheduled').length,
|
||||
issued: MOCK_COC_APPS.filter((a) => a.status === 'Certificate Issued' || a.status === 'Examination Passed').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>CoC / CoP Application Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Review documents, inspect TRBs, schedule examinations and issue certificates</Text>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'Total Applications', value: stats.total, color: 'blue', icon: IconShieldCheck },
|
||||
{ label: 'TRB Inspection', value: stats.trbPending, color: 'yellow', icon: IconBook2 },
|
||||
{ label: 'Exam Scheduled', value: stats.examSched, color: 'indigo', icon: IconClock },
|
||||
{ label: 'Passed / Issued', value: stats.issued, color: 'teal', icon: IconCircleCheck },
|
||||
].map(({ label, value, color, icon: Icon }) => (
|
||||
<Card key={label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
|
||||
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Table */}
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Application Queue</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Search by name, App ID or Seafarer ID…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ minWidth: rem(280) }}
|
||||
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
|
||||
/>
|
||||
<Select
|
||||
data={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={(v) => setStatusFilter(v ?? 'All')}
|
||||
size="sm"
|
||||
style={{ width: rem(200) }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Box py="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconShieldCheck size={22} /></ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No applications found</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['App ID', 'Seafarer', 'Certificate', 'Level', 'Docs', 'TRB', 'Payment', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((app) => {
|
||||
const allDocs = app.competencyDocs.every((d) => d.uploaded);
|
||||
return (
|
||||
<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}>{app.name}</Text>
|
||||
<Text fz="xs" c="dimmed">{app.seafarerId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="xs" maw={160} lh={1.3}>{app.certType}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={app.level === 'Management' ? 'violet' : app.level === 'Operational' ? 'blue' : 'gray'}>
|
||||
{app.level}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={allDocs ? 'teal' : 'orange'}>
|
||||
{app.competencyDocs.filter(d => d.uploaded).length}/{app.competencyDocs.length}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{app.trbFile
|
||||
? <Badge size="xs" variant="light" color="teal"><IconCheck size={10} /> Submitted</Badge>
|
||||
: <Badge size="xs" variant="light" color="gray">N/A</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'}>
|
||||
{app.paymentStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={STATUS_COLOR[app.status]}>{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/coc-queue/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 && (
|
||||
<Group px="md" py="sm" justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Showing {filtered.length} of {MOCK_COC_APPS.length} applications</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconBook2,
|
||||
IconCalendar,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCreditCard,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { MOCK_COC_APPS, STATUS_COLOR } from './CoCQueuePage';
|
||||
import type { CoCApp, CoCAppStatus } from './CoCQueuePage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demo doc viewer (same pattern as ApplicationReviewPage)
|
||||
// ---------------------------------------------------------------------------
|
||||
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
|
||||
|
||||
function getDocUrl(fileName: string) {
|
||||
if (!fileName) return '';
|
||||
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
function DocViewer({ fileName, label }: { fileName: string; label: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPdf = fileName.endsWith('.pdf');
|
||||
const url = getDocUrl(fileName);
|
||||
if (!fileName) return <Badge size="xs" color="red" variant="light">Not uploaded</Badge>;
|
||||
return (
|
||||
<>
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Group gap="sm" wrap="nowrap" mb="sm">
|
||||
<ThemeIcon size="md" variant="light" color={isPdf ? 'red' : 'blue'} radius="md">
|
||||
<IconFileDescription size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="sm" fw={600}>{label}</Text>
|
||||
<Text fz="xs" c="dimmed" truncate>{fileName}</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={isPdf ? 'red' : 'blue'}>{isPdf ? 'PDF' : 'IMG'}</Badge>
|
||||
</Group>
|
||||
<Box style={{ width: '100%', height: rem(180), borderRadius: rem(6), overflow: 'hidden', border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-gray-0)' }}>
|
||||
{isPdf
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Box>
|
||||
<Button size="xs" variant="subtle" fullWidth mt="xs" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
|
||||
View Full Document
|
||||
</Button>
|
||||
</Card>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} — {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
|
||||
{isPdf
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return <Text fw={600} fz="xs" tt="uppercase" c="gray.6" mb="sm">{children}</Text>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow progress bar
|
||||
// ---------------------------------------------------------------------------
|
||||
const STEP_ORDER: CoCAppStatus[] = [
|
||||
'Submitted', 'Document Review', 'TRB Inspection',
|
||||
'Examination Scheduled', 'Examination Passed', 'Certificate Issued',
|
||||
];
|
||||
|
||||
function WorkflowBar({ status }: { status: CoCAppStatus }) {
|
||||
const activeIdx = STEP_ORDER.indexOf(status);
|
||||
const isBranch = ['TRB Rejected', 'TRB Resubmit Required', 'Examination Failed'].includes(status);
|
||||
const labels = ['Submitted', 'Doc Review', 'TRB Inspect', 'Exam Scheduled', 'Exam Passed', 'Issued'];
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md" mb="md">
|
||||
<Text fz="xs" c="dimmed" fw={700} tt="uppercase" mb="xs">Application Progress</Text>
|
||||
<Group gap={0} align="flex-start" wrap="nowrap">
|
||||
{STEP_ORDER.map((s, i) => {
|
||||
const done = isBranch ? false : activeIdx > i;
|
||||
const current = !isBranch && activeIdx === i;
|
||||
return (
|
||||
<Group key={s} gap={0} align="center" style={{ flex: i < STEP_ORDER.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={2} align="center" style={{ minWidth: rem(52) }}>
|
||||
<Box style={{
|
||||
width: rem(30), height: rem(30), borderRadius: '50%',
|
||||
background: done ? 'var(--mantine-color-teal-6)' : current ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
{done
|
||||
? <IconCheck size={14} color="white" stroke={2.5} />
|
||||
: <Text fz="xs" fw={700} c={current ? 'white' : 'gray.5'}>{i + 1}</Text>}
|
||||
</Box>
|
||||
<Text fz={9} fw={current || done ? 700 : 400} c={done ? 'teal.7' : current ? 'blue.7' : 'dimmed'} ta="center" lh={1.2}>{labels[i]}</Text>
|
||||
</Stack>
|
||||
{i < STEP_ORDER.length - 1 && (
|
||||
<Box style={{ flex: 1, height: rem(2), background: done ? 'var(--mantine-color-teal-4)' : 'var(--mantine-color-gray-2)', marginBottom: rem(20) }} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
{isBranch && (
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={13} />} p="xs" mt="xs">
|
||||
<Text fz="xs" fw={600}>
|
||||
{status === 'TRB Resubmit Required' && 'TRB resubmission required — applicant notified.'}
|
||||
{status === 'TRB Rejected' && 'TRB rejected — applicant must submit a new application.'}
|
||||
{status === 'Examination Failed' && 'Examination failed — applicant must submit a new application.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action modal
|
||||
// ---------------------------------------------------------------------------
|
||||
const EXAM_VENUES = [
|
||||
{ value: 'addis', label: 'EMA HQ — Addis Ababa' },
|
||||
{ value: 'regional', label: 'EMA Regional Office — Djibouti Liaison' },
|
||||
];
|
||||
|
||||
type ActionKey = 'advance_trb' | 'request_docs' | 'approve_trb' | 'resubmit_trb' | 'reject_trb' | 'exam_pass' | 'exam_fail' | 'issue_cert';
|
||||
|
||||
const ACTION_TO_STATUS: Record<ActionKey, CoCAppStatus> = {
|
||||
advance_trb: 'TRB Inspection',
|
||||
request_docs: 'Document Review',
|
||||
approve_trb: 'Examination Scheduled',
|
||||
resubmit_trb: 'TRB Resubmit Required',
|
||||
reject_trb: 'TRB Rejected',
|
||||
exam_pass: 'Examination Passed',
|
||||
exam_fail: 'Examination Failed',
|
||||
issue_cert: 'Certificate Issued',
|
||||
};
|
||||
|
||||
function getActions(status: CoCAppStatus): { value: ActionKey; label: string }[] {
|
||||
switch (status) {
|
||||
case 'Submitted':
|
||||
case 'Document Review':
|
||||
return [
|
||||
{ value: 'advance_trb', label: 'Documents OK — Move to TRB Inspection' },
|
||||
{ value: 'request_docs', label: 'Request Additional Documents' },
|
||||
];
|
||||
case 'TRB Inspection':
|
||||
case 'TRB Resubmit Required':
|
||||
return [
|
||||
{ value: 'approve_trb', label: 'TRB Approved — Schedule Examination' },
|
||||
{ value: 'resubmit_trb', label: 'Request TRB Resubmission' },
|
||||
{ value: 'reject_trb', label: 'Reject TRB (Close Application)' },
|
||||
];
|
||||
case 'Examination Scheduled':
|
||||
return [
|
||||
{ value: 'exam_pass', label: 'Record Result — Passed' },
|
||||
{ value: 'exam_fail', label: 'Record Result — Failed' },
|
||||
];
|
||||
case 'Examination Passed':
|
||||
return [{ value: 'issue_cert', label: 'Issue Certificate' }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function ActionModal({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
}: {
|
||||
app: CoCApp;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (newStatus: CoCAppStatus, notes: string, examDate?: string, examVenue?: string) => void;
|
||||
}) {
|
||||
const [action, setAction] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [examDate, setExamDate] = useState('');
|
||||
const [venue, setVenue] = useState<string | null>(null);
|
||||
|
||||
const actionOptions = getActions(app.status);
|
||||
const needsExam = action === 'approve_trb';
|
||||
const isDestructive = action === 'reject_trb' || action === 'exam_fail';
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!action) return;
|
||||
if (needsExam && (!examDate || !venue)) {
|
||||
notify.error('Please set an examination date and venue.'); return;
|
||||
}
|
||||
const newStatus = ACTION_TO_STATUS[action as ActionKey];
|
||||
const venueLabel = EXAM_VENUES.find(v => v.value === venue)?.label ?? venue ?? undefined;
|
||||
onAction(newStatus, notes, examDate || undefined, venueLabel);
|
||||
setAction(null); setNotes(''); setExamDate(''); setVenue(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconShieldCheck size={17} /><Text fw={700}>Officer Action</Text></Group>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{actionOptions.length === 0 ? (
|
||||
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />}>
|
||||
<Text fz="sm" fw={600}>No further actions available for this status.</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
label="Action"
|
||||
placeholder="Select an action…"
|
||||
data={actionOptions}
|
||||
value={action}
|
||||
onChange={setAction}
|
||||
size="sm"
|
||||
/>
|
||||
{needsExam && (
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Examination Date" type="date" value={examDate} onChange={(e) => setExamDate(e.currentTarget.value)} leftSection={<IconCalendar size={14} />} size="sm" required />
|
||||
<Select label="Venue" placeholder="Select venue" data={EXAM_VENUES} value={venue} onChange={setVenue} size="sm" required />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<Textarea
|
||||
label="Notes / Remarks (sent to applicant)"
|
||||
placeholder="TRB inspection findings, exam result details, rejection reason…"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
rows={3}
|
||||
size="sm"
|
||||
/>
|
||||
{isDestructive && (
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={15} />} p="sm">
|
||||
<Text fz="xs" fw={600}>
|
||||
{action === 'reject_trb' && 'This will permanently close the application. The applicant must submit a new application.'}
|
||||
{action === 'exam_fail' && 'This will record an examination failure. The applicant must apply again to re-attempt.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
leftSection={<IconCheck size={14} />}
|
||||
disabled={!action}
|
||||
color={isDestructive ? 'red' : action === 'issue_cert' || action === 'approve_trb' || action === 'exam_pass' ? 'teal' : 'blue'}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main review page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function CoCReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const original = MOCK_COC_APPS.find((a) => a.id === id);
|
||||
|
||||
const [app, setApp] = useState<CoCApp | null>(original ?? null);
|
||||
const [actionModalOpen, setActionModalOpen] = useState(false);
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/coc-queue')}>Back to Queue</Button>
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={17} />}>Application not found.</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAction = (newStatus: CoCAppStatus, notes: string, examDate?: string, examVenue?: string) => {
|
||||
const trbStatuses: CoCAppStatus[] = ['TRB Inspection', 'TRB Resubmit Required', 'TRB Rejected', 'Examination Scheduled'];
|
||||
const examStatuses: CoCAppStatus[] = ['Examination Passed', 'Examination Failed'];
|
||||
setApp((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
status: newStatus,
|
||||
examDate: examDate ?? prev.examDate,
|
||||
examVenue: examVenue ?? prev.examVenue,
|
||||
trbInspectionNotes: trbStatuses.includes(newStatus) ? notes || prev.trbInspectionNotes : prev.trbInspectionNotes,
|
||||
examNotes: examStatuses.includes(newStatus) ? notes || prev.examNotes : prev.examNotes,
|
||||
officerRemarks: notes || prev.officerRemarks,
|
||||
};
|
||||
});
|
||||
const labels: Record<CoCAppStatus, string> = {
|
||||
'Submitted': 'moved to Submitted',
|
||||
'Document Review': 'moved to Document Review',
|
||||
'TRB Inspection': 'moved to TRB Inspection',
|
||||
'TRB Resubmit Required': 'TRB resubmission requested',
|
||||
'TRB Rejected': 'TRB rejected — application closed',
|
||||
'Examination Scheduled': `examination scheduled for ${examDate}`,
|
||||
'Examination Passed': 'examination result recorded — Passed',
|
||||
'Examination Failed': 'examination result recorded — Failed',
|
||||
'Certificate Issued': 'certificate issued',
|
||||
};
|
||||
notify.success(`Application ${labels[newStatus] ?? newStatus}.`);
|
||||
};
|
||||
|
||||
const allDocs = app.competencyDocs.every((d) => d.uploaded);
|
||||
const actionOptions = getActions(app.status);
|
||||
const isTerminal = ['TRB Rejected', 'Examination Failed', 'Certificate Issued'].includes(app.status);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/coc-queue')}>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Title order={3}>CoC / CoP Application Review</Title>
|
||||
<Group gap={6} mt={2}>
|
||||
<Text fz="sm" c="dimmed">{app.id}</Text>
|
||||
<Text fz="sm" c="dimmed">·</Text>
|
||||
<Text fz="sm" c="dimmed">Submitted {app.submitted}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge size="lg" variant="light" color={STATUS_COLOR[app.status]}>{app.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Action bar */}
|
||||
{!isTerminal && actionOptions.length > 0 && (
|
||||
<Paper withBorder radius="lg" p="md" bg="gray.0">
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Text fz="sm" c="dimmed" style={{ flex: 1 }}>Review all tabs, then take an action:</Text>
|
||||
<Button size="sm" leftSection={<IconShieldCheck size={15} />} onClick={() => setActionModalOpen(true)}>
|
||||
Take Action
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{isTerminal && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color={app.status === 'Certificate Issued' ? 'teal' : 'red'}
|
||||
icon={app.status === 'Certificate Issued' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
|
||||
>
|
||||
<Text fz="sm" fw={600}>
|
||||
{app.status === 'Certificate Issued' && 'Certificate has been issued and dispatched to the seafarer.'}
|
||||
{app.status === 'TRB Rejected' && 'TRB has been rejected. This application is closed. Applicant must re-apply.'}
|
||||
{app.status === 'Examination Failed' && 'Examination failed. This application is closed. Applicant must re-apply.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<WorkflowBar status={app.status} />
|
||||
|
||||
<Tabs defaultValue="profile" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="profile" leftSection={<IconUser size={16} />}>Applicant</Tabs.Tab>
|
||||
<Tabs.Tab value="competency" leftSection={<IconFileDescription size={16} />}>Competency Docs</Tabs.Tab>
|
||||
<Tabs.Tab value="trb" leftSection={<IconBook2 size={16} />}>TRB Inspection</Tabs.Tab>
|
||||
<Tabs.Tab value="exam" leftSection={<IconCertificate size={16} />}>Examination</Tabs.Tab>
|
||||
<Tabs.Tab value="payment" leftSection={<IconCreditCard size={16} />}>Payment</Tabs.Tab>
|
||||
<Tabs.Tab value="issuance" leftSection={<IconShieldCheck size={16} />}>Issuance</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ── Applicant ─────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="profile">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" radius="lg"><IconUser size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{app.name}</Text>
|
||||
<Text fz="sm" c="dimmed">{app.seafarerId}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Personal Information</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
|
||||
<InfoRow label="Email" value={app.email} />
|
||||
<InfoRow label="Mobile" value={app.mobile} />
|
||||
<InfoRow label="Nationality" value={app.nationality} />
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Certificate Applied For</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<InfoRow label="Certificate" value={app.certType} />
|
||||
<InfoRow label="STCW Reference" value={app.stcwRef} />
|
||||
<InfoRow label="Level" value={app.level} />
|
||||
<InfoRow label="Department" value={app.dept} />
|
||||
<InfoRow label="Submitted" value={app.submitted} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Competency Documents ──────────────────────────────── */}
|
||||
<Tabs.Panel value="competency">
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color={allDocs ? 'teal' : 'orange'} icon={allDocs ? <IconCircleCheck size={17} /> : <IconInfoCircle size={17} />}>
|
||||
{allDocs
|
||||
? `All ${app.competencyDocs.length} competency certificates uploaded.`
|
||||
: `${app.competencyDocs.filter(d => !d.uploaded).length} competency certificate(s) missing.`}
|
||||
</Alert>
|
||||
|
||||
{/* Sea service */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>Sea Service Record</SectionLabel>
|
||||
<Box maw={360}>
|
||||
<DocViewer fileName={app.seaServiceFile} label="Sea Service Record / Discharge Book" />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Competency certs */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>Competency Certificates ({app.competencyDocs.length} areas)</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{app.competencyDocs.map((doc) => (
|
||||
<div key={doc.area}>
|
||||
<Group gap="xs" mb="xs">
|
||||
{doc.uploaded
|
||||
? <ThemeIcon size={18} radius="xl" color="teal" variant="filled"><IconCheck size={11} /></ThemeIcon>
|
||||
: <ThemeIcon size={18} radius="xl" color="red" variant="light"><IconX size={11} /></ThemeIcon>}
|
||||
<Text fz="xs" fw={700}>{doc.area}</Text>
|
||||
</Group>
|
||||
<DocViewer fileName={doc.filename} label={doc.area} />
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── TRB Inspection ────────────────────────────────────── */}
|
||||
<Tabs.Panel value="trb">
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="yellow" icon={<IconInfoCircle size={17} />}>
|
||||
The Training Record Book (TRB) must be physically inspected at the EMA office.
|
||||
The applicant brings the original; record your inspection findings below after taking action.
|
||||
</Alert>
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>Submitted TRB</SectionLabel>
|
||||
{app.trbFile
|
||||
? <Box maw={360}><DocViewer fileName={app.trbFile} label="Training Record Book (TRB)" /></Box>
|
||||
: <Badge color="gray" variant="light">Not submitted (entry-level certificate — TRB not required)</Badge>}
|
||||
</Paper>
|
||||
{app.trbInspectionNotes && (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>TRB Inspection Notes</SectionLabel>
|
||||
<Text fz="sm">{app.trbInspectionNotes}</Text>
|
||||
</Paper>
|
||||
)}
|
||||
{!app.trbInspectionNotes && app.trbFile && (
|
||||
<Alert variant="light" color="blue" icon={<IconBook2 size={17} />}>
|
||||
TRB inspection has not yet been recorded. Use "Take Action" above to approve, request resubmission, or reject the TRB.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Examination ───────────────────────────────────────── */}
|
||||
<Tabs.Panel value="exam">
|
||||
<Stack gap="md">
|
||||
{app.examDate ? (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>Scheduled Examination</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="md">
|
||||
<InfoRow label="Examination Date" value={app.examDate} />
|
||||
<InfoRow label="Venue" value={app.examVenue ?? ''} />
|
||||
</SimpleGrid>
|
||||
{app.examNotes && (
|
||||
<>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Examination Result Notes</SectionLabel>
|
||||
<Text fz="sm">{app.examNotes}</Text>
|
||||
</>
|
||||
)}
|
||||
{['Examination Scheduled'].includes(app.status) && (
|
||||
<Alert variant="light" color="indigo" icon={<IconCalendar size={17} />} mt="md">
|
||||
Examination is scheduled. After the examination, use "Take Action" to record the result (Pass or Fail).
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
) : (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={17} />}>
|
||||
No examination scheduled yet. Examination is assigned by an EMA officer after TRB approval.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Payment ───────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="payment">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="green" radius="lg"><IconCreditCard size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Payment Status</Text>
|
||||
<Badge size="sm" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'} mt={2}>
|
||||
{app.paymentStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
|
||||
<Text fw={700} fz="sm" mb="md">CoC / CoP Application Fee</Text>
|
||||
{[
|
||||
{ label: 'Application Processing Fee', amount: 800 },
|
||||
{ label: 'Document Verification Fee', amount: 300 },
|
||||
{ label: 'Examination Fee', amount: 500 },
|
||||
{ label: 'Certificate Issuance Fee', amount: 400 },
|
||||
].map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb={4}>
|
||||
<Text fz="sm">{label}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Divider mt="sm" mb="xs" />
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" fw={800}>Total</Text>
|
||||
<Text fz="md" fw={800} c="blue">ETB 2,000.00</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<InfoRow label="Payment Method" value="CBE Bank Transfer" />
|
||||
<InfoRow label="Transaction Reference" value="CBE-TXN-20250315-042" />
|
||||
<InfoRow label="Payment Date" value={app.submitted} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Issuance ──────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="issuance">
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color={app.status === 'Certificate Issued' ? 'teal' : 'yellow'}
|
||||
icon={app.status === 'Certificate Issued' ? <IconCircleCheck size={17} /> : <IconInfoCircle size={17} />}
|
||||
>
|
||||
{app.status === 'Certificate Issued'
|
||||
? 'Certificate has been issued. The seafarer has been notified.'
|
||||
: 'Certificate will be issued after the application is fully approved and examination passed.'}
|
||||
</Alert>
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>Certificate to be Issued</SectionLabel>
|
||||
<Card withBorder radius="lg" p="md" style={{ borderColor: app.status === 'Certificate Issued' ? 'var(--mantine-color-teal-3)' : 'var(--mantine-color-gray-2)' }}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<ThemeIcon size="xl" variant="light" color={app.status === 'Certificate Issued' ? 'teal' : 'gray'} radius="lg">
|
||||
<IconShieldCheck size={22} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{app.certType}</Text>
|
||||
<Text fz="xs" c="dimmed">{app.stcwRef}</Text>
|
||||
<Badge size="xs" variant="light" color={app.status === 'Certificate Issued' ? 'teal' : 'yellow'} mt={4}>
|
||||
{app.status === 'Certificate Issued' ? 'Issued' : 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<InfoRow label="Validity" value="5 years" />
|
||||
<InfoRow label="Revalidation" value="Before expiry — STCW Reg I/11" />
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<ActionModal
|
||||
app={app}
|
||||
opened={actionModalOpen}
|
||||
onClose={() => setActionModalOpen(false)}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCertificate,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconEye,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconRubberStamp,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endorsement = STCW Reg I/10 — flag state endorsement of a foreign-issued CoC.
|
||||
// Workflow: Application Submitted → Document Verification → Endorsement Issued / Rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EndorsementStatus =
|
||||
| 'Submitted'
|
||||
| 'Document Verification'
|
||||
| 'Pending Approval'
|
||||
| 'Endorsed'
|
||||
| 'Rejected';
|
||||
|
||||
export interface EndorsementApp {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
nationality: string;
|
||||
submitted: string;
|
||||
status: EndorsementStatus;
|
||||
paymentStatus: 'Verified' | 'Pending';
|
||||
// Foreign CoC details
|
||||
foreignCocNumber: string;
|
||||
foreignCocIssuer: string;
|
||||
foreignCocCountry: string;
|
||||
foreignCocIssueDate: string;
|
||||
foreignCocExpiryDate: string;
|
||||
foreignCocType: string;
|
||||
stcwRef: string;
|
||||
// Endorsement result
|
||||
endorsementNumber: string;
|
||||
endorsementIssueDate: string;
|
||||
endorsementExpiryDate: string;
|
||||
officerRemarks: string;
|
||||
// Docs
|
||||
foreignCocFile: string;
|
||||
certifiedTranslationFile: string;
|
||||
seamanBookFile: string;
|
||||
medicalFile: string;
|
||||
photoFile: string;
|
||||
}
|
||||
|
||||
export const ENDORSEMENT_STATUS_COLOR: Record<EndorsementStatus, string> = {
|
||||
'Submitted': 'gray',
|
||||
'Document Verification': 'blue',
|
||||
'Pending Approval': 'yellow',
|
||||
'Endorsed': 'teal',
|
||||
'Rejected': 'red',
|
||||
};
|
||||
|
||||
export const MOCK_ENDORSEMENT_APPS: EndorsementApp[] = [
|
||||
{
|
||||
id: 'END-APP-2025-001',
|
||||
seafarerId: 'SF-2024-0010',
|
||||
name: 'Bereket Alemu',
|
||||
email: 'bereket.a@email.com',
|
||||
mobile: '+251 911 100 200',
|
||||
nationality: 'Ethiopian',
|
||||
submitted: '2025-04-05',
|
||||
status: 'Document Verification',
|
||||
paymentStatus: 'Verified',
|
||||
foreignCocNumber: 'PHL-COC-2022-0045',
|
||||
foreignCocIssuer: 'Maritime Industry Authority (MARINA)',
|
||||
foreignCocCountry: 'Philippines',
|
||||
foreignCocIssueDate: '2022-06-15',
|
||||
foreignCocExpiryDate: '2027-06-15',
|
||||
foreignCocType: 'Officer in Charge of a Navigational Watch',
|
||||
stcwRef: 'STCW Reg. II/1',
|
||||
endorsementNumber: '',
|
||||
endorsementIssueDate: '',
|
||||
endorsementExpiryDate: '',
|
||||
officerRemarks: '',
|
||||
foreignCocFile: 'bereket_foreign_coc.pdf',
|
||||
certifiedTranslationFile: '',
|
||||
seamanBookFile: 'bereket_seaman_book.pdf',
|
||||
medicalFile: 'bereket_medical.pdf',
|
||||
photoFile: 'bereket_photo.jpg',
|
||||
},
|
||||
{
|
||||
id: 'END-APP-2025-002',
|
||||
seafarerId: 'SF-2024-0011',
|
||||
name: 'Hiwot Girma',
|
||||
email: 'hiwot.g@email.com',
|
||||
mobile: '+251 922 200 300',
|
||||
nationality: 'Ethiopian',
|
||||
submitted: '2025-04-10',
|
||||
status: 'Pending Approval',
|
||||
paymentStatus: 'Verified',
|
||||
foreignCocNumber: 'GRC-COC-2021-0088',
|
||||
foreignCocIssuer: 'Hellenic Coast Guard',
|
||||
foreignCocCountry: 'Greece',
|
||||
foreignCocIssueDate: '2021-09-20',
|
||||
foreignCocExpiryDate: '2026-09-20',
|
||||
foreignCocType: 'Officer in Charge of an Engineering Watch',
|
||||
stcwRef: 'STCW Reg. III/1',
|
||||
endorsementNumber: '',
|
||||
endorsementIssueDate: '',
|
||||
endorsementExpiryDate: '',
|
||||
officerRemarks: 'All documents verified. Awaiting senior officer approval for endorsement issuance.',
|
||||
foreignCocFile: 'hiwot_foreign_coc.pdf',
|
||||
certifiedTranslationFile: 'hiwot_translation.pdf',
|
||||
seamanBookFile: 'hiwot_seaman_book.pdf',
|
||||
medicalFile: 'hiwot_medical.pdf',
|
||||
photoFile: 'hiwot_photo.jpg',
|
||||
},
|
||||
{
|
||||
id: 'END-APP-2025-003',
|
||||
seafarerId: 'SF-2023-0055',
|
||||
name: 'Mulat Bekele',
|
||||
email: 'mulat.b@email.com',
|
||||
mobile: '+251 933 300 400',
|
||||
nationality: 'Ethiopian',
|
||||
submitted: '2025-03-20',
|
||||
status: 'Endorsed',
|
||||
paymentStatus: 'Verified',
|
||||
foreignCocNumber: 'KOR-COC-2020-0112',
|
||||
foreignCocIssuer: 'Korea Maritime & Ocean University',
|
||||
foreignCocCountry: 'South Korea',
|
||||
foreignCocIssueDate: '2020-03-10',
|
||||
foreignCocExpiryDate: '2025-03-10',
|
||||
foreignCocType: 'Chief Mate',
|
||||
stcwRef: 'STCW Reg. II/2',
|
||||
endorsementNumber: 'EMA-END-2025-003',
|
||||
endorsementIssueDate: '2025-04-01',
|
||||
endorsementExpiryDate: '2025-03-10',
|
||||
officerRemarks: 'Foreign CoC verified. Endorsement issued in accordance with STCW Reg I/10. Note: foreign CoC expires 2025-03-10; endorsement co-terminous.',
|
||||
foreignCocFile: 'mulat_foreign_coc.pdf',
|
||||
certifiedTranslationFile: '',
|
||||
seamanBookFile: 'mulat_seaman_book.pdf',
|
||||
medicalFile: 'mulat_medical.pdf',
|
||||
photoFile: 'mulat_photo.jpg',
|
||||
},
|
||||
{
|
||||
id: 'END-APP-2025-004',
|
||||
seafarerId: 'SF-2024-0022',
|
||||
name: 'Tigist Haile',
|
||||
email: 'tigist.h@email.com',
|
||||
mobile: '+251 944 400 500',
|
||||
nationality: 'Ethiopian',
|
||||
submitted: '2025-04-18',
|
||||
status: 'Submitted',
|
||||
paymentStatus: 'Pending',
|
||||
foreignCocNumber: 'IND-COC-2023-0445',
|
||||
foreignCocIssuer: 'Directorate General of Shipping',
|
||||
foreignCocCountry: 'India',
|
||||
foreignCocIssueDate: '2023-11-01',
|
||||
foreignCocExpiryDate: '2028-11-01',
|
||||
foreignCocType: 'Electro-Technical Officer (ETO)',
|
||||
stcwRef: 'STCW Reg. III/6',
|
||||
endorsementNumber: '',
|
||||
endorsementIssueDate: '',
|
||||
endorsementExpiryDate: '',
|
||||
officerRemarks: '',
|
||||
foreignCocFile: 'tigist_foreign_coc.pdf',
|
||||
certifiedTranslationFile: '',
|
||||
seamanBookFile: '',
|
||||
medicalFile: '',
|
||||
photoFile: '',
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = ['All', 'Submitted', 'Document Verification', 'Pending Approval', 'Endorsed', 'Rejected'];
|
||||
|
||||
export function EndorsementQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('All');
|
||||
|
||||
const filtered = MOCK_ENDORSEMENT_APPS.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.seafarerId.toLowerCase().includes(q);
|
||||
const matchStatus = statusFilter === 'All' || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: MOCK_ENDORSEMENT_APPS.length,
|
||||
pending: MOCK_ENDORSEMENT_APPS.filter((a) => ['Submitted', 'Document Verification', 'Pending Approval'].includes(a.status)).length,
|
||||
endorsed: MOCK_ENDORSEMENT_APPS.filter((a) => a.status === 'Endorsed').length,
|
||||
rejected: MOCK_ENDORSEMENT_APPS.filter((a) => a.status === 'Rejected').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Endorsement Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Process flag-state endorsements of foreign-issued CoC certificates (STCW Reg I/10)</Text>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'Total Applications', value: stats.total, color: 'blue', icon: IconStamp },
|
||||
{ label: 'Awaiting Review', value: stats.pending, color: 'yellow', icon: IconClock },
|
||||
{ label: 'Endorsed', value: stats.endorsed, color: 'teal', icon: IconCircleCheck },
|
||||
{ label: 'Rejected', value: stats.rejected, color: 'red', icon: IconShieldCheck },
|
||||
].map(({ label, value, color, icon: Icon }) => (
|
||||
<Card key={label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
|
||||
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Table */}
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Endorsement Applications</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Search by name, App ID or Seafarer ID…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ minWidth: rem(280) }}
|
||||
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
|
||||
/>
|
||||
<Select
|
||||
data={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={(v) => setStatusFilter(v ?? 'All')}
|
||||
size="sm"
|
||||
style={{ width: rem(200) }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Box py="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconStamp size={22} /></ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No applications found</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['App ID', 'Seafarer', 'Foreign CoC Type', 'Issued By', 'Country', 'Expiry', 'Payment', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.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}>{app.name}</Text>
|
||||
<Text fz="xs" c="dimmed">{app.seafarerId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="xs" maw={140} lh={1.3}>{app.foreignCocType}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" maw={140} lh={1.3}>{app.foreignCocIssuer}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{app.foreignCocCountry}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{app.foreignCocExpiryDate}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'}>
|
||||
{app.paymentStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={ENDORSEMENT_STATUS_COLOR[app.status]}>{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/endorsement-queue/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 && (
|
||||
<Group px="md" py="sm" justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Showing {filtered.length} of {MOCK_ENDORSEMENT_APPS.length} applications</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCreditCard,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconRubberStamp,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
MOCK_ENDORSEMENT_APPS,
|
||||
ENDORSEMENT_STATUS_COLOR,
|
||||
} from './EndorsementQueuePage';
|
||||
import type { EndorsementApp, EndorsementStatus } from './EndorsementQueuePage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Doc viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
|
||||
|
||||
function getDocUrl(fileName: string) {
|
||||
if (!fileName) return '';
|
||||
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
function DocViewer({ fileName, label }: { fileName: string; label: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPdf = fileName.endsWith('.pdf');
|
||||
const url = getDocUrl(fileName);
|
||||
if (!fileName) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="md" variant="light" color="red" radius="md"><IconFileDescription size={16} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{label}</Text>
|
||||
<Badge size="xs" color="red" variant="light">Not uploaded</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Group gap="sm" wrap="nowrap" mb="sm">
|
||||
<ThemeIcon size="md" variant="light" color={isPdf ? 'red' : 'blue'} radius="md">
|
||||
<IconFileDescription size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="sm" fw={600}>{label}</Text>
|
||||
<Text fz="xs" c="dimmed" truncate>{fileName}</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={isPdf ? 'red' : 'blue'}>{isPdf ? 'PDF' : 'IMG'}</Badge>
|
||||
</Group>
|
||||
<Box style={{ width: '100%', height: rem(180), borderRadius: rem(6), overflow: 'hidden', border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-gray-0)' }}>
|
||||
{isPdf
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Box>
|
||||
<Button size="xs" variant="subtle" fullWidth mt="xs" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
|
||||
View Full Document
|
||||
</Button>
|
||||
</Card>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} — {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
|
||||
{isPdf
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return <Text fw={600} fz="xs" tt="uppercase" c="gray.6" mb="sm">{children}</Text>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow bar
|
||||
// ---------------------------------------------------------------------------
|
||||
const STEP_ORDER: EndorsementStatus[] = ['Submitted', 'Document Verification', 'Pending Approval', 'Endorsed'];
|
||||
const STEP_LABELS = ['Submitted', 'Doc Verification', 'Pending Approval', 'Endorsed'];
|
||||
|
||||
function WorkflowBar({ status }: { status: EndorsementStatus }) {
|
||||
const activeIdx = STEP_ORDER.indexOf(status);
|
||||
const isRejected = status === 'Rejected';
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md" mb="md">
|
||||
<Text fz="xs" c="dimmed" fw={700} tt="uppercase" mb="xs">Endorsement Progress</Text>
|
||||
<Group gap={0} align="flex-start" wrap="nowrap">
|
||||
{STEP_ORDER.map((s, i) => {
|
||||
const done = !isRejected && activeIdx > i;
|
||||
const current = !isRejected && activeIdx === i;
|
||||
return (
|
||||
<Group key={s} gap={0} align="center" style={{ flex: i < STEP_ORDER.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={2} align="center" style={{ minWidth: rem(60) }}>
|
||||
<Box style={{
|
||||
width: rem(30), height: rem(30), borderRadius: '50%',
|
||||
background: done ? 'var(--mantine-color-teal-6)' : current ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
{done
|
||||
? <IconCheck size={14} color="white" stroke={2.5} />
|
||||
: <Text fz="xs" fw={700} c={current ? 'white' : 'gray.5'}>{i + 1}</Text>}
|
||||
</Box>
|
||||
<Text fz={9} fw={current || done ? 700 : 400} c={done ? 'teal.7' : current ? 'blue.7' : 'dimmed'} ta="center" lh={1.2}>{STEP_LABELS[i]}</Text>
|
||||
</Stack>
|
||||
{i < STEP_ORDER.length - 1 && (
|
||||
<Box style={{ flex: 1, height: rem(2), background: done ? 'var(--mantine-color-teal-4)' : 'var(--mantine-color-gray-2)', marginBottom: rem(20) }} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
{isRejected && (
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={13} />} p="xs" mt="xs">
|
||||
<Text fz="xs" fw={600}>Application rejected. Applicant must submit a new application.</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action modal
|
||||
// ---------------------------------------------------------------------------
|
||||
type ActionKey = 'verify_docs' | 'request_docs' | 'pending_approval' | 'endorse' | 'reject';
|
||||
|
||||
function getActions(status: EndorsementStatus): { value: ActionKey; label: string }[] {
|
||||
switch (status) {
|
||||
case 'Submitted':
|
||||
return [
|
||||
{ value: 'verify_docs', label: 'Start Document Verification' },
|
||||
{ value: 'request_docs', label: 'Request Missing Documents' },
|
||||
];
|
||||
case 'Document Verification':
|
||||
return [
|
||||
{ value: 'pending_approval', label: 'Documents Verified — Submit for Approval' },
|
||||
{ value: 'request_docs', label: 'Request Additional Documents' },
|
||||
{ value: 'reject', label: 'Reject Application' },
|
||||
];
|
||||
case 'Pending Approval':
|
||||
return [
|
||||
{ value: 'endorse', label: 'Issue Endorsement' },
|
||||
{ value: 'reject', label: 'Reject Application' },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_TO_STATUS: Record<ActionKey, EndorsementStatus> = {
|
||||
verify_docs: 'Document Verification',
|
||||
request_docs: 'Document Verification',
|
||||
pending_approval: 'Pending Approval',
|
||||
endorse: 'Endorsed',
|
||||
reject: 'Rejected',
|
||||
};
|
||||
|
||||
function ActionModal({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
}: {
|
||||
app: EndorsementApp;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (newStatus: EndorsementStatus, remarks: string, endNo?: string, issueDate?: string, expiryDate?: string) => void;
|
||||
}) {
|
||||
const [action, setAction] = useState<string | null>(null);
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [endNo, setEndNo] = useState('');
|
||||
const [issueDate, setIssueDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
|
||||
const actionOptions = getActions(app.status);
|
||||
const isEndorse = action === 'endorse';
|
||||
const isDestructive = action === 'reject';
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!action) return;
|
||||
if (isEndorse && (!endNo || !issueDate || !expiryDate)) {
|
||||
notify.error('Please fill in endorsement number, issue date and expiry date.'); return;
|
||||
}
|
||||
const newStatus = ACTION_TO_STATUS[action as ActionKey];
|
||||
onAction(newStatus, remarks, endNo || undefined, issueDate || undefined, expiryDate || undefined);
|
||||
setAction(null); setRemarks(''); setEndNo(''); setIssueDate(''); setExpiryDate('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconStamp size={17} /><Text fw={700}>Officer Action</Text></Group>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{actionOptions.length === 0 ? (
|
||||
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />}>
|
||||
<Text fz="sm" fw={600}>No further actions available.</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
label="Action"
|
||||
placeholder="Select an action…"
|
||||
data={actionOptions}
|
||||
value={action}
|
||||
onChange={setAction}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{isEndorse && (
|
||||
<>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />} p="xs">
|
||||
<Text fz="xs">The endorsement is co-terminous with the foreign CoC. Expiry date must not exceed the foreign CoC expiry ({app.foreignCocExpiryDate}).</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Endorsement Number"
|
||||
placeholder="EMA-END-2025-XXX"
|
||||
value={endNo}
|
||||
onChange={(e) => setEndNo(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Issue Date" type="date" value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} size="sm" required />
|
||||
<TextInput label="Expiry Date" type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.currentTarget.value)} size="sm" required />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label="Remarks / Notes (sent to applicant)"
|
||||
placeholder="Verification findings, reason for rejection, or additional instructions…"
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={3}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{isDestructive && (
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={15} />} p="sm">
|
||||
<Text fz="xs" fw={600}>This will permanently reject the application. The applicant must submit a new application.</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
leftSection={<IconCheck size={14} />}
|
||||
disabled={!action}
|
||||
color={isDestructive ? 'red' : isEndorse ? 'teal' : 'blue'}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main review page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function EndorsementReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const original = MOCK_ENDORSEMENT_APPS.find((a) => a.id === id);
|
||||
const [app, setApp] = useState<EndorsementApp | null>(original ?? null);
|
||||
const [actionModalOpen, setActionModalOpen] = useState(false);
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/endorsement-queue')}>Back to Queue</Button>
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={17} />}>Application not found.</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAction = (newStatus: EndorsementStatus, remarks: string, endNo?: string, issueDate?: string, expiryDate?: string) => {
|
||||
setApp((prev) => prev ? {
|
||||
...prev,
|
||||
status: newStatus,
|
||||
officerRemarks: remarks || prev.officerRemarks,
|
||||
endorsementNumber: endNo ?? prev.endorsementNumber,
|
||||
endorsementIssueDate: issueDate ?? prev.endorsementIssueDate,
|
||||
endorsementExpiryDate: expiryDate ?? prev.endorsementExpiryDate,
|
||||
} : prev);
|
||||
|
||||
const msgs: Record<EndorsementStatus, string> = {
|
||||
'Submitted': 'moved to Submitted',
|
||||
'Document Verification': 'moved to Document Verification',
|
||||
'Pending Approval': 'documents verified — submitted for approval',
|
||||
'Endorsed': `endorsement ${endNo} issued`,
|
||||
'Rejected': 'application rejected',
|
||||
};
|
||||
notify.success(`Application ${msgs[newStatus]}.`);
|
||||
};
|
||||
|
||||
const isTerminal = app.status === 'Endorsed' || app.status === 'Rejected';
|
||||
const actionOptions = getActions(app.status);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/endorsement-queue')}>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Title order={3}>Endorsement Application Review</Title>
|
||||
<Group gap={6} mt={2}>
|
||||
<Text fz="sm" c="dimmed">{app.id}</Text>
|
||||
<Text fz="sm" c="dimmed">·</Text>
|
||||
<Text fz="sm" c="dimmed">Submitted {app.submitted}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge size="lg" variant="light" color={ENDORSEMENT_STATUS_COLOR[app.status]}>{app.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Action bar */}
|
||||
{!isTerminal && actionOptions.length > 0 && (
|
||||
<Paper withBorder radius="lg" p="md" bg="gray.0">
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Text fz="sm" c="dimmed" style={{ flex: 1 }}>Review all tabs, then take an action:</Text>
|
||||
<Button size="sm" leftSection={<IconStamp size={15} />} onClick={() => setActionModalOpen(true)}>
|
||||
Take Action
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{isTerminal && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color={app.status === 'Endorsed' ? 'teal' : 'red'}
|
||||
icon={app.status === 'Endorsed' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
|
||||
>
|
||||
<Text fz="sm" fw={600}>
|
||||
{app.status === 'Endorsed' && `Endorsement ${app.endorsementNumber} issued on ${app.endorsementIssueDate}.`}
|
||||
{app.status === 'Rejected' && 'Application rejected. Applicant must submit a new application.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<WorkflowBar status={app.status} />
|
||||
|
||||
<Tabs defaultValue="applicant" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="applicant" leftSection={<IconUser size={16} />}>Applicant</Tabs.Tab>
|
||||
<Tabs.Tab value="foreign-coc" leftSection={<IconShieldCheck size={16} />}>Foreign CoC</Tabs.Tab>
|
||||
<Tabs.Tab value="documents" leftSection={<IconFileDescription size={16} />}>Documents</Tabs.Tab>
|
||||
<Tabs.Tab value="payment" leftSection={<IconCreditCard size={16} />}>Payment</Tabs.Tab>
|
||||
<Tabs.Tab value="endorsement" leftSection={<IconStamp size={16} />}>Endorsement</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ── Applicant ─────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="applicant">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" radius="lg"><IconUser size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{app.name}</Text>
|
||||
<Text fz="sm" c="dimmed">{app.seafarerId}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Personal Information</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<InfoRow label="Email" value={app.email} />
|
||||
<InfoRow label="Mobile" value={app.mobile} />
|
||||
<InfoRow label="Nationality" value={app.nationality} />
|
||||
<InfoRow label="Submitted" value={app.submitted} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Foreign CoC ───────────────────────────────────────── */}
|
||||
<Tabs.Panel value="foreign-coc">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="blue" radius="lg"><IconShieldCheck size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Foreign Certificate of Competency</Text>
|
||||
<Text fz="xs" c="dimmed">{app.stcwRef}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
|
||||
<InfoRow label="CoC Number" value={app.foreignCocNumber} />
|
||||
<InfoRow label="Certificate Type" value={app.foreignCocType} />
|
||||
<InfoRow label="Issuing Authority" value={app.foreignCocIssuer} />
|
||||
<InfoRow label="Issuing Country" value={app.foreignCocCountry} />
|
||||
<InfoRow label="Issue Date" value={app.foreignCocIssueDate} />
|
||||
<InfoRow label="Expiry Date" value={app.foreignCocExpiryDate} />
|
||||
<InfoRow label="STCW Reference" value={app.stcwRef} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="sm">
|
||||
<Text fz="xs">
|
||||
<strong>Verification checklist (STCW Reg I/10):</strong> Confirm the issuing state is an STCW party,
|
||||
the certificate is authentic, valid, and not suspended or cancelled, and the seafarer holds the
|
||||
required sea service and medical fitness.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Box maw={360} mt="md">
|
||||
<DocViewer fileName={app.foreignCocFile} label="Foreign CoC" />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Supporting Documents ──────────────────────────────── */}
|
||||
<Tabs.Panel value="documents">
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
Verify all supporting documents. A certified translation is required if the foreign CoC is not in English.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<DocViewer fileName={app.foreignCocFile} label="Foreign CoC (Original or Certified Copy)" />
|
||||
<DocViewer fileName={app.certifiedTranslationFile} label="Certified Translation (if not English)" />
|
||||
<DocViewer fileName={app.seamanBookFile} label="Ethiopian Seaman Book" />
|
||||
<DocViewer fileName={app.medicalFile} label="Valid Medical Fitness Certificate (ENG I/2)" />
|
||||
<DocViewer fileName={app.photoFile} label="Passport-Size Photo" />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Payment ───────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="payment">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="green" radius="lg"><IconCreditCard size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Payment</Text>
|
||||
<Badge size="sm" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'} mt={2}>{app.paymentStatus}</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
|
||||
<Text fw={700} fz="sm" mb="md">Endorsement Fee Breakdown</Text>
|
||||
{[
|
||||
{ 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={4}>
|
||||
<Text fz="sm">{label}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Divider mt="sm" mb="xs" />
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" fw={800}>Total</Text>
|
||||
<Text fz="md" fw={800} c="blue">ETB 1,000.00</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<InfoRow label="Payment Method" value="CBE Bank Transfer" />
|
||||
<InfoRow label="Transaction Reference" value="CBE-TXN-20250405-088" />
|
||||
<InfoRow label="Payment Date" value={app.submitted} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Endorsement ───────────────────────────────────────── */}
|
||||
<Tabs.Panel value="endorsement">
|
||||
<Stack gap="md">
|
||||
{app.status === 'Endorsed' ? (
|
||||
<>
|
||||
<Alert variant="light" color="teal" icon={<IconCircleCheck size={17} />}>
|
||||
Endorsement has been issued. The seafarer has been notified.
|
||||
</Alert>
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="teal" radius="lg"><IconStamp size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Issued Endorsement</Text>
|
||||
<Text fz="xs" c="dimmed">STCW Reg I/10 — Flag State Endorsement</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<InfoRow label="Endorsement Number" value={app.endorsementNumber} />
|
||||
<InfoRow label="Issue Date" value={app.endorsementIssueDate} />
|
||||
<InfoRow label="Expiry Date" value={app.endorsementExpiryDate} />
|
||||
<InfoRow label="Certificate Type" value={app.foreignCocType} />
|
||||
<InfoRow label="Original CoC No." value={app.foreignCocNumber} />
|
||||
<InfoRow label="Issuing Country" value={app.foreignCocCountry} />
|
||||
</SimpleGrid>
|
||||
{app.officerRemarks && (
|
||||
<>
|
||||
<Divider mt="md" mb="md" />
|
||||
<SectionLabel>Officer Remarks</SectionLabel>
|
||||
<Text fz="sm">{app.officerRemarks}</Text>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
) : app.status === 'Rejected' ? (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={17} />} mb="md">
|
||||
Application rejected. No endorsement was issued.
|
||||
</Alert>
|
||||
{app.officerRemarks && (
|
||||
<>
|
||||
<SectionLabel>Rejection Reason</SectionLabel>
|
||||
<Text fz="sm">{app.officerRemarks}</Text>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
) : (
|
||||
<Alert variant="light" color="yellow" icon={<IconInfoCircle size={17} />}>
|
||||
Endorsement will be issued here once all documents are verified and the application is approved.
|
||||
The endorsement is co-terminous with the foreign CoC (expires {app.foreignCocExpiryDate}).
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<ActionModal
|
||||
app={app}
|
||||
opened={actionModalOpen}
|
||||
onClose={() => setActionModalOpen(false)}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconHeart,
|
||||
IconSearch,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
interface MedicalRecord {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
certNumber: string;
|
||||
issuedBy: string;
|
||||
issuedDate: string;
|
||||
expiryDate: string;
|
||||
daysLeft: number;
|
||||
status: 'Pending Verification' | 'Verified' | 'Rejected' | 'Expiring' | 'Expired';
|
||||
restrictions: string;
|
||||
fileName: string;
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
const MOCK_RECORDS: MedicalRecord[] = [
|
||||
{ id: 'MV-001', seafarerId: 'SF-2024-0001', name: 'Abebe Girma', email: 'abebe.g@email.com', certNumber: 'MC-2024-001', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-03-15', expiryDate: '2026-03-14', daysLeft: 45, status: 'Expiring', restrictions: 'None', fileName: 'medical_cert_abebe.pdf', submittedAt: '2024-03-16' },
|
||||
{ id: 'MV-002', seafarerId: 'SF-2024-0002', name: 'Sara Tadesse', email: 'sara.t@email.com', certNumber: 'MC-2024-002', issuedBy: 'Approved Medical Centre — Dire Dawa', issuedDate: '2024-05-01', expiryDate: '2026-04-30', daysLeft: 120, status: 'Pending Verification', restrictions: 'None', fileName: 'medical_cert_sara.pdf', submittedAt: '2024-05-02' },
|
||||
{ id: 'MV-003', seafarerId: 'SF-2023-0088', name: 'Tekle Haile', email: 'tekle.h@email.com', certNumber: 'MC-2023-088', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-02-20', expiryDate: '2026-02-28', daysLeft: 31, status: 'Expiring', restrictions: 'Colour blind - deck duties restricted', fileName: 'medical_cert_tekle.pdf', submittedAt: '2024-02-21' },
|
||||
{ id: 'MV-004', seafarerId: 'SF-2024-0003', name: 'Dawit Bekele', email: 'dawit.b@email.com', certNumber: 'MC-2024-003', issuedBy: 'Approved Medical Centre — Addis Ababa', issuedDate: '2024-04-10', expiryDate: '2026-04-09', daysLeft: 95, status: 'Pending Verification', restrictions: 'None', fileName: 'medical_cert_dawit.pdf', submittedAt: '2024-04-11' },
|
||||
{ id: 'MV-005', seafarerId: 'SF-2022-0210', name: 'Yonas Tesfaye', email: 'yonas.t@email.com', certNumber: 'MC-2022-210', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-01-05', expiryDate: '2026-01-30', daysLeft: 11, status: 'Expiring', restrictions: 'None', fileName: 'medical_cert_yonas.pdf', submittedAt: '2024-01-06' },
|
||||
{ id: 'MV-006', seafarerId: 'SF-2024-0004', name: 'Hana Mulugeta', email: 'hana.m@email.com', certNumber: 'MC-2024-004', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-06-01', expiryDate: '2026-05-31', daysLeft: 365, status: 'Verified', restrictions: 'None', fileName: 'medical_cert_hana.pdf', submittedAt: '2024-06-02' },
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
'Pending Verification': 'yellow',
|
||||
Verified: 'teal',
|
||||
Rejected: 'red',
|
||||
Expiring: 'orange',
|
||||
Expired: 'red',
|
||||
};
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function VerificationDrawer({
|
||||
record,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
}: {
|
||||
record: MedicalRecord | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'verify' | 'reject', remarks: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'verify' | 'reject' | null>(null);
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
const submit = (action: 'verify' | 'reject') => {
|
||||
onAction(record.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={`Medical Certificate — ${record.name}`}
|
||||
position="right"
|
||||
size="lg"
|
||||
padding="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Certificate details */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Certificate Details</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
['Seafarer ID', record.seafarerId],
|
||||
['Certificate No.', record.certNumber],
|
||||
['Issued By', record.issuedBy],
|
||||
['Issue Date', formatDate(record.issuedDate)],
|
||||
['Expiry Date', formatDate(record.expiryDate)],
|
||||
['Days Remaining', `${record.daysLeft} days`],
|
||||
['Restrictions', record.restrictions],
|
||||
['Submitted', formatDate(record.submittedAt)],
|
||||
].map(([label, value]) => (
|
||||
<Box key={label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={label === 'Days Remaining' ? 700 : 400} c={label === 'Days Remaining' && record.daysLeft <= 30 ? 'red' : label === 'Days Remaining' && record.daysLeft <= 90 ? 'orange' : undefined}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Status */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[record.status] ?? 'gray'} variant="light">{record.status}</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Document preview */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={700} fz="sm">Certificate Document</Text>
|
||||
<Button size="xs" variant="light" leftSection={<IconDownload size={13} />}>{record.fileName}</Button>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: rem(120),
|
||||
borderRadius: rem(8),
|
||||
border: '2px dashed var(--mantine-color-default-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--mantine-color-gray-0)',
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap={4}>
|
||||
<IconEye size={24} color="var(--mantine-color-gray-4)" />
|
||||
<Text fz="xs" c="dimmed">Click download to view the certificate</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Officer remarks */}
|
||||
<Textarea
|
||||
label="Verification Remarks"
|
||||
placeholder="Add notes about validity, restrictions, or rejection reason…"
|
||||
minRows={3}
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
{record.status === 'Pending Verification' && (
|
||||
<Group grow>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconCircleCheck size={15} />}
|
||||
onClick={() => setConfirmModal('verify')}
|
||||
>
|
||||
Verify & Approve
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={15} />}
|
||||
onClick={() => setConfirmModal('reject')}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{record.status !== 'Pending Verification' && (
|
||||
<Text fz="sm" c="dimmed" ta="center">This certificate has already been {record.status.toLowerCase()}.</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={`Confirm ${confirmModal === 'verify' ? 'Verification' : 'Rejection'}`}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'verify'
|
||||
? 'Are you sure you want to verify and approve this medical certificate? The seafarer will be notified.'
|
||||
: 'Are you sure you want to reject this certificate? Please ensure you have added a reason in the remarks.'}
|
||||
</Text>
|
||||
{!remarks && confirmModal === 'reject' && (
|
||||
<Text fz="xs" c="red" mb="sm">Please add rejection remarks before proceeding.</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
color={confirmModal === 'verify' ? 'teal' : 'red'}
|
||||
disabled={!remarks && confirmModal === 'reject'}
|
||||
onClick={() => submit(confirmModal!)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function MedicalVerificationPage() {
|
||||
const [records, setRecords] = useState<MedicalRecord[]>(MOCK_RECORDS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<MedicalRecord | null>(null);
|
||||
|
||||
const stats = {
|
||||
pending: records.filter((r) => r.status === 'Pending Verification').length,
|
||||
expiring: records.filter((r) => r.status === 'Expiring').length,
|
||||
verified: records.filter((r) => r.status === 'Verified').length,
|
||||
critical: records.filter((r) => r.daysLeft <= 30).length,
|
||||
};
|
||||
|
||||
const filtered = records.filter((r) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || r.name.toLowerCase().includes(q) || r.seafarerId.toLowerCase().includes(q) || r.certNumber.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || r.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const handleAction = (id: string, action: 'verify' | 'reject', remarks: string) => {
|
||||
setRecords((prev) => prev.map((r) =>
|
||||
r.id === id ? { ...r, status: action === 'verify' ? 'Verified' : 'Rejected' } : r
|
||||
));
|
||||
notify.success(`Medical certificate ${action === 'verify' ? 'verified' : 'rejected'} successfully.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Title order={3}>Medical Certificate Verification</Title>
|
||||
<Text fz="sm" c="dimmed">Review and verify seafarer medical certificates (STCW — valid 2 years)</Text>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'Pending Verification', value: stats.pending, color: 'yellow', icon: IconClock },
|
||||
{ label: 'Expiring (90 days)', value: stats.expiring, color: 'orange', icon: IconAlertTriangle },
|
||||
{ label: 'Critical (≤30 days)', value: stats.critical, color: 'red', icon: IconAlertCircle },
|
||||
{ label: 'Verified', value: stats.verified, color: 'teal', icon: IconCircleCheck },
|
||||
].map(({ label, value, color, icon: Icon }) => (
|
||||
<Card key={label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
|
||||
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Table */}
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Medical Certificate Records</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Search by name, ID or cert number…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ minWidth: rem(260) }}
|
||||
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Status"
|
||||
data={['Pending Verification', 'Verified', 'Rejected', 'Expiring', 'Expired']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
clearable
|
||||
size="sm"
|
||||
style={{ width: rem(190) }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Box py="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconHeart size={22} /></ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No records found</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Seafarer', 'Cert Number', 'Issued By', 'Issue Date', 'Expiry Date', 'Days Left', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((rec) => (
|
||||
<Table.Tr key={rec.id}>
|
||||
<Table.Td>
|
||||
<Text fz="xs" fw={500}>{rec.name}</Text>
|
||||
<Text fz="xs" c="dimmed">{rec.seafarerId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="xs" fw={600}>{rec.certNumber}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" maw={160} truncate>{rec.issuedBy}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{formatDate(rec.issuedDate)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{formatDate(rec.expiryDate)}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={rec.daysLeft <= 30 ? 'red' : rec.daysLeft <= 90 ? 'orange' : 'teal'}
|
||||
variant="light"
|
||||
size="xs"
|
||||
>
|
||||
{rec.daysLeft}d
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Badge color={STATUS_COLOR[rec.status] ?? 'gray'} variant="light" size="xs">{rec.status}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => setSelected(rec)}>
|
||||
{rec.status === 'Pending Verification' ? 'Verify' : 'View'}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 && (
|
||||
<Group px="md" py="sm" justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Showing {filtered.length} of {records.length} records</Text>
|
||||
<Group gap={4}>
|
||||
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="dimmed">Data loaded</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<VerificationDrawer
|
||||
record={selected}
|
||||
opened={!!selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconCreditCard,
|
||||
IconEdit,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
interface CertFee {
|
||||
id: string;
|
||||
certType: string;
|
||||
icon: typeof IconBook2;
|
||||
color: string;
|
||||
description: string;
|
||||
fees: { label: string; amount: number; editable: boolean }[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
instructions: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
const INITIAL_CERT_FEES: CertFee[] = [
|
||||
{
|
||||
id: 'seaman-book',
|
||||
certType: 'Seaman Book',
|
||||
icon: IconBook2,
|
||||
color: 'blue',
|
||||
description: 'Official EMA seafarer identification book — valid 5 years',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 500, editable: true },
|
||||
{ label: 'Document Verification Fee',amount: 200, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'btc',
|
||||
certType: 'Basic Training Certificate (BTC)',
|
||||
icon: IconCertificate,
|
||||
color: 'teal',
|
||||
description: 'EMA-issued BTC certifying all 5 basic safety training courses — valid 5 years',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 300, editable: true },
|
||||
{ label: 'Document Verification Fee',amount: 100, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'bsid',
|
||||
certType: 'BSID (Biometric Seafarer ID)',
|
||||
icon: IconId,
|
||||
color: 'violet',
|
||||
description: 'Biometric Seafarer Identity Document — valid 5 years',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 100, editable: true },
|
||||
{ label: 'Card Production Fee', amount: 150, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'certificate',
|
||||
certType: 'Certificate (CoC / CoP)',
|
||||
icon: IconShieldCheck,
|
||||
color: 'orange',
|
||||
description: 'Certificate of Competency or Certificate of Proficiency under STCW',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 800, editable: true },
|
||||
{ label: 'Examination Fee', amount: 400, editable: true },
|
||||
{ label: 'Certificate Issuance Fee', amount: 200, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const INITIAL_PAYMENT_METHODS: PaymentMethod[] = [
|
||||
{
|
||||
id: 'cbe',
|
||||
name: 'Commercial Bank of Ethiopia',
|
||||
shortName: 'CBE',
|
||||
accountNumber: '1000123456789',
|
||||
accountName: 'EMA Maritime Authority',
|
||||
instructions: 'Transfer the exact fee amount. Use your full name as the transfer description.',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'telebirr',
|
||||
name: 'Telebirr (Ethio Telecom)',
|
||||
shortName: 'Telebirr',
|
||||
accountNumber: '+251 11 551 0000',
|
||||
accountName: 'EMA Maritime Authority',
|
||||
instructions: 'Send to Telebirr number. Screenshot your confirmation and upload it with your application.',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fee edit modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function FeeEditModal({
|
||||
cert,
|
||||
opened,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
cert: CertFee | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, fees: CertFee['fees']) => void;
|
||||
}) {
|
||||
const [fees, setFees] = useState<CertFee['fees']>(cert?.fees ?? []);
|
||||
|
||||
const handleOpen = () => setFees(cert?.fees ?? []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconCreditCard size={18} /><Text fw={700}>Edit Fees — {cert?.certType}</Text></Group>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
onTransitionEnd={() => { if (opened) handleOpen(); }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
|
||||
Changes take effect on new applications immediately. Existing applications retain the fee at time of submission.
|
||||
</Alert>
|
||||
{fees.map((fee, i) => (
|
||||
<Group key={fee.label} gap="sm" align="flex-end">
|
||||
<TextInput label="Fee Label" value={fee.label} disabled style={{ flex: 1 }} size="sm" />
|
||||
<NumberInput
|
||||
label="Amount (ETB)"
|
||||
value={fee.amount}
|
||||
min={0}
|
||||
step={50}
|
||||
style={{ width: rem(140) }}
|
||||
size="sm"
|
||||
disabled={!fee.editable}
|
||||
onChange={(v) =>
|
||||
setFees((prev) => prev.map((f, idx) => idx === i ? { ...f, amount: Number(v) || 0 } : f))
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider />
|
||||
<Paper withBorder radius="md" p="sm" bg="gray.0">
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" fw={700}>Total</Text>
|
||||
<Text fz="sm" fw={700} c="blue">ETB {fees.reduce((s, f) => s + f.amount, 0).toFixed(2)}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button leftSection={<IconCheck size={15} />} onClick={() => { onSave(cert!.id, fees); onClose(); }}>
|
||||
Save Fees
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payment method edit modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function MethodEditModal({
|
||||
method,
|
||||
opened,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
method: PaymentMethod | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (updated: PaymentMethod) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<PaymentMethod>(method ?? INITIAL_PAYMENT_METHODS[0]);
|
||||
const set = (k: keyof PaymentMethod, v: string) => setForm((p) => ({ ...p, [k]: v }));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconCreditCard size={18} /><Text fw={700}>Edit Payment Method — {method?.shortName}</Text></Group>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
onTransitionEnd={() => { if (opened && method) setForm(method); }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput label="Account Number / Phone" value={form.accountNumber} onChange={(e) => set('accountNumber', e.currentTarget.value)} />
|
||||
<TextInput label="Account Name" value={form.accountName} onChange={(e) => set('accountName', e.currentTarget.value)} />
|
||||
<TextInput label="Instructions (shown to applicant)" value={form.instructions} onChange={(e) => set('instructions', e.currentTarget.value)} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button leftSection={<IconCheck size={15} />} onClick={() => { onSave(form); onClose(); }}>Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function PaymentConfigPage() {
|
||||
const [certFees, setCertFees] = useState<CertFee[]>(INITIAL_CERT_FEES);
|
||||
const [methods, setMethods] = useState<PaymentMethod[]>(INITIAL_PAYMENT_METHODS);
|
||||
|
||||
const [editingCert, setEditingCert] = useState<CertFee | null>(null);
|
||||
const [editingMethod, setEditingMethod] = useState<PaymentMethod | null>(null);
|
||||
|
||||
const totalCombined = certFees
|
||||
.filter((c) => c.enabled)
|
||||
.flatMap((c) => c.fees)
|
||||
.reduce((s, f) => s + f.amount, 0);
|
||||
|
||||
const handleSaveFees = (id: string, fees: CertFee['fees']) => {
|
||||
setCertFees((prev) => prev.map((c) => c.id === id ? { ...c, fees } : c));
|
||||
notify.success('Fees updated successfully.');
|
||||
};
|
||||
|
||||
const handleToggleCert = (id: string) => {
|
||||
setCertFees((prev) => prev.map((c) => c.id === id ? { ...c, enabled: !c.enabled } : c));
|
||||
};
|
||||
|
||||
const handleSaveMethod = (updated: PaymentMethod) => {
|
||||
setMethods((prev) => prev.map((m) => m.id === updated.id ? updated : m));
|
||||
notify.success('Payment method updated.');
|
||||
};
|
||||
|
||||
const handleToggleMethod = (id: string) => {
|
||||
setMethods((prev) => prev.map((m) => m.id === id ? { ...m, enabled: !m.enabled } : m));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Payment Configuration</Title>
|
||||
<Text fz="sm" c="dimmed">Manage certificate fees and accepted payment methods shown to applicants</Text>
|
||||
</div>
|
||||
<Badge size="lg" variant="light" color="blue">
|
||||
Combined Total: ETB {totalCombined.toFixed(2)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Fee summary table */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="md">Certificate Fees</Text>
|
||||
<Text fz="xs" c="dimmed">Click Edit to change fee amounts</Text>
|
||||
</Group>
|
||||
|
||||
<Table highlightOnHover verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Certificate Type', 'Fee Breakdown', 'Total', '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>
|
||||
{certFees.map((cert) => {
|
||||
const CertIcon = cert.icon;
|
||||
const total = cert.fees.reduce((s, f) => s + f.amount, 0);
|
||||
return (
|
||||
<Table.Tr key={cert.id} style={{ opacity: cert.enabled ? 1 : 0.5 }}>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size="md" variant="light" color={cert.color} radius="md">
|
||||
<CertIcon size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{cert.certType}</Text>
|
||||
<Text fz="xs" c="dimmed">{cert.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
{cert.fees.map((f) => (
|
||||
<Group key={f.label} gap={6}>
|
||||
<Text fz="xs" c="dimmed">{f.label}:</Text>
|
||||
<Text fz="xs" fw={500}>ETB {f.amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={700} c={cert.enabled ? 'blue.7' : 'dimmed'}>ETB {total.toFixed(2)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Switch
|
||||
checked={cert.enabled}
|
||||
onChange={() => handleToggleCert(cert.id)}
|
||||
size="sm"
|
||||
color="teal"
|
||||
label={cert.enabled ? 'Active' : 'Disabled'}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="light" color="blue" size="md" onClick={() => setEditingCert(cert)}>
|
||||
<IconEdit size={15} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={2}><Text fz="sm" fw={700} ta="right">Grand Total (all active)</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={800} c="blue">ETB {totalCombined.toFixed(2)}</Text></Table.Td>
|
||||
<Table.Td colSpan={2} />
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
{/* Payment methods */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} fz="md" mb="lg">Accepted Payment Methods</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{methods.map((method) => (
|
||||
<Card key={method.id} withBorder radius="md" p="md" style={{ opacity: method.enabled ? 1 : 0.6 }}>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="lg" variant="light" color={method.enabled ? 'blue' : 'gray'} radius="md">
|
||||
<IconCreditCard size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{method.name}</Text>
|
||||
<Badge size="xs" variant="light" color={method.enabled ? 'teal' : 'gray'}>
|
||||
{method.enabled ? 'Active' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Switch checked={method.enabled} onChange={() => handleToggleMethod(method.id)} size="sm" color="teal" />
|
||||
<ActionIcon variant="light" color="blue" size="md" onClick={() => setEditingMethod(method)}>
|
||||
<IconEdit size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Account / Number</Text>
|
||||
<Text fz="xs" fw={600}>{method.accountNumber}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Account Name</Text>
|
||||
<Text fz="xs" fw={600}>{method.accountName}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Text fz="xs" c="dimmed" mt="sm" lh={1.4}>{method.instructions}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Fee edit modal */}
|
||||
<FeeEditModal
|
||||
cert={editingCert}
|
||||
opened={!!editingCert}
|
||||
onClose={() => setEditingCert(null)}
|
||||
onSave={handleSaveFees}
|
||||
/>
|
||||
|
||||
{/* Method edit modal */}
|
||||
<MethodEditModal
|
||||
method={editingMethod}
|
||||
opened={!!editingMethod}
|
||||
onClose={() => setEditingMethod(null)}
|
||||
onSave={handleSaveMethod}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconEye,
|
||||
IconHeart,
|
||||
IconId,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
interface Seafarer {
|
||||
id: string;
|
||||
name: string;
|
||||
nationality: string;
|
||||
dob: string;
|
||||
rank: string;
|
||||
seamanBookNo: string;
|
||||
seamanBookExpiry: string;
|
||||
btcNo: string | null;
|
||||
bsidNo: string | null;
|
||||
medicalExpiry: string;
|
||||
medicalStatus: 'Valid' | 'Expiring' | 'Expired';
|
||||
cocCerts: { type: string; no: string; expiry: string }[];
|
||||
status: 'Active' | 'Inactive' | 'Suspended';
|
||||
}
|
||||
|
||||
const MOCK_SEAFARERS: Seafarer[] = [
|
||||
{
|
||||
id: 'SF-2024-0001', name: 'Abebe Girma', nationality: 'Ethiopian', dob: '1990-04-12',
|
||||
rank: 'Officer of the Watch', seamanBookNo: 'SB-2024-0001', seamanBookExpiry: '2029-03-14',
|
||||
btcNo: 'BTC-2024-0001', bsidNo: 'BSID-2024-0001',
|
||||
medicalExpiry: '2026-03-14', medicalStatus: 'Expiring',
|
||||
cocCerts: [{ type: 'CoC — STCW II/1', no: 'COC-2023-0042', expiry: '2028-06-20' }],
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
id: 'SF-2024-0002', name: 'Sara Tadesse', nationality: 'Ethiopian', dob: '1994-08-05',
|
||||
rank: 'Able Seaman', seamanBookNo: 'SB-2024-0002', seamanBookExpiry: '2029-05-10',
|
||||
btcNo: 'BTC-2024-0002', bsidNo: null,
|
||||
medicalExpiry: '2027-05-10', medicalStatus: 'Valid',
|
||||
cocCerts: [],
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
id: 'SF-2024-0003', name: 'Dawit Bekele', nationality: 'Ethiopian', dob: '1988-01-22',
|
||||
rank: 'Chief Engineer', seamanBookNo: 'SB-2024-0003', seamanBookExpiry: '2028-08-20',
|
||||
btcNo: 'BTC-2024-0003', bsidNo: 'BSID-2024-0003',
|
||||
medicalExpiry: '2025-08-20', medicalStatus: 'Expired',
|
||||
cocCerts: [
|
||||
{ type: 'CoC — STCW III/1', no: 'COC-2022-0018', expiry: '2027-08-20' },
|
||||
{ type: 'CoC — STCW III/2', no: 'COC-2023-0031', expiry: '2028-04-15' },
|
||||
],
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
id: 'SF-2023-0088', name: 'Tekle Haile', nationality: 'Ethiopian', dob: '1985-11-30',
|
||||
rank: 'Master', seamanBookNo: 'SB-2023-0088', seamanBookExpiry: '2028-01-15',
|
||||
btcNo: 'BTC-2023-0088', bsidNo: 'BSID-2023-0088',
|
||||
medicalExpiry: '2026-02-28', medicalStatus: 'Expiring',
|
||||
cocCerts: [{ type: 'CoC — STCW II/2 Master', no: 'COC-2021-0005', expiry: '2026-07-10' }],
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
id: 'SF-2022-0210', name: 'Yonas Tesfaye', nationality: 'Ethiopian', dob: '1992-06-14',
|
||||
rank: 'Deck Rating', seamanBookNo: 'SB-2022-0210', seamanBookExpiry: '2027-06-14',
|
||||
btcNo: null, bsidNo: null,
|
||||
medicalExpiry: '2026-01-30', medicalStatus: 'Expiring',
|
||||
cocCerts: [],
|
||||
status: 'Inactive',
|
||||
},
|
||||
];
|
||||
|
||||
const RANK_OPTIONS = ['All', 'Master', 'Chief Engineer', 'Officer of the Watch', 'Able Seaman', 'Deck Rating'];
|
||||
const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended'];
|
||||
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
|
||||
|
||||
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
|
||||
const STATUS_COLOR: Record<string, string> = { Active: 'teal', Inactive: 'gray', Suspended: 'red' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
|
||||
if (!sf) return null;
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} — {sf.id}</Text></Group>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Full Name</Text><Text fz="xs" fw={600}>{sf.name}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge></Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Documents</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Seaman Book</Text><Text fz="xs" fw={600}>{sf.seamanBookNo}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">SB Expiry</Text><Text fz="xs">{sf.seamanBookExpiry}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BTC No.</Text><Text fz="xs">{sf.btcNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BSID No.</Text><Text fz="xs">{sf.bsidNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Medical Expiry</Text>
|
||||
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalExpiry} ({sf.medicalStatus})</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{sf.cocCerts.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text>
|
||||
<Table fz="xs" verticalSpacing="xs">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Type', 'Certificate No.', 'Expiry'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{sf.cocCerts.map((c) => (
|
||||
<Table.Tr key={c.no}>
|
||||
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeafarerRegistryPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [rankFilter, setRankFilter] = useState('All');
|
||||
const [statusFilter, setStatusFilter] = useState('All');
|
||||
const [medicalFilter, setMedicalFilter] = useState('All');
|
||||
const [selected, setSelected] = useState<Seafarer | null>(null);
|
||||
const [filtersOpen, setFiltersOpen] = useState(true);
|
||||
|
||||
const filtered = MOCK_SEAFARERS.filter((sf) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || sf.name.toLowerCase().includes(q) || sf.id.toLowerCase().includes(q) || sf.seamanBookNo.toLowerCase().includes(q);
|
||||
const matchRank = rankFilter === 'All' || sf.rank === rankFilter;
|
||||
const matchStatus = statusFilter === 'All' || sf.status === statusFilter;
|
||||
const matchMed = medicalFilter === 'All' || sf.medicalStatus === medicalFilter;
|
||||
return matchSearch && matchRank && matchStatus && matchMed;
|
||||
});
|
||||
|
||||
const KPI = [
|
||||
{ label: 'Total Seafarers', value: MOCK_SEAFARERS.length, color: 'blue', icon: IconUsers },
|
||||
{ label: 'Active', value: MOCK_SEAFARERS.filter((s) => s.status === 'Active').length, color: 'teal', icon: IconUser },
|
||||
{ label: 'Medical Expiring',value: MOCK_SEAFARERS.filter((s) => s.medicalStatus === 'Expiring').length, color: 'orange', icon: IconHeart },
|
||||
{ label: 'Medical Expired', value: MOCK_SEAFARERS.filter((s) => s.medicalStatus === 'Expired').length, color: 'red', icon: IconHeart },
|
||||
{ label: 'With CoC', value: MOCK_SEAFARERS.filter((s) => s.cocCerts.length > 0).length, color: 'violet', icon: IconShieldCheck },
|
||||
{ label: 'Without BSID', value: MOCK_SEAFARERS.filter((s) => !s.bsidNo).length, color: 'yellow', icon: IconId },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Seafarer Registry</Title>
|
||||
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text>
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
|
||||
{KPI.map((k) => {
|
||||
const KIcon = k.icon;
|
||||
return (
|
||||
<Card key={k.label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size={28} radius="sm" color={k.color} variant="light"><KIcon size={14} /></ThemeIcon>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fz="lg" fw={800} lh={1}>{k.value}</Text>
|
||||
<Text fz="xs" c="dimmed" lh={1.2} style={{ lineHeight: 1.2 }}>{k.label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Search + filters */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group mb="sm" gap="sm" justify="space-between">
|
||||
<TextInput
|
||||
placeholder="Search by name, seafarer ID, or seaman book…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
rightSection={filtersOpen ? <IconChevronUp size={12} /> : <IconChevronDown size={12} />}
|
||||
onClick={() => setFiltersOpen((o) => !o)}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Collapse in={filtersOpen}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm" mb="md">
|
||||
<Select label="Rank" data={RANK_OPTIONS} value={rankFilter} onChange={(v) => setRankFilter(v ?? 'All')} size="sm" />
|
||||
<Select label="Status" data={STATUS_OPTIONS} value={statusFilter} onChange={(v) => setStatusFilter(v ?? 'All')} size="sm" />
|
||||
<Select label="Medical Status" data={MEDICAL_OPTIONS} value={medicalFilter} onChange={(v) => setMedicalFilter(v ?? 'All')} size="sm" />
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
</Collapse>
|
||||
|
||||
<Text fz="xs" c="dimmed" mb="sm">{filtered.length} seafarer(s) found</Text>
|
||||
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Seafarer ID', 'Name', 'Rank', 'Seaman Book', 'BTC', 'BSID', 'Medical', 'CoC/CoP', '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>
|
||||
{filtered.map((sf) => (
|
||||
<Table.Tr key={sf.id}>
|
||||
<Table.Td><Text fz="xs" fw={600} c="blue.7">{sf.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" fw={600}>{sf.name}</Text><Text fz="xs" c="dimmed">{sf.dob}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{sf.rank}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
<IconBook2 size={11} color="var(--mantine-color-blue-6)" />
|
||||
<Text fz="xs">{sf.seamanBookNo}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{sf.btcNo
|
||||
? <Group gap={4}><IconCertificate size={11} color="var(--mantine-color-teal-6)" /><Text fz="xs">{sf.btcNo}</Text></Group>
|
||||
: <Badge color="red" variant="light" size="xs">Missing</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{sf.bsidNo
|
||||
? <Group gap={4}><IconId size={11} color="var(--mantine-color-violet-6)" /><Text fz="xs">{sf.bsidNo}</Text></Group>
|
||||
: <Badge color="red" variant="light" size="xs">Missing</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalStatus}</Badge>
|
||||
<Text fz="xs" c="dimmed">{sf.medicalExpiry}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{sf.cocCerts.length > 0
|
||||
? <Badge color="violet" variant="light" size="xs">{sf.cocCerts.length} cert(s)</Badge>
|
||||
: <Text fz="xs" c="dimmed">—</Text>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>
|
||||
<IconEye size={13} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Text c="dimmed" fz="sm">No seafarers match your search.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconUsers,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
interface SeamanBookApp {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
nationality: string;
|
||||
submitted: string;
|
||||
status: 'Pending' | 'Under Review' | 'Awaiting Docs' | 'Approved' | 'Rejected' | 'Correction Required';
|
||||
medicalStatus: 'Valid' | 'Expiring' | 'Expired' | 'Missing';
|
||||
bstComplete: boolean;
|
||||
bstCount: number;
|
||||
docsComplete: boolean;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
const MOCK_APPS: SeamanBookApp[] = [
|
||||
{ id: 'SB-APP-2024-001', seafarerId: 'SF-2024-0001', name: 'Abebe Girma', email: 'abebe.g@email.com', mobile: '+251 911 234 567', nationality: 'Ethiopian', submitted: '2024-05-10', status: 'Under Review', medicalStatus: 'Expiring', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'All documents submitted. Under initial review.' },
|
||||
{ id: 'SB-APP-2024-002', seafarerId: 'SF-2024-0002', name: 'Sara Tadesse', email: 'sara.t@email.com', mobile: '+251 922 345 678', nationality: 'Ethiopian', submitted: '2024-05-12', status: 'Awaiting Docs', medicalStatus: 'Valid', bstComplete: false, bstCount: 3, docsComplete: false, remarks: 'Missing EFA and PSSR certificates.' },
|
||||
{ id: 'SB-APP-2024-003', seafarerId: 'SF-2024-0003', name: 'Dawit Bekele', email: 'dawit.b@email.com', mobile: '+251 933 456 789', nationality: 'Ethiopian', submitted: '2024-05-14', status: 'Under Review', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'Pending document authenticity check.' },
|
||||
{ id: 'SB-APP-2024-004', seafarerId: 'SF-2024-0004', name: 'Hana Mulugeta', email: 'hana.m@email.com', mobile: '+251 944 567 890', nationality: 'Ethiopian', submitted: '2024-05-15', status: 'Correction Required', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: false, remarks: 'National ID scan is unclear. Please resubmit.' },
|
||||
{ id: 'SB-APP-2024-005', seafarerId: 'SF-2024-0005', name: 'Yonas Tesfaye', email: 'yonas.t@email.com', mobile: '+251 955 678 901', nationality: 'Ethiopian', submitted: '2024-05-16', status: 'Pending', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: '' },
|
||||
{ id: 'SB-APP-2024-006', seafarerId: 'SF-2024-0006', name: 'Meron Alemu', email: 'meron.a@email.com', mobile: '+251 966 789 012', nationality: 'Ethiopian', submitted: '2024-05-18', status: 'Approved', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'All verified. Ready to print.' },
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray', 'Under Review': 'yellow', 'Awaiting Docs': 'orange',
|
||||
Approved: 'teal', Rejected: 'red', 'Correction Required': 'red',
|
||||
};
|
||||
|
||||
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red', Missing: 'red' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function AppDrawer({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
onFullReview,
|
||||
}: {
|
||||
app: SeamanBookApp | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'approve' | 'reject' | 'correction', remarks: string) => void;
|
||||
onFullReview: (id: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'correction' | null>(null);
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
const submit = (action: 'approve' | 'reject' | 'correction') => {
|
||||
onAction(app.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={`Application ${app.id}`}
|
||||
position="right"
|
||||
size="lg"
|
||||
padding="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconEye size={15} />}
|
||||
fullWidth
|
||||
onClick={() => { onClose(); onFullReview(app.id); }}
|
||||
>
|
||||
Open Full Review Page
|
||||
</Button>
|
||||
{/* Seafarer info */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Seafarer Information</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
['Seafarer ID', app.seafarerId],
|
||||
['Full Name', app.name],
|
||||
['Email', app.email],
|
||||
['Mobile', app.mobile],
|
||||
['Nationality', app.nationality],
|
||||
['Submitted', app.submitted],
|
||||
].map(([label, value]) => (
|
||||
<Box key={label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm">{value}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Eligibility checklist */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Eligibility Verification</Text>
|
||||
<Stack gap="xs">
|
||||
{[
|
||||
{ label: 'Seafarer Profile Complete', ok: true, icon: IconUsers },
|
||||
{ label: 'National ID / Fayda Uploaded', ok: app.docsComplete, icon: IconFileDescription },
|
||||
{ label: `Medical Certificate (${app.medicalStatus})`, ok: app.medicalStatus === 'Valid', icon: IconHeart },
|
||||
{ label: `Basic Safety Training (${app.bstCount}/5)`, ok: app.bstComplete, icon: IconShieldCheck },
|
||||
{ label: 'All Documents Uploaded', ok: app.docsComplete, icon: IconFileDescription },
|
||||
].map(({ label, ok, icon: Icon }) => (
|
||||
<Group key={label} 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>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Current status */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={700} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
|
||||
</Group>
|
||||
{app.remarks && <Text fz="sm" c="dimmed">{app.remarks}</Text>}
|
||||
</Paper>
|
||||
|
||||
{/* Officer remarks */}
|
||||
<Textarea
|
||||
label="Officer Remarks"
|
||||
placeholder="Add notes or reason for decision…"
|
||||
minRows={3}
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Action buttons */}
|
||||
<Group grow>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconCircleCheck size={15} />}
|
||||
onClick={() => setConfirmModal('approve')}
|
||||
disabled={!app.docsComplete || !app.bstComplete || app.medicalStatus === 'Expired' || app.medicalStatus === 'Missing'}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
color="orange"
|
||||
variant="light"
|
||||
leftSection={<IconAlertCircle size={15} />}
|
||||
onClick={() => setConfirmModal('correction')}
|
||||
>
|
||||
Request Correction
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={15} />}
|
||||
onClick={() => setConfirmModal('reject')}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={`Confirm ${confirmModal === 'approve' ? 'Approval' : confirmModal === 'reject' ? 'Rejection' : 'Correction Request'}`}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'approve'
|
||||
? 'Are you sure you want to approve this Seaman Book application? The seafarer will be notified.'
|
||||
: confirmModal === 'reject'
|
||||
? 'Are you sure you want to reject this application? Please ensure you have added remarks explaining the reason.'
|
||||
: 'A correction request will be sent to the seafarer with your remarks. Are you sure?'}
|
||||
</Text>
|
||||
{!remarks && confirmModal !== 'approve' && (
|
||||
<Text fz="xs" c="red" mb="sm">Please add remarks before proceeding.</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
|
||||
onClick={() => submit(confirmModal!)}
|
||||
disabled={!remarks && confirmModal !== 'approve'}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeamanBookQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [apps, setApps] = useState<SeamanBookApp[]>(MOCK_APPS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
|
||||
|
||||
const stats = {
|
||||
total: apps.length,
|
||||
pending: apps.filter((a) => a.status === 'Pending' || a.status === 'Under Review').length,
|
||||
awaitingDocs: apps.filter((a) => a.status === 'Awaiting Docs').length,
|
||||
approved: apps.filter((a) => a.status === 'Approved').length,
|
||||
};
|
||||
|
||||
const filtered = apps.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.seafarerId.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Title order={3}>Seaman Book Applications</Title>
|
||||
<Text fz="sm" c="dimmed">Review and process seafarer Seaman Book applications</Text>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'blue', icon: IconBook2 },
|
||||
{ label: 'Under Review', value: stats.pending, color: 'yellow', icon: IconClock },
|
||||
{ label: 'Awaiting Docs', value: stats.awaitingDocs,color: 'orange', icon: IconFileDescription },
|
||||
{ label: 'Approved', value: stats.approved, color: 'teal', icon: IconCircleCheck },
|
||||
].map(({ label, value, color, icon: Icon }) => (
|
||||
<Card key={label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
|
||||
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Table */}
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Application Queue</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Search by name, App ID or Seafarer ID…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ minWidth: rem(280) }}
|
||||
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Status"
|
||||
data={['Pending', 'Under Review', 'Awaiting Docs', 'Correction Required', 'Approved', 'Rejected']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
clearable
|
||||
size="sm"
|
||||
style={{ width: rem(180) }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Box py="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconBook2 size={22} /></ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No applications found</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['App ID', 'Seafarer', 'Submitted', 'Medical', 'BST', 'Documents', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.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}>{app.name}</Text>
|
||||
<Text fz="xs" c="dimmed">{app.seafarerId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="xs">{app.submitted}</Text></Table.Td>
|
||||
<Table.Td><Badge color={MEDICAL_COLOR[app.medicalStatus]} variant="light" size="xs">{app.medicalStatus}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={app.bstComplete ? 'teal' : 'red'} variant="light" size="xs">
|
||||
{app.bstCount}/5
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={app.docsComplete ? 'teal' : 'orange'} variant="light" size="xs">
|
||||
{app.docsComplete ? 'Complete' : 'Incomplete'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="xs">{app.status}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/applications/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 && (
|
||||
<Group px="md" py="sm" justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Showing {filtered.length} of {apps.length} applications</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,16 @@ export const am: Translations = {
|
||||
menu: 'ምናሌ',
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
userManagement: 'የተጠቃሚ አስተዳደር',
|
||||
seamanBookQueue: 'የመርከበኞች መጽሐፍ ወረፋ',
|
||||
cocQueue: 'የCoC/CoP ወረፋ',
|
||||
endorsementQueue: 'የማረጋገጫ ወረፋ',
|
||||
seafarerRegistry: 'የመርከበኞች መዝገብ',
|
||||
applications: 'ማመልከቻዎች',
|
||||
paymentConfig: 'የክፍያ ውቅረት',
|
||||
analytics: 'ትንታኔ',
|
||||
medicalVerification: 'የህክምና ማረጋገጫ',
|
||||
locations: 'አካባቢዎች',
|
||||
configuration: 'ውቅረት',
|
||||
profile: 'መገለጫ',
|
||||
collapseSidebar: 'ሰብስብ',
|
||||
expandSidebar: 'ዘርጋ',
|
||||
|
||||
@@ -16,6 +16,16 @@ export const en = {
|
||||
menu: 'MENU',
|
||||
dashboard: 'Dashboard',
|
||||
userManagement: 'User Management',
|
||||
seamanBookQueue: 'Seaman Book Queue',
|
||||
cocQueue: 'CoC / CoP Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
applications: 'Applications',
|
||||
paymentConfig: 'Payment Config',
|
||||
analytics: 'Analytics',
|
||||
medicalVerification: 'Medical Verification',
|
||||
locations: 'Locations',
|
||||
configuration: 'Configuration',
|
||||
profile: 'Profile',
|
||||
collapseSidebar: 'Collapse',
|
||||
expandSidebar: 'Expand sidebar',
|
||||
|
||||
@@ -7,20 +7,37 @@ import { logout } from '@ema-platform/auth';
|
||||
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem } from '@ema-platform/ui';
|
||||
import {
|
||||
IconBook2,
|
||||
IconChartBar,
|
||||
IconCreditCard,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconLayoutDashboard,
|
||||
IconUsers,
|
||||
IconUser,
|
||||
IconMap,
|
||||
IconShieldCheck,
|
||||
IconRubberStamp,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
|
||||
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUsers },
|
||||
{ to: '/configuration', label: 'Configuration', icon: IconSettings },
|
||||
{ to: '/profile', label: 'Profile', icon: IconUser },
|
||||
{ to: '/seaman-book-queue', label: 'Seaman Book Queue', icon: IconBook2 },
|
||||
{ to: '/coc-queue', label: 'CoC / CoP Queue', icon: IconShieldCheck },
|
||||
{ to: '/endorsement-queue', label: 'Endorsement Queue', icon: IconRubberStamp },
|
||||
{ to: '/seafarer-registry', label: 'Seafarer Registry', icon: IconUsers },
|
||||
{ to: '/applications', label: 'Applications', icon: IconFileDescription },
|
||||
{ to: '/payment-config', label: 'Payment Config', icon: IconCreditCard },
|
||||
{ to: '/analytics', label: 'Analytics', icon: IconChartBar },
|
||||
{ to: '/medical-verification', label: 'Medical Verification', icon: IconHeart },
|
||||
{ to: '/locations', label: 'Locations', icon: IconMap },
|
||||
{ to: '/configuration', label: 'Configuration', icon: IconSettings },
|
||||
{ to: '/profile', label: 'Profile', icon: IconUser },
|
||||
];
|
||||
|
||||
const HEADER_HEIGHT = 116;
|
||||
|
||||
@@ -15,6 +15,17 @@ import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
||||
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
|
||||
import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
|
||||
import { LocationPage } from '../features/location/pages/LocationPage';
|
||||
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
|
||||
import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
|
||||
import { CoCQueuePage } from '../features/coc-queue/pages/CoCQueuePage';
|
||||
import { CoCReviewPage } from '../features/coc-queue/pages/CoCReviewPage';
|
||||
import { EndorsementQueuePage } from '../features/endorsement/pages/EndorsementQueuePage';
|
||||
import { EndorsementReviewPage } from '../features/endorsement/pages/EndorsementReviewPage';
|
||||
import { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
||||
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
||||
import { SeamanBookQueuePage } from '../features/seaman-book-queue/pages/SeamanBookQueuePage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -36,6 +47,17 @@ const router = createBrowserRouter([
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'profile', element: <ProfilePage /> },
|
||||
{ path: 'configuration', element: <ConfigurationPage /> },
|
||||
{ path: 'locations', element: <LocationPage /> },
|
||||
{ path: 'analytics', element: <AnalyticsPage /> },
|
||||
{ path: 'applications/:id', element: <ApplicationReviewPage /> },
|
||||
{ path: 'coc-queue', element: <CoCQueuePage /> },
|
||||
{ path: 'coc-queue/:id', element: <CoCReviewPage /> },
|
||||
{ path: 'endorsement-queue', element: <EndorsementQueuePage /> },
|
||||
{ path: 'endorsement-queue/:id', element: <EndorsementReviewPage /> },
|
||||
{ path: 'medical-verification', element: <MedicalVerificationPage /> },
|
||||
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
||||
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,11 @@ export const am: Translations = {
|
||||
nav: {
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
seafarerRegistry: 'የመርከበኞች ምዝገባ',
|
||||
myApplication: 'ማመልከቻዬ',
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
endorsements: 'ማረጋገጫዎች',
|
||||
documents: 'ሰነዶቼ',
|
||||
notifications: 'ማሳወቂያዎች',
|
||||
profile: 'መገለጫ',
|
||||
support: 'እገዛና ድጋፍ',
|
||||
collapseSidebar: 'ሰብስብ',
|
||||
|
||||
@@ -14,6 +14,11 @@ export const en = {
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
myApplication: 'My Application',
|
||||
certificates: 'Certificates',
|
||||
endorsements: 'Endorsements',
|
||||
documents: 'My Documents',
|
||||
notifications: 'Notifications',
|
||||
profile: 'Profile',
|
||||
support: 'Help & Support',
|
||||
collapseSidebar: 'Collapse',
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { AppShell } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconLayoutDashboard,
|
||||
IconLifebuoy,
|
||||
IconBell,
|
||||
IconFolderOpen,
|
||||
IconHeadset,
|
||||
IconHome2,
|
||||
IconList,
|
||||
IconUser,
|
||||
IconRubberStamp,
|
||||
IconSend,
|
||||
IconShieldCheck,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -16,16 +21,27 @@ import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
|
||||
const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
|
||||
{ to: '/dashboard', label: 'Dashboard', i18nKey: 'nav.dashboard', icon: IconLayoutDashboard },
|
||||
{ to: '/dashboard', label: 'Dashboard', i18nKey: 'nav.dashboard', icon: IconHome2 },
|
||||
{ to: '/seafarer-registry', label: 'Seafarer Registry', i18nKey: 'nav.seafarerRegistry', icon: IconList },
|
||||
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUser },
|
||||
{ to: '/support', label: 'Help & Support', i18nKey: 'nav.support', icon: IconLifebuoy },
|
||||
{ to: '/seaman-book', label: 'My Application', i18nKey: 'nav.myApplication', icon: IconSend },
|
||||
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
||||
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp },
|
||||
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen },
|
||||
{ to: '/notifications',label: 'Notifications', i18nKey: 'nav.notifications', icon: IconBell },
|
||||
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUserCircle },
|
||||
{ to: '/support', label: 'Help & Support', i18nKey: 'nav.support', icon: IconHeadset },
|
||||
];
|
||||
|
||||
const PAGE_META: Record<string, { i18nKey: string }> = {
|
||||
'/dashboard': { i18nKey: 'nav.dashboard' },
|
||||
'/dashboard': { i18nKey: 'nav.dashboard' },
|
||||
'/seafarer-registry': { i18nKey: 'nav.seafarerRegistry' },
|
||||
'/profile': { i18nKey: 'nav.profile' },
|
||||
'/seaman-book': { i18nKey: 'nav.myApplication' },
|
||||
'/certificates': { i18nKey: 'nav.certificates' },
|
||||
'/endorsements': { i18nKey: 'nav.endorsements' },
|
||||
'/documents': { i18nKey: 'nav.documents' },
|
||||
'/notifications':{ i18nKey: 'nav.notifications' },
|
||||
'/profile': { i18nKey: 'nav.profile' },
|
||||
'/support': { i18nKey: 'nav.support' },
|
||||
};
|
||||
|
||||
export function PortalLayout() {
|
||||
|
||||
@@ -16,6 +16,19 @@ import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegi
|
||||
import { SeafarerRegistryPage } from './features/seafarer/pages/SeafarerRegistryPage';
|
||||
import { SeafarerProfilePage } from './features/seafarer/pages/SeafarerProfilePage';
|
||||
|
||||
// Phase 1 pages
|
||||
import { DocumentVaultPage } from './features/documents/pages/DocumentVaultPage';
|
||||
import { SeamanBookPage } from './features/seaman-book/pages/SeamanBookPage';
|
||||
import { SeamanBookApplicationPage } from './features/seaman-book/pages/SeamanBookApplicationPage';
|
||||
import { NotificationsPage } from './features/notifications/pages/NotificationsPage';
|
||||
|
||||
// Phase 2 — CoC / CoP
|
||||
import { CertificatesPage } from './features/certificates/pages/CertificatesPage';
|
||||
import { CoCApplicationPage } from './features/certificates/pages/CoCApplicationPage';
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
|
||||
|
||||
// IAM (admin user management) — kept reachable but isolated under its own
|
||||
// provider so it does not depend on the portal's provider tree.
|
||||
import {
|
||||
@@ -55,9 +68,26 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
|
||||
// Seafarer
|
||||
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
|
||||
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||
{ path: '/seafarer-registry/:id', element: <SeafarerProfilePage /> },
|
||||
|
||||
// Phase 1
|
||||
{ path: '/documents', element: <DocumentVaultPage /> },
|
||||
{ path: '/seaman-book', element: <SeamanBookPage /> },
|
||||
{ path: '/seaman-book/apply', element: <SeamanBookApplicationPage /> },
|
||||
{ path: '/notifications', element: <NotificationsPage /> },
|
||||
|
||||
// Phase 2 — CoC / CoP
|
||||
{ path: '/certificates', element: <CertificatesPage /> },
|
||||
{ path: '/certificates/apply', element: <CoCApplicationPage /> },
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
{ path: '/endorsements', element: <EndorsementPage /> },
|
||||
|
||||
// General
|
||||
{ path: '/profile', element: <ProfilePage /> },
|
||||
{ path: '/support', element: <SupportPage /> },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user