Files
pn-new-crm/docs/superpowers/specs/2026-06-02-reports-polish-design.md
Matt 244fb14ce5 docs(reports): design spec for beta-finish polish (empty states + Operational area filter)
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>
2026-06-02 09:57:12 +02:00

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:

  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:

interface ReportEmptyStateProps {
  icon: LucideIcon;
  title: string;
  body: string;
  actionLabel: string;
  actionHref: Route;
}

A centered hero: named Lucide icon, title, one-line body, primary ButtonLink. 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 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<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 %)
  • 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.tsparseOperationalFilters + 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.tssalesHasData(portId) helper
  • src/lib/services/reports/financial.service.tsfinancialHasData(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).