Adding more functionalities for all the license types

This commit is contained in:
Mulu Mehari
2026-08-07 11:50:07 +03:00
parent 35fb817b4b
commit 7c968c7093
66 changed files with 6468 additions and 3518 deletions

View File

@@ -1,18 +1,359 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Center,
Container,
Drawer,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAlertTriangle,
IconInfoCircle,
IconSearch,
IconShieldCog,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
extractErrorMessage,
useGetVesselIncidentsQuery,
useGetVesselsQuery,
useUpdateVesselStatusMutation,
} from '@ema-platform/api';
import type { Vessel } from '@ema-platform/api';
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
};
const CATEGORY_LABELS: Record<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
};
/** 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 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?.slice(0, 10) ?? 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" />
) : (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}>
{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>
);
}
/**
* Placeholder until this feature has a backend.
* The national vessel register (module 11).
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
* 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 } = 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 ?? [];
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Vessel registration queue"
description="Vessel registration is not connected to the backend yet."
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>Vessel register</Title>
<Text size="sm" c="dimmed">
{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>
.
</Text>
</div>
<TextInput
placeholder="Name, registration № or IMO"
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 vessels match that search.'
: 'No vessels registered yet.'}
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Registration </Table.Th>
<Table.Th>Vessel</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Registered</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((vessel) => (
<Table.Tr
key={vessel.id}
style={{ cursor: 'pointer' }}
onClick={() => setDetail(vessel)}
>
<Table.Td>
<Text size="sm" ff="monospace" fw={600}>
{vessel.registrationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{vessel.name}
</Text>
<Text size="xs" c="dimmed">
{vessel.vesselType ?? '—'}
{vessel.imoNumber ? ` · IMO ${vessel.imoNumber}` : ''}
</Text>
</Table.Td>
<Table.Td>
{CATEGORY_LABELS[vessel.category] ?? vessel.category}
</Table.Td>
<Table.Td>
<Text size="sm">{vessel.ownerName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{vessel.registeredAt?.slice(0, 10)}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={VESSEL_STATUS_COLORS[vessel.status]}
>
{vessel.status}
</Badge>
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
<Tooltip label="Suspend / deregister / reinstate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => setStatusTarget(vessel)}
>
Status
</Button>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
<VesselDetailDrawer vessel={detail} onClose={() => setDetail(null)} />
<StatusModal
vessel={statusTarget}
onClose={() => setStatusTarget(null)}
/>
</Container>
);