# 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.