mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat(backoffice): restore Mengestab's seafarer register and wire it up
The approved single-page register -- with the detail modal that shows a seafarer's full standing -- had been replaced by a three-file grid that carried no profile view. That is why the seafarer profile appeared missing from the backoffice entirely. Restores his page and points it at /seafarer-registry. The list 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 searches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
|
||||||
|
const STATUS_COLOR: Record<string, string> = { 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 (
|
||||||
|
<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><Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge></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>
|
||||||
|
<Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Button, Tooltip } from '@mantine/core';
|
|
||||||
import { IconShieldCog } from '@tabler/icons-react';
|
|
||||||
import type { TFunction } from 'i18next';
|
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
|
||||||
import type { ProfileRow } from './columns';
|
|
||||||
|
|
||||||
export function seafarerStatusActionColumn(
|
|
||||||
t: TFunction,
|
|
||||||
handlers: { onStatus: (profile: ProfileRow) => void },
|
|
||||||
): AdvancedColumn<ProfileRow> {
|
|
||||||
return {
|
|
||||||
header: '',
|
|
||||||
label: t('seafarerRegistry.columns.actions', 'Actions'),
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.seafarerNumber ? (
|
|
||||||
<RequirePermission
|
|
||||||
anyOf={[LICENSE_PERMISSIONS.MANAGE_SEAFARER_STATUS]}
|
|
||||||
hideOnly
|
|
||||||
>
|
|
||||||
<Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}>
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="subtle"
|
|
||||||
leftSection={<IconShieldCog size={14} />}
|
|
||||||
onClick={() => handlers.onStatus(row.original)}
|
|
||||||
>
|
|
||||||
{t('seafarerRegistry.statusAction', 'Status')}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</RequirePermission>
|
|
||||||
) : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { Badge, Text } from '@mantine/core';
|
|
||||||
import type { TFunction } from 'i18next';
|
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
|
||||||
|
|
||||||
export interface ProfileRow {
|
|
||||||
id: string;
|
|
||||||
firstName: string;
|
|
||||||
middleName?: string;
|
|
||||||
lastName: string;
|
|
||||||
gender?: string;
|
|
||||||
type?: string;
|
|
||||||
isComplete?: boolean;
|
|
||||||
seafarerNumber?: string | null;
|
|
||||||
seafarerStatus?: string | null;
|
|
||||||
seafarerDepartment?: string | null;
|
|
||||||
seafarerStatusReason?: string | null;
|
|
||||||
profession?: { name?: { en?: string } };
|
|
||||||
address?: { idNumber?: string; nationality?: string; primaryPhoneNumber?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SEAFARER_STATUS_COLORS: Record<string, string> = {
|
|
||||||
ACTIVE: 'green',
|
|
||||||
PENDING: 'yellow',
|
|
||||||
SUSPENDED: 'orange',
|
|
||||||
INACTIVE: 'gray',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEPARTMENT_LABELS: Record<string, string> = {
|
|
||||||
DECK: 'Deck',
|
|
||||||
ENGINE: 'Engine',
|
|
||||||
CATERING: 'Catering',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function seafarerRegistryColumns(
|
|
||||||
t: TFunction,
|
|
||||||
handlers: { onDetail: (profile: ProfileRow) => void },
|
|
||||||
): AdvancedColumn<ProfileRow>[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: t('seafarerRegistry.columns.name', 'Name'),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text
|
|
||||||
size="sm"
|
|
||||||
fw={500}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => handlers.onDetail(row.original)}
|
|
||||||
>
|
|
||||||
{[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t('seafarerRegistry.columns.number', 'Seafarer №'),
|
|
||||||
cell: ({ row }) => <Text size="sm" ff="monospace">{row.original.seafarerNumber ?? '—'}</Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t('seafarerRegistry.columns.department', 'Department'),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm">
|
|
||||||
{row.original.seafarerDepartment
|
|
||||||
? t(
|
|
||||||
`seafarerRegistry.departments.${row.original.seafarerDepartment}`,
|
|
||||||
DEPARTMENT_LABELS[row.original.seafarerDepartment] ?? row.original.seafarerDepartment,
|
|
||||||
)
|
|
||||||
: '—'}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t('seafarerRegistry.columns.idNumber', 'ID number'),
|
|
||||||
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.idNumber ?? '—'}</Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t('seafarerRegistry.columns.phone', 'Phone'),
|
|
||||||
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.primaryPhoneNumber ?? '—'}</Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t('seafarerRegistry.columns.status', 'Status'),
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.seafarerNumber ? (
|
|
||||||
<Badge size="sm" variant="light" color={SEAFARER_STATUS_COLORS[row.original.seafarerStatus ?? ''] ?? 'gray'}>
|
|
||||||
{t(`seafarerRegistry.status.${row.original.seafarerStatus}`, row.original.seafarerStatus ?? '')}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge size="sm" variant="light" color={row.original.isComplete ? 'teal' : 'gray'}>
|
|
||||||
{row.original.isComplete
|
|
||||||
? t('seafarerRegistry.notRegistered', 'Not registered')
|
|
||||||
: t('seafarerRegistry.incomplete', 'Incomplete')}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Container,
|
|
||||||
Drawer,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Tabs,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
|
||||||
IconAnchor,
|
|
||||||
IconSearch,
|
|
||||||
IconStethoscope,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
|
||||||
import {
|
|
||||||
extractErrorMessage,
|
|
||||||
useApiQuery,
|
|
||||||
useGetMedicalForProfileQuery,
|
|
||||||
useGetSeaServiceForProfileQuery,
|
|
||||||
useUpdateSeafarerStatusMutation,
|
|
||||||
} from '@ema-platform/api';
|
|
||||||
import {
|
|
||||||
DEPARTMENT_LABELS,
|
|
||||||
SEAFARER_STATUS_COLORS,
|
|
||||||
seafarerRegistryColumns,
|
|
||||||
type ProfileRow,
|
|
||||||
} from './columns';
|
|
||||||
import { seafarerStatusActionColumn } from './actions';
|
|
||||||
|
|
||||||
const RECORD_STATUS_COLORS: Record<string, string> = {
|
|
||||||
SUBMITTED: 'blue',
|
|
||||||
VERIFIED: 'green',
|
|
||||||
REJECTED: 'red',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The registered seafarer's records, read-only (verification is module 06). */
|
|
||||||
function SeafarerDetailDrawer({
|
|
||||||
profile,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
profile: ProfileRow | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const profileId = profile?.id ?? '';
|
|
||||||
const { data: seaService, isLoading: loadingSea } =
|
|
||||||
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
|
|
||||||
const { data: medical, isLoading: loadingMedical } =
|
|
||||||
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
|
|
||||||
const showDate = useDateDisplayer();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
opened={Boolean(profile)}
|
|
||||||
onClose={onClose}
|
|
||||||
position="right"
|
|
||||||
size="lg"
|
|
||||||
title={
|
|
||||||
profile
|
|
||||||
? [profile.firstName, profile.middleName, profile.lastName]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{profile && (
|
|
||||||
<Stack>
|
|
||||||
<Group gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase">
|
|
||||||
{t('seafarerRegistry.drawer.seafarerNumber', 'Seafarer number')}
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} ff="monospace">
|
|
||||||
{profile.seafarerNumber ?? '—'}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase">
|
|
||||||
{t('seafarerRegistry.drawer.department', 'Department')}
|
|
||||||
</Text>
|
|
||||||
<Text fw={600}>
|
|
||||||
{profile.seafarerDepartment
|
|
||||||
? t(
|
|
||||||
`seafarerRegistry.departments.${profile.seafarerDepartment}`,
|
|
||||||
DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
|
||||||
profile.seafarerDepartment,
|
|
||||||
)
|
|
||||||
: '—'}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase">
|
|
||||||
{t('seafarerRegistry.drawer.status', 'Status')}
|
|
||||||
</Text>
|
|
||||||
<Badge
|
|
||||||
color={
|
|
||||||
SEAFARER_STATUS_COLORS[profile.seafarerStatus ?? ''] ?? 'gray'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{profile.seafarerStatus
|
|
||||||
? t(`seafarerRegistry.status.${profile.seafarerStatus}`, profile.seafarerStatus)
|
|
||||||
: t('seafarerRegistry.drawer.notRegistered', 'NOT REGISTERED')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
{profile.seafarerStatusReason && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t('seafarerRegistry.drawer.statusReason', {
|
|
||||||
reason: profile.seafarerStatusReason,
|
|
||||||
defaultValue: 'Status reason: {{reason}}',
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tabs defaultValue="sea-service" keepMounted={false}>
|
|
||||||
<Tabs.List>
|
|
||||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
|
|
||||||
{t('seafarerRegistry.drawer.seaServiceTab', 'Sea Service')}
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
|
|
||||||
{t('seafarerRegistry.drawer.medicalTab', 'Medical')}
|
|
||||||
</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value="sea-service" pt="sm">
|
|
||||||
{loadingSea ? (
|
|
||||||
<Loader size="sm" />
|
|
||||||
) : (seaService ?? []).length === 0 ? (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t('seafarerRegistry.drawer.noSeaService', 'No sea-service records.')}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Table striped>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t('seafarerRegistry.drawer.vessel', 'Vessel')}</Table.Th>
|
|
||||||
<Table.Th>{t('seafarerRegistry.drawer.rank', 'Rank')}</Table.Th>
|
|
||||||
<Table.Th>{t('seafarerRegistry.drawer.period', 'Period')}</Table.Th>
|
|
||||||
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{(seaService ?? []).map((record) => (
|
|
||||||
<Table.Tr key={record.id}>
|
|
||||||
<Table.Td>
|
|
||||||
{record.vesselName}
|
|
||||||
{record.imoNumber && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t('seafarerRegistry.drawer.imoPrefix', {
|
|
||||||
number: record.imoNumber,
|
|
||||||
defaultValue: 'IMO {{number}}',
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{record.rank}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
{showDate(record.engagementDate)} → {showDate(record.dischargeDate)}
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
|
|
||||||
{t(`seafarerRegistry.recordStatus.${record.status}`, record.status)}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="medical" pt="sm">
|
|
||||||
{loadingMedical ? (
|
|
||||||
<Loader size="sm" />
|
|
||||||
) : (medical ?? []).length === 0 ? (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t('seafarerRegistry.drawer.noMedical', 'No medical certificates.')}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Table striped>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t('seafarerRegistry.drawer.issuer', 'Issuer')}</Table.Th>
|
|
||||||
<Table.Th>{t('seafarerRegistry.drawer.validity', 'Validity')}</Table.Th>
|
|
||||||
<Table.Th>{t('seafarerRegistry.drawer.fitness', 'Fitness')}</Table.Th>
|
|
||||||
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{(medical ?? []).map((certificate) => (
|
|
||||||
<Table.Tr key={certificate.id}>
|
|
||||||
<Table.Td>{certificate.issuerName}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
{showDate(certificate.issueDate)} → {showDate(certificate.expiryDate)}
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{certificate.fitnessStatus}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
color={RECORD_STATUS_COLORS[certificate.status]}
|
|
||||||
>
|
|
||||||
{t(`seafarerRegistry.recordStatus.${certificate.status}`, certificate.status)}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** US-SEA-013: suspend / reinstate / close, always with a reason. */
|
|
||||||
function StatusModal({
|
|
||||||
profile,
|
|
||||||
onClose,
|
|
||||||
onDone,
|
|
||||||
}: {
|
|
||||||
profile: ProfileRow | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onDone: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
|
||||||
const [reason, setReason] = useState('');
|
|
||||||
const [updateStatus, { isLoading }] = useUpdateSeafarerStatusMutation();
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!profile || !status) return;
|
|
||||||
try {
|
|
||||||
await updateStatus({
|
|
||||||
profileId: profile.id,
|
|
||||||
status: status as 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'PENDING',
|
|
||||||
reason,
|
|
||||||
}).unwrap();
|
|
||||||
notify.success(t('seafarerRegistry.modal.updated', 'Seafarer status updated'));
|
|
||||||
onClose();
|
|
||||||
onDone();
|
|
||||||
} catch (error) {
|
|
||||||
notify.error(
|
|
||||||
extractErrorMessage(error, t('seafarerRegistry.modal.updateFailed', 'Could not update the status')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={Boolean(profile)}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t('seafarerRegistry.modal.title', 'Change seafarer status')}
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t('seafarerRegistry.modal.body', {
|
|
||||||
number: profile?.seafarerNumber,
|
|
||||||
status: profile?.seafarerStatus
|
|
||||||
? t(`seafarerRegistry.status.${profile.seafarerStatus}`, profile.seafarerStatus)
|
|
||||||
: profile?.seafarerStatus,
|
|
||||||
defaultValue:
|
|
||||||
'{{number}} — currently {{status}}. The reason is recorded and visible to the seafarer.',
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
<Select
|
|
||||||
label={t('seafarerRegistry.modal.newStatus', 'New status')}
|
|
||||||
required
|
|
||||||
data={[
|
|
||||||
{ value: 'SUSPENDED', label: t('seafarerRegistry.modal.suspend', 'Suspend') },
|
|
||||||
{ value: 'INACTIVE', label: t('seafarerRegistry.modal.close', 'Close') },
|
|
||||||
{ value: 'ACTIVE', label: t('seafarerRegistry.modal.reinstate', 'Reinstate') },
|
|
||||||
].filter((o) => o.value !== profile?.seafarerStatus)}
|
|
||||||
value={status}
|
|
||||||
onChange={setStatus}
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
label={t('seafarerRegistry.modal.reason', 'Reason')}
|
|
||||||
required
|
|
||||||
minRows={2}
|
|
||||||
value={reason}
|
|
||||||
onChange={(e) => setReason(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={onClose}>
|
|
||||||
{t('seafarerRegistry.modal.cancel', 'Cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color={status === 'ACTIVE' ? 'green' : 'orange'}
|
|
||||||
disabled={!status || reason.trim().length < 3}
|
|
||||||
loading={isLoading}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
{t('seafarerRegistry.modal.confirm', 'Confirm')}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registered seafarer profiles, read from the real profiles endpoint.
|
|
||||||
*
|
|
||||||
* Registration review itself happens in the licence queue (the
|
|
||||||
* SEAFARER_REGISTRATION application type); this page is the resulting
|
|
||||||
* register — numbers, departments, statuses, and each seafarer's records.
|
|
||||||
*/
|
|
||||||
export function SeafarerRegistryPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [detail, setDetail] = useState<ProfileRow | null>(null);
|
|
||||||
const [statusTarget, setStatusTarget] = useState<ProfileRow | null>(null);
|
|
||||||
const { data, isLoading, refetch } = useApiQuery<{
|
|
||||||
total: number;
|
|
||||||
items: ProfileRow[];
|
|
||||||
}>({
|
|
||||||
url: '/profiles',
|
|
||||||
method: 'GET',
|
|
||||||
params: { q: 'i=profession,address&t=200' },
|
|
||||||
});
|
|
||||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
|
||||||
|
|
||||||
const items = (data?.items ?? []).filter((p) => {
|
|
||||||
if (!search.trim()) return true;
|
|
||||||
const term = search.toLowerCase();
|
|
||||||
return [
|
|
||||||
p.firstName,
|
|
||||||
p.middleName,
|
|
||||||
p.lastName,
|
|
||||||
p.address?.idNumber,
|
|
||||||
p.seafarerNumber,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((v) => String(v).toLowerCase().includes(term));
|
|
||||||
});
|
|
||||||
const page = paginate(items);
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
...seafarerRegistryColumns(t, { onDetail: setDetail }),
|
|
||||||
seafarerStatusActionColumn(t, { onStatus: setStatusTarget }),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="xl" py="md">
|
|
||||||
<Group justify="space-between" mb="md">
|
|
||||||
<div>
|
|
||||||
<Title order={3}>{t('seafarerRegistry.title', 'Seafarer registry')}</Title>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t('seafarerRegistry.profileCount', {
|
|
||||||
count: data?.total ?? 0,
|
|
||||||
defaultValue: '{{count}} profile(s)',
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<TextInput
|
|
||||||
placeholder={t('seafarerRegistry.searchPlaceholder', 'Name, ID or seafarer number')}
|
|
||||||
leftSection={<IconSearch size={14} />}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
||||||
w={280}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Card withBorder padding={0}>
|
|
||||||
<AdvancedTable
|
|
||||||
columns={columns}
|
|
||||||
data={page.rows}
|
|
||||||
tableName={t('seafarerRegistry.title', 'Seafarer registry')}
|
|
||||||
itemCount={page.itemCount}
|
|
||||||
pageIndex={page.pageIndex}
|
|
||||||
onPageChange={setPageIndex}
|
|
||||||
pageSize={pageSize}
|
|
||||||
onPageSizeChange={setPageSize}
|
|
||||||
refresh={refetch}
|
|
||||||
isLoading={isLoading}
|
|
||||||
emptyText={
|
|
||||||
search
|
|
||||||
? t('seafarerRegistry.emptySearch', 'No profiles match that search.')
|
|
||||||
: t('seafarerRegistry.emptyNone', 'No seafarers registered yet.')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<SeafarerDetailDrawer profile={detail} onClose={() => setDetail(null)} />
|
|
||||||
<StatusModal
|
|
||||||
profile={statusTarget}
|
|
||||||
onClose={() => setStatusTarget(null)}
|
|
||||||
onDone={refetch}
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SeafarerRegistryPage;
|
|
||||||
Reference in New Issue
Block a user