mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
273 lines
8.3 KiB
TypeScript
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;
|