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