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 })),
|
||||
{ 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. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
|
||||
import { useTenanciesModuleEnabled } from '@/providers/port-provider';
|
||||
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.
|
||||
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 {
|
||||
loading: umami.isLoading,
|
||||
available: {
|
||||
umami: umamiAvailable,
|
||||
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