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:
247
src/lib/services/tenancy-reports.service.ts
Normal file
247
src/lib/services/tenancy-reports.service.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user