Merge branch 'estif-branch-1' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-18 11:59:52 +03:00
16 changed files with 2333 additions and 24 deletions

View File

@@ -1,21 +0,0 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function VesselRegistrationReportPage() {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Vessel registration report"
description="Vessel registration is not connected to the backend yet, so there is nothing to report on."
/>
</Container>
);
}
export default VesselRegistrationReportPage;

View File

@@ -0,0 +1,171 @@
import { Badge, Card, Group, SimpleGrid, Text, Tooltip } from '@mantine/core';
import {
IconAlarm,
IconAnchor,
IconCalendarStats,
IconCoin,
IconClockHour4,
IconScale,
IconShip,
IconThumbUp,
type Icon,
} from '@tabler/icons-react';
import type { VesselReport } from '@ema-platform/api';
import {
DASH,
deltaColor,
formatDelta,
formatMoney,
formatNumber,
formatPercent,
} from './report-format';
interface TileProps {
icon: Icon;
label: string;
value: string;
/** The second line: what the headline figure is made of. */
detail?: string;
/** Hover text for anything the headline alone would misrepresent. */
hint?: string;
delta?: { text: string; color: string };
color?: string;
}
function Tile({ icon: TileIcon, label, value, detail, hint, delta, color = 'blue' }: TileProps) {
const card = (
<Card withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" mb={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.3}>
{label}
</Text>
<TileIcon size={18} stroke={1.6} color={`var(--mantine-color-${color}-6)`} />
</Group>
<Group gap="xs" align="baseline" wrap="nowrap">
<Text fz={26} fw={700} lh={1.1}>
{value}
</Text>
{delta && (
<Badge size="sm" variant="light" color={delta.color}>
{delta.text}
</Badge>
)}
</Group>
{detail && (
<Text size="xs" c="dimmed" mt={6} lh={1.4}>
{detail}
</Text>
)}
</Card>
);
return hint ? (
<Tooltip label={hint} multiline w={260} withArrow>
{card}
</Tooltip>
) : (
card
);
}
/**
* The headline figures.
*
* Two different scopes sit side by side here and the labels have to keep them
* apart: the register totals describe the whole book regardless of the date
* filter, while "new in period" and the pipeline figures answer to it. A tile
* reading "12 vessels" under a one-month filter would be taken for the size of
* the national fleet.
*/
export function KpiTiles({ report }: { report: VesselReport }) {
const { register, fleet, pipeline, certificates, revenue } = report.kpis;
return (
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
<Tile
icon={IconShip}
label="Vessels on the register"
value={formatNumber(register.total)}
detail={`${formatNumber(register.registered)} registered · ${formatNumber(register.suspended)} suspended · ${formatNumber(register.deregistered)} deregistered`}
hint="The whole register. Not affected by the date filter."
/>
<Tile
icon={IconCalendarStats}
color="teal"
label="New in period"
value={formatNumber(register.registeredInPeriod)}
detail={`${formatNumber(register.registeredInPreviousPeriod)} in the previous period`}
delta={{
text: formatDelta(register.changePct),
color: deltaColor(register.changePct),
}}
hint="Vessels entered on the register inside the selected window, against the equally long window before it."
/>
<Tile
icon={IconScale}
color="indigo"
label="Fleet tonnage"
value={formatNumber(fleet.totalGrossTonnage)}
// The coverage count is not decoration: an average over 2 of 300 hulls
// is a different claim from an average over all of them.
detail={`avg ${formatNumber(fleet.avgGrossTonnage, { decimals: 1 })} GT across ${formatNumber(fleet.grossTonnageKnownFor)} of ${formatNumber(register.total)} vessels`}
hint="Gross tonnage is optional on the register, so the average covers only the vessels that declared one."
/>
<Tile
icon={IconAnchor}
color="cyan"
label="Average age"
value={
fleet.avgAgeYears === null
? DASH
: formatNumber(fleet.avgAgeYears, { decimals: 1, suffix: ' yrs' })
}
detail={`${formatNumber(fleet.seaGoing)} sea-going · ${formatNumber(fleet.inlandWaterway)} inland · known for ${formatNumber(fleet.ageKnownFor)}`}
hint="Derived from the build year, which not every entry carries."
/>
<Tile
icon={IconThumbUp}
color="green"
label="Approval rate"
value={formatPercent(pipeline.approvalRatePct)}
detail={`${formatNumber(pipeline.approved)} approved · ${formatNumber(pipeline.rejected)} rejected · ${formatNumber(pipeline.inProgress)} in flight`}
hint="Approved as a share of decided applications. Drafts and applications still in the queue are excluded."
/>
<Tile
icon={IconClockHour4}
color="grape"
label="Processing time"
value={
pipeline.medianProcessingDays === null
? DASH
: formatNumber(pipeline.medianProcessingDays, {
decimals: 1,
suffix: ' d',
})
}
detail={`median · mean ${formatNumber(pipeline.avgProcessingDays, { decimals: 1, suffix: ' d' })} · ${formatNumber(pipeline.avgAdjustmentRounds, { decimals: 2 })} adjustment rounds`}
hint="Submission to decision. Only applications that have been decided are counted."
/>
<Tile
icon={IconAlarm}
color="orange"
label="Certificates expiring"
value={formatNumber(certificates.expiringIn30)}
detail={`within 30 days · ${formatNumber(certificates.expiringIn60)} within 60 · ${formatNumber(certificates.expiringIn90)} within 90`}
hint="Cumulative: a certificate due in a fortnight is counted in all three figures."
/>
<Tile
icon={IconCoin}
color="yellow"
label="Fees collected"
value={formatMoney(revenue.paid, revenue.currency)}
detail={`${formatMoney(revenue.pending, revenue.currency)} outstanding · ${formatNumber(revenue.failedCount)} failed`}
hint={
revenue.mixedCurrency
? 'The register holds payments in more than one currency; this total sums across them.'
: undefined
}
/>
</SimpleGrid>
);
}

View File

@@ -0,0 +1,369 @@
import type { ReactNode } from 'react';
import { Card, Group, SimpleGrid, Text } from '@mantine/core';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { BreakdownItem, ReportGranularity, VesselReport } from '@ema-platform/api';
import {
expiryBands,
formatBucket,
formatNumber,
officerLabel,
sliceColor,
} from './report-format';
// Recharts is unused elsewhere in this repo, so the shared setup lives here
// rather than being repeated per chart: one grid style, one tooltip style, one
// axis style, and a fixed height so the dashboard's rows line up.
const CHART_HEIGHT = 260;
const AXIS = { fontSize: 11, stroke: 'var(--mantine-color-dimmed)' } as const;
const GRID = 'var(--mantine-color-default-border)';
const TOOLTIP_STYLE = {
background: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 8,
fontSize: 12,
} as const;
function ChartCard({
title,
subtitle,
children,
empty,
}: {
title: string;
subtitle?: string;
children: ReactNode;
/** True when there is genuinely nothing to draw — say so, don't draw axes. */
empty?: boolean;
}) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="xs" wrap="nowrap">
<Text fw={600} size="sm">
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</Group>
{empty ? (
<Text size="sm" c="dimmed" py="xl" ta="center">
Nothing to show for this filter yet.
</Text>
) : (
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
{children as never}
</ResponsiveContainer>
)}
</Card>
);
}
/**
* A ranked breakdown as horizontal bars.
*
* Horizontal because the labels are flag states, ports and vessel types —
* words, which a vertical axis can show in full instead of rotating them.
*/
function BreakdownBars({
title,
subtitle,
items,
}: {
title: string;
subtitle?: string;
items: BreakdownItem[];
}) {
return (
<ChartCard title={title} subtitle={subtitle} empty={items.length === 0}>
<BarChart data={items} layout="vertical" margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} horizontal={false} />
<XAxis type="number" allowDecimals={false} {...AXIS} />
<YAxis type="category" dataKey="label" width={130} {...AXIS} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, _name, entry) => [
countWithShare(value, entry),
'Vessels',
]}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{items.map((item, index) => (
<Cell key={item.key} fill={sliceColor(item, index)} />
))}
</Bar>
</BarChart>
</ChartCard>
);
}
function BreakdownDonut({
title,
subtitle,
items,
}: {
title: string;
subtitle?: string;
items: BreakdownItem[];
}) {
return (
<ChartCard title={title} subtitle={subtitle} empty={items.length === 0}>
<PieChart>
<Pie
data={items}
dataKey="count"
nameKey="label"
innerRadius="52%"
outerRadius="78%"
paddingAngle={2}
>
{items.map((item, index) => (
<Cell key={item.key} fill={sliceColor(item, index)} />
))}
</Pie>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, name, entry) => [countWithShare(value, entry), name]}
/>
<Legend
verticalAlign="bottom"
height={36}
wrapperStyle={{ fontSize: 11 }}
/>
</PieChart>
</ChartCard>
);
}
/**
* "12 (7.5%)" for a breakdown tooltip.
*
* The share comes off the payload rather than being recomputed: the API's
* percentage is of the whole, including the slices folded into "Other", and
* dividing by what is on screen would quietly disagree with it.
*/
function countWithShare(value: unknown, entry: unknown): string {
const count = typeof value === 'number' ? value : Number(value ?? 0);
const payload = (entry as { payload?: BreakdownItem } | undefined)?.payload;
const share = payload?.percentage ?? 0;
return `${formatNumber(count)} (${formatNumber(share, { decimals: 1 })}%)`;
}
/** True when every bucket in a zero-filled series is empty. */
const allZero = (values: number[]): boolean =>
values.every((value) => value === 0);
export function ReportCharts({ report }: { report: VesselReport }) {
const { timeSeries, breakdowns, kpis } = report;
const granularity: ReportGranularity = report.filters.granularity;
const tick = (bucket: string) => formatBucket(bucket, granularity);
// Recharts types the tooltip label as a ReactNode; only a string is ever a
// bucket key, and anything else is passed through untouched.
const tickLabel = (label: unknown) =>
typeof label === 'string' ? tick(label) : String(label ?? '');
// Expiry counts arrive cumulative; drawn side by side they have to be
// disjoint or the three bars double-count each other.
const expiry = expiryBands(kpis.certificates);
const officers = breakdowns.byOfficer.map((item) => ({
...item,
label: officerLabel(item.key),
}));
return (
<>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<ChartCard
title="Registrations over time"
subtitle="count and gross tonnage"
empty={allZero(timeSeries.registrations.map((b) => b.count))}
>
<AreaChart data={timeSeries.registrations}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis yAxisId="count" allowDecimals={false} {...AXIS} />
<YAxis yAxisId="tonnage" orientation="right" {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Area
yAxisId="count"
type="monotone"
dataKey="count"
name="Vessels"
stroke="var(--mantine-color-blue-6)"
fill="var(--mantine-color-blue-2)"
/>
<Area
yAxisId="tonnage"
type="monotone"
dataKey="grossTonnage"
name="Gross tonnage"
stroke="var(--mantine-color-teal-6)"
fill="transparent"
/>
</AreaChart>
</ChartCard>
<ChartCard
title="Application throughput"
subtitle="decisions land in the month they were made"
empty={allZero(
timeSeries.applications.flatMap((b) => [
b.submitted,
b.approved,
b.rejected,
]),
)}
>
<BarChart data={timeSeries.applications}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis allowDecimals={false} {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar
dataKey="submitted"
name="Submitted"
fill="var(--mantine-color-blue-4)"
/>
{/* Approved and rejected stack: together they are the decisions
made in that bucket, which reads against intake beside it. */}
<Bar
dataKey="approved"
name="Approved"
stackId="decided"
fill="var(--mantine-color-teal-6)"
/>
<Bar
dataKey="rejected"
name="Rejected"
stackId="decided"
fill="var(--mantine-color-red-6)"
/>
</BarChart>
</ChartCard>
<ChartCard
title="Fees collected"
subtitle={kpis.revenue.currency}
empty={allZero(timeSeries.revenue.map((b) => b.amount))}
>
<LineChart data={timeSeries.revenue}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Line
type="monotone"
dataKey="amount"
name={`Paid (${kpis.revenue.currency})`}
stroke="var(--mantine-color-yellow-7)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartCard>
<ChartCard
title="Incidents over time"
empty={allZero(timeSeries.incidents.map((b) => b.count))}
>
<BarChart data={timeSeries.incidents}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis allowDecimals={false} {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Bar
dataKey="count"
name="Incidents"
fill="var(--mantine-color-orange-6)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartCard>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
<BreakdownDonut title="Register status" items={breakdowns.byStatus} />
<BreakdownDonut title="Category" items={breakdowns.byCategory} />
<ChartCard
title="Certificate expiry"
subtitle="disjoint bands"
empty={allZero(expiry.map((band) => band.count))}
>
<BarChart data={expiry} layout="vertical" margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} horizontal={false} />
<XAxis type="number" allowDecimals={false} {...AXIS} />
<YAxis type="category" dataKey="label" width={110} {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Bar
dataKey="count"
name="Certificates"
fill="var(--mantine-color-orange-6)"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ChartCard>
<BreakdownBars title="Tonnage bands" items={breakdowns.byTonnageBand} />
<BreakdownBars title="Age bands" items={breakdowns.byAgeBand} />
<BreakdownBars title="Length bands" items={breakdowns.byLengthBand} />
<BreakdownBars
title="Flag states"
subtitle="top slices, rest grouped"
items={breakdowns.byFlagState}
/>
<BreakdownBars
title="Ports of registry"
subtitle="top slices, rest grouped"
items={breakdowns.byPortOfRegistry}
/>
<BreakdownBars title="Vessel types" items={breakdowns.byVesselType} />
<BreakdownBars title="Hull material" items={breakdowns.byHullMaterial} />
<BreakdownBars title="Engine type" items={breakdowns.byEngineType} />
<BreakdownBars title="Build decade" items={breakdowns.byBuildDecade} />
<BreakdownBars
title="Application status"
items={breakdowns.byApplicationStatus}
/>
<BreakdownDonut
title="New vs renewal"
items={breakdowns.byApplicationKind}
/>
<BreakdownDonut
title="Incident severity"
subtitle="free text on the register"
items={breakdowns.byIncidentSeverity}
/>
<BreakdownBars
title="Officer workload"
subtitle="user id — names not resolved"
items={officers}
/>
</SimpleGrid>
</>
);
}

View File

@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react';
import {
Button,
Card,
Group,
MultiSelect,
SegmentedControl,
TextInput,
} from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import { IconDownload, IconSearch, IconX } from '@tabler/icons-react';
import type {
ReportGranularity,
VesselCategory,
VesselReport,
VesselReportQuery,
VesselStatus,
} from '@ema-platform/api';
import { optionsFrom } from './report-format';
const CATEGORY_OPTIONS = [
{ value: 'SEA_GOING', label: 'Sea-going' },
{ value: 'INLAND_WATERWAY', label: 'Inland waterway' },
];
const STATUS_OPTIONS = [
{ value: 'REGISTERED', label: 'Registered' },
{ value: 'SUSPENDED', label: 'Suspended' },
{ value: 'DEREGISTERED', label: 'Deregistered' },
];
const GRANULARITY_OPTIONS = [
{ value: 'DAY', label: 'Day' },
{ value: 'WEEK', label: 'Week' },
{ value: 'MONTH', label: 'Month' },
];
interface ReportFiltersProps {
query: VesselReportQuery;
onChange: (next: VesselReportQuery) => void;
/**
* The last successful response. Flag states, ports and vessel types are free
* text on the register with no lookup endpoint behind them, so the only
* honest source for the options is what the register actually holds.
*/
report?: VesselReport;
onExport: () => void;
exporting: boolean;
}
export function ReportFilters({
query,
onChange,
report,
onExport,
exporting,
}: ReportFiltersProps) {
// The search box is local so typing does not refetch on every keystroke; it
// is pushed up on a debounce.
const [search, setSearch] = useState(query.search ?? '');
useEffect(() => {
setSearch(query.search ?? '');
}, [query.search]);
useEffect(() => {
const current = query.search ?? '';
if (search === current) return;
const timer = setTimeout(
() => onChange({ ...query, search: search.trim() || undefined }),
350,
);
return () => clearTimeout(timer);
}, [search, query, onChange]);
const set = <K extends keyof VesselReportQuery>(
key: K,
value: VesselReportQuery[K],
) => onChange({ ...query, [key]: value });
// Mantine 8 works in `YYYY-MM-DD` strings here, which is exactly what the
// API wants — no Date round trip, and no timezone to shift the day.
const range: [string | null, string | null] = [
query.from ?? null,
query.to ?? null,
];
const filtered =
Boolean(query.search) ||
Boolean(query.from) ||
Boolean(query.to) ||
[
query.category,
query.status,
query.flagState,
query.portOfRegistry,
query.vesselType,
].some((values) => (values ?? []).length > 0);
return (
<Card withBorder radius="md" p="md" mb="md">
<Group align="flex-end" gap="sm" wrap="wrap">
<DatePickerInput
type="range"
label="Period"
placeholder="Last 12 months"
value={range}
// Both ends before refetching: a half-set range would send `from`
// with no `to` and redraw the charts against a window the user is
// still in the middle of choosing.
onChange={([from, to]) => {
if (from && !to) return;
onChange({
...query,
from: from ?? undefined,
to: to ?? undefined,
});
}}
clearable
w={250}
/>
<SegmentedControl
size="sm"
data={GRANULARITY_OPTIONS}
value={query.granularity ?? 'MONTH'}
onChange={(value) => set('granularity', value as ReportGranularity)}
/>
<MultiSelect
label="Category"
placeholder="All"
data={CATEGORY_OPTIONS}
value={query.category ?? []}
onChange={(value) => set('category', value as VesselCategory[])}
clearable
w={190}
/>
<MultiSelect
label="Status"
placeholder="All"
data={STATUS_OPTIONS}
value={query.status ?? []}
onChange={(value) => set('status', value as VesselStatus[])}
clearable
w={190}
/>
<MultiSelect
label="Flag state"
placeholder="All"
data={optionsFrom(report?.breakdowns.byFlagState)}
value={query.flagState ?? []}
onChange={(value) => set('flagState', value)}
searchable
clearable
w={190}
/>
<MultiSelect
label="Port of registry"
placeholder="All"
data={optionsFrom(report?.breakdowns.byPortOfRegistry)}
value={query.portOfRegistry ?? []}
onChange={(value) => set('portOfRegistry', value)}
searchable
clearable
w={190}
/>
<MultiSelect
label="Vessel type"
placeholder="All"
data={optionsFrom(report?.breakdowns.byVesselType)}
value={query.vesselType ?? []}
onChange={(value) => set('vesselType', value)}
searchable
clearable
w={190}
/>
<TextInput
label="Search"
placeholder="Name, register №, IMO or owner"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
w={250}
/>
<Group gap="xs" ml="auto">
{filtered && (
<Button
variant="subtle"
color="gray"
leftSection={<IconX size={14} />}
onClick={() => onChange({})}
>
Clear
</Button>
)}
<Button
variant="light"
leftSection={<IconDownload size={16} />}
loading={exporting}
onClick={onExport}
>
Export CSV
</Button>
</Group>
</Group>
</Card>
);
}

View File

@@ -0,0 +1,211 @@
import { Link } from 'react-router-dom';
import { Badge, Card, Group, SimpleGrid, Table, Text } from '@mantine/core';
import type { ReactNode } from 'react';
import type { VesselReport } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
import { expiryUrgency, formatNumber } from './report-format';
/**
* The worklists.
*
* Plain Mantine tables rather than `AdvancedTable`: every one of these is
* already capped server-side by `tableLimit`, so the pagination, search and
* column-picker that component brings would all be controls over a list that
* is only ever ten rows of a much longer one. Each card links out to the screen
* that does own the full list.
*/
function TableCard({
title,
subtitle,
to,
linkLabel,
empty,
head,
children,
}: {
title: string;
subtitle?: string;
to?: string;
linkLabel?: string;
empty: boolean;
head: string[];
children: ReactNode;
}) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="xs" wrap="nowrap">
<div>
<Text fw={600} size="sm">
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</div>
{to && (
<Text component={Link} to={to} size="xs" c="blue">
{linkLabel ?? 'View all'}
</Text>
)}
</Group>
{empty ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
Nothing to show.
</Text>
) : (
<Table highlightOnHover verticalSpacing="xs" fz="sm">
<Table.Thead>
<Table.Tr>
{head.map((column) => (
<Table.Th key={column}>{column}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>{children}</Table.Tbody>
</Table>
)}
</Card>
);
}
export function ReportTables({ report }: { report: VesselReport }) {
const showDate = useDateDisplayer();
const { tables, filters } = report;
return (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" mt="md">
<TableCard
title="Certificates expiring"
subtitle={`within ${filters.expiringWithinDays} days`}
to="/licence-register"
empty={tables.expiringCertificates.length === 0}
head={['Vessel', 'Certificate', 'Expires', 'Days']}
>
{tables.expiringCertificates.map((row) => (
<Table.Tr key={row.vesselId}>
<Table.Td>
<Text size="sm" fw={500}>
{row.name}
</Text>
<Text size="xs" c="dimmed">
{row.registrationNumber}
{row.ownerName ? ` · ${row.ownerName}` : ''}
</Text>
</Table.Td>
<Table.Td>{row.certificateNumber ?? '—'}</Table.Td>
<Table.Td>{showDate(row.expiryDate)}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={expiryUrgency(row.daysToExpiry)}
>
{/* 0 is today, and a certificate is valid through its last day. */}
{row.daysToExpiry === 0
? 'Today'
: `${formatNumber(row.daysToExpiry)} d`}
</Badge>
</Table.Td>
</Table.Tr>
))}
</TableCard>
<TableCard
title="Recent registrations"
to="/vessel-registration-queue"
linkLabel="Open register"
empty={tables.recentRegistrations.length === 0}
head={['Vessel', 'Category', 'Flag', 'Registered']}
>
{tables.recentRegistrations.map((row) => (
<Table.Tr key={row.vesselId}>
<Table.Td>
<Text size="sm" fw={500}>
{row.name}
</Text>
<Text size="xs" c="dimmed">
{row.registrationNumber}
{row.vesselType ? ` · ${row.vesselType}` : ''}
</Text>
</Table.Td>
<Table.Td>
{row.category === 'SEA_GOING' ? 'Sea-going' : 'Inland'}
</Table.Td>
<Table.Td>{row.flagState ?? '—'}</Table.Td>
<Table.Td>{showDate(row.registeredAt)}</Table.Td>
</Table.Tr>
))}
</TableCard>
<TableCard
title="Applications in the queue"
subtitle="oldest first"
to="/licence-review/type/VESSEL_REGISTRATION"
linkLabel="Open queue"
empty={tables.pendingApplications.length === 0}
head={['Application', 'Status', 'Submitted', 'Open']}
>
{tables.pendingApplications.map((row) => (
<Table.Tr key={row.applicationNumber}>
<Table.Td>
<Text size="sm" fw={500}>
{row.applicationNumber}
</Text>
<Text size="xs" c="dimmed">
{row.kind === 'RENEWAL' ? 'Renewal' : 'New'}
{row.adjustmentRound > 0
? ` · ${row.adjustmentRound} adjustment round${row.adjustmentRound === 1 ? '' : 's'}`
: ''}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{row.status.replaceAll('_', ' ')}
</Badge>
</Table.Td>
<Table.Td>
{row.submittedAt ? showDate(row.submittedAt) : '—'}
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={row.daysOpen > 30 ? 'red' : row.daysOpen > 14 ? 'orange' : 'gray'}
>
{formatNumber(row.daysOpen)} d
</Badge>
</Table.Td>
</Table.Tr>
))}
</TableCard>
<TableCard
title="Recent incidents"
empty={tables.recentIncidents.length === 0}
head={['Vessel', 'Occurred', 'Severity', 'Reported by']}
>
{tables.recentIncidents.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Text size="sm" fw={500}>
{row.vesselName}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{row.description}
</Text>
</Table.Td>
<Table.Td>{showDate(row.occurredAt)}</Table.Td>
<Table.Td>{row.severity ?? '—'}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={row.reportedByOfficer ? 'blue' : 'gray'}>
{row.reportedByOfficer ? 'Officer' : 'Owner'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</TableCard>
</SimpleGrid>
);
}

View File

@@ -0,0 +1,149 @@
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Alert, Container, Group, Text, Title } from '@mantine/core';
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
import {
ApiErrorAlert,
EmptyState,
PageLoader,
notify,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
downloadAuthedFile,
extractErrorMessage,
useGetVesselReportQuery,
} from '@ema-platform/api';
import type { VesselReportQuery } from '@ema-platform/api';
import { KpiTiles } from './KpiTiles';
import { ReportCharts } from './ReportCharts';
import { ReportFilters } from './ReportFilters';
import { ReportTables } from './ReportTables';
import { queryToSearchParams, searchParamsToQuery } from './report-format';
/**
* The vessel registration dashboard (module 11).
*
* One `GET /vessels/report` call fills the whole screen — KPIs, four time
* series, fifteen breakdowns and four worklists — so the filter bar drives a
* single refetch rather than a dozen independent ones.
*
* Filter state lives in the URL. A filtered dashboard is the thing an officer
* wants to send someone, and rebuilding six selects from a description is not
* how that conversation should go.
*/
export function VesselRegistrationReportPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [exporting, setExporting] = useState(false);
const showDate = useDateDisplayer();
const query: VesselReportQuery = useMemo(
() => searchParamsToQuery(searchParams),
[searchParams],
);
const setQuery = useCallback(
(next: VesselReportQuery) => {
// `replace` so a session of narrowing filters does not bury the page the
// officer arrived from under twenty history entries.
setSearchParams(queryToSearchParams(next), { replace: true });
},
[setSearchParams],
);
const { data: report, isLoading, isFetching, error } = useGetVesselReportQuery(
query,
);
const exportCsv = useCallback(async () => {
setExporting(true);
try {
const params = queryToSearchParams(query).toString();
const { rowCount, truncated } = await downloadAuthedFile(
`/vessels/report/export${params ? `?${params}` : ''}`,
'vessel-register.csv',
);
if (truncated) {
notify.error(
`Export cut off at ${rowCount ?? 'the row limit'} rows. Narrow the filter and export again.`,
);
} else {
notify.success(
`Exported ${rowCount ?? 'the filtered'} vessel${rowCount === 1 ? '' : 's'}.`,
);
}
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not export the register'));
} finally {
setExporting(false);
}
}, [query]);
// Only the very first load blanks the page; a filter change keeps the last
// report on screen so the dashboard does not flash between every tweak.
if (isLoading) return <PageLoader />;
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md" align="flex-start">
<div>
<Title order={3}>Vessel registration report</Title>
<Text size="sm" c="dimmed">
{report
? `Register-wide totals with a ${showDate(report.filters.from)} ${showDate(report.filters.to)} window on the trends.`
: 'The national vessel register at a glance.'}
</Text>
</div>
</Group>
<ReportFilters
query={query}
onChange={setQuery}
report={report}
onExport={exportCsv}
exporting={exporting}
/>
{error && <ApiErrorAlert error={error} title="Could not load the report" />}
{report && (
<>
{report.truncated && (
<Alert
color="yellow"
icon={<IconAlertTriangle size={16} />}
mb="md"
title="Partial figures"
>
The register is larger than this report can scan in one pass, so
every figure below covers only part of it. Narrow the filter for
an exact answer.
</Alert>
)}
{report.kpis.register.total === 0 ? (
<EmptyState
icon={IconShip}
title="No vessels match this filter"
description={
Object.keys(query).length > 0
? 'Nothing on the register matches the current filter. Clear it to see the whole book.'
: 'No vessels have been registered yet. Entries appear here once a registration certificate is issued.'
}
/>
) : (
<div style={{ opacity: isFetching ? 0.6 : 1, transition: 'opacity 120ms' }}>
<KpiTiles report={report} />
<div style={{ marginTop: 'var(--mantine-spacing-md)' }}>
<ReportCharts report={report} />
</div>
<ReportTables report={report} />
</div>
)}
</>
)}
</Container>
);
}
export default VesselRegistrationReportPage;

View File

@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest';
import type { BreakdownItem, CertificateKpis } from '@ema-platform/api';
import {
DASH,
defaultRange,
deltaColor,
expiryBands,
expiryUrgency,
formatBucket,
formatDelta,
formatNumber,
formatPercent,
officerLabel,
optionsFrom,
queryToSearchParams,
searchParamsToQuery,
sliceColor,
} from './report-format';
const certificates = (partial: Partial<CertificateKpis>): CertificateKpis => ({
total: 0,
active: 0,
expired: 0,
suspended: 0,
expiringIn30: 0,
expiringIn60: 0,
expiringIn90: 0,
missingCertificate: 0,
...partial,
});
const item = (key: string, count = 1): BreakdownItem => ({
key,
label: key,
count,
percentage: 0,
});
describe('formatNumber', () => {
it('renders a dash for a figure the API had no answer for', () => {
expect(formatNumber(null)).toBe(DASH);
expect(formatNumber(undefined)).toBe(DASH);
expect(formatNumber(Number.NaN)).toBe(DASH);
});
it('keeps a real zero', () => {
expect(formatNumber(0)).toBe('0');
});
it('honours decimals and a suffix', () => {
expect(formatNumber(12.345, { decimals: 2 })).toBe('12.35');
expect(formatNumber(7, { suffix: ' GT' })).toBe('7 GT');
});
});
describe('formatPercent / formatDelta', () => {
it('distinguishes no answer from zero', () => {
expect(formatPercent(null)).toBe(DASH);
expect(formatPercent(0)).toBe('0.0%');
expect(formatDelta(null)).toBe(DASH);
});
it('signs a positive change', () => {
expect(formatDelta(12.5)).toBe('+12.5%');
expect(formatDelta(-4)).toBe('-4.0%');
});
it('colours a flat or absent change neutrally', () => {
expect(deltaColor(null)).toBe('gray');
expect(deltaColor(0)).toBe('gray');
expect(deltaColor(1)).toBe('teal');
expect(deltaColor(-1)).toBe('red');
});
});
describe('expiryBands', () => {
it('differences the API cumulative counts into disjoint bands', () => {
expect(
expiryBands(
certificates({ expiringIn30: 4, expiringIn60: 9, expiringIn90: 11 }),
),
).toEqual([
{ label: 'Within 30 days', count: 4 },
{ label: '3160 days', count: 5 },
{ label: '6190 days', count: 2 },
]);
});
it('never draws a negative bar if the counts are not monotonic', () => {
const bands = expiryBands(
certificates({ expiringIn30: 9, expiringIn60: 4, expiringIn90: 4 }),
);
expect(bands.every((band) => band.count >= 0)).toBe(true);
});
});
describe('expiryUrgency', () => {
it('escalates on the boundaries', () => {
expect(expiryUrgency(0)).toBe('red');
expect(expiryUrgency(7)).toBe('red');
expect(expiryUrgency(8)).toBe('orange');
expect(expiryUrgency(30)).toBe('orange');
expect(expiryUrgency(31)).toBe('gray');
});
});
describe('officerLabel', () => {
it('spells out the unassigned bucket and shortens a uuid', () => {
expect(officerLabel('UNASSIGNED')).toBe('Unassigned');
expect(officerLabel('c8d0a151-91e9-433e-b221-db331480b10f')).toBe(
'c8d0a151…',
);
expect(officerLabel('short')).toBe('short');
});
});
describe('sliceColor', () => {
it('mutes the bookkeeping slices and cycles the rest', () => {
const muted = sliceColor(item('OTHER'), 0);
expect(sliceColor(item('Unknown'), 3)).toBe(muted);
expect(sliceColor(item('SEA_GOING'), 0)).not.toBe(muted);
});
it('is stable for a given position', () => {
expect(sliceColor(item('A'), 2)).toBe(sliceColor(item('B'), 2));
});
});
describe('formatBucket', () => {
it('reads a month bucket as a month and a day bucket as a day', () => {
expect(formatBucket('2026-03-01', 'MONTH')).toMatch(/2026/);
expect(formatBucket('2026-03-04', 'DAY')).not.toMatch(/2026/);
});
it('passes an unparseable bucket through rather than printing NaN', () => {
expect(formatBucket('not-a-date', 'MONTH')).toBe('not-a-date');
});
});
describe('defaultRange', () => {
it('spans the twelve months the API defaults to', () => {
const [from, to] = defaultRange(new Date('2026-08-18T00:00:00Z'));
expect(from.toISOString().slice(0, 10)).toBe('2025-08-18');
expect(to.toISOString().slice(0, 10)).toBe('2026-08-18');
});
});
describe('url round trip', () => {
it('drops empty values so an untouched dashboard has a clean link', () => {
const params = queryToSearchParams({
search: '',
category: [],
topN: 15,
});
expect(params.toString()).toBe('topN=15');
});
it('restores the filter state a shared link carries', () => {
const query = {
from: '2026-01-01',
to: '2026-08-18',
granularity: 'WEEK' as const,
status: ['REGISTERED' as const, 'SUSPENDED' as const],
flagState: ['Ethiopia'],
search: 'abay',
topN: 20,
};
expect(searchParamsToQuery(queryToSearchParams(query))).toEqual(query);
});
it('ignores a hand-edited value the API would reject', () => {
const query = searchParamsToQuery(
new URLSearchParams('topN=abc&granularity=YEAR'),
);
expect(query.topN).toBeUndefined();
expect(query.granularity).toBeUndefined();
});
});
describe('optionsFrom', () => {
it('offers the register values but not the bookkeeping slices', () => {
expect(
optionsFrom([item('Ethiopia'), item('Unknown'), item('OTHER')]),
).toEqual(['Ethiopia']);
expect(optionsFrom(undefined)).toEqual([]);
});
});

View File

@@ -0,0 +1,221 @@
import type {
BreakdownItem,
CertificateKpis,
ReportGranularity,
VesselReportQuery,
} from '@ema-platform/api';
/** Nothing measurable is not zero — an em dash says so without lying. */
export const DASH = '—';
/**
* A figure the API may legitimately have no answer for.
*
* `avgGrossTonnage` is null on an empty register and `approvalRatePct` is null
* until something has been decided; rendering either as 0 would report a fleet
* that weighs nothing and a service that approves nobody.
*/
export function formatNumber(
value: number | null | undefined,
options: { decimals?: number; suffix?: string } = {},
): string {
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
const text = value.toLocaleString(undefined, {
minimumFractionDigits: options.decimals ?? 0,
maximumFractionDigits: options.decimals ?? 0,
});
return options.suffix ? `${text}${options.suffix}` : text;
}
export function formatPercent(value: number | null | undefined): string {
return value === null || value === undefined
? DASH
: `${formatNumber(value, { decimals: 1 })}%`;
}
export function formatMoney(value: number, currency: string): string {
return `${formatNumber(value, { decimals: 2 })} ${currency}`;
}
/** A signed delta for the change-vs-previous chip. */
export function formatDelta(value: number | null): string {
if (value === null) return DASH;
const sign = value > 0 ? '+' : '';
return `${sign}${formatNumber(value, { decimals: 1 })}%`;
}
export function deltaColor(value: number | null): string {
if (value === null || value === 0) return 'gray';
return value > 0 ? 'teal' : 'red';
}
/**
* The API's expiry counts are cumulative — a certificate due in eleven days is
* inside the 30-, 60- and 90-day figures, which is how a renewals desk reads
* them. Stacked side by side in a chart that reads as three separate groups,
* so they are differenced into disjoint bands first.
*/
export function expiryBands(
certificates: CertificateKpis,
): Array<{ label: string; count: number }> {
const { expiringIn30, expiringIn60, expiringIn90 } = certificates;
return [
{ label: 'Within 30 days', count: expiringIn30 },
// Math.max guards against a server that ever answers non-monotonically —
// a negative bar is worse than a zero one.
{ label: '3160 days', count: Math.max(0, expiringIn60 - expiringIn30) },
{ label: '6190 days', count: Math.max(0, expiringIn90 - expiringIn60) },
];
}
/** Red inside a week, orange inside a month, otherwise unremarkable. */
export function expiryUrgency(daysToExpiry: number): string {
if (daysToExpiry <= 7) return 'red';
if (daysToExpiry <= 30) return 'orange';
return 'gray';
}
/**
* Officer ids are IAM uuids, which make useless axis labels. Until the
* dashboard has a name lookup, shorten them and keep "UNASSIGNED" readable.
*/
export function officerLabel(key: string): string {
if (key === 'UNASSIGNED') return 'Unassigned';
return key.length > 8 ? `${key.slice(0, 8)}` : key;
}
/**
* Chart colours, assigned by position so a slice keeps its colour between
* renders. Mantine's palette rather than invented hex codes, so the charts
* follow the theme the rest of the app is built on.
*/
const PALETTE = [
'var(--mantine-color-blue-6)',
'var(--mantine-color-teal-6)',
'var(--mantine-color-orange-6)',
'var(--mantine-color-grape-6)',
'var(--mantine-color-cyan-6)',
'var(--mantine-color-lime-7)',
'var(--mantine-color-pink-6)',
'var(--mantine-color-indigo-6)',
];
const MUTED = 'var(--mantine-color-gray-5)';
/**
* "Unknown" and "Other" are bookkeeping slices rather than findings, so they
* always take the muted colour instead of competing with the real categories
* for one of the bright ones.
*/
export function sliceColor(item: BreakdownItem, index: number): string {
if (item.key === 'OTHER' || item.key === 'Unknown') return MUTED;
return PALETTE[index % PALETTE.length];
}
/** Bucket keys are ISO dates; the axis wants something a human reads. */
export function formatBucket(
bucket: string,
granularity: ReportGranularity,
): string {
const date = new Date(bucket);
if (Number.isNaN(date.getTime())) return bucket;
if (granularity === 'MONTH') {
return date.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
return date.toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
}
/** The default window the API applies when none is given: the last 12 months. */
export function defaultRange(now: Date): [Date, Date] {
const from = new Date(
Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), now.getUTCDate()),
);
return [from, now];
}
export const ISO_DAY_LENGTH = 10;
export const toIsoDay = (date: Date): string =>
date.toISOString().slice(0, ISO_DAY_LENGTH);
/**
* The filter state as URL search params, so a filtered dashboard is a
* shareable link rather than something the next person has to rebuild.
*
* Empty arrays and blank strings are dropped rather than serialised, which
* keeps an untouched dashboard's URL clean and lets the API apply its own
* defaults instead of being handed an empty filter to honour.
*/
export function queryToSearchParams(query: VesselReportQuery): URLSearchParams {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
if (value.length === 0) continue;
params.set(key, value.join(','));
} else {
params.set(key, String(value));
}
}
return params;
}
const ARRAY_KEYS = [
'category',
'status',
'flagState',
'portOfRegistry',
'vesselType',
] as const;
const NUMBER_KEYS = ['expiringWithinDays', 'topN', 'tableLimit'] as const;
/** The inverse, for restoring state from a shared link. */
export function searchParamsToQuery(
params: URLSearchParams,
): VesselReportQuery {
const query: Record<string, unknown> = {};
for (const key of ARRAY_KEYS) {
const raw = params.get(key);
if (raw) query[key] = raw.split(',').filter(Boolean);
}
for (const key of NUMBER_KEYS) {
const raw = params.get(key);
// An unparseable number in a hand-edited URL is ignored rather than sent
// on to fail the API's validation pipe.
if (raw !== null && raw !== '' && Number.isFinite(Number(raw))) {
query[key] = Number(raw);
}
}
for (const key of ['from', 'to', 'search'] as const) {
const raw = params.get(key);
if (raw) query[key] = raw;
}
const granularity = params.get('granularity');
if (granularity === 'DAY' || granularity === 'WEEK' || granularity === 'MONTH') {
query.granularity = granularity;
}
return query as VesselReportQuery;
}
/**
* The multi-select options a filter offers, taken from the breakdown the last
* response carried — there is no lookup endpoint for flag states or ports, and
* the register is the only place that knows which ones are in use.
*
* "Unknown" is dropped: it stands for a missing value, and there is nothing to
* filter the register down to.
*/
export function optionsFrom(items: BreakdownItem[] | undefined): string[] {
return (items ?? [])
.filter((item) => item.key !== 'Unknown' && item.key !== 'OTHER')
.map((item) => item.key);
}

View File

@@ -118,7 +118,7 @@ export const NAV_SECTIONS: NavSection[] = [
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: APPLICATION_QUEUE },
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
],
},

View File

@@ -95,7 +95,7 @@ const router = createBrowserRouter([
{ path: 'vessel-registration-queue', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationQueuePage />) },
{ path: 'vessel-registration-queue/new', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationFormBuilderPage />) },
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
{ path: 'vessel-registration-report', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationReportPage />) },
{ path: 'vessel-ownership-transfer', element: <Navigate to="/licence-review/type/VESSEL_OWNERSHIP_TRANSFER" replace /> },
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
// Config-driven review workspace, shared by every licence type.

View File

@@ -42,4 +42,14 @@ export default defineConfig({
emptyOutDir: true,
reportCompressedSize: true,
},
// Unit tests for the pure helpers behind a screen (formatters, URL state).
// Component tests are deliberately not set up: nothing here renders React,
// so no jsdom environment or setup file is needed.
test: {
watch: false,
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts'],
reporters: ['default'],
},
});