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:
25
src/app/api/v1/dashboard/berth-heat/route.ts
Normal file
25
src/app/api/v1/dashboard/berth-heat/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
96
src/components/dashboard/berth-heat-widget.tsx
Normal file
96
src/components/dashboard/berth-heat-widget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import dynamic from 'next/dynamic';
|
|||||||
|
|
||||||
import { ActiveDealsTile } from './active-deals-tile';
|
import { ActiveDealsTile } from './active-deals-tile';
|
||||||
import { ActivityFeed } from './activity-feed';
|
import { ActivityFeed } from './activity-feed';
|
||||||
|
import { BerthHeatWidget } from './berth-heat-widget';
|
||||||
import { HotDealsCard } from './hot-deals-card';
|
import { HotDealsCard } from './hot-deals-card';
|
||||||
import { PipelineValueTile } from './pipeline-value-tile';
|
import { PipelineValueTile } from './pipeline-value-tile';
|
||||||
import { WebsiteGlanceTile } from './website-glance-tile';
|
import { WebsiteGlanceTile } from './website-glance-tile';
|
||||||
@@ -122,10 +123,13 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
|||||||
{
|
{
|
||||||
id: 'kpi_pipeline_value',
|
id: 'kpi_pipeline_value',
|
||||||
label: '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 />,
|
render: () => <PipelineValueTile />,
|
||||||
group: 'rail',
|
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) ──────────────────────────────────────────────
|
// ── 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.',
|
description: 'Win rate per lead source — which channels deliver buyers, not just leads.',
|
||||||
render: () => <SourceConversionChart />,
|
render: () => <SourceConversionChart />,
|
||||||
group: 'chart',
|
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',
|
id: 'website_analytics',
|
||||||
|
|||||||
60
src/lib/services/berth-heat.service.ts
Normal file
60
src/lib/services/berth-heat.service.ts
Normal file
@@ -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<BerthHeatRow[]> {
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user