Files
emaui/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx
2026-08-21 11:16:22 +00:00

298 lines
13 KiB
TypeScript

import { StatusBadge } from '@ema-platform/ui';
import { type StatusTone } from '@ema-platform/shared';
import { useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
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 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_TONE: Record<string, StatusTone> = { Active: 'success', Inactive: 'neutral', Suspended: 'danger' };
// ---------------------------------------------------------------------------
// 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><StatusBadge tone={STATUS_TONE[sf.status]} label={sf.status} variant="light" size="xs" /></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() {
// The register is served whole and filtered in the browser: the filters are
// instant facets over a page-sized list, and a round trip per keystroke
// would make the search feel slower than the data it is searching.
const { data } = useApiQuery<{ total: number; items: Seafarer[] }>({
url: '/seafarer-registry',
method: 'GET',
});
const seafarers = data?.items ?? [];
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 = 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: seafarers.length, color: 'blue', icon: IconUsers },
{ label: 'Active', value: seafarers.filter((s) => s.status === 'Active').length, color: 'teal', icon: IconUser },
{ label: 'Medical Expiring',value: seafarers.filter((s) => s.medicalStatus === 'Expiring').length, color: 'orange', icon: IconHeart },
{ label: 'Medical Expired', value: seafarers.filter((s) => s.medicalStatus === 'Expired').length, color: 'red', icon: IconHeart },
{ label: 'With CoC', value: seafarers.filter((s) => s.cocCerts.length > 0).length, color: 'violet', icon: IconShieldCheck },
{ label: 'Without BSID', value: 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>
<StatusBadge
tone={STATUS_TONE[sf.status]}
label={sf.status}
variant="light"
size="xs"
/>
</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>
);
}