1148 lines
34 KiB
Markdown
1148 lines
34 KiB
Markdown
|
|
# 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 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. ✓
|