Locked decisions from brainstorming: report-level empty states across Sales/Operational/Financial gated on a window-independent hasData flag; Operational gains an Area-only berth-scope filter (Status dropped as a light filter in this report); rep/source confirmed not applicable to Operational. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
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:
- 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.
- 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 anyinterestsrow? - Operational (
/api/v1/reports/operational) → does the port have anyberthsrow? - Financial (
/api/v1/reports/financial) → does the port have anypaymentsrow or anyexpensesrow?
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:
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 <ReportEmptyState .../> 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 theareaCSV param as a free list (port-defined strings; Drizzle parameterizes the downstreaminArray, so unvalidated values are injection-safe). Empty/whitespace entries dropped. Returnsundefinedwhen no areas → no filter.
Area options
New getOperationalAreaOptions(portId: string): Promise<string[]> — 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 %)getOccupancyByAreagetUtilisationHeatmapgetVacantBerthsgetHighestValueVacant
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+getOperationalAreaOptionssrc/components/reports/shared/report-empty-state.tsx— shared herotests/unit/reports/operational-filters.test.tstests/unit/reports/report-has-data.test.ts— the three existence helpers
Modified (~10):
src/app/api/v1/reports/sales/route.ts—+ hasDatasrc/app/api/v1/reports/operational/route.ts—+ hasData,+ areaOptions, parse + thread area filtersrc/app/api/v1/reports/financial/route.ts—+ hasDatasrc/components/reports/sales/sales-report-client.tsx— empty-state wiringsrc/components/reports/operational/operational-report-client.tsx— empty-state + FilterBar/area scope + template configsrc/components/reports/financial/financial-report-client.tsx— empty-state wiringsrc/lib/services/reports/operational.service.ts— optionalfilterson 5 fns + area-options query +operationalHasData(portId)helpersrc/lib/services/reports/sales.service.ts—salesHasData(portId)helpersrc/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:
parseOperationalFilters— single area, CSV multi, whitespace trimming, empty →undefined, noareaparam →undefined.- The three
hasDatahelpers — returnfalsefor a port with no rows,trueonce 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=falseifport-nimarahas data) with correct copy + working action links. pnpm exec tsc --noEmitclean; affected unit tests green.
Edge cases
- Berths with
area = NULL— excluded fromareaOptions; 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).
hasDataignores 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).