+ )}
+
+ );
+}
+
+export function ReportTables({ report }: { report: VesselReport }) {
+ const showDate = useDateDisplayer();
+ const { tables, filters } = report;
+
+ return (
+
+
+ {tables.expiringCertificates.map((row) => (
+
+
+
+ {row.name}
+
+
+ {row.registrationNumber}
+ {row.ownerName ? ` · ${row.ownerName}` : ''}
+
+
+ {row.certificateNumber ?? '—'}
+ {showDate(row.expiryDate)}
+
+
+ {/* 0 is today, and a certificate is valid through its last day. */}
+ {row.daysToExpiry === 0
+ ? 'Today'
+ : `${formatNumber(row.daysToExpiry)} d`}
+
+
+
+ ))}
+
+
+
+ {tables.recentRegistrations.map((row) => (
+
+
+
+ {row.name}
+
+
+ {row.registrationNumber}
+ {row.vesselType ? ` · ${row.vesselType}` : ''}
+
+
+
+ {row.category === 'SEA_GOING' ? 'Sea-going' : 'Inland'}
+
+ {row.flagState ?? '—'}
+ {showDate(row.registeredAt)}
+
+ ))}
+
+
+
+ {tables.pendingApplications.map((row) => (
+
+
+
+ {row.applicationNumber}
+
+
+ {row.kind === 'RENEWAL' ? 'Renewal' : 'New'}
+ {row.adjustmentRound > 0
+ ? ` · ${row.adjustmentRound} adjustment round${row.adjustmentRound === 1 ? '' : 's'}`
+ : ''}
+
+
+
+
+ {row.status.replaceAll('_', ' ')}
+
+
+
+ {row.submittedAt ? showDate(row.submittedAt) : '—'}
+
+
+ 30 ? 'red' : row.daysOpen > 14 ? 'orange' : 'gray'}
+ >
+ {formatNumber(row.daysOpen)} d
+
+
+
+ ))}
+
+
+
+ {tables.recentIncidents.map((row) => (
+
+
+
+ {row.vesselName}
+
+
+ {row.description}
+
+
+ {showDate(row.occurredAt)}
+ {row.severity ?? '—'}
+
+
+ {row.reportedByOfficer ? 'Officer' : 'Owner'}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/index.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/index.tsx
new file mode 100644
index 000000000..e41c9b3fd
--- /dev/null
+++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/index.tsx
@@ -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 ;
+
+ return (
+
+
+
+ Vessel registration report
+
+ {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.'}
+
+
+
+
+
+
+ {error && }
+
+ {report && (
+ <>
+ {report.truncated && (
+ }
+ 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.
+
+ )}
+
+ {report.kpis.register.total === 0 ? (
+ 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.'
+ }
+ />
+ ) : (
+
+
+
+
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+export default VesselRegistrationReportPage;
diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/report-format.spec.ts b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/report-format.spec.ts
new file mode 100644
index 000000000..52134a22b
--- /dev/null
+++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/report-format.spec.ts
@@ -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 => ({
+ 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([]);
+ });
+});
diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/report-format.ts b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/report-format.ts
new file mode 100644
index 000000000..0ac963995
--- /dev/null
+++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/report-format.ts
@@ -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 = {};
+ 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);
+}
diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts
index b3f9e77b0..45bc03990 100644
--- a/apps/backoffice/src/app/layouts/nav-config.ts
+++ b/apps/backoffice/src/app/layouts/nav-config.ts
@@ -118,7 +118,7 @@ export const NAV_SECTIONS: NavSection[] = [
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: APPLICATION_QUEUE },
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
- { to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
+ { to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
],
},
diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx
index e04280c63..5e6ae8894 100644
--- a/apps/backoffice/src/app/router/index.tsx
+++ b/apps/backoffice/src/app/router/index.tsx
@@ -95,7 +95,7 @@ const router = createBrowserRouter([
{ path: 'vessel-registration-queue', element: guard([P.VIEW_VESSEL_REGISTRY], ) },
{ path: 'vessel-registration-queue/new', element: guard([P.VIEW_VESSEL_REGISTRY], ) },
// { path: 'vessel-registration-queue/:id', element: },
- //{ path: 'vessel-registration-report', element: },
+ { path: 'vessel-registration-report', element: guard([P.VIEW_VESSEL_REGISTRY], ) },
{ path: 'vessel-ownership-transfer', element: },
{ path: 'vessel-ownership-transfer/:id', element: },
// Config-driven review workspace, shared by every licence type.
diff --git a/apps/backoffice/vite.config.mts b/apps/backoffice/vite.config.mts
index 331bc27fd..f6b8e3ace 100644
--- a/apps/backoffice/vite.config.mts
+++ b/apps/backoffice/vite.config.mts
@@ -33,4 +33,14 @@ export default defineConfig({
emptyOutDir: true,
reportCompressedSize: true,
},
+ // Unit tests for the pure helpers behind a screen (formatters, URL state).
+ // Component tests are deliberately not set up: nothing here renders React,
+ // so no jsdom environment or setup file is needed.
+ test: {
+ watch: false,
+ globals: true,
+ environment: 'node',
+ include: ['src/**/*.spec.ts'],
+ reporters: ['default'],
+ },
});
diff --git a/docs/vessel-registration-report-frontend.md b/docs/vessel-registration-report-frontend.md
new file mode 100644
index 000000000..799f1c1f1
--- /dev/null
+++ b/docs/vessel-registration-report-frontend.md
@@ -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 ``, 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> & {
+ 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({
+ 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], ) },
+```
+
+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` ``
+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 ``
+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 — ``.
+- Error — ``, and use `useErrorHandler` if that is the pattern
+ in neighbouring pages.
+- Empty register (`register.total === 0`) — `` 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.
diff --git a/libs/api/src/index.ts b/libs/api/src/index.ts
index 5eef5d064..f985f1f0a 100644
--- a/libs/api/src/index.ts
+++ b/libs/api/src/index.ts
@@ -5,4 +5,4 @@ export * from './lib/features/licensing';
export * from './lib/features/seafarer';
export * from './lib/features/vessel';
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';
diff --git a/libs/api/src/lib/base-api/download.ts b/libs/api/src/lib/base-api/download.ts
index a910ac3a8..c984c523f 100644
--- a/libs/api/src/lib/base-api/download.ts
+++ b/libs/api/src/lib/base-api/download.ts
@@ -41,3 +41,57 @@ export async function openAuthedDocument(
// Revoking immediately would race the new tab's load.
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;
+}
diff --git a/libs/api/src/lib/features/vessel/vessel-api.ts b/libs/api/src/lib/features/vessel/vessel-api.ts
index 52d3e58a7..d52991131 100644
--- a/libs/api/src/lib/features/vessel/vessel-api.ts
+++ b/libs/api/src/lib/features/vessel/vessel-api.ts
@@ -3,6 +3,8 @@ import type {
CreateVesselIncident,
Vessel,
VesselIncident,
+ VesselReport,
+ VesselReportQuery,
VesselStatus,
} from './vessel.types';
@@ -35,6 +37,22 @@ export const vesselApi = baseApi
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({
+ query: (params) => ({
+ url: '/vessels/report',
+ params: params ?? undefined,
+ }),
+ providesTags: () => [listTag('Vessel')],
+ }),
+
getVessel: builder.query({
query: (id) => ({ url: `/vessels/${id}` }),
providesTags: (_r, _e, id) => [{ type: 'Vessel', id }],
@@ -77,6 +95,7 @@ export const vesselApi = baseApi
export const {
useGetMyVesselsQuery,
useGetVesselsQuery,
+ useGetVesselReportQuery,
useGetVesselQuery,
useUpdateVesselStatusMutation,
useGetVesselIncidentsQuery,
diff --git a/libs/api/src/lib/features/vessel/vessel.types.ts b/libs/api/src/lib/features/vessel/vessel.types.ts
index 437a0acff..355e64b1f 100644
--- a/libs/api/src/lib/features/vessel/vessel.types.ts
+++ b/libs/api/src/lib/features/vessel/vessel.types.ts
@@ -49,3 +49,247 @@ export interface CreateVesselIncident {
description: 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[];
+ };
+}