From 66869c9a90f5b5b5ad752a073766b4db1bd6dc04 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 15:47:49 +0200 Subject: [PATCH] feat(dashboard): berth-heat widget + investor-default surfacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6 minimal-but-functional per PRE-DEPLOY-PLAN § 1.6. Berth Heat — new widget showing top 15 berths by active interest count via the interest_berths junction (non-primary links included so multi-berth deals warm every berth in their bundle). Investor-friendly demand-pressure view; the ranked-table shape exports cleanly to PDF/ CSV. Future heatmap viz reads the same shape via /api/v1/dashboard/ berth-heat. Defaults flipped for investor-friendliness: - kpi_pipeline_value → defaultVisible (currency-aware headline number). - source_conversion → defaultVisible (conversion funnel by source; reads the inquiry → client linkage from Step 3). - berth_heat → defaultVisible. Pipeline-velocity-over-time + true heatmap viz deferred. pipeline_funnel covers snapshot stage breakdowns; over-time velocity warrants its own design pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/v1/dashboard/berth-heat/route.ts | 25 +++++ .../dashboard/berth-heat-widget.tsx | 96 +++++++++++++++++++ src/components/dashboard/widget-registry.tsx | 21 +++- src/lib/services/berth-heat.service.ts | 60 ++++++++++++ 4 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 src/app/api/v1/dashboard/berth-heat/route.ts create mode 100644 src/components/dashboard/berth-heat-widget.tsx create mode 100644 src/lib/services/berth-heat.service.ts diff --git a/src/app/api/v1/dashboard/berth-heat/route.ts b/src/app/api/v1/dashboard/berth-heat/route.ts new file mode 100644 index 00000000..4b64320c --- /dev/null +++ b/src/app/api/v1/dashboard/berth-heat/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { getBerthHeatRanking } from '@/lib/services/berth-heat.service'; + +/** + * GET /api/v1/dashboard/berth-heat + * + * Returns the top-N berths by active interest count, sorted hottest- + * first. Drives the BerthHeatWidget on the dashboard and the future + * heatmap visualization. + */ +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (req, ctx) => { + try { + const url = new URL(req.url); + const limit = Math.max(1, Math.min(50, Number(url.searchParams.get('limit') ?? '20'))); + const rows = await getBerthHeatRanking(ctx.portId, limit); + return NextResponse.json({ data: { rows } }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/dashboard/berth-heat-widget.tsx b/src/components/dashboard/berth-heat-widget.tsx new file mode 100644 index 00000000..9ec0d712 --- /dev/null +++ b/src/components/dashboard/berth-heat-widget.tsx @@ -0,0 +1,96 @@ +'use client'; + +/** + * Berth Heat widget — ranked table of berths by active interest count. + * Investor-friendly "where is the demand pressure?" surface. Renders + * a sortable table that exports cleanly to PDF/CSV. A future heatmap + * visualization can sit beside this table reading the same data. + */ +import { useQuery } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { StatusPill } from '@/components/ui/status-pill'; +import { apiFetch } from '@/lib/api/client'; + +interface HeatRow { + berthId: string; + mooringNumber: string; + area: string | null; + status: string; + activeInterestCount: number; +} + +interface HeatResponse { + data: { rows: HeatRow[] }; +} + +// Render the raw status — StatusPill recognizes 'available' / +// 'under_offer' / 'sold' as canonical tokens and applies the right tone. +function statusToken(s: string): 'available' | 'under_offer' | 'sold' | 'pending' { + if (s === 'available' || s === 'under_offer' || s === 'sold') return s; + return 'pending'; +} + +export function BerthHeatWidget() { + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + const { data, isLoading } = useQuery({ + queryKey: ['dashboard', 'berth-heat'], + queryFn: () => apiFetch('/api/v1/dashboard/berth-heat?limit=15'), + staleTime: 60_000, + }); + + return ( + + + Berth heat + Top 15 berths by active interest count. + + + {isLoading ? ( +
+ Loading… +
+ ) : !data || data.data.rows.length === 0 ? ( +

No active interests yet.

+ ) : ( + + + + + + + + + + + {data.data.rows.map((r) => ( + + + + + + + ))} + +
BerthDockStatusInterests
+ + {r.mooringNumber} + + {r.area ?? '—'} + + {r.status.replace(/_/g, ' ')} + + {r.activeInterestCount}
+ )} +
+
+ ); +} diff --git a/src/components/dashboard/widget-registry.tsx b/src/components/dashboard/widget-registry.tsx index 9f572e01..76ba9ce1 100644 --- a/src/components/dashboard/widget-registry.tsx +++ b/src/components/dashboard/widget-registry.tsx @@ -15,6 +15,7 @@ import dynamic from 'next/dynamic'; import { ActiveDealsTile } from './active-deals-tile'; import { ActivityFeed } from './activity-feed'; +import { BerthHeatWidget } from './berth-heat-widget'; import { HotDealsCard } from './hot-deals-card'; import { PipelineValueTile } from './pipeline-value-tile'; import { WebsiteGlanceTile } from './website-glance-tile'; @@ -122,10 +123,13 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ { id: 'kpi_pipeline_value', label: 'Pipeline Value', - description: 'Compact tile: total berth value of active deals (USD).', + description: 'Total berth value of active deals, converted to the port default currency.', render: () => , group: 'rail', - defaultVisible: false, + // Flipped on by default 2026-05-14 — the dashboard wave prioritized + // investor-facing tiles, and this is the headline number leadership + // looks at first. + defaultVisible: true, }, // ── Charts (main area) ────────────────────────────────────────────── @@ -175,7 +179,18 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ description: 'Win rate per lead source — which channels deliver buyers, not just leads.', render: () => , group: 'chart', - defaultVisible: false, + // Flipped on 2026-05-14 — investor-facing conversion-funnel-by-source + // surface (PRE-DEPLOY-PLAN § 1.6.23). Reads inquiry → client linkage + // (clients.source_inquiry_id) added in migration 0065. + defaultVisible: true, + }, + { + id: 'berth_heat', + label: 'Berth Heat', + description: 'Top 15 berths by active interest count. Investor-friendly demand pressure view.', + render: () => , + group: 'chart', + defaultVisible: true, }, { id: 'website_analytics', diff --git a/src/lib/services/berth-heat.service.ts b/src/lib/services/berth-heat.service.ts new file mode 100644 index 00000000..2ddc7a3f --- /dev/null +++ b/src/lib/services/berth-heat.service.ts @@ -0,0 +1,60 @@ +/** + * Per-berth interest-count rankings — investor-facing analytics surface. + * For each berth in the port, returns the count of active interests + * (archived_at IS NULL AND outcome IS NULL) currently linked via the + * primary `interest_berths` row. + * + * Drives the BerthHeatWidget on the dashboard (ranked table view). + * A future heatmap-style visualization can read the same shape. + */ +import { and, count, desc, eq, isNull } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { berths } from '@/lib/db/schema/berths'; +import { interests, interestBerths } from '@/lib/db/schema/interests'; + +export interface BerthHeatRow { + berthId: string; + mooringNumber: string; + area: string | null; + status: string; + /** Count of active (non-terminal, non-archived) interests linked to + * this berth via interest_berths. Treats every interest-berth link + * equally (no is_primary requirement) so a multi-berth deal warms + * every berth in its bundle. */ + activeInterestCount: number; +} + +export async function getBerthHeatRanking(portId: string, limit = 20): Promise { + const rows = await db + .select({ + berthId: berths.id, + mooringNumber: berths.mooringNumber, + area: berths.area, + status: berths.status, + activeInterestCount: count(interests.id), + }) + .from(berths) + .leftJoin(interestBerths, eq(interestBerths.berthId, berths.id)) + .leftJoin( + interests, + and( + eq(interests.id, interestBerths.interestId), + eq(interests.portId, portId), + isNull(interests.archivedAt), + isNull(interests.outcome), + ), + ) + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt))) + .groupBy(berths.id, berths.mooringNumber, berths.area, berths.status) + .orderBy(desc(count(interests.id)), berths.mooringNumber) + .limit(limit); + + return rows.map((r) => ({ + berthId: r.berthId, + mooringNumber: r.mooringNumber, + area: r.area, + status: r.status, + activeInterestCount: Number(r.activeInterestCount ?? 0), + })); +}