feat: implement interactive vessel registration reporting page with filters, charts, and data tables

This commit is contained in:
estifanos
2026-08-18 08:57:51 +00:00
parent 1dc82d943a
commit 2b95454b4b
16 changed files with 2333 additions and 24 deletions

View File

@@ -0,0 +1,480 @@
# Vessel registration report — frontend integration brief
Paste the **Prompt** section below to Claude Code from the `emaui` repo root.
Everything after it is reference the prompt points at.
---
## Prompt
> Wire up the vessel registration report dashboard in the backoffice app.
>
> The backend endpoint is **new and already deployed** — `GET /api/vessels/report`
> plus `GET /api/vessels/report/export` (CSV). Nothing about it is mocked; do not
> invent sample data, and do not add a mock branch to `mock-base-query.ts`.
>
> The page it belongs on already exists as a placeholder:
> `apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage.tsx`
> currently renders `<FeatureUnavailable />`, and its route is commented out at
> `apps/backoffice/src/app/router/index.tsx:98`. Replace the placeholder with the
> real dashboard and re-enable the route, guarded by `P.VIEW_VESSEL_REGISTRY`
> exactly like the vessel queue route two lines above it.
>
> Read `docs/vessel-registration-report-frontend.md` in this repo for the full
> response contract, the chart plan, and the conventions to follow. Follow the
> conventions already in the codebase over anything you would do by default:
> RTK Query in `libs/api`, Mantine 8 for layout, `recharts` for charts (already a
> dependency, not yet used anywhere — you are establishing the pattern), i18next
> for every user-visible string.
>
> Scope, in order:
> 1. Types + RTK Query endpoints in `libs/api/src/lib/features/vessel/`.
> 2. The page: filter bar, KPI tiles, charts, tables.
> 3. Export button.
> 4. Route + nav.
> 5. A vitest test for whatever pure logic you extract.
>
> Ask me before adding any new dependency. `recharts`, `@mantine/*`,
> `@mantine/dates`, `dayjs` and `@tabler/icons-react` are all already installed.
---
## 1. What the endpoint is
| | |
|---|---|
| Report | `GET /api/vessels/report` → JSON |
| Export | `GET /api/vessels/report/export``text/csv` |
| Permission | `can:View:vessel-registry` (`P.VIEW_VESSEL_REGISTRY`, `libs/auth/src/lib/permissions.constants.ts:48`) |
| Auth | Bearer, same as every other backoffice call |
One call fills the whole dashboard. Both routes take the **same** query
parameters, so the export button reuses whatever the filter bar holds.
Backend source, if you need to check a figure:
`emaback/emaapi/apps/server/emaapi/src/module/vessel/services/vessel-report.service.ts`.
### Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
| `from` | ISO date | 12 months before `to` | bounds the **time series and "in period" figures only** |
| `to` | ISO date | now | a bare `YYYY-MM-DD` covers that whole day |
| `granularity` | `DAY \| WEEK \| MONTH` | `MONTH` | bucket width; weeks are Monday-anchored |
| `category` | `SEA_GOING \| INLAND_WATERWAY`, repeatable or CSV | all | |
| `status` | `REGISTERED \| SUSPENDED \| DEREGISTERED`, repeatable or CSV | all | |
| `flagState` | string[], repeatable or CSV | all | |
| `portOfRegistry` | string[], repeatable or CSV | all | |
| `vesselType` | string[], repeatable or CSV | all | |
| `search` | string | — | name / register number / IMO / owner name |
| `expiringWithinDays` | 1365 | 90 | horizon for the expiring-certificates table |
| `topN` | 150 | 15 | slices kept per high-cardinality chart |
| `tableLimit` | 1200 | 10 | rows per table |
Arrays accept both `?status=A&status=B` and `?status=A,B`. RTK Query's `params`
serialises the array form correctly — pass arrays, not joined strings.
**Important distinction to carry into the UI copy:** the register-wide totals
(`kpis.register.total`, the status mix, every `breakdowns.*`) are **not**
windowed. Only `registeredInPeriod`, `submittedInPeriod`, `decidedInPeriod`,
`incidents.inPeriod` and the whole `timeSeries` block respect `from`/`to`.
Label the tiles accordingly or the dashboard will be misread.
## 2. Response contract
Add these to `libs/api/src/lib/features/vessel/vessel.types.ts`. Numeric fields
are real numbers (the backend already casts pg `numeric` strings) — unlike the
existing `Vessel` type, which still carries `string | number`.
```ts
export type ReportGranularity = 'DAY' | 'WEEK' | 'MONTH';
/** One slice of a breakdown chart. Percentages are of the whole, and sum to 100. */
export interface BreakdownItem {
key: string;
label: string;
count: number;
percentage: number;
}
export interface VesselReportQuery {
from?: string;
to?: string;
granularity?: ReportGranularity;
category?: VesselCategory[];
status?: VesselStatus[];
flagState?: string[];
portOfRegistry?: string[];
vesselType?: string[];
search?: string;
expiringWithinDays?: number;
topN?: number;
tableLimit?: number;
}
export interface VesselReport {
generatedAt: string;
/** True when the register exceeded the 50k scan cap — figures are partial. */
truncated: boolean;
filters: Required<Pick<VesselReportQuery, 'granularity'>> & {
from: string;
to: string;
expiringWithinDays: number;
topN: number;
tableLimit: number;
category: VesselCategory[] | null;
status: VesselStatus[] | null;
flagState: string[] | null;
portOfRegistry: string[] | null;
vesselType: string[] | null;
search: string | null;
};
kpis: {
register: {
total: number;
registered: number;
suspended: number;
deregistered: number;
registeredInPeriod: number;
registeredInPreviousPeriod: number;
/** null when there is no previous period to compare against. */
changePct: number | null;
};
fleet: {
totalGrossTonnage: number;
avgGrossTonnage: number | null;
/** How many hulls the tonnage average actually covers. */
grossTonnageKnownFor: number;
totalPassengerCapacity: number;
avgLengthMeters: number | null;
avgAgeYears: number | null;
ageKnownFor: number;
seaGoing: number;
inlandWaterway: number;
};
pipeline: {
total: number;
draft: number;
inProgress: number;
approved: number;
rejected: number;
issued: number;
submittedInPeriod: number;
decidedInPeriod: number;
newCount: number;
renewalCount: number;
/** Approved ÷ settled. null when nothing has been decided yet. */
approvalRatePct: number | null;
avgProcessingDays: number | null;
medianProcessingDays: number | null;
avgAdjustmentRounds: number | null;
};
certificates: {
total: number;
active: number;
expired: number;
suspended: number;
/** Cumulative: a cert due in 11 days is in all three. */
expiringIn30: number;
expiringIn60: number;
expiringIn90: number;
missingCertificate: number;
};
incidents: {
total: number;
inPeriod: number;
reportedByOfficer: number;
reportedByOwner: number;
vesselsWithIncidents: number;
};
revenue: {
currency: string;
/** True when the register holds more than one currency — warn, don't sum blindly. */
mixedCurrency: boolean;
paid: number;
pending: number;
paidCount: number;
pendingCount: number;
failedCount: number;
};
};
timeSeries: {
/** `bucket` is an ISO date. Zero-filled across the window — no gaps. */
registrations: Array<{ bucket: string; count: number; grossTonnage: number }>;
applications: Array<{
bucket: string;
submitted: number;
approved: number;
rejected: number;
issued: number;
}>;
incidents: Array<{ bucket: string; count: number }>;
revenue: Array<{ bucket: string; amount: number; count: number }>;
};
breakdowns: {
byStatus: BreakdownItem[];
byCategory: BreakdownItem[];
byFlagState: BreakdownItem[];
byPortOfRegistry: BreakdownItem[];
byVesselType: BreakdownItem[];
byHullMaterial: BreakdownItem[];
byEngineType: BreakdownItem[];
byTonnageBand: BreakdownItem[];
byLengthBand: BreakdownItem[];
byAgeBand: BreakdownItem[];
byBuildDecade: BreakdownItem[];
byApplicationStatus: BreakdownItem[];
byApplicationKind: BreakdownItem[];
/** `key` is an IAM user uuid, or the literal "UNASSIGNED". */
byOfficer: BreakdownItem[];
byIncidentSeverity: BreakdownItem[];
};
tables: {
expiringCertificates: Array<{
vesselId: string;
registrationNumber: string;
name: string;
ownerName: string | null;
ownerUserId: string;
certificateNumber: string | null;
expiryDate: string;
certificateStatus: string | null;
/** 0 means it expires today, which still counts as live. */
daysToExpiry: number;
}>;
recentRegistrations: Array<{
vesselId: string;
registrationNumber: string;
name: string;
category: VesselCategory;
vesselType: string | null;
flagState: string | null;
grossTonnage: number | null;
ownerName: string | null;
status: VesselStatus;
registeredAt: string;
}>;
recentIncidents: Array<{
id: string;
vesselId: string;
registrationNumber: string;
vesselName: string;
occurredAt: string;
severity: string | null;
location: string | null;
description: string;
reportedByOfficer: boolean;
}>;
pendingApplications: Array<{
applicationNumber: string;
status: string;
kind: 'NEW' | 'RENEWAL';
assignedOfficerId: string | null;
submittedAt: string | null;
adjustmentRound: number;
daysOpen: number;
}>;
};
}
```
### Contract details that will bite if ignored
- **`null` is not `0`.** Averages come back `null` when nothing measurable
exists (an empty register, no decided applications). Render an em dash, never
`0` or `NaN`. Same for `changePct` and `approvalRatePct`.
- **`grossTonnageKnownFor` / `ageKnownFor`** say how much of the fleet the
average covers. Show it as sub-text on the tile — an average over 2 of 300
hulls is misleading on its own.
- **`Unknown`** is a real breakdown key (missing flag state, no build year). It
is deliberate; do not filter it out.
- **`OTHER`** appears as the last slice of a capped breakdown, labelled
`Other (n)`. It exists so slices still sum to the total — do not drop it.
- **Expiry buckets are cumulative.** If you draw them as a bar chart, either
say "within 30 / 60 / 90 days" or difference them yourself into disjoint
bands. Do not present cumulative counts as if they were disjoint.
- **`truncated: true`** means the register passed the 50k scan cap and every
figure is partial. Show a persistent warning banner when it is set.
- **`byOfficer.key` is a uuid**, not a name. Resolve it against whatever user
lookup the backoffice already uses, or show a shortened id. Do not print the
raw uuid as a chart axis label.
- **`mixedCurrency: true`** means revenue was summed across currencies. Warn
rather than showing one total.
## 3. Where the code goes
### 3.1 API layer — `libs/api/src/lib/features/vessel/`
Extend the existing slice; do not create a new one.
`vessel-api.ts` already uses `baseApi.enhanceEndpoints({ addTagTypes: TAGS })`
followed by `injectEndpoints` — add to it:
```ts
getVesselReport: builder.query<VesselReport, VesselReportQuery | void>({
query: (params) => ({ url: '/vessels/report', params: params ?? undefined }),
providesTags: () => [listTag('Vessel')],
}),
```
Export `useGetVesselReportQuery` from the bottom of the file and re-export the
new types through `vessel.types.ts` (already barrelled by `index.ts`).
**The CSV export is not an RTK Query endpoint.** `fetchBaseQuery` parses
responses as JSON and would mangle it. Follow the precedent in
`libs/api/src/lib/base-api/download.ts`: `openAuthedDocument` fetches with the
bearer token into a blob. Either reuse it or add a sibling
`downloadAuthedFile(path, fallbackName)` next to it that forces the anchor
download path rather than `window.open`. Note the backend sets
`Content-Disposition`, `X-Total-Rows` and `X-Truncated`, and the API's CORS
config exposes all three — read the filename from the header and fall back to a
local default only if it is absent.
### 3.2 The page — `apps/backoffice/src/app/features/vessel-registration/`
Replace `pages/VesselRegistrationReportPage.tsx`. Split it rather than shipping
one 600-line file; suggested layout, matching how `VesselRegistrationQueuePage`
is already organised as a directory:
```
pages/VesselRegistrationReportPage/
index.tsx // page shell: PageHeader, filter bar, layout, states
ReportFilters.tsx // the filter bar
KpiTiles.tsx
ReportCharts.tsx
ReportTables.tsx
report-format.ts // pure: em-dash formatting, cumulative→disjoint, palette
report-format.spec.ts // vitest
```
Keep the route import path working (`../features/vessel-registration/pages/VesselRegistrationReportPage`
resolves to the directory's `index.tsx`).
### 3.3 Route + nav
`apps/backoffice/src/app/router/index.tsx:98` — uncomment and guard it, matching
line 95:
```tsx
{ path: 'vessel-registration-report', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationReportPage />) },
```
Then add the nav entry wherever `vessel-registration-queue` is listed in the
sidebar config, gated on the same permission.
## 4. What to render
Use Mantine `Grid`/`SimpleGrid` for layout and `recharts` `<ResponsiveContainer>`
for every chart. Recharts is installed but unused — you are setting the house
style, so put shared axis/tooltip/colour setup in one place rather than
repeating props per chart.
### Filter bar (sticky, top)
Date range (`@mantine/dates` `DatePickerInput type="range"`), granularity
`SegmentedControl`, multi-selects for category / status / flag state / port /
vessel type, a debounced search input, and the export button. Seed the
multi-select options from the first response's `breakdowns` keys — no separate
lookup endpoint exists. Mirror the filter state into the URL query string so a
filtered dashboard is shareable, which is how the licence queue already behaves.
### KPI tiles (row 1)
| Tile | Fields |
|---|---|
| Registered vessels | `register.total`, with `registered / suspended / deregistered` beneath |
| New in period | `register.registeredInPeriod`, delta chip from `register.changePct` |
| Fleet tonnage | `fleet.totalGrossTonnage`, sub-text avg + `grossTonnageKnownFor` |
| Average age | `fleet.avgAgeYears`, sub-text `ageKnownFor` |
| Approval rate | `pipeline.approvalRatePct`, sub-text approved/rejected |
| Processing time | `pipeline.medianProcessingDays` median, avg as sub-text |
| Expiring soon | `certificates.expiringIn30`, sub-text 60/90 |
| Fees collected | `revenue.paid` + currency, sub-text pending |
### Charts (row 2+)
| Chart | Data | Type |
|---|---|---|
| Registrations over time | `timeSeries.registrations` | area or bar, `count`; tonnage on a second axis |
| Application throughput | `timeSeries.applications` | stacked bar — submitted vs approved vs rejected |
| Fees over time | `timeSeries.revenue` | line |
| Incidents over time | `timeSeries.incidents` | bar |
| Register status mix | `breakdowns.byStatus` | donut |
| Category split | `breakdowns.byCategory` | donut |
| Tonnage bands | `breakdowns.byTonnageBand` | horizontal bar |
| Age bands | `breakdowns.byAgeBand` | horizontal bar |
| Top flag states | `breakdowns.byFlagState` | horizontal bar |
| Top ports of registry | `breakdowns.byPortOfRegistry` | horizontal bar |
| Vessel types | `breakdowns.byVesselType` | horizontal bar |
| Application status funnel | `breakdowns.byApplicationStatus` | horizontal bar |
| Officer workload | `breakdowns.byOfficer` | horizontal bar, ids resolved to names |
| Incident severity | `breakdowns.byIncidentSeverity` | donut |
`BreakdownItem` is already chart-shaped: `label` on the axis, `count` as the
value, `percentage` in the tooltip. Do not recompute percentages.
Every breakdown can be empty (`[]`) on a fresh register — render `<EmptyState />`
from `@ema-platform/ui` inside the card, not an empty axis.
### Tables (bottom)
Use `AdvancedTable` from `@ema-platform/ui` (already exported from
`libs/ui/src/index.ts`). All four tables are server-limited by `tableLimit`, so
they are **not** paginated — do not wire pagination controls to them. Each gets
a "view all" link to the corresponding existing screen where one exists
(register, incident log, application queue).
- **Expiring certificates** — the renewals worklist. Colour `daysToExpiry`:
red ≤ 7, orange ≤ 30, otherwise neutral. `0` means today, still live.
- **Recent registrations** — link each row to the vessel detail screen.
- **Recent incidents** — severity is free text and may be `null`.
- **Pending applications** — sorted by `daysOpen` descending; link to the review
screen by `applicationNumber`.
### States
- Loading — `<PageLoader />`.
- Error — `<ApiErrorAlert />`, and use `useErrorHandler` if that is the pattern
in neighbouring pages.
- Empty register (`register.total === 0`) — `<EmptyState />` for the whole page,
explaining that no vessels are registered yet, rather than a grid of zeros.
- `truncated === true` — a persistent `Alert color="yellow"` above the tiles.
## 5. Rules
1. **No new dependencies** without asking. Everything needed is installed.
2. **Every user-visible string through i18next**, including chart axis labels,
tooltip text and band names. Note that band labels
(`"100499 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.