mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: implement interactive vessel registration reporting page with filters, charts, and data tables
This commit is contained in:
480
docs/vessel-registration-report-frontend.md
Normal file
480
docs/vessel-registration-report-frontend.md
Normal 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` | 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<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
|
||||
(`"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.
|
||||
Reference in New Issue
Block a user