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,98 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
interface TenureRow {
tenureType: string;
activeCount: number;
}
interface TenureResponse {
data: TenureRow[];
}
const TENURE_LABELS: Record<string, string> = {
permanent: 'Permanent',
fee_simple: 'Fee simple',
strata_lot: 'Strata lot',
fixed_term: 'Fixed term',
seasonal: 'Seasonal',
};
const TENURE_COLORS: Record<string, string> = {
permanent: 'bg-emerald-500',
fee_simple: 'bg-blue-500',
strata_lot: 'bg-indigo-500',
fixed_term: 'bg-amber-500',
seasonal: 'bg-sky-500',
};
/**
* Distribution of active tenancies by tenure type. Lightweight bar
* variant of the design's donut spec; ECharts donut lands alongside the
* recharts→ECharts pass.
*/
export function TenancyByTenureTypeWidget() {
const { data, isLoading } = useQuery<TenureResponse>({
queryKey: ['dashboard', 'tenancy_tenure'],
queryFn: () => apiFetch<TenureResponse>('/api/v1/dashboard/tenancy-tenure'),
staleTime: 60_000,
});
const rows = data?.data ?? [];
const total = rows.reduce((acc, r) => acc + r.activeCount, 0);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Tenancies by tenure type</CardTitle>
<CardDescription>
{total === 0
? 'No active tenancies yet.'
: `${total} active tenanc${total === 1 ? 'y' : 'ies'} across the port.`}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-[180px] w-full" aria-hidden />
) : rows.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Tenancy data appears here once active rows exist.
</p>
) : (
<ul className="space-y-2">
{rows
.slice()
.sort((a, b) => b.activeCount - a.activeCount)
.map((r) => {
const pct = total > 0 ? Math.round((r.activeCount / total) * 100) : 0;
return (
<li key={r.tenureType} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-medium">
{TENURE_LABELS[r.tenureType] ?? r.tenureType}
</span>
<span className="text-muted-foreground">
{r.activeCount} · {pct}%
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={`h-full ${TENURE_COLORS[r.tenureType] ?? 'bg-slate-400'}`}
style={{ width: `${pct}%` }}
aria-hidden
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
interface Cell {
area: string;
month: string;
occupancyPct: number;
berthCount: number;
occupiedBerthMonths: number;
}
interface HeatmapResponse {
data: Cell[];
}
function shade(pct: number): string {
if (pct >= 90) return 'bg-emerald-700 text-white';
if (pct >= 75) return 'bg-emerald-500 text-white';
if (pct >= 50) return 'bg-emerald-300';
if (pct >= 25) return 'bg-emerald-100';
if (pct > 0) return 'bg-slate-100';
return 'bg-slate-50 text-slate-400';
}
function formatMonth(iso: string): string {
const [year, month] = iso.split('-');
if (!year || !month) return iso;
const d = new Date(Number(year), Number(month) - 1, 1);
return d.toLocaleString(undefined, { month: 'short' });
}
/**
* Per-(berth-area × month) occupancy heatmap. Cell shade encodes the
* fraction of berths in that area covered by an active or ended tenancy
* whose date range overlaps the month.
*/
export function TenancyOccupancyHeatmapWidget() {
const { data, isLoading } = useQuery<HeatmapResponse>({
queryKey: ['dashboard', 'tenancy_occupancy'],
queryFn: () => apiFetch<HeatmapResponse>('/api/v1/dashboard/tenancy-occupancy'),
staleTime: 60_000,
});
const { areas, months, byKey } = useMemo(() => {
const cells = data?.data ?? [];
const areaSet = new Set<string>();
const monthSet = new Set<string>();
const map = new Map<string, Cell>();
for (const c of cells) {
areaSet.add(c.area);
monthSet.add(c.month);
map.set(`${c.area}|${c.month}`, c);
}
return {
areas: [...areaSet].sort(),
months: [...monthSet].sort(),
byKey: map,
};
}, [data]);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Occupancy heatmap</CardTitle>
<CardDescription>
{areas.length === 0
? 'No berths configured yet.'
: 'Monthly occupancy across berth areas.'}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-[200px] w-full" aria-hidden />
) : areas.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">No data.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
<tr>
<th className="px-2 py-1 text-left font-semibold text-muted-foreground">Area</th>
{months.map((m) => (
<th key={m} className="px-1 py-1 text-center font-normal text-muted-foreground">
{formatMonth(m)}
</th>
))}
</tr>
</thead>
<tbody>
{areas.map((area) => (
<tr key={area}>
<td className="px-2 py-1 font-medium">{area}</td>
{months.map((m) => {
const cell = byKey.get(`${area}|${m}`);
const pct = cell?.occupancyPct ?? 0;
return (
<td
key={m}
className={`px-1 py-1 text-center ${shade(pct)}`}
title={`${area} · ${m}: ${pct}% (${cell?.occupiedBerthMonths ?? 0}/${cell?.berthCount ?? 0})`}
>
{pct}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
interface Renewal {
tenancyId: string;
berthId: string;
mooringNumber: string;
clientId: string;
clientName: string;
yachtName: string | null;
endDate: string;
daysUntilEnd: number;
tenureType: string;
}
interface RenewalsResponse {
data: Renewal[];
}
/**
* Lists active tenancies expiring in the next 90 days with no successor
* row in place. Surfaces the rep's renewal pipeline so a fixed-term
* tenancy doesn't lapse silently. Mounted in the dashboard chart grid;
* gated by the `tenancies_module` integration channel.
*/
export function TenancyRenewalsAtRiskWidget() {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const { data, isLoading } = useQuery<RenewalsResponse>({
queryKey: ['dashboard', 'tenancy_renewals'],
queryFn: () => apiFetch<RenewalsResponse>('/api/v1/dashboard/tenancy-renewals?windowDays=90'),
staleTime: 60_000,
});
const renewals = data?.data ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Renewals at risk</CardTitle>
<CardDescription>
{renewals.length === 0
? 'No fixed-term tenancies expiring in the next 90 days.'
: `${renewals.length} tenanc${renewals.length === 1 ? 'y' : 'ies'} expiring without a successor.`}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-[200px] w-full" aria-hidden />
) : renewals.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
All active fixed-term tenancies have a successor or expire later.
</p>
) : (
<ul className="divide-y">
{renewals.slice(0, 10).map((r) => (
<li key={r.tenancyId} className="flex items-center justify-between py-2 text-sm">
<div className="min-w-0 flex-1">
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/tenancies/${r.tenancyId}` as any}
className="font-medium text-primary hover:underline"
>
Berth {r.mooringNumber}
</Link>
<span className="ml-2 text-xs text-muted-foreground">
{r.clientName}
{r.yachtName ? ` · ${r.yachtName}` : ''}
</span>
</div>
<div className="ml-3 flex items-center gap-2 text-xs">
<Badge
variant={r.daysUntilEnd <= 30 ? 'destructive' : 'secondary'}
className="font-mono"
>
{r.daysUntilEnd}d
</Badge>
<span className="text-muted-foreground">
{new Date(r.endDate).toLocaleDateString()}
</span>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils/currency';
interface Bucket {
quarterEnd: string;
endingTenancyCount: number;
totalAtRisk: number;
currency: string | null;
}
interface RevenueResponse {
data: Bucket[];
}
function formatQuarter(iso: string): string {
const d = new Date(iso);
const q = Math.floor(d.getMonth() / 3) + 1;
return `Q${q} ${d.getFullYear()}`;
}
/**
* Forward-looking projection: sums berth prices for active tenancies
* whose end-date falls in each quarter through the configured horizon.
* Highlights renewal-cliff quarters.
*/
export function TenancyRevenueForecastWidget() {
const { data, isLoading } = useQuery<RevenueResponse>({
queryKey: ['dashboard', 'tenancy_revenue'],
queryFn: () => apiFetch<RevenueResponse>('/api/v1/dashboard/tenancy-revenue?horizonQuarters=8'),
staleTime: 60_000,
});
const buckets = data?.data ?? [];
const maxValue = buckets.reduce((acc, b) => Math.max(acc, b.totalAtRisk), 0);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Revenue forecast</CardTitle>
<CardDescription>
{buckets.length === 0
? 'No fixed-term tenancies expire in the forecast window.'
: 'Berth value tied to tenancies ending each quarter.'}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-[200px] w-full" aria-hidden />
) : buckets.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Nothing expiring in the next 8 quarters.
</p>
) : (
<ul className="space-y-2">
{buckets.map((b) => {
const widthPct = maxValue > 0 ? Math.round((b.totalAtRisk / maxValue) * 100) : 0;
return (
<li key={b.quarterEnd} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-medium">{formatQuarter(b.quarterEnd)}</span>
<span className="text-muted-foreground">
{b.endingTenancyCount} {b.endingTenancyCount === 1 ? 'tenancy' : 'tenancies'}{' '}
·{' '}
{b.currency
? formatCurrency(b.totalAtRisk, b.currency, { maxFractionDigits: 0 })
: b.totalAtRisk.toLocaleString()}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-amber-500"
style={{ width: `${widthPct}%` }}
aria-hidden
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@@ -54,6 +54,34 @@ const SourceConversionChart = dynamic(
() => import('./source-conversion-chart').then((m) => ({ default: m.SourceConversionChart })),
{ loading: ChartFallback, ssr: false },
);
const TenancyOccupancyHeatmapWidget = dynamic(
() =>
import('./tenancy-occupancy-heatmap').then((m) => ({
default: m.TenancyOccupancyHeatmapWidget,
})),
{ loading: ChartFallback, ssr: false },
);
const TenancyRenewalsAtRiskWidget = dynamic(
() =>
import('./tenancy-renewals-at-risk').then((m) => ({
default: m.TenancyRenewalsAtRiskWidget,
})),
{ loading: ChartFallback, ssr: false },
);
const TenancyRevenueForecastWidget = dynamic(
() =>
import('./tenancy-revenue-forecast').then((m) => ({
default: m.TenancyRevenueForecastWidget,
})),
{ loading: ChartFallback, ssr: false },
);
const TenancyByTenureTypeWidget = dynamic(
() =>
import('./tenancy-by-tenure-type').then((m) => ({
default: m.TenancyByTenureTypeWidget,
})),
{ loading: ChartFallback, ssr: false },
);
/**
* Where a widget lives on the dashboard. The shell renders three
@@ -74,7 +102,7 @@ export type WidgetGroup = 'chart' | 'rail' | 'feed';
* something that would render nothing. Wire new integrations through
* `useDashboardIntegrations()`.
*/
export type WidgetIntegration = 'umami' | 'documenso';
export type WidgetIntegration = 'umami' | 'documenso' | 'tenancies_module';
export interface DashboardWidget {
/** Stable persistence key. Don't rename - old preferences would break. */
@@ -251,6 +279,50 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
group: 'feed',
defaultVisible: true,
},
// ── Tenancies module widgets ───────────────────────────────────────────
// All four self-gate on `tenancies_module`. Hidden from picker + render
// when the module isn't enabled for the active port.
{
id: 'tenancy_occupancy_heatmap',
label: 'Occupancy heatmap',
description: 'Per-(berth area × month) occupancy across the year.',
render: () => <TenancyOccupancyHeatmapWidget />,
group: 'chart',
defaultVisible: true,
selfGates: true,
requires: 'tenancies_module',
},
{
id: 'tenancy_renewals_at_risk',
label: 'Renewals at risk',
description: 'Active tenancies expiring in the next 90 days without a successor.',
render: () => <TenancyRenewalsAtRiskWidget />,
group: 'rail',
defaultVisible: true,
selfGates: true,
requires: 'tenancies_module',
},
{
id: 'tenancy_revenue_forecast',
label: 'Tenancy revenue forecast',
description: 'Berth value tied to tenancies ending each quarter, projected forward.',
render: () => <TenancyRevenueForecastWidget />,
group: 'chart',
defaultVisible: true,
selfGates: true,
requires: 'tenancies_module',
},
{
id: 'tenancy_by_tenure_type',
label: 'Tenancies by tenure type',
description: 'Active tenancy mix by tenure (permanent / fixed-term / seasonal …).',
render: () => <TenancyByTenureTypeWidget />,
group: 'rail',
defaultVisible: true,
selfGates: true,
requires: 'tenancies_module',
},
];
/** Lookup helper so consumers don't have to scan the array. */