Files
emaui/docs/vessel-registration-report-frontend.md

481 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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