mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: implement interactive vessel registration reporting page with filters, charts, and data tables
This commit is contained in:
@@ -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;
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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: '31–60 days', count: 5 },
|
||||||
|
{ label: '61–90 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: '31–60 days', count: Math.max(0, expiringIn60 - expiringIn30) },
|
||||||
|
{ label: '61–90 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);
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
{ 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: '/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-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] },
|
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'vessel-registration-queue', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationQueuePage />) },
|
{ 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/new', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationFormBuilderPage />) },
|
||||||
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
|
// { 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', element: <Navigate to="/licence-review/type/VESSEL_OWNERSHIP_TRANSFER" replace /> },
|
||||||
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
// Config-driven review workspace, shared by every licence type.
|
// Config-driven review workspace, shared by every licence type.
|
||||||
|
|||||||
@@ -33,4 +33,14 @@ export default defineConfig({
|
|||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
reportCompressedSize: 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'],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
480
docs/vessel-registration-report-frontend.md
Normal file
480
docs/vessel-registration-report-frontend.md
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
# Vessel registration report — frontend integration brief
|
||||||
|
|
||||||
|
Paste the **Prompt** section below to Claude Code from the `emaui` repo root.
|
||||||
|
Everything after it is reference the prompt points at.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
> Wire up the vessel registration report dashboard in the backoffice app.
|
||||||
|
>
|
||||||
|
> The backend endpoint is **new and already deployed** — `GET /api/vessels/report`
|
||||||
|
> plus `GET /api/vessels/report/export` (CSV). Nothing about it is mocked; do not
|
||||||
|
> invent sample data, and do not add a mock branch to `mock-base-query.ts`.
|
||||||
|
>
|
||||||
|
> The page it belongs on already exists as a placeholder:
|
||||||
|
> `apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage.tsx`
|
||||||
|
> currently renders `<FeatureUnavailable />`, and its route is commented out at
|
||||||
|
> `apps/backoffice/src/app/router/index.tsx:98`. Replace the placeholder with the
|
||||||
|
> real dashboard and re-enable the route, guarded by `P.VIEW_VESSEL_REGISTRY`
|
||||||
|
> exactly like the vessel queue route two lines above it.
|
||||||
|
>
|
||||||
|
> Read `docs/vessel-registration-report-frontend.md` in this repo for the full
|
||||||
|
> response contract, the chart plan, and the conventions to follow. Follow the
|
||||||
|
> conventions already in the codebase over anything you would do by default:
|
||||||
|
> RTK Query in `libs/api`, Mantine 8 for layout, `recharts` for charts (already a
|
||||||
|
> dependency, not yet used anywhere — you are establishing the pattern), i18next
|
||||||
|
> for every user-visible string.
|
||||||
|
>
|
||||||
|
> Scope, in order:
|
||||||
|
> 1. Types + RTK Query endpoints in `libs/api/src/lib/features/vessel/`.
|
||||||
|
> 2. The page: filter bar, KPI tiles, charts, tables.
|
||||||
|
> 3. Export button.
|
||||||
|
> 4. Route + nav.
|
||||||
|
> 5. A vitest test for whatever pure logic you extract.
|
||||||
|
>
|
||||||
|
> Ask me before adding any new dependency. `recharts`, `@mantine/*`,
|
||||||
|
> `@mantine/dates`, `dayjs` and `@tabler/icons-react` are all already installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What the endpoint is
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Report | `GET /api/vessels/report` → JSON |
|
||||||
|
| Export | `GET /api/vessels/report/export` → `text/csv` |
|
||||||
|
| Permission | `can:View:vessel-registry` (`P.VIEW_VESSEL_REGISTRY`, `libs/auth/src/lib/permissions.constants.ts:48`) |
|
||||||
|
| Auth | Bearer, same as every other backoffice call |
|
||||||
|
|
||||||
|
One call fills the whole dashboard. Both routes take the **same** query
|
||||||
|
parameters, so the export button reuses whatever the filter bar holds.
|
||||||
|
|
||||||
|
Backend source, if you need to check a figure:
|
||||||
|
`emaback/emaapi/apps/server/emaapi/src/module/vessel/services/vessel-report.service.ts`.
|
||||||
|
|
||||||
|
### Query parameters
|
||||||
|
|
||||||
|
| Param | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `from` | ISO date | 12 months before `to` | bounds the **time series and "in period" figures only** |
|
||||||
|
| `to` | ISO date | now | a bare `YYYY-MM-DD` covers that whole day |
|
||||||
|
| `granularity` | `DAY \| WEEK \| MONTH` | `MONTH` | bucket width; weeks are Monday-anchored |
|
||||||
|
| `category` | `SEA_GOING \| INLAND_WATERWAY`, repeatable or CSV | all | |
|
||||||
|
| `status` | `REGISTERED \| SUSPENDED \| DEREGISTERED`, repeatable or CSV | all | |
|
||||||
|
| `flagState` | string[], repeatable or CSV | all | |
|
||||||
|
| `portOfRegistry` | string[], repeatable or CSV | all | |
|
||||||
|
| `vesselType` | string[], repeatable or CSV | all | |
|
||||||
|
| `search` | string | — | name / register number / IMO / owner name |
|
||||||
|
| `expiringWithinDays` | 1–365 | 90 | horizon for the expiring-certificates table |
|
||||||
|
| `topN` | 1–50 | 15 | slices kept per high-cardinality chart |
|
||||||
|
| `tableLimit` | 1–200 | 10 | rows per table |
|
||||||
|
|
||||||
|
Arrays accept both `?status=A&status=B` and `?status=A,B`. RTK Query's `params`
|
||||||
|
serialises the array form correctly — pass arrays, not joined strings.
|
||||||
|
|
||||||
|
**Important distinction to carry into the UI copy:** the register-wide totals
|
||||||
|
(`kpis.register.total`, the status mix, every `breakdowns.*`) are **not**
|
||||||
|
windowed. Only `registeredInPeriod`, `submittedInPeriod`, `decidedInPeriod`,
|
||||||
|
`incidents.inPeriod` and the whole `timeSeries` block respect `from`/`to`.
|
||||||
|
Label the tiles accordingly or the dashboard will be misread.
|
||||||
|
|
||||||
|
## 2. Response contract
|
||||||
|
|
||||||
|
Add these to `libs/api/src/lib/features/vessel/vessel.types.ts`. Numeric fields
|
||||||
|
are real numbers (the backend already casts pg `numeric` strings) — unlike the
|
||||||
|
existing `Vessel` type, which still carries `string | number`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type ReportGranularity = 'DAY' | 'WEEK' | 'MONTH';
|
||||||
|
|
||||||
|
/** One slice of a breakdown chart. Percentages are of the whole, and sum to 100. */
|
||||||
|
export interface BreakdownItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselReportQuery {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
granularity?: ReportGranularity;
|
||||||
|
category?: VesselCategory[];
|
||||||
|
status?: VesselStatus[];
|
||||||
|
flagState?: string[];
|
||||||
|
portOfRegistry?: string[];
|
||||||
|
vesselType?: string[];
|
||||||
|
search?: string;
|
||||||
|
expiringWithinDays?: number;
|
||||||
|
topN?: number;
|
||||||
|
tableLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselReport {
|
||||||
|
generatedAt: string;
|
||||||
|
/** True when the register exceeded the 50k scan cap — figures are partial. */
|
||||||
|
truncated: boolean;
|
||||||
|
filters: Required<Pick<VesselReportQuery, 'granularity'>> & {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
expiringWithinDays: number;
|
||||||
|
topN: number;
|
||||||
|
tableLimit: number;
|
||||||
|
category: VesselCategory[] | null;
|
||||||
|
status: VesselStatus[] | null;
|
||||||
|
flagState: string[] | null;
|
||||||
|
portOfRegistry: string[] | null;
|
||||||
|
vesselType: string[] | null;
|
||||||
|
search: string | null;
|
||||||
|
};
|
||||||
|
kpis: {
|
||||||
|
register: {
|
||||||
|
total: number;
|
||||||
|
registered: number;
|
||||||
|
suspended: number;
|
||||||
|
deregistered: number;
|
||||||
|
registeredInPeriod: number;
|
||||||
|
registeredInPreviousPeriod: number;
|
||||||
|
/** null when there is no previous period to compare against. */
|
||||||
|
changePct: number | null;
|
||||||
|
};
|
||||||
|
fleet: {
|
||||||
|
totalGrossTonnage: number;
|
||||||
|
avgGrossTonnage: number | null;
|
||||||
|
/** How many hulls the tonnage average actually covers. */
|
||||||
|
grossTonnageKnownFor: number;
|
||||||
|
totalPassengerCapacity: number;
|
||||||
|
avgLengthMeters: number | null;
|
||||||
|
avgAgeYears: number | null;
|
||||||
|
ageKnownFor: number;
|
||||||
|
seaGoing: number;
|
||||||
|
inlandWaterway: number;
|
||||||
|
};
|
||||||
|
pipeline: {
|
||||||
|
total: number;
|
||||||
|
draft: number;
|
||||||
|
inProgress: number;
|
||||||
|
approved: number;
|
||||||
|
rejected: number;
|
||||||
|
issued: number;
|
||||||
|
submittedInPeriod: number;
|
||||||
|
decidedInPeriod: number;
|
||||||
|
newCount: number;
|
||||||
|
renewalCount: number;
|
||||||
|
/** Approved ÷ settled. null when nothing has been decided yet. */
|
||||||
|
approvalRatePct: number | null;
|
||||||
|
avgProcessingDays: number | null;
|
||||||
|
medianProcessingDays: number | null;
|
||||||
|
avgAdjustmentRounds: number | null;
|
||||||
|
};
|
||||||
|
certificates: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
expired: number;
|
||||||
|
suspended: number;
|
||||||
|
/** Cumulative: a cert due in 11 days is in all three. */
|
||||||
|
expiringIn30: number;
|
||||||
|
expiringIn60: number;
|
||||||
|
expiringIn90: number;
|
||||||
|
missingCertificate: number;
|
||||||
|
};
|
||||||
|
incidents: {
|
||||||
|
total: number;
|
||||||
|
inPeriod: number;
|
||||||
|
reportedByOfficer: number;
|
||||||
|
reportedByOwner: number;
|
||||||
|
vesselsWithIncidents: number;
|
||||||
|
};
|
||||||
|
revenue: {
|
||||||
|
currency: string;
|
||||||
|
/** True when the register holds more than one currency — warn, don't sum blindly. */
|
||||||
|
mixedCurrency: boolean;
|
||||||
|
paid: number;
|
||||||
|
pending: number;
|
||||||
|
paidCount: number;
|
||||||
|
pendingCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
timeSeries: {
|
||||||
|
/** `bucket` is an ISO date. Zero-filled across the window — no gaps. */
|
||||||
|
registrations: Array<{ bucket: string; count: number; grossTonnage: number }>;
|
||||||
|
applications: Array<{
|
||||||
|
bucket: string;
|
||||||
|
submitted: number;
|
||||||
|
approved: number;
|
||||||
|
rejected: number;
|
||||||
|
issued: number;
|
||||||
|
}>;
|
||||||
|
incidents: Array<{ bucket: string; count: number }>;
|
||||||
|
revenue: Array<{ bucket: string; amount: number; count: number }>;
|
||||||
|
};
|
||||||
|
breakdowns: {
|
||||||
|
byStatus: BreakdownItem[];
|
||||||
|
byCategory: BreakdownItem[];
|
||||||
|
byFlagState: BreakdownItem[];
|
||||||
|
byPortOfRegistry: BreakdownItem[];
|
||||||
|
byVesselType: BreakdownItem[];
|
||||||
|
byHullMaterial: BreakdownItem[];
|
||||||
|
byEngineType: BreakdownItem[];
|
||||||
|
byTonnageBand: BreakdownItem[];
|
||||||
|
byLengthBand: BreakdownItem[];
|
||||||
|
byAgeBand: BreakdownItem[];
|
||||||
|
byBuildDecade: BreakdownItem[];
|
||||||
|
byApplicationStatus: BreakdownItem[];
|
||||||
|
byApplicationKind: BreakdownItem[];
|
||||||
|
/** `key` is an IAM user uuid, or the literal "UNASSIGNED". */
|
||||||
|
byOfficer: BreakdownItem[];
|
||||||
|
byIncidentSeverity: BreakdownItem[];
|
||||||
|
};
|
||||||
|
tables: {
|
||||||
|
expiringCertificates: Array<{
|
||||||
|
vesselId: string;
|
||||||
|
registrationNumber: string;
|
||||||
|
name: string;
|
||||||
|
ownerName: string | null;
|
||||||
|
ownerUserId: string;
|
||||||
|
certificateNumber: string | null;
|
||||||
|
expiryDate: string;
|
||||||
|
certificateStatus: string | null;
|
||||||
|
/** 0 means it expires today, which still counts as live. */
|
||||||
|
daysToExpiry: number;
|
||||||
|
}>;
|
||||||
|
recentRegistrations: Array<{
|
||||||
|
vesselId: string;
|
||||||
|
registrationNumber: string;
|
||||||
|
name: string;
|
||||||
|
category: VesselCategory;
|
||||||
|
vesselType: string | null;
|
||||||
|
flagState: string | null;
|
||||||
|
grossTonnage: number | null;
|
||||||
|
ownerName: string | null;
|
||||||
|
status: VesselStatus;
|
||||||
|
registeredAt: string;
|
||||||
|
}>;
|
||||||
|
recentIncidents: Array<{
|
||||||
|
id: string;
|
||||||
|
vesselId: string;
|
||||||
|
registrationNumber: string;
|
||||||
|
vesselName: string;
|
||||||
|
occurredAt: string;
|
||||||
|
severity: string | null;
|
||||||
|
location: string | null;
|
||||||
|
description: string;
|
||||||
|
reportedByOfficer: boolean;
|
||||||
|
}>;
|
||||||
|
pendingApplications: Array<{
|
||||||
|
applicationNumber: string;
|
||||||
|
status: string;
|
||||||
|
kind: 'NEW' | 'RENEWAL';
|
||||||
|
assignedOfficerId: string | null;
|
||||||
|
submittedAt: string | null;
|
||||||
|
adjustmentRound: number;
|
||||||
|
daysOpen: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contract details that will bite if ignored
|
||||||
|
|
||||||
|
- **`null` is not `0`.** Averages come back `null` when nothing measurable
|
||||||
|
exists (an empty register, no decided applications). Render an em dash, never
|
||||||
|
`0` or `NaN`. Same for `changePct` and `approvalRatePct`.
|
||||||
|
- **`grossTonnageKnownFor` / `ageKnownFor`** say how much of the fleet the
|
||||||
|
average covers. Show it as sub-text on the tile — an average over 2 of 300
|
||||||
|
hulls is misleading on its own.
|
||||||
|
- **`Unknown`** is a real breakdown key (missing flag state, no build year). It
|
||||||
|
is deliberate; do not filter it out.
|
||||||
|
- **`OTHER`** appears as the last slice of a capped breakdown, labelled
|
||||||
|
`Other (n)`. It exists so slices still sum to the total — do not drop it.
|
||||||
|
- **Expiry buckets are cumulative.** If you draw them as a bar chart, either
|
||||||
|
say "within 30 / 60 / 90 days" or difference them yourself into disjoint
|
||||||
|
bands. Do not present cumulative counts as if they were disjoint.
|
||||||
|
- **`truncated: true`** means the register passed the 50k scan cap and every
|
||||||
|
figure is partial. Show a persistent warning banner when it is set.
|
||||||
|
- **`byOfficer.key` is a uuid**, not a name. Resolve it against whatever user
|
||||||
|
lookup the backoffice already uses, or show a shortened id. Do not print the
|
||||||
|
raw uuid as a chart axis label.
|
||||||
|
- **`mixedCurrency: true`** means revenue was summed across currencies. Warn
|
||||||
|
rather than showing one total.
|
||||||
|
|
||||||
|
## 3. Where the code goes
|
||||||
|
|
||||||
|
### 3.1 API layer — `libs/api/src/lib/features/vessel/`
|
||||||
|
|
||||||
|
Extend the existing slice; do not create a new one.
|
||||||
|
`vessel-api.ts` already uses `baseApi.enhanceEndpoints({ addTagTypes: TAGS })`
|
||||||
|
followed by `injectEndpoints` — add to it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
getVesselReport: builder.query<VesselReport, VesselReportQuery | void>({
|
||||||
|
query: (params) => ({ url: '/vessels/report', params: params ?? undefined }),
|
||||||
|
providesTags: () => [listTag('Vessel')],
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
Export `useGetVesselReportQuery` from the bottom of the file and re-export the
|
||||||
|
new types through `vessel.types.ts` (already barrelled by `index.ts`).
|
||||||
|
|
||||||
|
**The CSV export is not an RTK Query endpoint.** `fetchBaseQuery` parses
|
||||||
|
responses as JSON and would mangle it. Follow the precedent in
|
||||||
|
`libs/api/src/lib/base-api/download.ts`: `openAuthedDocument` fetches with the
|
||||||
|
bearer token into a blob. Either reuse it or add a sibling
|
||||||
|
`downloadAuthedFile(path, fallbackName)` next to it that forces the anchor
|
||||||
|
download path rather than `window.open`. Note the backend sets
|
||||||
|
`Content-Disposition`, `X-Total-Rows` and `X-Truncated`, and the API's CORS
|
||||||
|
config exposes all three — read the filename from the header and fall back to a
|
||||||
|
local default only if it is absent.
|
||||||
|
|
||||||
|
### 3.2 The page — `apps/backoffice/src/app/features/vessel-registration/`
|
||||||
|
|
||||||
|
Replace `pages/VesselRegistrationReportPage.tsx`. Split it rather than shipping
|
||||||
|
one 600-line file; suggested layout, matching how `VesselRegistrationQueuePage`
|
||||||
|
is already organised as a directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
pages/VesselRegistrationReportPage/
|
||||||
|
index.tsx // page shell: PageHeader, filter bar, layout, states
|
||||||
|
ReportFilters.tsx // the filter bar
|
||||||
|
KpiTiles.tsx
|
||||||
|
ReportCharts.tsx
|
||||||
|
ReportTables.tsx
|
||||||
|
report-format.ts // pure: em-dash formatting, cumulative→disjoint, palette
|
||||||
|
report-format.spec.ts // vitest
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the route import path working (`../features/vessel-registration/pages/VesselRegistrationReportPage`
|
||||||
|
resolves to the directory's `index.tsx`).
|
||||||
|
|
||||||
|
### 3.3 Route + nav
|
||||||
|
|
||||||
|
`apps/backoffice/src/app/router/index.tsx:98` — uncomment and guard it, matching
|
||||||
|
line 95:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{ path: 'vessel-registration-report', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationReportPage />) },
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the nav entry wherever `vessel-registration-queue` is listed in the
|
||||||
|
sidebar config, gated on the same permission.
|
||||||
|
|
||||||
|
## 4. What to render
|
||||||
|
|
||||||
|
Use Mantine `Grid`/`SimpleGrid` for layout and `recharts` `<ResponsiveContainer>`
|
||||||
|
for every chart. Recharts is installed but unused — you are setting the house
|
||||||
|
style, so put shared axis/tooltip/colour setup in one place rather than
|
||||||
|
repeating props per chart.
|
||||||
|
|
||||||
|
### Filter bar (sticky, top)
|
||||||
|
|
||||||
|
Date range (`@mantine/dates` `DatePickerInput type="range"`), granularity
|
||||||
|
`SegmentedControl`, multi-selects for category / status / flag state / port /
|
||||||
|
vessel type, a debounced search input, and the export button. Seed the
|
||||||
|
multi-select options from the first response's `breakdowns` keys — no separate
|
||||||
|
lookup endpoint exists. Mirror the filter state into the URL query string so a
|
||||||
|
filtered dashboard is shareable, which is how the licence queue already behaves.
|
||||||
|
|
||||||
|
### KPI tiles (row 1)
|
||||||
|
|
||||||
|
| Tile | Fields |
|
||||||
|
|---|---|
|
||||||
|
| Registered vessels | `register.total`, with `registered / suspended / deregistered` beneath |
|
||||||
|
| New in period | `register.registeredInPeriod`, delta chip from `register.changePct` |
|
||||||
|
| Fleet tonnage | `fleet.totalGrossTonnage`, sub-text avg + `grossTonnageKnownFor` |
|
||||||
|
| Average age | `fleet.avgAgeYears`, sub-text `ageKnownFor` |
|
||||||
|
| Approval rate | `pipeline.approvalRatePct`, sub-text approved/rejected |
|
||||||
|
| Processing time | `pipeline.medianProcessingDays` median, avg as sub-text |
|
||||||
|
| Expiring soon | `certificates.expiringIn30`, sub-text 60/90 |
|
||||||
|
| Fees collected | `revenue.paid` + currency, sub-text pending |
|
||||||
|
|
||||||
|
### Charts (row 2+)
|
||||||
|
|
||||||
|
| Chart | Data | Type |
|
||||||
|
|---|---|---|
|
||||||
|
| Registrations over time | `timeSeries.registrations` | area or bar, `count`; tonnage on a second axis |
|
||||||
|
| Application throughput | `timeSeries.applications` | stacked bar — submitted vs approved vs rejected |
|
||||||
|
| Fees over time | `timeSeries.revenue` | line |
|
||||||
|
| Incidents over time | `timeSeries.incidents` | bar |
|
||||||
|
| Register status mix | `breakdowns.byStatus` | donut |
|
||||||
|
| Category split | `breakdowns.byCategory` | donut |
|
||||||
|
| Tonnage bands | `breakdowns.byTonnageBand` | horizontal bar |
|
||||||
|
| Age bands | `breakdowns.byAgeBand` | horizontal bar |
|
||||||
|
| Top flag states | `breakdowns.byFlagState` | horizontal bar |
|
||||||
|
| Top ports of registry | `breakdowns.byPortOfRegistry` | horizontal bar |
|
||||||
|
| Vessel types | `breakdowns.byVesselType` | horizontal bar |
|
||||||
|
| Application status funnel | `breakdowns.byApplicationStatus` | horizontal bar |
|
||||||
|
| Officer workload | `breakdowns.byOfficer` | horizontal bar, ids resolved to names |
|
||||||
|
| Incident severity | `breakdowns.byIncidentSeverity` | donut |
|
||||||
|
|
||||||
|
`BreakdownItem` is already chart-shaped: `label` on the axis, `count` as the
|
||||||
|
value, `percentage` in the tooltip. Do not recompute percentages.
|
||||||
|
|
||||||
|
Every breakdown can be empty (`[]`) on a fresh register — render `<EmptyState />`
|
||||||
|
from `@ema-platform/ui` inside the card, not an empty axis.
|
||||||
|
|
||||||
|
### Tables (bottom)
|
||||||
|
|
||||||
|
Use `AdvancedTable` from `@ema-platform/ui` (already exported from
|
||||||
|
`libs/ui/src/index.ts`). All four tables are server-limited by `tableLimit`, so
|
||||||
|
they are **not** paginated — do not wire pagination controls to them. Each gets
|
||||||
|
a "view all" link to the corresponding existing screen where one exists
|
||||||
|
(register, incident log, application queue).
|
||||||
|
|
||||||
|
- **Expiring certificates** — the renewals worklist. Colour `daysToExpiry`:
|
||||||
|
red ≤ 7, orange ≤ 30, otherwise neutral. `0` means today, still live.
|
||||||
|
- **Recent registrations** — link each row to the vessel detail screen.
|
||||||
|
- **Recent incidents** — severity is free text and may be `null`.
|
||||||
|
- **Pending applications** — sorted by `daysOpen` descending; link to the review
|
||||||
|
screen by `applicationNumber`.
|
||||||
|
|
||||||
|
### States
|
||||||
|
|
||||||
|
- Loading — `<PageLoader />`.
|
||||||
|
- Error — `<ApiErrorAlert />`, and use `useErrorHandler` if that is the pattern
|
||||||
|
in neighbouring pages.
|
||||||
|
- Empty register (`register.total === 0`) — `<EmptyState />` for the whole page,
|
||||||
|
explaining that no vessels are registered yet, rather than a grid of zeros.
|
||||||
|
- `truncated === true` — a persistent `Alert color="yellow"` above the tiles.
|
||||||
|
|
||||||
|
## 5. Rules
|
||||||
|
|
||||||
|
1. **No new dependencies** without asking. Everything needed is installed.
|
||||||
|
2. **Every user-visible string through i18next**, including chart axis labels,
|
||||||
|
tooltip text and band names. Note that band labels
|
||||||
|
(`"100–499 GT"`, `"30 years and older"`, `"Unknown"`) arrive from the API
|
||||||
|
already rendered — map them to translation keys rather than printing raw
|
||||||
|
English into an Amharic UI.
|
||||||
|
3. **No client-side aggregation.** If a figure is not in the response, ask for
|
||||||
|
a backend change rather than deriving it in the browser. The one exception
|
||||||
|
is differencing the cumulative expiry buckets, which is presentational.
|
||||||
|
4. **Do not touch `mock-base-query.ts`.** This endpoint is live.
|
||||||
|
5. **Extract the pure bits** (formatters, cumulative→disjoint, colour
|
||||||
|
assignment) into `report-format.ts` and cover them with one vitest file. Do
|
||||||
|
not write component tests unless asked.
|
||||||
|
6. **Dates** — `dayjs` is installed and used elsewhere. Backoffice dates render
|
||||||
|
in Gregorian; do not pull in the Ethiopic pickers unless neighbouring
|
||||||
|
backoffice pages already do.
|
||||||
|
7. Match the file, import and naming conventions of
|
||||||
|
`features/vessel-registration/pages/VesselRegistrationQueuePage/` — it is the
|
||||||
|
nearest sibling and the closest thing to a template.
|
||||||
|
|
||||||
|
## 6. Verifying
|
||||||
|
|
||||||
|
1. `npx nx run backoffice:build` and the repo's lint task must pass.
|
||||||
|
2. `npx nx test api` / the vitest task for whatever project holds
|
||||||
|
`report-format.spec.ts`.
|
||||||
|
3. Run the backoffice against a local API, sign in as a user holding
|
||||||
|
`can:View:vessel-registry`, and open `/vessel-registration-report`:
|
||||||
|
- tiles match `GET /api/vessels/report` in the network tab;
|
||||||
|
- changing the date range refetches and redraws only the time series, while
|
||||||
|
`register.total` stays put;
|
||||||
|
- `granularity=DAY` produces one bucket per day, zeros included;
|
||||||
|
- the export button downloads a CSV whose row count equals
|
||||||
|
`kpis.register.total`.
|
||||||
|
4. Sign in **without** the permission — the route must not resolve and the nav
|
||||||
|
entry must not appear.
|
||||||
|
5. Point at a database with an empty vessel register and confirm the page shows
|
||||||
|
the empty state rather than zeros, `NaN`, or a crash.
|
||||||
@@ -5,4 +5,4 @@ export * from './lib/features/licensing';
|
|||||||
export * from './lib/features/seafarer';
|
export * from './lib/features/seafarer';
|
||||||
export * from './lib/features/vessel';
|
export * from './lib/features/vessel';
|
||||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||||
export { openAuthedDocument } from './lib/base-api/download';
|
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';
|
||||||
|
|||||||
@@ -41,3 +41,57 @@ export async function openAuthedDocument(
|
|||||||
// Revoking immediately would race the new tab's load.
|
// Revoking immediately would race the new tab's load.
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads an authenticated endpoint straight to a file.
|
||||||
|
*
|
||||||
|
* Same reason as `openAuthedDocument` for bypassing RTK Query — `fetchBaseQuery`
|
||||||
|
* would parse a CSV body as JSON — but a spreadsheet is something you save, not
|
||||||
|
* something the browser can display, so this always takes the anchor path.
|
||||||
|
*
|
||||||
|
* The server names the file via `Content-Disposition`, and the API's CORS
|
||||||
|
* config exposes that header along with `X-Total-Rows` and `X-Truncated`; those
|
||||||
|
* two are returned so a caller can say when an export was cut short instead of
|
||||||
|
* handing over a silently partial file.
|
||||||
|
*/
|
||||||
|
export async function downloadAuthedFile(
|
||||||
|
path: string,
|
||||||
|
fallbackName: string,
|
||||||
|
): Promise<{ rowCount: number | null; truncated: boolean }> {
|
||||||
|
const token = resolveTokenFromStorage();
|
||||||
|
const response = await fetch(`${BASE_API_URL}${path}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `${response.status}`;
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
message = body?.message ?? message;
|
||||||
|
} catch {
|
||||||
|
/* non-JSON error body — the status is all we have */
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filenameFrom(response.headers) ?? fallbackName;
|
||||||
|
anchor.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
|
||||||
|
const rows = response.headers.get('X-Total-Rows');
|
||||||
|
return {
|
||||||
|
rowCount: rows === null ? null : Number(rows),
|
||||||
|
truncated: response.headers.get('X-Truncated') === 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `attachment; filename="vessel-register-2026-08-18.csv"` → the file name. */
|
||||||
|
function filenameFrom(headers: Headers): string | null {
|
||||||
|
const disposition = headers.get('Content-Disposition');
|
||||||
|
if (!disposition) return null;
|
||||||
|
const match = /filename="?([^";]+)"?/.exec(disposition);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type {
|
|||||||
CreateVesselIncident,
|
CreateVesselIncident,
|
||||||
Vessel,
|
Vessel,
|
||||||
VesselIncident,
|
VesselIncident,
|
||||||
|
VesselReport,
|
||||||
|
VesselReportQuery,
|
||||||
VesselStatus,
|
VesselStatus,
|
||||||
} from './vessel.types';
|
} from './vessel.types';
|
||||||
|
|
||||||
@@ -35,6 +37,22 @@ export const vesselApi = baseApi
|
|||||||
providesTags: () => [listTag('Vessel')],
|
providesTags: () => [listTag('Vessel')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole backoffice dashboard in one call — KPIs, time series,
|
||||||
|
* breakdowns and worklists. Backoffice only (`can:View:vessel-registry`).
|
||||||
|
*
|
||||||
|
* Array filters are passed as arrays, not joined strings: the API accepts
|
||||||
|
* both the repeated and the comma-separated form, and `params` serialises
|
||||||
|
* the repeated one.
|
||||||
|
*/
|
||||||
|
getVesselReport: builder.query<VesselReport, VesselReportQuery | void>({
|
||||||
|
query: (params) => ({
|
||||||
|
url: '/vessels/report',
|
||||||
|
params: params ?? undefined,
|
||||||
|
}),
|
||||||
|
providesTags: () => [listTag('Vessel')],
|
||||||
|
}),
|
||||||
|
|
||||||
getVessel: builder.query<Vessel, string>({
|
getVessel: builder.query<Vessel, string>({
|
||||||
query: (id) => ({ url: `/vessels/${id}` }),
|
query: (id) => ({ url: `/vessels/${id}` }),
|
||||||
providesTags: (_r, _e, id) => [{ type: 'Vessel', id }],
|
providesTags: (_r, _e, id) => [{ type: 'Vessel', id }],
|
||||||
@@ -77,6 +95,7 @@ export const vesselApi = baseApi
|
|||||||
export const {
|
export const {
|
||||||
useGetMyVesselsQuery,
|
useGetMyVesselsQuery,
|
||||||
useGetVesselsQuery,
|
useGetVesselsQuery,
|
||||||
|
useGetVesselReportQuery,
|
||||||
useGetVesselQuery,
|
useGetVesselQuery,
|
||||||
useUpdateVesselStatusMutation,
|
useUpdateVesselStatusMutation,
|
||||||
useGetVesselIncidentsQuery,
|
useGetVesselIncidentsQuery,
|
||||||
|
|||||||
@@ -49,3 +49,247 @@ export interface CreateVesselIncident {
|
|||||||
description: string;
|
description: string;
|
||||||
severity?: string;
|
severity?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Vessel registration report (GET /vessels/report)
|
||||||
|
//
|
||||||
|
// One call fills the whole backoffice dashboard. Unlike `Vessel` above, every
|
||||||
|
// numeric field here is already a real number — the API casts the Postgres
|
||||||
|
// `numeric` strings before it answers.
|
||||||
|
|
||||||
|
export type ReportGranularity = 'DAY' | 'WEEK' | 'MONTH';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One slice of a breakdown chart.
|
||||||
|
*
|
||||||
|
* `percentage` is of the whole, not of the slices that survived the `topN`
|
||||||
|
* cut, so a set of slices always totals 100.
|
||||||
|
*/
|
||||||
|
export interface BreakdownItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselReportQuery {
|
||||||
|
/** Bounds the time series and the "in period" figures only. */
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
granularity?: ReportGranularity;
|
||||||
|
category?: VesselCategory[];
|
||||||
|
status?: VesselStatus[];
|
||||||
|
flagState?: string[];
|
||||||
|
portOfRegistry?: string[];
|
||||||
|
vesselType?: string[];
|
||||||
|
search?: string;
|
||||||
|
expiringWithinDays?: number;
|
||||||
|
/** Slices kept per high-cardinality chart; the tail collapses into "Other". */
|
||||||
|
topN?: number;
|
||||||
|
tableLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterKpis {
|
||||||
|
total: number;
|
||||||
|
registered: number;
|
||||||
|
suspended: number;
|
||||||
|
deregistered: number;
|
||||||
|
registeredInPeriod: number;
|
||||||
|
registeredInPreviousPeriod: number;
|
||||||
|
/** Null when there is no previous period to compare against. */
|
||||||
|
changePct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetKpis {
|
||||||
|
totalGrossTonnage: number;
|
||||||
|
avgGrossTonnage: number | null;
|
||||||
|
/** How many hulls the tonnage average actually covers. */
|
||||||
|
grossTonnageKnownFor: number;
|
||||||
|
totalPassengerCapacity: number;
|
||||||
|
avgLengthMeters: number | null;
|
||||||
|
avgAgeYears: number | null;
|
||||||
|
ageKnownFor: number;
|
||||||
|
seaGoing: number;
|
||||||
|
inlandWaterway: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineKpis {
|
||||||
|
total: number;
|
||||||
|
draft: number;
|
||||||
|
inProgress: number;
|
||||||
|
approved: number;
|
||||||
|
rejected: number;
|
||||||
|
issued: number;
|
||||||
|
submittedInPeriod: number;
|
||||||
|
decidedInPeriod: number;
|
||||||
|
newCount: number;
|
||||||
|
renewalCount: number;
|
||||||
|
/** Approved over settled. Null while nothing has been decided. */
|
||||||
|
approvalRatePct: number | null;
|
||||||
|
avgProcessingDays: number | null;
|
||||||
|
medianProcessingDays: number | null;
|
||||||
|
avgAdjustmentRounds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CertificateKpis {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
expired: number;
|
||||||
|
suspended: number;
|
||||||
|
/** Cumulative: a certificate due in 11 days is inside all three. */
|
||||||
|
expiringIn30: number;
|
||||||
|
expiringIn60: number;
|
||||||
|
expiringIn90: number;
|
||||||
|
missingCertificate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncidentKpis {
|
||||||
|
total: number;
|
||||||
|
inPeriod: number;
|
||||||
|
reportedByOfficer: number;
|
||||||
|
reportedByOwner: number;
|
||||||
|
vesselsWithIncidents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueKpis {
|
||||||
|
currency: string;
|
||||||
|
/** True when more than one currency was summed — warn rather than total. */
|
||||||
|
mixedCurrency: boolean;
|
||||||
|
paid: number;
|
||||||
|
pending: number;
|
||||||
|
paidCount: number;
|
||||||
|
pendingCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucketed series. `bucket` is an ISO date; the window is zero-filled. */
|
||||||
|
export interface RegistrationBucket {
|
||||||
|
bucket: string;
|
||||||
|
count: number;
|
||||||
|
grossTonnage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplicationBucket {
|
||||||
|
bucket: string;
|
||||||
|
submitted: number;
|
||||||
|
approved: number;
|
||||||
|
rejected: number;
|
||||||
|
issued: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncidentBucket {
|
||||||
|
bucket: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueBucket {
|
||||||
|
bucket: string;
|
||||||
|
amount: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpiringCertificateRow {
|
||||||
|
vesselId: string;
|
||||||
|
registrationNumber: string;
|
||||||
|
name: string;
|
||||||
|
ownerName: string | null;
|
||||||
|
ownerUserId: string;
|
||||||
|
certificateNumber: string | null;
|
||||||
|
expiryDate: string;
|
||||||
|
certificateStatus: string | null;
|
||||||
|
/** 0 means it expires today, which still counts as live. */
|
||||||
|
daysToExpiry: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentRegistrationRow {
|
||||||
|
vesselId: string;
|
||||||
|
registrationNumber: string;
|
||||||
|
name: string;
|
||||||
|
category: VesselCategory;
|
||||||
|
vesselType: string | null;
|
||||||
|
flagState: string | null;
|
||||||
|
grossTonnage: number | null;
|
||||||
|
ownerName: string | null;
|
||||||
|
status: VesselStatus;
|
||||||
|
registeredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentIncidentRow {
|
||||||
|
id: string;
|
||||||
|
vesselId: string;
|
||||||
|
registrationNumber: string;
|
||||||
|
vesselName: string;
|
||||||
|
occurredAt: string;
|
||||||
|
severity: string | null;
|
||||||
|
location: string | null;
|
||||||
|
description: string;
|
||||||
|
reportedByOfficer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApplicationRow {
|
||||||
|
applicationNumber: string;
|
||||||
|
status: string;
|
||||||
|
kind: 'NEW' | 'RENEWAL';
|
||||||
|
assignedOfficerId: string | null;
|
||||||
|
submittedAt: string | null;
|
||||||
|
adjustmentRound: number;
|
||||||
|
daysOpen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselReport {
|
||||||
|
generatedAt: string;
|
||||||
|
/** True when the register passed the API's scan cap — figures are partial. */
|
||||||
|
truncated: boolean;
|
||||||
|
filters: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
granularity: ReportGranularity;
|
||||||
|
expiringWithinDays: number;
|
||||||
|
topN: number;
|
||||||
|
tableLimit: number;
|
||||||
|
category: VesselCategory[] | null;
|
||||||
|
status: VesselStatus[] | null;
|
||||||
|
flagState: string[] | null;
|
||||||
|
portOfRegistry: string[] | null;
|
||||||
|
vesselType: string[] | null;
|
||||||
|
search: string | null;
|
||||||
|
};
|
||||||
|
kpis: {
|
||||||
|
register: RegisterKpis;
|
||||||
|
fleet: FleetKpis;
|
||||||
|
pipeline: PipelineKpis;
|
||||||
|
certificates: CertificateKpis;
|
||||||
|
incidents: IncidentKpis;
|
||||||
|
revenue: RevenueKpis;
|
||||||
|
};
|
||||||
|
timeSeries: {
|
||||||
|
registrations: RegistrationBucket[];
|
||||||
|
applications: ApplicationBucket[];
|
||||||
|
incidents: IncidentBucket[];
|
||||||
|
revenue: RevenueBucket[];
|
||||||
|
};
|
||||||
|
breakdowns: {
|
||||||
|
byStatus: BreakdownItem[];
|
||||||
|
byCategory: BreakdownItem[];
|
||||||
|
byFlagState: BreakdownItem[];
|
||||||
|
byPortOfRegistry: BreakdownItem[];
|
||||||
|
byVesselType: BreakdownItem[];
|
||||||
|
byHullMaterial: BreakdownItem[];
|
||||||
|
byEngineType: BreakdownItem[];
|
||||||
|
byTonnageBand: BreakdownItem[];
|
||||||
|
byLengthBand: BreakdownItem[];
|
||||||
|
byAgeBand: BreakdownItem[];
|
||||||
|
byBuildDecade: BreakdownItem[];
|
||||||
|
byApplicationStatus: BreakdownItem[];
|
||||||
|
byApplicationKind: BreakdownItem[];
|
||||||
|
/** `key` is an IAM user uuid, or the literal "UNASSIGNED". */
|
||||||
|
byOfficer: BreakdownItem[];
|
||||||
|
byIncidentSeverity: BreakdownItem[];
|
||||||
|
};
|
||||||
|
tables: {
|
||||||
|
expiringCertificates: ExpiringCertificateRow[];
|
||||||
|
recentRegistrations: RecentRegistrationRow[];
|
||||||
|
recentIncidents: RecentIncidentRow[];
|
||||||
|
pendingApplications: PendingApplicationRow[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user