From 2b95454b4beceb4d9038516eac8f7e5b2086973b Mon Sep 17 00:00:00 2001 From: estifanos Date: Tue, 18 Aug 2026 08:57:51 +0000 Subject: [PATCH] feat: implement interactive vessel registration reporting page with filters, charts, and data tables --- .../pages/VesselRegistrationReportPage.tsx | 21 - .../VesselRegistrationReportPage/KpiTiles.tsx | 171 +++++++ .../ReportCharts.tsx | 369 ++++++++++++++ .../ReportFilters.tsx | 215 ++++++++ .../ReportTables.tsx | 211 ++++++++ .../VesselRegistrationReportPage/index.tsx | 149 ++++++ .../report-format.spec.ts | 187 +++++++ .../report-format.ts | 221 ++++++++ apps/backoffice/src/app/layouts/nav-config.ts | 2 +- apps/backoffice/src/app/router/index.tsx | 2 +- apps/backoffice/vite.config.mts | 10 + docs/vessel-registration-report-frontend.md | 480 ++++++++++++++++++ libs/api/src/index.ts | 2 +- libs/api/src/lib/base-api/download.ts | 54 ++ .../api/src/lib/features/vessel/vessel-api.ts | 19 + .../src/lib/features/vessel/vessel.types.ts | 244 +++++++++ 16 files changed, 2333 insertions(+), 24 deletions(-) delete mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/KpiTiles.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportCharts.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportFilters.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportTables.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/index.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/report-format.spec.ts create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/report-format.ts create mode 100644 docs/vessel-registration-report-frontend.md diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage.tsx deleted file mode 100644 index 108412120..000000000 --- a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage.tsx +++ /dev/null @@ -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 ( - - - - ); -} - -export default VesselRegistrationReportPage; diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/KpiTiles.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/KpiTiles.tsx new file mode 100644 index 000000000..64b8941f0 --- /dev/null +++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/KpiTiles.tsx @@ -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 = ( + + + + {label} + + + + + + {value} + + {delta && ( + + {delta.text} + + )} + + {detail && ( + + {detail} + + )} + + ); + + return hint ? ( + + {card} + + ) : ( + 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 ( + + + + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportCharts.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportCharts.tsx new file mode 100644 index 000000000..2dd3f5e48 --- /dev/null +++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportCharts.tsx @@ -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 ( + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + {empty ? ( + + Nothing to show for this filter yet. + + ) : ( + + {children as never} + + )} + + ); +} + +/** + * 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 ( + + + + + + [ + countWithShare(value, entry), + 'Vessels', + ]} + /> + + {items.map((item, index) => ( + + ))} + + + + ); +} + +function BreakdownDonut({ + title, + subtitle, + items, +}: { + title: string; + subtitle?: string; + items: BreakdownItem[]; +}) { + return ( + + + + {items.map((item, index) => ( + + ))} + + [countWithShare(value, entry), name]} + /> + + + + ); +} + +/** + * "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 ( + <> + + b.count))} + > + + + + + + + + + + + + + [ + b.submitted, + b.approved, + b.rejected, + ]), + )} + > + + + + + + + + {/* Approved and rejected stack: together they are the decisions + made in that bucket, which reads against intake beside it. */} + + + + + + b.amount))} + > + + + + + + + + + + b.count))} + > + + + + + + + + + + + + + + band.count))} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportFilters.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportFilters.tsx new file mode 100644 index 000000000..8a9bf24c1 --- /dev/null +++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportFilters.tsx @@ -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 = ( + 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 ( + + + { + if (from && !to) return; + onChange({ + ...query, + from: from ?? undefined, + to: to ?? undefined, + }); + }} + clearable + w={250} + /> + + set('granularity', value as ReportGranularity)} + /> + + set('category', value as VesselCategory[])} + clearable + w={190} + /> + + set('status', value as VesselStatus[])} + clearable + w={190} + /> + + set('flagState', value)} + searchable + clearable + w={190} + /> + + set('portOfRegistry', value)} + searchable + clearable + w={190} + /> + + set('vesselType', value)} + searchable + clearable + w={190} + /> + + } + value={search} + onChange={(event) => setSearch(event.currentTarget.value)} + w={250} + /> + + + {filtered && ( + + )} + + + + + ); +} diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportTables.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportTables.tsx new file mode 100644 index 000000000..6a234984f --- /dev/null +++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage/ReportTables.tsx @@ -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 ( + + +
+ + {title} + + {subtitle && ( + + {subtitle} + + )} +
+ {to && ( + + {linkLabel ?? 'View all'} + + )} +
+ {empty ? ( + + Nothing to show. + + ) : ( + + + + {head.map((column) => ( + {column} + ))} + + + {children} +
+ )} +
+ ); +} + +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[]; + }; +}