diff --git a/docs/superpowers/plans/2026-06-02-reports-polish.md b/docs/superpowers/plans/2026-06-02-reports-polish.md new file mode 100644 index 00000000..39c4e1be --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-reports-polish.md @@ -0,0 +1,1147 @@ +# Reports Polish (beta-finish) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the reports surface feel finished for beta — a report-level empty state on Sales/Operational/Financial when the port has no data, plus an Area-scope filter on the Operational report. + +**Architecture:** Each report route gains a window-independent `hasData` boolean (a thin existence query in its service file); the client renders a shared `` hero when it's false. The Operational report additionally gains an Area berth-scope filter: a pure `parseOperationalFilters` parser, a `getOperationalAreaOptions` query for the dropdown, and an optional `filters` arg threaded through the five berth-derived Operational service functions. + +**Tech Stack:** Next.js 15 App Router, Drizzle ORM (Postgres), TanStack Query, the existing shared `FilterBar` + `DateRangePicker` components, Vitest. + +**Spec:** `docs/superpowers/specs/2026-06-02-reports-polish-design.md` + +**Testing note:** Only the pure parser (`parseOperationalFilters`) gets a unit test — that mirrors how the codebase already tests reports logic (`tests/unit/services/reports/sales-filters.test.ts`). The DB-backed helpers (`getOperationalAreaOptions`, the three `*HasData`) are thin existence/distinct queries that mirror trusted existing patterns (e.g. `getRepFilterOptions`); like those, they're verified by `tsc` + the live browser pass in Task 11, not by brittle `db`-mock unit tests. + +**Dev server:** assume `pnpm dev` is already running on `http://localhost:3000` (port slug `port-nimara`). If not, start it. + +--- + +### Task 1: `parseOperationalFilters` (pure parser + type) + +**Files:** + +- Create: `src/lib/services/reports/operational-filters.ts` +- Test: `tests/unit/services/reports/operational-filters.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/services/reports/operational-filters.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; + +import { parseOperationalFilters } from '@/lib/services/reports/operational-filters'; + +function params(qs: string): URLSearchParams { + return new URLSearchParams(qs); +} + +describe('parseOperationalFilters', () => { + it('returns undefined when no area param is present', () => { + expect(parseOperationalFilters(params(''))).toBeUndefined(); + expect(parseOperationalFilters(params('from=x&to=y'))).toBeUndefined(); + }); + + it('parses a single area', () => { + expect(parseOperationalFilters(params('area=A'))).toEqual({ areas: ['A'] }); + }); + + it('parses a CSV of areas and trims whitespace', () => { + expect(parseOperationalFilters(params('area=A,%20B%20,C'))).toEqual({ + areas: ['A', 'B', 'C'], + }); + }); + + it('drops empty / whitespace-only entries, returning undefined when nothing is left', () => { + expect(parseOperationalFilters(params('area=%20,%20'))).toBeUndefined(); + expect(parseOperationalFilters(params('area='))).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm exec vitest run tests/unit/services/reports/operational-filters.test.ts` +Expected: FAIL — cannot resolve `@/lib/services/reports/operational-filters`. + +- [ ] **Step 3: Write the implementation** + +Create `src/lib/services/reports/operational-filters.ts`: + +```ts +/** + * Operational report filters. Mirrors `sales-filters.ts`: the parser is a + * pure, unit-testable function so the route just hands it the query params. + * + * Beta scope is Area only (a berth-area scope). The shape is intentionally + * an object so a Status dimension can be added later without a rename. + */ +export interface OperationalFilters { + areas?: string[]; +} + +/** + * Parse the `area` CSV query param into a free list of port-defined area + * strings. Empty / whitespace entries are dropped. Drizzle parameterises + * the downstream `inArray`, so unvalidated values are injection-safe. + * Returns `undefined` when no areas are active (→ no filter). + */ +export function parseOperationalFilters(params: URLSearchParams): OperationalFilters | undefined { + const raw = params.get('area'); + if (!raw) return undefined; + const areas = raw + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + if (areas.length === 0) return undefined; + return { areas }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm exec vitest run tests/unit/services/reports/operational-filters.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/services/reports/operational-filters.ts tests/unit/services/reports/operational-filters.test.ts +git commit -m "feat(reports): parseOperationalFilters pure parser (Area scope)" +``` + +--- + +### Task 2: Thread Area filter + add helpers in `operational.service.ts` + +**Files:** + +- Modify: `src/lib/services/reports/operational.service.ts` + +- [ ] **Step 1: Add the `inArray` import and the `OperationalFilters` import** + +At the top of the file, change the drizzle import (line 1) to add `inArray`: + +```ts +import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, sql } from 'drizzle-orm'; +``` + +Add below the existing schema imports (after the `isTenanciesModuleEnabled` import, ~line 8): + +```ts +import type { OperationalFilters } from './operational-filters'; +``` + +- [ ] **Step 2: Add the area-condition helper** + +Immediately after the `interface DateRange { … }` block (~line 26), add: + +```ts +/** + * Optional berth-area WHERE-condition. Returns `undefined` when no area + * filter is active, so it drops cleanly out of a drizzle `and(...)` + * (which ignores undefined operands). + */ +function areaCond(filters?: OperationalFilters) { + return filters?.areas && filters.areas.length > 0 + ? inArray(berths.area, filters.areas) + : undefined; +} +``` + +- [ ] **Step 3: Thread `filters` into `getOperationalKpis` and its berth-count internals** + +Change the `getOperationalKpis` signature and the five berth-count calls inside it: + +```ts +export async function getOperationalKpis( + portId: string, + range: DateRange, + filters?: OperationalFilters, +): Promise { + const [ + totalBerths, + soldNow, + soldAtStart, + underOfferNow, + underOfferAtStart, + tenanciesEnabled, + activeTenancies, + avgTenancyLength, + signingTurnaround, + conflicts, + ] = await Promise.all([ + countActiveBerths(portId, filters), + countBerthsByStatusNow(portId, 'sold', filters), + countBerthsByStatusAtTimestamp(portId, 'sold', range.from, filters), + countBerthsByStatusNow(portId, 'under_offer', filters), + countBerthsByStatusAtTimestamp(portId, 'under_offer', range.from, filters), + isTenanciesModuleEnabled(portId), + countActiveTenancies(portId), + medianTenancyLengthYears(portId), + perTypeSigningTurnaround(portId), + countBerthsInConflict(portId), + ]); +``` + +(The rest of the function body — the percentage math and the returned object — is unchanged. Tenancy / signing / conflict stay port-wide.) + +- [ ] **Step 4: Add `filters` to the three berth-count internal helpers** + +Update `countActiveBerths` (~line 825): + +```ts +async function countActiveBerths(portId: string, filters?: OperationalFilters): Promise { + const [row] = await db + .select({ value: sql`count(*)::int` }) + .from(berths) + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters))); + return row?.value ?? 0; +} +``` + +Update `countBerthsByStatusNow` (~line 833): + +```ts +async function countBerthsByStatusNow( + portId: string, + status: string, + filters?: OperationalFilters, +): Promise { + const [row] = await db + .select({ value: sql`count(*)::int` }) + .from(berths) + .where( + and( + eq(berths.portId, portId), + isNull(berths.archivedAt), + eq(berths.status, status), + areaCond(filters), + ), + ); + return row?.value ?? 0; +} +``` + +Update the `berthRows` query inside `countBerthsByStatusAtTimestamp` (~line 847). Change the signature and the first query's `.where(...)`: + +```ts +async function countBerthsByStatusAtTimestamp( + portId: string, + targetStatus: string, + at: Date, + filters?: OperationalFilters, +): Promise { + const berthRows = await db + .select({ id: berths.id, status: berths.status, createdAt: berths.createdAt }) + .from(berths) + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters))); +``` + +(The audit-log query and the replay loop below it are unchanged — they already intersect with `berthRows`, so scoping the berth set scopes the count.) + +- [ ] **Step 5: Thread `filters` into the heatmap berth snapshot** + +Update `getUtilisationHeatmap` signature (~line 116) and its `berthRows` query (~line 130): + +```ts +export async function getUtilisationHeatmap( + portId: string, + months = 24, + filters?: OperationalFilters, +): Promise { +``` + +```ts +const berthRows = await db + .select({ id: berths.id, area: berths.area, status: berths.status }) + .from(berths) + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters))); +``` + +(Leave `getStatusMixOverTime`'s internal `getUtilisationHeatmap(portId, months)` call as-is — no filters — so the status-mix trend stays port-wide.) + +- [ ] **Step 6: Thread `filters` into `getOccupancyByArea`, `getVacantBerths`, `getHighestValueVacant`** + +`getOccupancyByArea` (~line 514): + +```ts +export async function getOccupancyByArea( + portId: string, + filters?: OperationalFilters, +): Promise { + const rows = await db + .select({ + area: berths.area, + status: berths.status, + n: sql`count(*)::int`, + }) + .from(berths) + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters))) + .groupBy(berths.area, berths.status); +``` + +`getVacantBerths` (~line 652): + +```ts +export async function getVacantBerths( + portId: string, + minDaysAvailable = 60, + filters?: OperationalFilters, +): Promise { + const now = Date.now(); + const rows = await db + .select({ + id: berths.id, + mooring: berths.mooringNumber, + area: berths.area, + lengthFt: berths.lengthFt, + widthFt: berths.widthFt, + price: berths.price, + currency: berths.priceCurrency, + statusLastModified: berths.statusLastModified, + }) + .from(berths) + .where( + and( + eq(berths.portId, portId), + eq(berths.status, 'available'), + isNull(berths.archivedAt), + areaCond(filters), + ), + ) + .orderBy(berths.mooringNumber); +``` + +`getHighestValueVacant` (~line 775): + +```ts +export async function getHighestValueVacant( + portId: string, + limit = 10, + filters?: OperationalFilters, +): Promise { + const now = Date.now(); + const rows = await db + .select({ + id: berths.id, + mooring: berths.mooringNumber, + area: berths.area, + lengthFt: berths.lengthFt, + widthFt: berths.widthFt, + price: berths.price, + currency: berths.priceCurrency, + statusLastModified: berths.statusLastModified, + }) + .from(berths) + .where( + and( + eq(berths.portId, portId), + eq(berths.status, 'available'), + isNull(berths.archivedAt), + isNotNull(berths.price), + areaCond(filters), + ), + ) + .orderBy(desc(berths.price)) + .limit(limit); +``` + +- [ ] **Step 7: Add `getOperationalAreaOptions` and `operationalHasData`** + +At the end of the file, just before the `// ─── Internals ───` divider (~line 823), add: + +```ts +/** + * Distinct, non-null berth areas for the Operational report's Area filter. + * Mirrors `getRepFilterOptions` in sales.service.ts. The FilterBar hides + * the Area control when this is empty, so ports with no areas defined never + * see it. + */ +export async function getOperationalAreaOptions(portId: string): Promise { + const rows = await db + .selectDistinct({ area: berths.area }) + .from(berths) + .where(and(eq(berths.portId, portId), isNotNull(berths.area), isNull(berths.archivedAt))) + .orderBy(berths.area); + return rows.map((r) => r.area).filter((a): a is string => a !== null); +} + +/** + * Window-independent existence check: does this port have any berth at all? + * Drives the report-level empty state (distinct from the per-window empty + * states the charts already render). + */ +export async function operationalHasData(portId: string): Promise { + const rows = await db + .select({ one: sql`1` }) + .from(berths) + .where(eq(berths.portId, portId)) + .limit(1); + return rows.length > 0; +} +``` + +- [ ] **Step 8: Typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: exit 0, no output. + +- [ ] **Step 9: Commit** + +```bash +git add src/lib/services/reports/operational.service.ts +git commit -m "feat(reports): thread Area filter + add area-options/hasData helpers (operational service)" +``` + +--- + +### Task 3: Operational route — parse filters, thread them, add `areaOptions` + `hasData` + +**Files:** + +- Modify: `src/app/api/v1/reports/operational/route.ts` + +- [ ] **Step 1: Add imports** + +Add the filter parser import and the two new service fns to the existing imports: + +```ts +import { parseOperationalFilters } from '@/lib/services/reports/operational-filters'; +import { + getOperationalKpis, + getUtilisationHeatmap, + getStatusMixOverTime, + getTenancyChurn, + getTenureDistribution, + getSigningBoxPlot, + getOccupancyByArea, + getDocumentsInPipeline, + getTenanciesEndingSoon, + getVacantBerths, + getStuckSigning, + getHighestValueVacant, + getOperationalAreaOptions, + operationalHasData, +} from '@/lib/services/reports/operational.service'; +``` + +- [ ] **Step 2: Parse filters and thread them into the fan-out** + +Replace the body from `const range = resolveRange(from, to);` through the end of the `Promise.all([...])` with: + +```ts +const range = resolveRange(from, to); +const filters = parseOperationalFilters(params); + +const [ + kpis, + utilisationHeatmap, + statusMix, + tenancyChurn, + tenureDistribution, + signingBoxPlot, + occupancyByArea, + docsInPipeline, + endingSoon, + vacantBerths, + stuckSigning, + highestValueVacant, + areaOptions, + hasData, +] = await Promise.all([ + getOperationalKpis(ctx.portId, range, filters), + getUtilisationHeatmap(ctx.portId, 24, filters), + getStatusMixOverTime(ctx.portId), + getTenancyChurn(ctx.portId), + getTenureDistribution(ctx.portId), + getSigningBoxPlot(ctx.portId), + getOccupancyByArea(ctx.portId, filters), + getDocumentsInPipeline(ctx.portId), + getTenanciesEndingSoon(ctx.portId), + getVacantBerths(ctx.portId, 60, filters), + getStuckSigning(ctx.portId), + getHighestValueVacant(ctx.portId, 10, filters), + getOperationalAreaOptions(ctx.portId), + operationalHasData(ctx.portId), +]); +``` + +- [ ] **Step 3: Add `areaOptions` + `hasData` to the response payload** + +In the `NextResponse.json({ data: { … } })` block, add the two fields next to `range`: + +```ts + highestValueVacant, + areaOptions, + hasData, + range: { + from: range.from.toISOString(), + to: range.to.toISOString(), + }, +``` + +- [ ] **Step 4: Typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/v1/reports/operational/route.ts +git commit -m "feat(reports): operational route — Area filter + areaOptions + hasData" +``` + +--- + +### Task 4: Sales `hasData` (service + route) + +**Files:** + +- Modify: `src/lib/services/reports/sales.service.ts` +- Modify: `src/app/api/v1/reports/sales/route.ts` + +- [ ] **Step 1: Add `salesHasData` to the service** + +At the end of `src/lib/services/reports/sales.service.ts`, add (the file already imports `db`, `interests`, `eq`, and `sql`): + +```ts +/** + * Window-independent existence check: does this port have any interest at + * all? Drives the Sales report-level empty state. + */ +export async function salesHasData(portId: string): Promise { + const rows = await db + .select({ one: sql`1` }) + .from(interests) + .where(eq(interests.portId, portId)) + .limit(1); + return rows.length > 0; +} +``` + +- [ ] **Step 2: Wire it into the route** + +In `src/app/api/v1/reports/sales/route.ts`, add `salesHasData` to the service import block (alongside `getSalesKpis` etc.), then add it to the `Promise.all` and the payload. + +Add to the destructure + `Promise.all` (place after `priorKpis`): + +```ts + lostReasonBreakdown, + priorKpis, + hasData, + ] = await Promise.all([ +``` + +…and as the final array entry (after the `priorBounds ? … : Promise.resolve(null)` line): + +```ts + priorBounds ? getSalesKpis(ctx.portId, priorBounds) : Promise.resolve(null), + salesHasData(ctx.portId), + ]); +``` + +Add `hasData` to the response `data` object (next to `range`): + +```ts + lostReasonBreakdown, + hasData, + range: { + from: range.from.toISOString(), + to: range.to.toISOString(), + }, +``` + +- [ ] **Step 3: Typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/services/reports/sales.service.ts src/app/api/v1/reports/sales/route.ts +git commit -m "feat(reports): sales hasData existence flag (service + route)" +``` + +--- + +### Task 5: Financial `hasData` (service + route) + +**Files:** + +- Modify: `src/lib/services/reports/financial.service.ts` +- Modify: `src/app/api/v1/reports/financial/route.ts` + +- [ ] **Step 1: Add `financialHasData` to the service** + +At the end of `src/lib/services/reports/financial.service.ts`, add (the file already imports `payments` and `expenses`). Ensure `eq` and `sql` are in its `drizzle-orm` import — add them if missing: + +```ts +/** + * Window-independent existence check: does this port have any payment OR + * expense? Drives the Financial report-level empty state. + */ +export async function financialHasData(portId: string): Promise { + const [pay, exp] = await Promise.all([ + db + .select({ one: sql`1` }) + .from(payments) + .where(eq(payments.portId, portId)) + .limit(1), + db + .select({ one: sql`1` }) + .from(expenses) + .where(eq(expenses.portId, portId)) + .limit(1), + ]); + return pay.length > 0 || exp.length > 0; +} +``` + +- [ ] **Step 2: Wire it into the route** + +In `src/app/api/v1/reports/financial/route.ts`, add `financialHasData` to the service import block, then to the `Promise.all` + payload. + +Destructure + `Promise.all` (add as the final entry): + +```ts + refundLog, + expenseLedger, + hasData, + ] = await Promise.all([ + … + getExpenseLedger(ctx.portId, range), + financialHasData(ctx.portId), + ]); +``` + +Payload (next to `range`): + +```ts + expenseLedger, + hasData, + range: { from: range.from.toISOString(), to: range.to.toISOString() }, +``` + +- [ ] **Step 3: Typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/services/reports/financial.service.ts src/app/api/v1/reports/financial/route.ts +git commit -m "feat(reports): financial hasData existence flag (service + route)" +``` + +--- + +### Task 6: Shared `` component + +**Files:** + +- Create: `src/components/reports/shared/report-empty-state.tsx` + +- [ ] **Step 1: Create the component** + +```tsx +import Link from 'next/link'; +import type { Route } from 'next'; +import type { LucideIcon } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; + +interface ReportEmptyStateProps { + icon: LucideIcon; + title: string; + body: string; + actionLabel: string; + actionHref: Route; +} + +/** + * Report-level empty state. Rendered when a report's `hasData` flag is + * false (the port has no underlying data at all), in place of the report + * body — distinct from the per-chart "no data in this window" states. + */ +export function ReportEmptyState({ + icon: Icon, + title, + body, + actionLabel, + actionHref, +}: ReportEmptyStateProps) { + return ( +
+
+ +
+

{title}

+

{body}

+ +
+ ); +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/reports/shared/report-empty-state.tsx +git commit -m "feat(reports): shared ReportEmptyState component" +``` + +--- + +### Task 7: Wire empty state into the Sales client + +**Files:** + +- Modify: `src/components/reports/sales/sales-report-client.tsx` + +- [ ] **Step 1: Add imports + un-alias `portSlug`** + +Add `import type { Route } from 'next';` near the other imports, and: + +```ts +import { ReportEmptyState } from '@/components/reports/shared/report-empty-state'; +``` + +(`TrendingUp` is already imported from `lucide-react`.) + +Change the component signature (line ~277): + +```ts +export function SalesReportClient({ portSlug }: { portSlug: string }) { +``` + +- [ ] **Step 2: Add `hasData` to the payload type** + +In the `SalesReportPayload` interface's `data` object (~line 213), add after `range`: + +```ts + range: { from: string; to: string }; + hasData: boolean; + }; +} +``` + +- [ ] **Step 3: Add the `data` accessor + empty-state early return** + +After `const kpis = query.data?.data.kpis;` (~line 347) add: + +```ts +const data = query.data?.data; +``` + +Immediately before the main `return (` (~line 597), add: + +```ts + if (!query.isLoading && data && !data.hasData) { + return ( +
+ + +
+ ); + } +``` + +- [ ] **Step 4: Typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/reports/sales/sales-report-client.tsx +git commit -m "feat(reports): sales report-level empty state" +``` + +--- + +### Task 8: Wire empty state into the Financial client + +**Files:** + +- Modify: `src/components/reports/financial/financial-report-client.tsx` + +- [ ] **Step 1: Add imports + un-alias `portSlug`** + +Add near the imports: + +```ts +import Link from 'next/link'; +import type { Route } from 'next'; +import { Wallet } from 'lucide-react'; + +import { ReportEmptyState } from '@/components/reports/shared/report-empty-state'; +``` + +(`Link` may already be imported — if so, skip that line.) Change the component signature (line ~141): + +```ts +export function FinancialReportClient({ portSlug }: { portSlug: string }) { +``` + +- [ ] **Step 2: Add `hasData` to the payload type** + +In the `FinancialPayload` interface's `data` object, add `hasData: boolean;` alongside `kpis` / `range` (additive — place it next to `range`). + +- [ ] **Step 3: Add the empty-state early return** + +The data accessor is `const d = query.data?.data;` (~line 174). Immediately before the main `return (` (~line 274), add: + +```ts + if (!query.isLoading && d && !d.hasData) { + return ( +
+ + +
+ ); + } +``` + +- [ ] **Step 4: Typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/reports/financial/financial-report-client.tsx +git commit -m "feat(reports): financial report-level empty state" +``` + +--- + +### Task 9: Wire empty state into the Operational client + +**Files:** + +- Modify: `src/components/reports/operational/operational-report-client.tsx` + +- [ ] **Step 1: Add import + payload fields** + +Add near the imports: + +```ts +import { ReportEmptyState } from '@/components/reports/shared/report-empty-state'; +``` + +(`Anchor` and `type Route` are already imported.) In `OperationalReportPayload`'s `data` object (~line 164), add after `range`: + +```ts + range: { from: string; to: string }; + hasData: boolean; + areaOptions: string[]; + }; +} +``` + +- [ ] **Step 2: Add the empty-state early return** + +The data accessor is `const data = query.data?.data;` (~line 216). Immediately before the main `return (` (~line 315), add: + +```ts + if (!query.isLoading && data && !data.hasData) { + return ( +
+ + +
+ ); + } +``` + +- [ ] **Step 3: Typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/reports/operational/operational-report-client.tsx +git commit -m "feat(reports): operational report-level empty state" +``` + +--- + +### Task 10: Operational Area filter UI (FilterBar + query + template + scope note) + +**Files:** + +- Modify: `src/components/reports/operational/operational-report-client.tsx` + +- [ ] **Step 1: Add the FilterBar import** + +```ts +import { + FilterBar, + type FilterDefinition, + type FilterValues, +} from '@/components/shared/filter-bar'; +``` + +- [ ] **Step 2: Add filter state + handlers + template config field** + +Inside `OperationalReportClient`, after the existing `useState` declarations (~line 180), add: + +```ts +const [filterValues, setFilterValues] = useState({}); +``` + +Add two handlers next to `handleStatusMixChange` (~line 190). They clear the active-template badge, matching the existing user-driven setters: + +```ts +const handleFilterChange = useCallback((key: string, value: unknown) => { + setFilterValues((prev) => ({ ...prev, [key]: value })); + setActiveTemplateId(null); +}, []); + +const handleFiltersClear = useCallback(() => { + setFilterValues({}); + setActiveTemplateId(null); +}, []); +``` + +Extend the template config interface (~line 168): + +```ts +interface OperationalTemplateConfig extends Record { + kind: 'operational'; + range: DateRange; + statusMixMode: 'absolute' | 'proportional'; + filters?: FilterValues; +} +``` + +Add `filters` to `currentConfig` (~line 195): + +```ts +const currentConfig: OperationalTemplateConfig = useMemo( + () => ({ kind: 'operational', range, statusMixMode, filters: filterValues }), + [range, statusMixMode, filterValues], +); +``` + +Restore filters in `handleApplyTemplate` (~line 200) using the raw setter (so it doesn't clear its own badge): + +```ts +const handleApplyTemplate = useCallback((config: OperationalTemplateConfig) => { + if (config.range) setRange(config.range); + if (config.statusMixMode) setStatusMixMode(config.statusMixMode); + setFilterValues(config.filters ?? {}); +}, []); +``` + +- [ ] **Step 3: Build `filterDefs` from `areaOptions` and thread area into the query** + +After `const bounds = useMemo(...)` (~line 205), add: + +```ts +const areaOptions = query.data?.data.areaOptions; +const filterDefs = useMemo(() => { + if (!areaOptions || areaOptions.length === 0) return []; + return [ + { + key: 'area', + label: 'Berth area', + type: 'multi-select', + options: areaOptions.map((a) => ({ value: a, label: a })), + }, + ]; +}, [areaOptions]); + +const filterQs = useMemo(() => { + const areas = filterValues.area; + return Array.isArray(areas) && areas.length > 0 + ? `&area=${encodeURIComponent(areas.join(','))}` + : ''; +}, [filterValues]); +``` + +> Note: `areaOptions` is read off `query.data` (defined just above this block); referencing it before the `useQuery` call below is fine because it's evaluated at render time, not hoisted. If your linter complains about use-before-assign, move these three `const`s to just after the `useQuery({...})` block instead. + +Update the `useQuery` (~line 207) to include `filterQs` in both the key and the URL: + +```ts +const query = useQuery({ + queryKey: [ + 'reports', + 'operational', + bounds.from.toISOString(), + bounds.to.toISOString(), + filterQs, + ], + queryFn: () => + apiFetch( + `/api/v1/reports/operational?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}${filterQs}`, + ), + staleTime: 30_000, +}); +``` + +- [ ] **Step 4: Render the FilterBar in the header + add the scope note** + +In the `PageHeader` `actions` prop (~line 321), add the FilterBar before the `DateRangePicker` (gated so an empty Filters button never shows): + +```tsx + actions={ +
+ {filterDefs.length > 0 ? ( + + ) : null} + + +``` + +Immediately after the `` element closes (before the `{/* KPI strip */}` comment, ~line 337), add the scope note: + +```tsx +{ + Array.isArray(filterValues.area) && filterValues.area.length > 0 ? ( +

+ Berth surfaces (KPIs, occupancy, vacant lists) scoped to:{' '} + + {(filterValues.area as string[]).join(', ')} + + . Trend and tenancy panels show the full port. +

+ ) : null; +} +``` + +- [ ] **Step 5: Typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/reports/operational/operational-report-client.tsx +git commit -m "feat(reports): operational Area filter (FilterBar + query + template scope)" +``` + +--- + +### Task 11: Browser verification + tracker update + +**Files:** + +- Modify: `docs/launch-readiness.md` + +- [ ] **Step 1: Run the full unit suite + typecheck** + +Run: `pnpm exec tsc --noEmit && pnpm exec vitest run tests/unit/services/reports/` +Expected: tsc exit 0; vitest all green (includes the new `operational-filters.test.ts`). + +- [ ] **Step 2: Browser-verify the Operational Area filter** + +With the dev server on `http://localhost:3000`, drive the Playwright MCP (or a browser): + +- Navigate to `http://localhost:3000/port-nimara/reports/operational`. +- Confirm a "Filters" button appears in the header; open it, select one berth area (e.g. `A`). +- Confirm the scope note renders ("Berth surfaces … scoped to: A …"), and that **Occupancy by area** + the **vacant-berth tables** now show only that area, while **Status mix over time** and the tenancy panels are unchanged. +- Clear the filter; confirm everything returns to port-wide. + +- [ ] **Step 3: Browser-verify an empty-state hero** + +`port-nimara` has data, so verify the empty-state render path directly: in the Operational client, temporarily hard-code `data` to `{ ...data, hasData: false }` (or set `!data.hasData` → `true` in the guard), reload `/port-nimara/reports/operational`, and confirm the "No berths yet" hero renders with a working "Add berths" button linking to `/port-nimara/berths`. **Revert the temporary edit** before committing. (The Sales/Financial heroes use the identical pattern, so verifying one confirms the shape.) + +- [ ] **Step 4: Update the launch-readiness tracker** + +In `docs/launch-readiness.md`, under "Reports — what's left", mark the two shipped items. Change the empty-state bullet (currently `❌ Empty-state copy per report`) to: + +```markdown +- ✅ **Empty-state copy per report** — **SHIPPED.** Window-independent + `hasData` flag on the Sales / Operational / Financial routes drives a + shared `` hero (icon + onboarding action) when the + port has no underlying data, distinct from the per-chart "no data in + window" states. +``` + +And under "Phase 2 — Operational report gaps", update the Operational-filters bullet to note Area shipped: + +```markdown +- ⚠️ **Operational-specific filters**: **Area SHIPPED** (berth-scope: + `parseOperationalFilters` + `getOperationalAreaOptions`, threaded + through the 5 berth-derived service fns; KPIs/occupancy/vacant lists + reflect the selected areas, trend + tenancy panels stay port-wide). + Status / tenure type / document type deferred (Status is a light + filter here — see 2026-06-02 design spec). +``` + +- [ ] **Step 5: Final commit** + +```bash +git add docs/launch-readiness.md +git commit -m "docs(launch): reports polish shipped — empty states + Operational Area filter" +``` + +--- + +## Self-Review + +**Spec coverage:** + +- Empty states (Sales/Operational/Financial) → Tasks 4, 5, 2 (hasData helpers) + 6 (component) + 7, 8, 9 (wiring). ✓ +- `hasData` window-independence → existence helpers ignore the range. ✓ +- Operational Area filter parse → Task 1; area options → Task 2; route threading → Task 3; service threading → Task 2; UI/FilterBar/template/scope note → Task 10. ✓ +- Area applies to KPIs/occupancy/heatmap/vacant lists only; trend + tenancy/signing/docs port-wide → Task 2 (Steps 3–6 thread only the 5 fns; `getStatusMixOverTime` left unfiltered). ✓ +- Template round-trip of area scope → Task 10 Step 2. ✓ +- Out-of-scope (Status, tenure/doc-type, rep/source) → not implemented. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows full code. ✓ + +**Type consistency:** `OperationalFilters` defined in Task 1, imported in Task 2, used in Tasks 2–3. `hasData`/`areaOptions` added to payload types (Tasks 7–9) match the route additions (Tasks 3–5). `areaCond` defined once (Task 2 Step 2) and reused. Component prop names (`icon`/`title`/`body`/`actionLabel`/`actionHref`) match all three call sites. ✓