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