Files
emaui/apps/portal/src/app/features/seafarer/pages/SeafarerRegistryPage.tsx
2026-06-15 13:54:50 +00:00

380 lines
12 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Loader,
Menu,
Paper,
Select,
SimpleGrid,
Skeleton,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconClock,
IconDotsVertical,
IconEdit,
IconEye,
IconFileExport,
IconSearch,
IconUserCheck,
IconUsers,
IconUserX,
IconX,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Seafarer {
id: string;
seafarerId: string;
firstName: string;
lastName: string;
email: string;
gender: 'Male' | 'Female';
nationality: string;
mobile: string;
region: string;
registeredAt: string;
medicalStatus: 'Fit' | 'Unfit' | 'Pending';
bookStatus: 'Active' | 'Expired' | 'Suspended' | 'Pending';
status: 'Active' | 'Pending' | 'Suspended';
}
// ---------------------------------------------------------------------------
// Dummy API — replace with real fetch later
// ---------------------------------------------------------------------------
async function fetchSeafarers(): Promise<Seafarer[]> {
await new Promise((r) => setTimeout(r, 900));
return [
{
id: '1',
seafarerId: 'SF-2024-0001',
firstName: 'Abebe',
lastName: 'Girma',
email: 'abebe.g@email.com',
gender: 'Male',
nationality: 'Ethiopian',
mobile: '+251 911 234 567',
region: 'Addis Ababa',
registeredAt: '2024-01-10',
medicalStatus: 'Fit',
bookStatus: 'Active',
status: 'Active',
},
{
id: '2',
seafarerId: 'SF-2024-0002',
firstName: 'Sara',
lastName: 'Tadesse',
email: 'sara.t@email.com',
gender: 'Female',
nationality: 'Ethiopian',
mobile: '+251 922 345 678',
region: 'Dire Dawa',
registeredAt: '2024-02-14',
medicalStatus: 'Pending',
bookStatus: 'Pending',
status: 'Pending',
},
{
id: '3',
seafarerId: 'SF-2024-0003',
firstName: 'Dawit',
lastName: 'Bekele',
email: 'dawit.b@email.com',
gender: 'Male',
nationality: 'Ethiopian',
mobile: '+251 933 456 789',
region: 'Oromia',
registeredAt: '2024-03-05',
medicalStatus: 'Fit',
bookStatus: 'Expired',
status: 'Suspended',
},
{
id: '4',
seafarerId: 'SF-2024-0004',
firstName: 'Hana',
lastName: 'Mulugeta',
email: 'hana.m@email.com',
gender: 'Female',
nationality: 'Ethiopian',
mobile: '+251 944 567 890',
region: 'Amhara',
registeredAt: '2024-04-20',
medicalStatus: 'Fit',
bookStatus: 'Active',
status: 'Active',
},
];
}
// ---------------------------------------------------------------------------
// Stat card
// ---------------------------------------------------------------------------
function StatCard({
label,
value,
icon: Icon,
color,
loading,
}: {
label: string;
value: number;
icon: typeof IconUsers;
color: string;
loading: boolean;
}) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<div>
{loading ? (
<Skeleton height={28} width={40} mb={6} />
) : (
<Title order={2} lh={1}>{value}</Title>
)}
<Text fz="sm" c="dimmed" mt={4}>{label}</Text>
</div>
<ThemeIcon variant="light" color={color} size={46} radius="md">
<Icon size={22} stroke={1.6} />
</ThemeIcon>
</Group>
</Card>
);
}
// ---------------------------------------------------------------------------
// Status badges
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal',
Pending: 'yellow',
Suspended: 'red',
Expired: 'orange',
Fit: 'teal',
Unfit: 'red',
};
function StatusBadge({ value }: { value: string }) {
return (
<Badge
color={STATUS_COLOR[value] ?? 'gray'}
variant="light"
radius="sm"
size="sm"
>
{value}
</Badge>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeafarerRegistryPage() {
const navigate = useNavigate();
const [seafarers, setSeafarers] = useState<Seafarer[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
useEffect(() => {
fetchSeafarers()
.then(setSeafarers)
.catch(() => notify.error('Failed to load seafarers.'))
.finally(() => setLoading(false));
}, []);
const stats = {
total: seafarers.length,
active: seafarers.filter((s) => s.status === 'Active').length,
pending: seafarers.filter((s) => s.status === 'Pending').length,
suspended: seafarers.filter((s) => s.status === 'Suspended').length,
};
const filtered = seafarers.filter((s) => {
const q = search.toLowerCase();
const matchSearch =
!q ||
s.seafarerId.toLowerCase().includes(q) ||
`${s.firstName} ${s.lastName}`.toLowerCase().includes(q) ||
s.mobile.includes(q) ||
s.email.toLowerCase().includes(q);
const matchStatus = !statusFilter || s.status === statusFilter;
return matchSearch && matchStatus;
});
const rows = filtered.map((s) => (
<Table.Tr key={s.id}>
<Table.Td>
<Text fz="sm" fw={600} c="blue.7" style={{ cursor: 'pointer' }} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
{s.seafarerId}
</Text>
</Table.Td>
<Table.Td>
<div>
<Text fz="sm" fw={500}>{s.firstName} {s.lastName}</Text>
<Text fz="xs" c="dimmed">{s.email}</Text>
</div>
</Table.Td>
<Table.Td><Text fz="sm">{s.gender}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.nationality}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
<Table.Td><StatusBadge value={s.medicalStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.bookStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.status} /></Table.Td>
<Table.Td>
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconDotsVertical size={15} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
View
</Menu.Item>
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
Edit
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconX size={14} />} color="red" onClick={() => notify.info('Suspend — coming soon.')}>
Suspend
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start">
<div>
<Title order={3}>Seafarer Registry</Title>
<Text fz="sm" c="dimmed">Manage all registered seafarers</Text>
</div>
<Button
leftSection={<IconAnchor size={16} />}
onClick={() => navigate('/seafarer-registration')}
>
+ New Registration
</Button>
</Group>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatCard label="Total Seafarers" value={stats.total} icon={IconUsers} color="blue" loading={loading} />
<StatCard label="Active" value={stats.active} icon={IconUserCheck} color="teal" loading={loading} />
<StatCard label="Pending" value={stats.pending} icon={IconClock} color="yellow" loading={loading} />
<StatCard label="Suspended" value={stats.suspended} icon={IconUserX} color="red" loading={loading} />
</SimpleGrid>
{/* Table card */}
<Paper withBorder radius="md">
{/* Toolbar */}
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Seafarer List</Text>
<Group gap="sm" wrap="nowrap">
<TextInput
placeholder="Search by name, ID or mobile…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ minWidth: rem(260) }}
size="sm"
rightSection={
search ? (
<ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}>
<IconX size={13} />
</ActionIcon>
) : null
}
/>
<Select
placeholder="All Status"
data={['Active', 'Pending', 'Suspended']}
value={statusFilter}
onChange={setStatusFilter}
clearable
size="sm"
style={{ width: rem(140) }}
/>
<ActionIcon
variant="default"
size={34}
title="Export"
onClick={() => notify.info('Export — coming soon.')}
>
<IconFileExport size={16} />
</ActionIcon>
</Group>
</Group>
{/* Table */}
{loading ? (
<Stack gap="xs" p="md">
{[...Array(4)].map((_, i) => <Skeleton key={i} height={44} radius="sm" />)}
</Stack>
) : filtered.length === 0 ? (
<Box py="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
<IconUsers size={22} />
</ThemeIcon>
<Text fz="sm" c="dimmed">No seafarers found</Text>
{(search || statusFilter) && (
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setSearch(''); setStatusFilter(null); }}>
Clear filters
</Button>
)}
</Box>
) : (
<Table highlightOnHover striped withColumnBorders={false} verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Seafarer ID', 'Name', 'Gender', 'Nationality', 'Mobile', 'Region', 'Reg. Date', 'Medical', 'Book Status', 'Status', ''].map((h) => (
<Table.Th key={h} style={{ whiteSpace: 'nowrap', fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>
{h}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
)}
{/* Footer */}
{!loading && filtered.length > 0 && (
<Group px="md" py="sm" justify="space-between">
<Text fz="xs" c="dimmed">Showing {filtered.length} of {seafarers.length} seafarers</Text>
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="dimmed">Data loaded</Text>
</Group>
</Group>
)}
</Paper>
</Stack>
);
}