feat(vessel-registration): add vessel registration page with incident reporting and certificate download functionality

feat(waiver): implement waiver page with application tracking and letter download feature

feat(ui): introduce AdvancedTable component for enhanced table functionality across the application

chore: update package-lock.json to remove unnecessary dependencies
This commit is contained in:
Nati
2026-08-13 09:11:50 +00:00
parent a7e74cb7bd
commit 2af9e60800
67 changed files with 4209 additions and 3446 deletions

View File

@@ -1,475 +0,0 @@
import { useState } from 'react';
import {
Badge,
Button,
Card,
Center,
Container,
Drawer,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAnchor,
IconSearch,
IconShieldCog,
IconStethoscope,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
extractErrorMessage,
useApiQuery,
useGetMedicalForProfileQuery,
useGetSeaServiceForProfileQuery,
useUpdateSeafarerStatusMutation,
} from '@ema-platform/api';
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 };
}
const SEAFARER_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
PENDING: 'yellow',
SUSPENDED: 'orange',
INACTIVE: 'gray',
};
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
};
const DEPARTMENT_LABELS: Record<string, string> = {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
};
/** The registered seafarer's records, read-only (verification is module 06). */
function SeafarerDetailDrawer({
profile,
onClose,
}: {
profile: ProfileRow | null;
onClose: () => void;
}) {
const profileId = profile?.id ?? '';
const { data: seaService, isLoading: loadingSea } =
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
const { data: medical, isLoading: loadingMedical } =
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
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">
Seafarer number
</Text>
<Text fw={700} ff="monospace">
{profile.seafarerNumber ?? '—'}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Department
</Text>
<Text fw={600}>
{profile.seafarerDepartment
? DEPARTMENT_LABELS[profile.seafarerDepartment] ??
profile.seafarerDepartment
: '—'}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Status
</Text>
<Badge
color={
SEAFARER_STATUS_COLORS[profile.seafarerStatus ?? ''] ?? 'gray'
}
>
{profile.seafarerStatus ?? 'NOT REGISTERED'}
</Badge>
</div>
</Group>
{profile.seafarerStatusReason && (
<Text size="sm" c="dimmed">
Status reason: {profile.seafarerStatusReason}
</Text>
)}
<Tabs defaultValue="sea-service" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
Sea Service
</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
Medical
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="sea-service" pt="sm">
{loadingSea ? (
<Loader size="sm" />
) : (seaService ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
No sea-service records.
</Text>
) : (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Vessel</Table.Th>
<Table.Th>Rank</Table.Th>
<Table.Th>Period</Table.Th>
<Table.Th>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">
IMO {record.imoNumber}
</Text>
)}
</Table.Td>
<Table.Td>{record.rank}</Table.Td>
<Table.Td>
{record.engagementDate} {record.dischargeDate}
</Table.Td>
<Table.Td>
<Badge size="sm" color={RECORD_STATUS_COLORS[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">
No medical certificates.
</Text>
) : (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Issuer</Table.Th>
<Table.Th>Validity</Table.Th>
<Table.Th>Fitness</Table.Th>
<Table.Th>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>
{certificate.issueDate} {certificate.expiryDate}
</Table.Td>
<Table.Td>{certificate.fitnessStatus}</Table.Td>
<Table.Td>
<Badge
size="sm"
color={RECORD_STATUS_COLORS[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 [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('Seafarer status updated');
onClose();
onDone();
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not update the status'));
}
};
return (
<Modal
opened={Boolean(profile)}
onClose={onClose}
title="Change seafarer status"
centered
>
<Stack>
<Text size="sm" c="dimmed">
{profile?.seafarerNumber} currently {profile?.seafarerStatus}. The
reason is recorded and visible to the seafarer.
</Text>
<Select
label="New status"
required
data={[
{ value: 'SUSPENDED', label: 'Suspend' },
{ value: 'INACTIVE', label: 'Close' },
{ value: 'ACTIVE', label: 'Reinstate' },
].filter((o) => o.value !== profile?.seafarerStatus)}
value={status}
onChange={setStatus}
/>
<Textarea
label="Reason"
required
minRows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
color={status === 'ACTIVE' ? 'green' : 'orange'}
disabled={!status || reason.trim().length < 3}
loading={isLoading}
onClick={submit}
>
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 [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 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));
});
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>Seafarer registry</Title>
<Text size="sm" c="dimmed">
{data?.total ?? 0} profile{(data?.total ?? 0) === 1 ? '' : 's'}
</Text>
</div>
<TextInput
placeholder="Name, ID or seafarer number"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
</Group>
<Card withBorder padding={0}>
{isLoading ? (
<Center h={200}>
<Loader />
</Center>
) : items.length === 0 ? (
<Center h={160}>
<Text size="sm" c="dimmed">
{search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Seafarer </Table.Th>
<Table.Th>Department</Table.Th>
<Table.Th>ID number</Table.Th>
<Table.Th>Phone</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((p) => (
<Table.Tr
key={p.id}
style={{ cursor: 'pointer' }}
onClick={() => setDetail(p)}
>
<Table.Td>
<Text size="sm" fw={500}>
{[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" ff="monospace">
{p.seafarerNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{p.seafarerDepartment
? DEPARTMENT_LABELS[p.seafarerDepartment] ?? p.seafarerDepartment
: '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{p.address?.idNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{p.address?.primaryPhoneNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
{p.seafarerNumber ? (
<Badge
size="sm"
variant="light"
color={SEAFARER_STATUS_COLORS[p.seafarerStatus ?? ''] ?? 'gray'}
>
{p.seafarerStatus}
</Badge>
) : (
<Badge size="sm" variant="light" color={p.isComplete ? 'teal' : 'gray'}>
{p.isComplete ? 'Not registered' : 'Incomplete'}
</Badge>
)}
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
{p.seafarerNumber && (
<Tooltip label="Suspend / reinstate / close">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => setStatusTarget(p)}
>
Status
</Button>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
<SeafarerDetailDrawer profile={detail} onClose={() => setDetail(null)} />
<StatusModal
profile={statusTarget}
onClose={() => setStatusTarget(null)}
onDone={refetch}
/>
</Container>
);
}
export default SeafarerRegistryPage;