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:
fitse-yotor
2026-08-17 07:15:08 +03:00
parent c52d739e18
commit fa7eb3dc5f
4 changed files with 290 additions and 538 deletions

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
),
},
];
}

View File

@@ -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;