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:
21
src/app/api/v1/dashboard/tenancy-occupancy/route.ts
Normal file
21
src/app/api/v1/dashboard/tenancy-occupancy/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { getOccupancyHeatmap } from '@/lib/services/tenancy-reports.service';
|
||||||
|
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const from = url.searchParams.get('from');
|
||||||
|
const to = url.searchParams.get('to');
|
||||||
|
const now = new Date();
|
||||||
|
const range = {
|
||||||
|
from: from ? new Date(from) : new Date(now.getFullYear(), now.getMonth() - 11, 1),
|
||||||
|
to: to ? new Date(to) : now,
|
||||||
|
};
|
||||||
|
const data = await getOccupancyHeatmap(ctx.portId, range);
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
}),
|
||||||
|
);
|
||||||
15
src/app/api/v1/dashboard/tenancy-renewals/route.ts
Normal file
15
src/app/api/v1/dashboard/tenancy-renewals/route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { getRenewalsAtRisk } from '@/lib/services/tenancy-reports.service';
|
||||||
|
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const windowDays = Number(url.searchParams.get('windowDays') ?? 90);
|
||||||
|
const data = await getRenewalsAtRisk(ctx.portId, { windowDays });
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
}),
|
||||||
|
);
|
||||||
15
src/app/api/v1/dashboard/tenancy-revenue/route.ts
Normal file
15
src/app/api/v1/dashboard/tenancy-revenue/route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { getRevenueForecast } from '@/lib/services/tenancy-reports.service';
|
||||||
|
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const horizonQuarters = Number(url.searchParams.get('horizonQuarters') ?? 8);
|
||||||
|
const data = await getRevenueForecast(ctx.portId, { horizonQuarters });
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
}),
|
||||||
|
);
|
||||||
13
src/app/api/v1/dashboard/tenancy-tenure/route.ts
Normal file
13
src/app/api/v1/dashboard/tenancy-tenure/route.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { getTenureTypeBreakdown } from '@/lib/services/tenancy-reports.service';
|
||||||
|
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('reports', 'view_dashboard', async (_req: NextRequest, ctx) => {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
|
const data = await getTenureTypeBreakdown(ctx.portId);
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
}),
|
||||||
|
);
|
||||||
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 })),
|
() => import('./source-conversion-chart').then((m) => ({ default: m.SourceConversionChart })),
|
||||||
{ loading: ChartFallback, ssr: false },
|
{ 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
|
* 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
|
* something that would render nothing. Wire new integrations through
|
||||||
* `useDashboardIntegrations()`.
|
* `useDashboardIntegrations()`.
|
||||||
*/
|
*/
|
||||||
export type WidgetIntegration = 'umami' | 'documenso';
|
export type WidgetIntegration = 'umami' | 'documenso' | 'tenancies_module';
|
||||||
|
|
||||||
export interface DashboardWidget {
|
export interface DashboardWidget {
|
||||||
/** Stable persistence key. Don't rename - old preferences would break. */
|
/** Stable persistence key. Don't rename - old preferences would break. */
|
||||||
@@ -251,6 +279,50 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
|||||||
group: 'feed',
|
group: 'feed',
|
||||||
defaultVisible: true,
|
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. */
|
/** Lookup helper so consumers don't have to scan the array. */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
|
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
|
||||||
|
import { useTenanciesModuleEnabled } from '@/providers/port-provider';
|
||||||
import type { WidgetIntegration } from '@/components/dashboard/widget-registry';
|
import type { WidgetIntegration } from '@/components/dashboard/widget-registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,11 +36,16 @@ export function useDashboardIntegrations(): {
|
|||||||
// a Documenso widget is added before this hook is updated.
|
// a Documenso widget is added before this hook is updated.
|
||||||
const documensoAvailable = true;
|
const documensoAvailable = true;
|
||||||
|
|
||||||
|
// Tenancies module flag is resolved server-side in the dashboard layout
|
||||||
|
// and surfaced through PortProvider — no extra round-trip.
|
||||||
|
const tenanciesModuleAvailable = useTenanciesModuleEnabled();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading: umami.isLoading,
|
loading: umami.isLoading,
|
||||||
available: {
|
available: {
|
||||||
umami: umamiAvailable,
|
umami: umamiAvailable,
|
||||||
documenso: documensoAvailable,
|
documenso: documensoAvailable,
|
||||||
|
tenancies_module: tenanciesModuleAvailable,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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