# Reports polish — beta-finish design **Date:** 2026-06-02 **Initiative:** Launch-readiness Initiative 1 (Reports overhaul) — "Reports — what's left" gap audit. **Goal (locked with user):** make the reports surface _feel finished for beta_ — every report opens cleanly even on an empty port, plus a modest, obviously-useful Operational filter. Not a deep power-filtering pass. ## Scope Two pieces: 1. **Report-level empty states** across Sales · Operational · Financial — one friendly "add X to see this" hero when the port has no underlying data, instead of a page scattered with per-chart "No data" badges. 2. **Operational Area filter** — a single berth-area multi-select that scopes the whole Operational report's berth-derived surfaces. ### Out of scope (deferred, recorded in launch-readiness) - **Status filter** on Operational — turned out to be a _light_ filter here (can't retro-apply to historical trend charts; the vacant lists are available-by-definition). Defer until there's a general berth-inventory table where Status is genuinely useful. - Other Operational dimensions (tenure type, document type). - Rep / source filters on Operational — they don't map (berths have no assigned rep; tenancies have no lead source). - Custom-builder, scheduling, and template gaps from the same audit. ## Decisions locked | Question | Decision | | ----------------------------- | ------------------------------------------------------------------------------------- | | Polish goal | "Make reports feel finished for beta" (empty states + modest filter) | | Operational filter dimensions | Area + Status chosen → **narrowed to Area only** after the Status-is-light finding | | Operational filter reach | **Approach A — berth scope**: filters re-query the berth-derived surfaces server-side | | Status handling | **Drop for now**; ship Area as the real scope | --- ## Piece 1 — Report-level empty states ### Data flow Each report's GET route adds one field, `hasData: boolean`, to its `data` payload. It is a **window-independent, port-scoped existence check** (ignores the selected date range) via a tiny `SELECT 1 … LIMIT 1` helper per report: - **Sales** (`/api/v1/reports/sales`) → does the port have **any** `interests` row? - **Operational** (`/api/v1/reports/operational`) → does the port have **any** `berths` row? - **Financial** (`/api/v1/reports/financial`) → does the port have **any** `payments` row **or** **any** `expenses` row? Window-independence is the design crux: it distinguishes a _brand-new port_ (show the onboarding hero) from _a port with history but nothing in the selected 30 days_ (show the normal report, whose per-chart empty states already degrade gracefully). Client-side inference from the payload can't tell those two apart — hence a server flag. ### Component New `src/components/reports/shared/report-empty-state.tsx`: ```tsx interface ReportEmptyStateProps { icon: LucideIcon; title: string; body: string; actionLabel: string; actionHref: Route; } ``` A centered hero: named Lucide icon, title, one-line body, primary `Button` → `Link`. Visual language extends the existing inline `EmptyState` in `sales-report-client.tsx` (muted, centered) but elevated to full-report scale (more vertical padding, larger icon). Lives in `reports/shared/` so all three clients import it. No decorative emoji — named icon components only. ### Client wiring (3 report clients) After the query resolves: if `data && data.hasData === false`, render `` in place of the report body. **Keep the `PageHeader`** so the page retains its title; disable the export/template buttons (no data to export). Keep skeletons while `query.isLoading`. ### Copy + targets Plain text, no emoji. | Report | Icon | Title | Body | Action → href | | ----------- | ------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------- | | Sales | `TrendingUp` | "No sales activity yet" | "Once you add clients and log interests, this report fills with win rates, pipeline value, and deal heat." | "Add an interest" → `/[portSlug]/interests` | | Operational | `Anchor` | "No berths yet" | "Add berths to see utilisation, occupancy, and signing turnaround." | "Add berths" → `/[portSlug]/berths` | | Financial | `Wallet` | "No financial activity yet" | "Record a payment on a deal or log an expense to see revenue, deposits, and cash flow." | "Go to expenses" → `/[portSlug]/expenses` | --- ## Piece 2 — Operational Area filter (Approach A: berth scope) ### Parsing New pure, unit-tested module `src/lib/services/reports/operational-filters.ts`, mirroring `sales-filters.ts`: - `OperationalFilters = { areas?: string[] }` — extensible shape (Status can be added later without a rename). - `parseOperationalFilters(params: URLSearchParams): OperationalFilters | undefined` — reads the `area` CSV param as a free list (port-defined strings; Drizzle parameterizes the downstream `inArray`, so unvalidated values are injection-safe). Empty/whitespace entries dropped. Returns `undefined` when no areas → no filter. ### Area options New `getOperationalAreaOptions(portId: string): Promise` — `SELECT DISTINCT area FROM berths WHERE port_id = ? AND area IS NOT NULL ORDER BY area`. Returned in the payload as `areaOptions` (mirrors Sales' `repOptions`). The shared `FilterBar` auto-hides a multi-select with no options, so the Area control simply doesn't render for a port with no areas defined. ### Where Area applies Area is a **scope** over the berth-derived surfaces. It threads into these service fns as an optional `filters?: OperationalFilters` arg, adding `inArray(berths.area, filters.areas)` when present (index-backed by `idx_berths_area`): - `getOperationalKpis` (berth counts: total / sold % / under-offer %) - `getOccupancyByArea` - `getUtilisationHeatmap` - `getVacantBerths` - `getHighestValueVacant` **Left port-wide (unfiltered):** status-mix-over-time trend, tenancy churn / tenure / ending-soon, signing box plot, documents-in-pipeline, stuck-signing. A small caption ("Scoped to {areas}") appears on the filtered cards; the port-wide panels are visually unchanged. ### UI The Operational report adds the shared `FilterBar` with a **single Area multi-select**, placed at the **top of the report next to the `DateRangePicker`** — because Area scopes the whole report (unlike Sales, where the FilterBar sits above the detail tables because it only scopes those tables). The Operational report currently has no FilterBar; this introduces it. ### Template config The Operational template config (`{ kind: 'operational', range, statusMixMode }`) gains `filters: { areas?: string[] }` so a saved template round-trips its area scope. Changing the area clears the active-template badge (same pattern as `handleRangeChange` / Sales `handleFilterChange`); applying a template restores it via the raw setter. --- ## Files **New (4):** - `src/lib/services/reports/operational-filters.ts` — `parseOperationalFilters` + `getOperationalAreaOptions` - `src/components/reports/shared/report-empty-state.tsx` — shared hero - `tests/unit/reports/operational-filters.test.ts` - `tests/unit/reports/report-has-data.test.ts` — the three existence helpers **Modified (~10):** - `src/app/api/v1/reports/sales/route.ts` — `+ hasData` - `src/app/api/v1/reports/operational/route.ts` — `+ hasData`, `+ areaOptions`, parse + thread area filter - `src/app/api/v1/reports/financial/route.ts` — `+ hasData` - `src/components/reports/sales/sales-report-client.tsx` — empty-state wiring - `src/components/reports/operational/operational-report-client.tsx` — empty-state + FilterBar/area scope + template config - `src/components/reports/financial/financial-report-client.tsx` — empty-state wiring - `src/lib/services/reports/operational.service.ts` — optional `filters` on 5 fns + area-options query + `operationalHasData(portId)` helper - `src/lib/services/reports/sales.service.ts` — `salesHasData(portId)` helper - `src/lib/services/reports/financial.service.ts` — `financialHasData(portId)` helper Each `hasData` helper lives in its report's service file alongside that report's other queries (consistent with the existing one-service-per-report layout), and is the single existence check the route awaits in its `Promise.all`. - `docs/launch-readiness.md` — mark the empty-state + Operational-filter items shipped ## Testing (TDD) Write tests first: 1. `parseOperationalFilters` — single area, CSV multi, whitespace trimming, empty → `undefined`, no `area` param → `undefined`. 2. The three `hasData` helpers — return `false` for a port with no rows, `true` once a row exists, correct port isolation. Then implement to green, then browser-verify on `port-nimara`: - Area multi-select renders, narrows occupancy-by-area + vacant lists + berth-count KPIs; port-wide panels unchanged; "Scoped to {area}" caption shows. - Empty-state heroes render for an empty port (force `hasData=false` if `port-nimara` has data) with correct copy + working action links. - `pnpm exec tsc --noEmit` clean; affected unit tests green. ## Edge cases - Berths with `area = NULL` — excluded from `areaOptions`; an active area filter hides them (correct: they're not in any selected area). - Area filter matching nothing → filtered surfaces fall back to their existing per-chart empty states (NOT the report-level hero, because the port _does_ have data). - `hasData` ignores the date window entirely — a port with old-but-real data never shows the onboarding hero. - Export/template buttons disabled in the empty-state view (nothing to export).