19 KiB
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/reportplusGET /api/vessels/report/export(CSV). Nothing about it is mocked; do not invent sample data, and do not add a mock branch tomock-base-query.ts.The page it belongs on already exists as a placeholder:
apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage.tsxcurrently renders<FeatureUnavailable />, and its route is commented out atapps/backoffice/src/app/router/index.tsx:98. Replace the placeholder with the real dashboard and re-enable the route, guarded byP.VIEW_VESSEL_REGISTRYexactly like the vessel queue route two lines above it.Read
docs/vessel-registration-report-frontend.mdin 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 inlibs/api, Mantine 8 for layout,rechartsfor charts (already a dependency, not yet used anywhere — you are establishing the pattern), i18next for every user-visible string.Scope, in order:
- Types + RTK Query endpoints in
libs/api/src/lib/features/vessel/.- The page: filter bar, KPI tiles, charts, tables.
- Export button.
- Route + nav.
- A vitest test for whatever pure logic you extract.
Ask me before adding any new dependency.
recharts,@mantine/*,@mantine/dates,dayjsand@tabler/icons-reactare 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.
export type ReportGranularity = 'DAY' | 'WEEK' | 'MONTH';
/** One slice of a breakdown chart. Percentages are of the whole, and sum to 100. */
export interface BreakdownItem {
key: string;
label: string;
count: number;
percentage: number;
}
export interface VesselReportQuery {
from?: string;
to?: string;
granularity?: ReportGranularity;
category?: VesselCategory[];
status?: VesselStatus[];
flagState?: string[];
portOfRegistry?: string[];
vesselType?: string[];
search?: string;
expiringWithinDays?: number;
topN?: number;
tableLimit?: number;
}
export interface VesselReport {
generatedAt: string;
/** True when the register exceeded the 50k scan cap — figures are partial. */
truncated: boolean;
filters: Required<Pick<VesselReportQuery, 'granularity'>> & {
from: string;
to: string;
expiringWithinDays: number;
topN: number;
tableLimit: number;
category: VesselCategory[] | null;
status: VesselStatus[] | null;
flagState: string[] | null;
portOfRegistry: string[] | null;
vesselType: string[] | null;
search: string | null;
};
kpis: {
register: {
total: number;
registered: number;
suspended: number;
deregistered: number;
registeredInPeriod: number;
registeredInPreviousPeriod: number;
/** null when there is no previous period to compare against. */
changePct: number | null;
};
fleet: {
totalGrossTonnage: number;
avgGrossTonnage: number | null;
/** How many hulls the tonnage average actually covers. */
grossTonnageKnownFor: number;
totalPassengerCapacity: number;
avgLengthMeters: number | null;
avgAgeYears: number | null;
ageKnownFor: number;
seaGoing: number;
inlandWaterway: number;
};
pipeline: {
total: number;
draft: number;
inProgress: number;
approved: number;
rejected: number;
issued: number;
submittedInPeriod: number;
decidedInPeriod: number;
newCount: number;
renewalCount: number;
/** Approved ÷ settled. null when nothing has been decided yet. */
approvalRatePct: number | null;
avgProcessingDays: number | null;
medianProcessingDays: number | null;
avgAdjustmentRounds: number | null;
};
certificates: {
total: number;
active: number;
expired: number;
suspended: number;
/** Cumulative: a cert due in 11 days is in all three. */
expiringIn30: number;
expiringIn60: number;
expiringIn90: number;
missingCertificate: number;
};
incidents: {
total: number;
inPeriod: number;
reportedByOfficer: number;
reportedByOwner: number;
vesselsWithIncidents: number;
};
revenue: {
currency: string;
/** True when the register holds more than one currency — warn, don't sum blindly. */
mixedCurrency: boolean;
paid: number;
pending: number;
paidCount: number;
pendingCount: number;
failedCount: number;
};
};
timeSeries: {
/** `bucket` is an ISO date. Zero-filled across the window — no gaps. */
registrations: Array<{ bucket: string; count: number; grossTonnage: number }>;
applications: Array<{
bucket: string;
submitted: number;
approved: number;
rejected: number;
issued: number;
}>;
incidents: Array<{ bucket: string; count: number }>;
revenue: Array<{ bucket: string; amount: number; count: number }>;
};
breakdowns: {
byStatus: BreakdownItem[];
byCategory: BreakdownItem[];
byFlagState: BreakdownItem[];
byPortOfRegistry: BreakdownItem[];
byVesselType: BreakdownItem[];
byHullMaterial: BreakdownItem[];
byEngineType: BreakdownItem[];
byTonnageBand: BreakdownItem[];
byLengthBand: BreakdownItem[];
byAgeBand: BreakdownItem[];
byBuildDecade: BreakdownItem[];
byApplicationStatus: BreakdownItem[];
byApplicationKind: BreakdownItem[];
/** `key` is an IAM user uuid, or the literal "UNASSIGNED". */
byOfficer: BreakdownItem[];
byIncidentSeverity: BreakdownItem[];
};
tables: {
expiringCertificates: Array<{
vesselId: string;
registrationNumber: string;
name: string;
ownerName: string | null;
ownerUserId: string;
certificateNumber: string | null;
expiryDate: string;
certificateStatus: string | null;
/** 0 means it expires today, which still counts as live. */
daysToExpiry: number;
}>;
recentRegistrations: Array<{
vesselId: string;
registrationNumber: string;
name: string;
category: VesselCategory;
vesselType: string | null;
flagState: string | null;
grossTonnage: number | null;
ownerName: string | null;
status: VesselStatus;
registeredAt: string;
}>;
recentIncidents: Array<{
id: string;
vesselId: string;
registrationNumber: string;
vesselName: string;
occurredAt: string;
severity: string | null;
location: string | null;
description: string;
reportedByOfficer: boolean;
}>;
pendingApplications: Array<{
applicationNumber: string;
status: string;
kind: 'NEW' | 'RENEWAL';
assignedOfficerId: string | null;
submittedAt: string | null;
adjustmentRound: number;
daysOpen: number;
}>;
};
}
Contract details that will bite if ignored
nullis not0. Averages come backnullwhen nothing measurable exists (an empty register, no decided applications). Render an em dash, never0orNaN. Same forchangePctandapprovalRatePct.grossTonnageKnownFor/ageKnownForsay 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.Unknownis a real breakdown key (missing flag state, no build year). It is deliberate; do not filter it out.OTHERappears as the last slice of a capped breakdown, labelledOther (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: truemeans the register passed the 50k scan cap and every figure is partial. Show a persistent warning banner when it is set.byOfficer.keyis 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: truemeans 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:
getVesselReport: builder.query<VesselReport, VesselReportQuery | void>({
query: (params) => ({ url: '/vessels/report', params: params ?? undefined }),
providesTags: () => [listTag('Vessel')],
}),
Export useGetVesselReportQuery from the bottom of the file and re-export the
new types through vessel.types.ts (already barrelled by index.ts).
The CSV export is not an RTK Query endpoint. fetchBaseQuery parses
responses as JSON and would mangle it. Follow the precedent in
libs/api/src/lib/base-api/download.ts: openAuthedDocument fetches with the
bearer token into a blob. Either reuse it or add a sibling
downloadAuthedFile(path, fallbackName) next to it that forces the anchor
download path rather than window.open. Note the backend sets
Content-Disposition, X-Total-Rows and X-Truncated, and the API's CORS
config exposes all three — read the filename from the header and fall back to a
local default only if it is absent.
3.2 The page — apps/backoffice/src/app/features/vessel-registration/
Replace pages/VesselRegistrationReportPage.tsx. Split it rather than shipping
one 600-line file; suggested layout, matching how VesselRegistrationQueuePage
is already organised as a directory:
pages/VesselRegistrationReportPage/
index.tsx // page shell: PageHeader, filter bar, layout, states
ReportFilters.tsx // the filter bar
KpiTiles.tsx
ReportCharts.tsx
ReportTables.tsx
report-format.ts // pure: em-dash formatting, cumulative→disjoint, palette
report-format.spec.ts // vitest
Keep the route import path working (../features/vessel-registration/pages/VesselRegistrationReportPage
resolves to the directory's index.tsx).
3.3 Route + nav
apps/backoffice/src/app/router/index.tsx:98 — uncomment and guard it, matching
line 95:
{ path: 'vessel-registration-report', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationReportPage />) },
Then add the nav entry wherever vessel-registration-queue is listed in the
sidebar config, gated on the same permission.
4. What to render
Use Mantine Grid/SimpleGrid for layout and recharts <ResponsiveContainer>
for every chart. Recharts is installed but unused — you are setting the house
style, so put shared axis/tooltip/colour setup in one place rather than
repeating props per chart.
Filter bar (sticky, top)
Date range (@mantine/dates DatePickerInput type="range"), granularity
SegmentedControl, multi-selects for category / status / flag state / port /
vessel type, a debounced search input, and the export button. Seed the
multi-select options from the first response's breakdowns keys — no separate
lookup endpoint exists. Mirror the filter state into the URL query string so a
filtered dashboard is shareable, which is how the licence queue already behaves.
KPI tiles (row 1)
| Tile | Fields |
|---|---|
| Registered vessels | register.total, with registered / suspended / deregistered beneath |
| New in period | register.registeredInPeriod, delta chip from register.changePct |
| Fleet tonnage | fleet.totalGrossTonnage, sub-text avg + grossTonnageKnownFor |
| Average age | fleet.avgAgeYears, sub-text ageKnownFor |
| Approval rate | pipeline.approvalRatePct, sub-text approved/rejected |
| Processing time | pipeline.medianProcessingDays median, avg as sub-text |
| Expiring soon | certificates.expiringIn30, sub-text 60/90 |
| Fees collected | revenue.paid + currency, sub-text pending |
Charts (row 2+)
| Chart | Data | Type |
|---|---|---|
| Registrations over time | timeSeries.registrations |
area or bar, count; tonnage on a second axis |
| Application throughput | timeSeries.applications |
stacked bar — submitted vs approved vs rejected |
| Fees over time | timeSeries.revenue |
line |
| Incidents over time | timeSeries.incidents |
bar |
| Register status mix | breakdowns.byStatus |
donut |
| Category split | breakdowns.byCategory |
donut |
| Tonnage bands | breakdowns.byTonnageBand |
horizontal bar |
| Age bands | breakdowns.byAgeBand |
horizontal bar |
| Top flag states | breakdowns.byFlagState |
horizontal bar |
| Top ports of registry | breakdowns.byPortOfRegistry |
horizontal bar |
| Vessel types | breakdowns.byVesselType |
horizontal bar |
| Application status funnel | breakdowns.byApplicationStatus |
horizontal bar |
| Officer workload | breakdowns.byOfficer |
horizontal bar, ids resolved to names |
| Incident severity | breakdowns.byIncidentSeverity |
donut |
BreakdownItem is already chart-shaped: label on the axis, count as the
value, percentage in the tooltip. Do not recompute percentages.
Every breakdown can be empty ([]) on a fresh register — render <EmptyState />
from @ema-platform/ui inside the card, not an empty axis.
Tables (bottom)
Use AdvancedTable from @ema-platform/ui (already exported from
libs/ui/src/index.ts). All four tables are server-limited by tableLimit, so
they are not paginated — do not wire pagination controls to them. Each gets
a "view all" link to the corresponding existing screen where one exists
(register, incident log, application queue).
- Expiring certificates — the renewals worklist. Colour
daysToExpiry: red ≤ 7, orange ≤ 30, otherwise neutral.0means 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
daysOpendescending; link to the review screen byapplicationNumber.
States
- Loading —
<PageLoader />. - Error —
<ApiErrorAlert />, and useuseErrorHandlerif that is the pattern in neighbouring pages. - Empty register (
register.total === 0) —<EmptyState />for the whole page, explaining that no vessels are registered yet, rather than a grid of zeros. truncated === true— a persistentAlert color="yellow"above the tiles.
5. Rules
- No new dependencies without asking. Everything needed is installed.
- 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. - 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.
- Do not touch
mock-base-query.ts. This endpoint is live. - Extract the pure bits (formatters, cumulative→disjoint, colour
assignment) into
report-format.tsand cover them with one vitest file. Do not write component tests unless asked. - Dates —
dayjsis installed and used elsewhere. Backoffice dates render in Gregorian; do not pull in the Ethiopic pickers unless neighbouring backoffice pages already do. - 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
npx nx run backoffice:buildand the repo's lint task must pass.npx nx test api/ the vitest task for whatever project holdsreport-format.spec.ts.- 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/reportin the network tab; - changing the date range refetches and redraws only the time series, while
register.totalstays put; granularity=DAYproduces one bucket per day, zeros included;- the export button downloads a CSV whose row count equals
kpis.register.total.
- tiles match
- Sign in without the permission — the route must not resolve and the nav entry must not appear.
- Point at a database with an empty vessel register and confirm the page shows
the empty state rather than zeros,
NaN, or a crash.