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

34 KiB
Raw Blame History

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:

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:

/**
 * 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
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:

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):

import type { OperationalFilters } from './operational-filters';
  • Step 2: Add the area-condition helper

Immediately after the interface DateRange { … } block (~line 26), add:

/**
 * 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:

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):

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):

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(...):

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):

export async function getUtilisationHeatmap(
  portId: string,
  months = 24,
  filters?: OperationalFilters,
): Promise<UtilisationCell[]> {
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):

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):

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):

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:

/**
 * 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
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:

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:

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:

          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
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):

/**
 * 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):

        lostReasonBreakdown,
        priorKpis,
        hasData,
      ] = await Promise.all([

…and as the final array entry (after the priorBounds ? … : Promise.resolve(null) line):

        priorBounds ? getSalesKpis(ctx.portId, priorBounds) : Promise.resolve(null),
        salesHasData(ctx.portId),
      ]);

Add hasData to the response data object (next to range):

          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
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:

/**
 * 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):

        refundLog,
        expenseLedger,
        hasData,
      ] = await Promise.all([
        
        getExpenseLedger(ctx.portId, range),
        financialHasData(ctx.portId),
      ]);

Payload (next to range):

          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
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

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
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:

import { ReportEmptyState } from '@/components/reports/shared/report-empty-state';

(TrendingUp is already imported from lucide-react.)

Change the component signature (line ~277):

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:

    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:

const data = query.data?.data;

Immediately before the main return ( (~line 597), add:

  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
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:

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):

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:

  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
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:

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:

    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:

  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
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

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:

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:

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):

interface OperationalTemplateConfig extends Record<string, unknown> {
  kind: 'operational';
  range: DateRange;
  statusMixMode: 'absolute' | 'proportional';
  filters?: FilterValues;
}

Add filters to currentConfig (~line 195):

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):

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:

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 consts to just after the useQuery({...}) block instead.

Update the useQuery (~line 207) to include filterQs in both the key and the URL:

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):

        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:

{
  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
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.hasDatatrue 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:

-**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:

- ⚠️ **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
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. ✓