feat(dashboard): berth-heat widget + investor-default surfacing

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 15:47:49 +02:00
parent 709ef350ff
commit 66869c9a90
4 changed files with 199 additions and 3 deletions

View File

@@ -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<HeatResponse>({
queryKey: ['dashboard', 'berth-heat'],
queryFn: () => apiFetch<HeatResponse>('/api/v1/dashboard/berth-heat?limit=15'),
staleTime: 60_000,
});
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Berth heat</CardTitle>
<CardDescription>Top 15 berths by active interest count.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden /> Loading
</div>
) : !data || data.data.rows.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No active interests yet.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-muted-foreground">
<th className="py-1.5 text-left font-medium">Berth</th>
<th className="py-1.5 text-left font-medium">Dock</th>
<th className="py-1.5 text-left font-medium">Status</th>
<th className="py-1.5 text-right font-medium">Interests</th>
</tr>
</thead>
<tbody>
{data.data.rows.map((r) => (
<tr key={r.berthId} className="border-b last:border-b-0">
<td className="py-1.5">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/berths/${r.berthId}` as any}
className="font-medium hover:underline"
>
{r.mooringNumber}
</Link>
</td>
<td className="py-1.5 text-muted-foreground">{r.area ?? '—'}</td>
<td className="py-1.5">
<StatusPill status={statusToken(r.status)}>
{r.status.replace(/_/g, ' ')}
</StatusPill>
</td>
<td className="py-1.5 text-right font-semibold">{r.activeInterestCount}</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
);
}

View File

@@ -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: () => <PipelineValueTile />,
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: () => <SourceConversionChart />,
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: () => <BerthHeatWidget />,
group: 'chart',
defaultVisible: true,
},
{
id: 'website_analytics',