Files
pn-new-crm/docs/superpowers/plans/2026-06-02-reports-polish.md
Matt 93e96da43b docs(reports): implementation plan for beta-finish polish
11 bite-sized TDD tasks: parseOperationalFilters (unit-tested), Area
filter threaded through the operational service + route, hasData
existence flags on all three report routes, shared ReportEmptyState
component, and per-client wiring. Verification + tracker update in the
final task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:05:13 +02:00

1148 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<ReportEmptyState>` 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<OperationalKpis> {
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<number> {
const [row] = await db
.select({ value: sql<number>`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<number> {
const [row] = await db
.select({ value: sql<number>`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<number | null> {
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<UtilisationCell[]> {
```
```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<AreaOccupancyRow[]> {
const rows = await db
.select({
area: berths.area,
status: berths.status,
n: sql<number>`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<VacantBerthRow[]> {
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<HighestValueVacantRow[]> {
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<string[]> {
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<boolean> {
const rows = await db
.select({ one: sql<number>`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<boolean> {
const rows = await db
.select({ one: sql<number>`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<boolean> {
const [pay, exp] = await Promise.all([
db
.select({ one: sql<number>`1` })
.from(payments)
.where(eq(payments.portId, portId))
.limit(1),
db
.select({ one: sql<number>`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 `<ReportEmptyState>` 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 (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border px-6 py-20 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Icon className="h-6 w-6 text-muted-foreground" aria-hidden />
</div>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
<p className="mt-1.5 max-w-sm text-sm text-muted-foreground">{body}</p>
<Button asChild className="mt-5">
<Link href={actionHref}>{actionLabel}</Link>
</Button>
</div>
);
}
```
- [ ] **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 (
<div className="space-y-6">
<PageHeader
eyebrow="Reports"
title="Sales performance"
description="Rep performance, win rates, pipeline value, stalled deals, and deal heat."
/>
<ReportEmptyState
icon={TrendingUp}
title="No sales activity yet"
body="Once you add clients and log interests, this report fills with win rates, pipeline value, and deal heat."
actionLabel="Add an interest"
actionHref={`/${portSlug}/interests` as Route}
/>
</div>
);
}
```
- [ ] **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 (
<div className="space-y-6">
<PageHeader
eyebrow="Reports"
title="Financial"
description="Revenue collected, deposits, outstanding balances, cash flow, and expense breakdown."
/>
<ReportEmptyState
icon={Wallet}
title="No financial activity yet"
body="Record a payment on a deal or log an expense to see revenue, deposits, and cash flow."
actionLabel="Go to expenses"
actionHref={`/${portSlug}/expenses` as Route}
/>
</div>
);
}
```
- [ ] **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 (
<div className="space-y-6">
<PageHeader
eyebrow="Reports"
title="Operational"
description="Berth utilisation, tenancy lifecycle, signing turnaround, operational bottlenecks."
/>
<ReportEmptyState
icon={Anchor}
title="No berths yet"
body="Add berths to see utilisation, occupancy, and signing turnaround."
actionLabel="Add berths"
actionHref={`/${portSlug}/berths` as Route}
/>
</div>
);
}
```
- [ ] **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<FilterValues>({});
```
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<string, unknown> {
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<FilterDefinition[]>(() => {
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<OperationalReportPayload>({
queryKey: [
'reports',
'operational',
bounds.from.toISOString(),
bounds.to.toISOString(),
filterQs,
],
queryFn: () =>
apiFetch<OperationalReportPayload>(
`/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={
<div className="flex items-center gap-2">
{filterDefs.length > 0 ? (
<FilterBar
filters={filterDefs}
values={filterValues}
onChange={handleFilterChange}
onClear={handleFiltersClear}
/>
) : null}
<DateRangePicker value={range} onChange={handleRangeChange} />
<ReportTemplatesButton<OperationalTemplateConfig>
```
Immediately after the `<PageHeader … />` element closes (before the `{/* KPI strip */}` comment, ~line 337), add the scope note:
```tsx
{
Array.isArray(filterValues.area) && filterValues.area.length > 0 ? (
<p className="text-xs text-muted-foreground">
Berth surfaces (KPIs, occupancy, vacant lists) scoped to:{' '}
<span className="font-medium text-foreground">
{(filterValues.area as string[]).join(', ')}
</span>
. Trend and tenancy panels show the full port.
</p>
) : 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 `<ReportEmptyState>` 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 36 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 23. `hasData`/`areaOptions` added to payload types (Tasks 79) match the route additions (Tasks 35). `areaCond` defined once (Task 2 Step 2) and reused. Component prop names (`icon`/`title`/`body`/`actionLabel`/`actionHref`) match all three call sites. ✓