mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
298 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|