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:
98
src/components/dashboard/tenancy-by-tenure-type.tsx
Normal file
98
src/components/dashboard/tenancy-by-tenure-type.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/components/dashboard/tenancy-occupancy-heatmap.tsx
Normal file
121
src/components/dashboard/tenancy-occupancy-heatmap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/dashboard/tenancy-renewals-at-risk.tsx
Normal file
98
src/components/dashboard/tenancy-renewals-at-risk.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/components/dashboard/tenancy-revenue-forecast.tsx
Normal file
90
src/components/dashboard/tenancy-revenue-forecast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user