'use client'; import { useQuery } from '@tanstack/react-query'; import { AlertTriangle, Info } from 'lucide-react'; import { apiFetch } from '@/lib/api/client'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Skeleton } from '@/components/ui/skeleton'; import { PIPELINE_STAGES, STAGE_WEIGHTS, stageLabel } from '@/lib/constants'; import { formatCurrency } from '@/lib/utils/currency'; import { cn } from '@/lib/utils'; interface KpiResponse { pipelineValue: number; pipelineValueCurrency: string; activeInterests: number; } interface StageRow { stage: string; count: number; grossValue: number; weightedValue: number; weight: number; dealsMissingPrice: number; } interface ForecastResponse { totalGrossValue: number; totalWeightedValue: number; stageBreakdown: StageRow[]; weightsSource: 'db' | 'default'; } // Same brand-coloured family the pipeline-funnel chart uses so the two // surfaces feel anchored to the same palette. const STAGE_BAR_CLASS: Record = { enquiry: 'bg-slate-300', qualified: 'bg-brand-200', nurturing: 'bg-brand-300', eoi: 'bg-brand-400', reservation: 'bg-amber-400', deposit_paid: 'bg-orange-400', contract: 'bg-success/70', }; /** * Headline pipeline value plus a per-stage breakdown showing gross * value, deal count, and the weighted forecast (gross × stage close- * probability). Replaces the single-number KPI: leadership can now see * how much of the headline number is near-close vs speculative. * * Pulls from two endpoints: `/kpis` for the gross headline + currency * and `/forecast` for the weighted breakdown. Both share cache entries * with other widgets so this is mostly free. */ export function PipelineValueTile() { const kpis = useQuery({ queryKey: ['dashboard', 'kpis'], queryFn: () => apiFetch('/api/v1/dashboard/kpis'), staleTime: 60_000, }); const forecast = useQuery({ queryKey: ['dashboard', 'forecast'], queryFn: () => apiFetch('/api/v1/dashboard/forecast'), staleTime: 60_000, }); const isLoading = kpis.isLoading || forecast.isLoading; const currency = kpis.data?.pipelineValueCurrency ?? 'USD'; const grossTotal = kpis.data?.pipelineValue ?? 0; const weightedTotal = forecast.data?.totalWeightedValue ?? 0; const activeDeals = kpis.data?.activeInterests ?? 0; const activeStages = (forecast.data?.stageBreakdown ?? []).filter((s) => s.count > 0); const stageMax = activeStages.reduce((m, s) => Math.max(m, s.grossValue), 0) || 1; const fmt = (v: number) => formatCurrency(v, currency, { maxFractionDigits: 0 }); return ( Pipeline value {activeDeals > 0 ? `${activeDeals} active deal${activeDeals === 1 ? '' : 's'} · weighted by stage close-probability` : 'Gross berth value across active deals, with weighted forecast.'}

How the weighted forecast works

Each pipeline stage has a close-probability — how likely a deal at that stage is to actually close. Multiplying the berth price by the stage weight gives an{' '} expected value for that deal. Summing across every active deal yields the weighted forecast — a defensible “what will likely land” number, vs the gross which assumes every deal closes at full value.

{PIPELINE_STAGES.map((s) => { const dbWeight = forecast.data?.stageBreakdown.find((r) => r.stage === s)?.weight; const weight = dbWeight ?? STAGE_WEIGHTS[s]; return (
{stageLabel(s)} {Math.round(weight * 100)}%
); })}

{forecast.data?.weightsSource === 'db' ? 'Using per-port weights (admins tune these in Settings → Pipeline).' : 'Using system defaults. Admins can override per port in Settings → Pipeline.'}

{/* ── Headline numbers ─────────────────────────────────────── */}

Gross

{isLoading ? ( ) : (

{fmt(grossTotal)}

)}

Weighted forecast

{isLoading ? ( ) : (

{fmt(weightedTotal)}

)}
{/* ── Per-stage breakdown ─────────────────────────────────── */} {isLoading ? (
{Array.from({ length: 4 }).map((_, i) => ( ))}
) : activeStages.length === 0 ? (

No active deals with linked berths yet.

) : (
    {activeStages.map((s) => { const widthPct = Math.max(6, Math.round((s.grossValue / stageMax) * 100)); return (
  • {stageLabel(s.stage)}

    {fmt(s.grossValue)}

    {s.count} {s.count === 1 ? 'deal' : 'deals'} · {Math.round(s.weight * 100)}%

    {s.dealsMissingPrice > 0 ? (

    {s.dealsMissingPrice === s.count ? 'berth price missing' : `${s.dealsMissingPrice} of ${s.count} missing price`}

    ) : null}
  • ); })}
)} {forecast.data?.weightsSource === 'default' ? (

Using default stage weights. Tune them in Settings → Pipeline.

) : null}
); }