feat(tenancies-p7): 4 module-gated dashboard widgets

- tenancy-reports.service.ts: 4 read-only query functions backing the
  widgets. Heatmap uses a months×areas SQL grid with date-range overlap;
  renewals-at-risk filters active tenancies whose end_date is inside a
  90d window with NO successor pending/active row already minted on the
  same berth; revenue forecast buckets active tenancies by their
  end-date quarter; tenure breakdown is a simple GROUP BY status='active'.
- 4 new API routes under /api/v1/dashboard/tenancy-*:
  - tenancy-occupancy (heatmap)
  - tenancy-renewals (at-risk list)
  - tenancy-revenue (forecast)
  - tenancy-tenure (breakdown)
  Each prepended with assertTenanciesModuleEnabled so a port without
  the module gets 404 instead of an empty payload.
- 4 widget components:
  - TenancyOccupancyHeatmapWidget — areas × months table with shaded
    cells (5-tier emerald ramp by occupancy %)
  - TenancyRenewalsAtRiskWidget — top-10 list, 30-day urgency badge
  - TenancyRevenueForecastWidget — horizontal bar list by quarter,
    currency-formatted totals
  - TenancyByTenureTypeWidget — proportional bars, color-coded per
    tenure type
- WidgetIntegration union extended with 'tenancies_module'; the
  useDashboardIntegrations hook reads it off PortProvider (no extra
  fetch). All four widgets register with selfGates=true +
  requires='tenancies_module' so the picker AND render path filter
  them out when the module is off.

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:34:43 +02:00
parent e4daa482de
commit db14056018
11 changed files with 797 additions and 1 deletions

View File

@@ -0,0 +1,247 @@
/**
* Read-only dashboard widget queries for the Tenancies module (P7).
*
* Every function is port-scoped at the SQL level + assumes the module is
* enabled by the caller. The four shapes mirror the dashboard widgets
* documented in `docs/tenancies-design.md` § "Reporting widgets":
*
* 1. Occupancy heatmap by month
* 2. Renewals at risk (next 90 days)
* 3. Revenue forecast by tenure expiry
* 4. Tenancy by tenure type breakdown
*/
import { and, count, eq, gte, lte, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { clients } from '@/lib/db/schema/clients';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { yachts } from '@/lib/db/schema/yachts';
const DAY_MS = 86_400_000;
// ─── 1. Occupancy heatmap by month ───────────────────────────────────────────
export interface OccupancyHeatmapCell {
area: string;
month: string; // ISO YYYY-MM
occupancyPct: number; // 0..100
berthCount: number;
occupiedBerthMonths: number;
}
/**
* Returns one cell per (area, month) pair across the requested range.
* Occupancy = fraction of the area's berths covered by an active /
* ended tenancy whose date range overlaps the month.
*/
export async function getOccupancyHeatmap(
portId: string,
range: { from: Date; to: Date },
): Promise<OccupancyHeatmapCell[]> {
const rows = await db.execute<{
area: string;
month: string;
berth_count: string;
occupied_count: string;
}>(sql`
WITH months AS (
SELECT to_char(generate_series(
date_trunc('month', ${range.from.toISOString()}::timestamptz),
date_trunc('month', ${range.to.toISOString()}::timestamptz),
'1 month'
), 'YYYY-MM') AS month
),
areas AS (
SELECT DISTINCT COALESCE(area, '—') AS area
FROM berths
WHERE port_id = ${portId} AND archived_at IS NULL
),
grid AS (
SELECT a.area, m.month FROM areas a CROSS JOIN months m
),
berth_per_area AS (
SELECT COALESCE(area, '—') AS area, COUNT(*) AS berth_count
FROM berths
WHERE port_id = ${portId} AND archived_at IS NULL
GROUP BY area
),
occupied AS (
SELECT
COALESCE(b.area, '—') AS area,
to_char(date_trunc('month', m.month::date), 'YYYY-MM') AS month,
COUNT(DISTINCT bt.berth_id) AS occupied_count
FROM berth_tenancies bt
INNER JOIN berths b ON b.id = bt.berth_id
CROSS JOIN months m
WHERE bt.port_id = ${portId}
AND bt.status IN ('active', 'ended')
AND bt.start_date <= (date_trunc('month', m.month::date) + interval '1 month' - interval '1 day')
AND (bt.end_date IS NULL OR bt.end_date >= date_trunc('month', m.month::date))
GROUP BY b.area, month
)
SELECT g.area, g.month,
COALESCE(bpa.berth_count, 0)::text AS berth_count,
COALESCE(o.occupied_count, 0)::text AS occupied_count
FROM grid g
LEFT JOIN berth_per_area bpa ON bpa.area = g.area
LEFT JOIN occupied o ON o.area = g.area AND o.month = g.month
ORDER BY g.area, g.month
`);
return rows.map((r) => {
const berthCount = Number(r.berth_count) || 0;
const occupied = Number(r.occupied_count) || 0;
return {
area: r.area,
month: r.month,
berthCount,
occupiedBerthMonths: occupied,
occupancyPct: berthCount > 0 ? Math.round((occupied / berthCount) * 100) : 0,
};
});
}
// ─── 2. Renewals at risk ─────────────────────────────────────────────────────
export interface RenewalAtRisk {
tenancyId: string;
berthId: string;
mooringNumber: string;
clientId: string;
clientName: string;
yachtId: string;
yachtName: string | null;
endDate: string; // ISO date
daysUntilEnd: number;
tenureType: string;
}
export async function getRenewalsAtRisk(
portId: string,
options: { windowDays?: number } = {},
): Promise<RenewalAtRisk[]> {
const windowDays = options.windowDays ?? 90;
const now = new Date();
const horizon = new Date(now.getTime() + windowDays * DAY_MS);
const rows = await db
.select({
id: berthTenancies.id,
berthId: berthTenancies.berthId,
mooringNumber: berths.mooringNumber,
clientId: berthTenancies.clientId,
clientName: clients.fullName,
yachtId: berthTenancies.yachtId,
yachtName: yachts.name,
endDate: berthTenancies.endDate,
tenureType: berthTenancies.tenureType,
})
.from(berthTenancies)
.innerJoin(berths, eq(berths.id, berthTenancies.berthId))
.innerJoin(clients, eq(clients.id, berthTenancies.clientId))
.innerJoin(yachts, eq(yachts.id, berthTenancies.yachtId))
.where(
and(
eq(berthTenancies.portId, portId),
eq(berthTenancies.status, 'active'),
sql`${berthTenancies.endDate} IS NOT NULL`,
lte(berthTenancies.endDate, horizon),
gte(berthTenancies.endDate, now),
// No successor tenancy started before the end date for this berth.
sql`NOT EXISTS (
SELECT 1 FROM berth_tenancies succ
WHERE succ.berth_id = ${berthTenancies.berthId}
AND succ.port_id = ${portId}
AND succ.id <> ${berthTenancies.id}
AND succ.status IN ('pending', 'active')
AND succ.start_date >= ${berthTenancies.startDate}
)`,
),
)
.orderBy(berthTenancies.endDate);
return rows.map((r) => ({
tenancyId: r.id,
berthId: r.berthId,
mooringNumber: r.mooringNumber,
clientId: r.clientId,
clientName: r.clientName,
yachtId: r.yachtId,
yachtName: r.yachtName,
endDate: r.endDate!.toISOString(),
daysUntilEnd: Math.max(0, Math.floor((r.endDate!.getTime() - now.getTime()) / DAY_MS)),
tenureType: r.tenureType,
}));
}
// ─── 3. Revenue forecast by tenure expiry ────────────────────────────────────
export interface RevenueForecastBucket {
quarterEnd: string; // ISO YYYY-MM-DD
endingTenancyCount: number;
totalAtRisk: number;
currency: string | null;
}
export async function getRevenueForecast(
portId: string,
options: { horizonQuarters?: number } = {},
): Promise<RevenueForecastBucket[]> {
const horizonQuarters = options.horizonQuarters ?? 8;
const now = new Date();
const horizon = new Date(now.getFullYear(), now.getMonth() + horizonQuarters * 3, 0);
const rows = await db.execute<{
quarter_end: string;
ending_count: string;
total_at_risk: string;
currency: string | null;
}>(sql`
SELECT
to_char(date_trunc('quarter', bt.end_date) + interval '3 months' - interval '1 day', 'YYYY-MM-DD') AS quarter_end,
COUNT(*)::text AS ending_count,
COALESCE(SUM(b.price::numeric), 0)::text AS total_at_risk,
MAX(b.price_currency) AS currency
FROM berth_tenancies bt
INNER JOIN berths b ON b.id = bt.berth_id
WHERE bt.port_id = ${portId}
AND bt.status = 'active'
AND bt.end_date IS NOT NULL
AND bt.end_date >= ${now.toISOString()}
AND bt.end_date <= ${horizon.toISOString()}
GROUP BY quarter_end
ORDER BY quarter_end
`);
return rows.map((r) => ({
quarterEnd: r.quarter_end,
endingTenancyCount: Number(r.ending_count) || 0,
totalAtRisk: Number(r.total_at_risk) || 0,
currency: r.currency,
}));
}
// ─── 4. Tenancy by tenure type breakdown ─────────────────────────────────────
export interface TenureBreakdownRow {
tenureType: string;
activeCount: number;
}
export async function getTenureTypeBreakdown(portId: string): Promise<TenureBreakdownRow[]> {
const rows = await db
.select({
tenureType: berthTenancies.tenureType,
activeCount: count(),
})
.from(berthTenancies)
.where(and(eq(berthTenancies.portId, portId), eq(berthTenancies.status, 'active')))
.groupBy(berthTenancies.tenureType);
return rows.map((r) => ({
tenureType: r.tenureType,
activeCount: Number(r.activeCount),
}));
}