mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 11:08:13 +00:00
Adding more functionalities for all the license types
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user