ui componenet based on the requirements

This commit is contained in:
mengstabketemaw
2026-06-20 09:44:41 +03:00
parent 1f27a36b90
commit 5d8e749cf7
28 changed files with 8652 additions and 41 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 5003,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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}