mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
481 lines
19 KiB
Markdown
481 lines
19 KiB
Markdown
# 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.
|