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

19 KiB
Raw Blame History

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 deployedGET /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/exporttext/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.

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:

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:

{ 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. Datesdayjs 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.