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,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 });
}),
);

View 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 });
}),
);

View 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 });
}),
);

View 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 });
}),
);

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. */

View File

@@ -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,
},
};
}

View 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),
}));
}