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