Files
emaui/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/index.tsx
2026-08-21 13:09:40 +00:00

273 lines
8.3 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
import {Alert, Badge, Button, Card, Container, Drawer, Group, Loader, Modal, Select, Stack, Table, Text, TextInput, Textarea} from '@mantine/core';
import {
IconAlertTriangle,
IconInfoCircle,
IconSearch,
} from '@tabler/icons-react';
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
useGetVesselIncidentsQuery,
useGetVesselsQuery,
useUpdateVesselStatusMutation,
} from '@ema-platform/api';
import type { Vessel } from '@ema-platform/api';
import {
CATEGORY_LABELS,
vesselRegistrationQueueColumns,
} from './columns';
/** Full particulars + incident log, read-only. */
function VesselDetailDrawer({
vessel,
onClose,
}: {
vessel: Vessel | null;
onClose: () => void;
}) {
const { data: incidents, isLoading: loadingIncidents } =
useGetVesselIncidentsQuery(vessel?.id ?? '', { skip: !vessel });
const showDate = useDateDisplayer();
const particulars: [string, string | number | null][] = vessel
? [
['Registration №', vessel.registrationNumber],
['Category', CATEGORY_LABELS[vessel.category] ?? vessel.category],
['Type', vessel.vesselType],
['IMO number', vessel.imoNumber],
['Hull number', vessel.hullNumber],
['Flag state', vessel.flagState],
['Port of registry', vessel.portOfRegistry],
['Gross tonnage', vessel.grossTonnage],
['Passenger capacity', vessel.passengerCapacity],
['Length (m)', vessel.lengthMeters],
['Year built', vessel.yearBuilt],
['Engine', vessel.engineType],
['Engine power (kW)', vessel.enginePowerKw],
['Engines', vessel.numberOfEngines],
['Hull material', vessel.hullMaterial],
['Owner', vessel.ownerName],
['Registered', vessel.registeredAt ? showDate(vessel.registeredAt) : null],
]
: [];
return (
<Drawer
opened={Boolean(vessel)}
onClose={onClose}
position="right"
size="lg"
title={vessel?.name ?? ''}
>
{vessel && (
<Stack>
{vessel.statusReason && (
<Alert
color={vessel.status === 'SUSPENDED' ? 'orange' : 'gray'}
icon={<IconInfoCircle size={16} />}
>
{vessel.status}: {vessel.statusReason}
</Alert>
)}
<Table variant="vertical" layout="fixed">
<Table.Tbody>
{particulars
.filter(([, value]) => value !== null && value !== undefined)
.map(([label, value]) => (
<Table.Tr key={label}>
<Table.Th w={180}>{label}</Table.Th>
<Table.Td>{String(value)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Group gap="xs">
<IconAlertTriangle size={16} />
<Text fw={600} size="sm">
Incidents
</Text>
</Group>
{loadingIncidents ? (
<Loader size="sm" type="oval" />
) : (incidents ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
No incidents recorded.
</Text>
) : (
(incidents ?? []).map((incident) => (
<Card key={incident.id} withBorder radius="md" p="sm">
<Group justify="space-between">
<Text size="sm" fw={600}>
{showDate(incident.occurredAt)}
{incident.location ? `${incident.location}` : ''}
</Text>
<Badge size="sm" variant="light">
{incident.reportedByOfficer ? 'Officer' : 'Owner'}
</Badge>
</Group>
<Text size="sm" mt={4}>
{incident.description}
</Text>
</Card>
))
)}
</Stack>
)}
</Drawer>
);
}
/** Suspend / deregister / reinstate with a mandatory reason (US-VES-015). */
function StatusModal({
vessel,
onClose,
}: {
vessel: Vessel | null;
onClose: () => void;
}) {
const [status, setStatus] = useState<string | null>(null);
const [reason, setReason] = useState('');
const [updateStatus, { isLoading }] = useUpdateVesselStatusMutation();
const submit = async () => {
if (!vessel || !status) return;
try {
await updateStatus({
vesselId: vessel.id,
status: status as Vessel['status'],
reason,
}).unwrap();
notify.success('Vessel status updated');
onClose();
setStatus(null);
setReason('');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not update the vessel'));
}
};
return (
<Modal
opened={Boolean(vessel)}
onClose={onClose}
title="Change vessel status"
centered
>
<Stack>
<Text size="sm" c="dimmed">
{vessel?.registrationNumber} currently {vessel?.status}.
Deregistration is terminal. The reason is recorded and visible to the
owner.
</Text>
<Select
label="New status"
required
data={[
{ value: 'SUSPENDED', label: 'Suspend' },
{ value: 'DEREGISTERED', label: 'Deregister' },
{ value: 'REGISTERED', label: 'Reinstate' },
].filter((option) => option.value !== vessel?.status)}
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 === 'REGISTERED' ? 'green' : 'orange'}
disabled={!status || reason.trim().length < 3}
loading={isLoading}
onClick={submit}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
);
}
/**
* The national vessel register (module 11).
*
* Entries appear here automatically when a VESSEL_REGISTRATION application's
* certificate is issued; the applications themselves are reviewed in the
* ordinary licence queue.
*/
export function VesselRegistrationQueuePage() {
const [search, setSearch] = useState('');
const { data, isLoading, refetch } = useGetVesselsQuery(
search.trim() ? { search: search.trim() } : undefined,
);
const [detail, setDetail] = useState<Vessel | null>(null);
const [statusTarget, setStatusTarget] = useState<Vessel | null>(null);
const items = data?.items ?? [];
const showDate = useDateDisplayer();
const table = useServerTable();
const paged = table.paginate(items);
return (
<Container size="xl" py="md">
<PageHeader
title="Vessel register"
subtitle={
<>
{data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'}
pending registrations are reviewed in the{' '}
<Text component={Link} to="/licence-review" inherit c="blue">
licence queue
</Text>
.
</>
}
action={
<TextInput
placeholder="Name, registration № or IMO"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
}
/>
<AdvancedTable
tableName="Vessel register"
columns={vesselRegistrationQueueColumns(showDate, { onStatus: setStatusTarget })}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
isLoading={isLoading}
refresh={refetch}
onRowClick={(vessel) => setDetail(vessel)}
emptyText={
search ? 'No vessels match that search.' : 'No vessels registered yet.'
}
/>
<VesselDetailDrawer vessel={detail} onClose={() => setDetail(null)} />
<StatusModal
vessel={statusTarget}
onClose={() => setStatusTarget(null)}
/>
</Container>
);
}
export default VesselRegistrationQueuePage;