mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 05:58:19 +00:00
382 lines
16 KiB
TypeScript
382 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|