diff --git a/src/app/api/v1/dashboard/tenancy-occupancy/route.ts b/src/app/api/v1/dashboard/tenancy-occupancy/route.ts new file mode 100644 index 00000000..4556a732 --- /dev/null +++ b/src/app/api/v1/dashboard/tenancy-occupancy/route.ts @@ -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 }); + }), +); diff --git a/src/app/api/v1/dashboard/tenancy-renewals/route.ts b/src/app/api/v1/dashboard/tenancy-renewals/route.ts new file mode 100644 index 00000000..49d9004c --- /dev/null +++ b/src/app/api/v1/dashboard/tenancy-renewals/route.ts @@ -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 }); + }), +); diff --git a/src/app/api/v1/dashboard/tenancy-revenue/route.ts b/src/app/api/v1/dashboard/tenancy-revenue/route.ts new file mode 100644 index 00000000..3560d93f --- /dev/null +++ b/src/app/api/v1/dashboard/tenancy-revenue/route.ts @@ -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 }); + }), +); diff --git a/src/app/api/v1/dashboard/tenancy-tenure/route.ts b/src/app/api/v1/dashboard/tenancy-tenure/route.ts new file mode 100644 index 00000000..df1cc352 --- /dev/null +++ b/src/app/api/v1/dashboard/tenancy-tenure/route.ts @@ -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 }); + }), +); diff --git a/src/components/dashboard/tenancy-by-tenure-type.tsx b/src/components/dashboard/tenancy-by-tenure-type.tsx new file mode 100644 index 00000000..d7e76516 --- /dev/null +++ b/src/components/dashboard/tenancy-by-tenure-type.tsx @@ -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 = { + permanent: 'Permanent', + fee_simple: 'Fee simple', + strata_lot: 'Strata lot', + fixed_term: 'Fixed term', + seasonal: 'Seasonal', +}; + +const TENURE_COLORS: Record = { + 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({ + queryKey: ['dashboard', 'tenancy_tenure'], + queryFn: () => apiFetch('/api/v1/dashboard/tenancy-tenure'), + staleTime: 60_000, + }); + + const rows = data?.data ?? []; + const total = rows.reduce((acc, r) => acc + r.activeCount, 0); + + return ( + + + Tenancies by tenure type + + {total === 0 + ? 'No active tenancies yet.' + : `${total} active tenanc${total === 1 ? 'y' : 'ies'} across the port.`} + + + + {isLoading ? ( + + ) : rows.length === 0 ? ( +

+ Tenancy data appears here once active rows exist. +

+ ) : ( +
    + {rows + .slice() + .sort((a, b) => b.activeCount - a.activeCount) + .map((r) => { + const pct = total > 0 ? Math.round((r.activeCount / total) * 100) : 0; + return ( +
  • +
    + + {TENURE_LABELS[r.tenureType] ?? r.tenureType} + + + {r.activeCount} · {pct}% + +
    +
    +
    +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/dashboard/tenancy-occupancy-heatmap.tsx b/src/components/dashboard/tenancy-occupancy-heatmap.tsx new file mode 100644 index 00000000..6e9c83ce --- /dev/null +++ b/src/components/dashboard/tenancy-occupancy-heatmap.tsx @@ -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({ + queryKey: ['dashboard', 'tenancy_occupancy'], + queryFn: () => apiFetch('/api/v1/dashboard/tenancy-occupancy'), + staleTime: 60_000, + }); + + const { areas, months, byKey } = useMemo(() => { + const cells = data?.data ?? []; + const areaSet = new Set(); + const monthSet = new Set(); + const map = new Map(); + 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 ( + + + Occupancy heatmap + + {areas.length === 0 + ? 'No berths configured yet.' + : 'Monthly occupancy across berth areas.'} + + + + {isLoading ? ( + + ) : areas.length === 0 ? ( +

No data.

+ ) : ( +
+ + + + + {months.map((m) => ( + + ))} + + + + {areas.map((area) => ( + + + {months.map((m) => { + const cell = byKey.get(`${area}|${m}`); + const pct = cell?.occupancyPct ?? 0; + return ( + + ); + })} + + ))} + +
Area + {formatMonth(m)} +
{area} + {pct} +
+
+ )} +
+
+ ); +} diff --git a/src/components/dashboard/tenancy-renewals-at-risk.tsx b/src/components/dashboard/tenancy-renewals-at-risk.tsx new file mode 100644 index 00000000..d7b0130e --- /dev/null +++ b/src/components/dashboard/tenancy-renewals-at-risk.tsx @@ -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({ + queryKey: ['dashboard', 'tenancy_renewals'], + queryFn: () => apiFetch('/api/v1/dashboard/tenancy-renewals?windowDays=90'), + staleTime: 60_000, + }); + + const renewals = data?.data ?? []; + + return ( + + + Renewals at risk + + {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.`} + + + + {isLoading ? ( + + ) : renewals.length === 0 ? ( +

+ All active fixed-term tenancies have a successor or expire later. +

+ ) : ( +
    + {renewals.slice(0, 10).map((r) => ( +
  • +
    + + Berth {r.mooringNumber} + + + {r.clientName} + {r.yachtName ? ` · ${r.yachtName}` : ''} + +
    +
    + + {r.daysUntilEnd}d + + + {new Date(r.endDate).toLocaleDateString()} + +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/dashboard/tenancy-revenue-forecast.tsx b/src/components/dashboard/tenancy-revenue-forecast.tsx new file mode 100644 index 00000000..e9b0babe --- /dev/null +++ b/src/components/dashboard/tenancy-revenue-forecast.tsx @@ -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({ + queryKey: ['dashboard', 'tenancy_revenue'], + queryFn: () => apiFetch('/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 ( + + + Revenue forecast + + {buckets.length === 0 + ? 'No fixed-term tenancies expire in the forecast window.' + : 'Berth value tied to tenancies ending each quarter.'} + + + + {isLoading ? ( + + ) : buckets.length === 0 ? ( +

+ Nothing expiring in the next 8 quarters. +

+ ) : ( +
    + {buckets.map((b) => { + const widthPct = maxValue > 0 ? Math.round((b.totalAtRisk / maxValue) * 100) : 0; + return ( +
  • +
    + {formatQuarter(b.quarterEnd)} + + {b.endingTenancyCount} {b.endingTenancyCount === 1 ? 'tenancy' : 'tenancies'}{' '} + ·{' '} + {b.currency + ? formatCurrency(b.totalAtRisk, b.currency, { maxFractionDigits: 0 }) + : b.totalAtRisk.toLocaleString()} + +
    +
    +
    +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/dashboard/widget-registry.tsx b/src/components/dashboard/widget-registry.tsx index ba480681..0293cf06 100644 --- a/src/components/dashboard/widget-registry.tsx +++ b/src/components/dashboard/widget-registry.tsx @@ -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: () => , + 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: () => , + 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: () => , + 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: () => , + group: 'rail', + defaultVisible: true, + selfGates: true, + requires: 'tenancies_module', + }, ]; /** Lookup helper so consumers don't have to scan the array. */ diff --git a/src/hooks/use-dashboard-integrations.ts b/src/hooks/use-dashboard-integrations.ts index 734d79b9..6686fce3 100644 --- a/src/hooks/use-dashboard-integrations.ts +++ b/src/hooks/use-dashboard-integrations.ts @@ -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, }, }; } diff --git a/src/lib/services/tenancy-reports.service.ts b/src/lib/services/tenancy-reports.service.ts new file mode 100644 index 00000000..4aa084c2 --- /dev/null +++ b/src/lib/services/tenancy-reports.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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), + })); +}