diff --git a/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx new file mode 100644 index 000000000..5efcd6776 --- /dev/null +++ b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx @@ -0,0 +1,290 @@ +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 = { Valid: 'teal', Expiring: 'orange', Expired: 'red' }; +const STATUS_COLOR: Record = { 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 ( + {sf.name} — {sf.id}} + size="xl" + radius="lg" + > + + + + Personal + + Full Name{sf.name} + Nationality{sf.nationality} + Date of Birth{sf.dob} + Rank{sf.rank} + Status{sf.status} + + + + Documents + + Seaman Book{sf.seamanBookNo} + SB Expiry{sf.seamanBookExpiry} + BTC No.{sf.btcNo ?? '—'} + BSID No.{sf.bsidNo ?? '—'} + + Medical Expiry + {sf.medicalExpiry} ({sf.medicalStatus}) + + + + + + {sf.cocCerts.length > 0 && ( + + CoC / CoP Certificates + + + + {['Type', 'Certificate No.', 'Expiry'].map((h) => ( + {h} + ))} + + + + {sf.cocCerts.map((c) => ( + + {c.type} + {c.no} + {c.expiry} + + ))} + +
+
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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(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 ( + +
+ Seafarer Registry + Search and view all registered seafarers, their documents, and certificate status +
+ + {/* KPIs */} + + {KPI.map((k) => { + const KIcon = k.icon; + return ( + + + +
+ {k.value} + {k.label} +
+
+
+ ); + })} +
+ + {/* Search + filters */} + + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + style={{ flex: 1, minWidth: 200 }} + size="sm" + /> + + + + + + setStatusFilter(v ?? 'All')} size="sm" /> + o.value !== profile?.seafarerStatus)} - value={status} - onChange={setStatus} - /> -