2026-05-12 14:50:58 +02:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import { useQuery } from '@tanstack/react-query';
|
2026-05-20 15:56:11 +02:00
|
|
|
|
import { AlertTriangle, Info } from 'lucide-react';
|
2026-05-12 14:50:58 +02:00
|
|
|
|
|
|
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
2026-05-20 15:56:11 +02:00
|
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
2026-05-12 14:50:58 +02:00
|
|
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
2026-05-20 15:56:11 +02:00
|
|
|
|
import { PIPELINE_STAGES, STAGE_WEIGHTS, stageLabel } from '@/lib/constants';
|
2026-05-12 14:50:58 +02:00
|
|
|
|
import { formatCurrency } from '@/lib/utils/currency';
|
2026-05-20 15:56:11 +02:00
|
|
|
|
import { cn } from '@/lib/utils';
|
2026-05-12 14:50:58 +02:00
|
|
|
|
|
|
|
|
|
|
interface KpiResponse {
|
2026-05-14 15:19:38 +02:00
|
|
|
|
pipelineValue: number;
|
|
|
|
|
|
pipelineValueCurrency: string;
|
2026-05-20 15:56:11 +02:00
|
|
|
|
activeInterests: number;
|
2026-05-12 14:50:58 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 15:56:11 +02:00
|
|
|
|
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<string, string> = {
|
|
|
|
|
|
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',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-12 14:50:58 +02:00
|
|
|
|
/**
|
2026-05-20 15:56:11 +02:00
|
|
|
|
* 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.
|
2026-05-12 14:50:58 +02:00
|
|
|
|
*/
|
|
|
|
|
|
export function PipelineValueTile() {
|
2026-05-20 15:56:11 +02:00
|
|
|
|
const kpis = useQuery<KpiResponse>({
|
2026-05-12 14:50:58 +02:00
|
|
|
|
queryKey: ['dashboard', 'kpis'],
|
|
|
|
|
|
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
|
|
|
|
|
|
staleTime: 60_000,
|
|
|
|
|
|
});
|
2026-05-20 15:56:11 +02:00
|
|
|
|
const forecast = useQuery<ForecastResponse>({
|
|
|
|
|
|
queryKey: ['dashboard', 'forecast'],
|
|
|
|
|
|
queryFn: () => apiFetch<ForecastResponse>('/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 });
|
2026-05-12 14:50:58 +02:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Card>
|
2026-05-20 15:56:11 +02:00
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle className="text-base">Pipeline value</CardTitle>
|
|
|
|
|
|
<CardDescription className="flex items-center gap-1.5">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{activeDeals > 0
|
|
|
|
|
|
? `${activeDeals} active deal${activeDeals === 1 ? '' : 's'} · weighted by stage close-probability`
|
|
|
|
|
|
: 'Gross berth value across active deals, with weighted forecast.'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Popover>
|
|
|
|
|
|
<PopoverTrigger
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="How does the weighted forecast work?"
|
|
|
|
|
|
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
2026-05-12 14:50:58 +02:00
|
|
|
|
>
|
2026-05-20 15:56:11 +02:00
|
|
|
|
<Info className="size-3.5" aria-hidden />
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
|
|
|
|
|
<p className="font-semibold text-foreground">How the weighted forecast works</p>
|
|
|
|
|
|
<p className="mt-2 text-muted-foreground">
|
|
|
|
|
|
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{' '}
|
|
|
|
|
|
<strong>expected</strong> 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.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="mt-3 grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 rounded-md bg-muted/50 p-2.5 text-[11px]">
|
|
|
|
|
|
{PIPELINE_STAGES.map((s) => {
|
|
|
|
|
|
const dbWeight = forecast.data?.stageBreakdown.find((r) => r.stage === s)?.weight;
|
|
|
|
|
|
const weight = dbWeight ?? STAGE_WEIGHTS[s];
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={s} className="contents">
|
|
|
|
|
|
<span className="text-muted-foreground">{stageLabel(s)}</span>
|
|
|
|
|
|
<span className="text-right font-medium tabular-nums text-foreground">
|
|
|
|
|
|
{Math.round(weight * 100)}%
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="mt-3 text-[11px] text-muted-foreground">
|
|
|
|
|
|
{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.'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
{/* ── Headline numbers ─────────────────────────────────────── */}
|
|
|
|
|
|
<div className="flex items-end justify-between gap-4">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Gross
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
|
<Skeleton className="mt-1 h-7 w-28" aria-hidden />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p
|
|
|
|
|
|
className="truncate text-2xl font-bold leading-tight text-foreground"
|
|
|
|
|
|
title={formatCurrency(grossTotal, currency)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{fmt(grossTotal)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-right">
|
|
|
|
|
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Weighted forecast
|
2026-05-12 14:50:58 +02:00
|
|
|
|
</p>
|
2026-05-20 15:56:11 +02:00
|
|
|
|
{isLoading ? (
|
|
|
|
|
|
<Skeleton className="ml-auto mt-1 h-6 w-24" aria-hidden />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p
|
|
|
|
|
|
className="text-lg font-semibold leading-tight text-foreground tabular-nums"
|
|
|
|
|
|
title={formatCurrency(weightedTotal, currency)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{fmt(weightedTotal)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-12 14:50:58 +02:00
|
|
|
|
</div>
|
2026-05-20 15:56:11 +02:00
|
|
|
|
|
|
|
|
|
|
{/* ── Per-stage breakdown ─────────────────────────────────── */}
|
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
|
|
|
|
<Skeleton key={i} className="h-7 w-full" aria-hidden />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : activeStages.length === 0 ? (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">No active deals with linked berths yet.</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ul className="space-y-0.5">
|
|
|
|
|
|
{activeStages.map((s) => {
|
|
|
|
|
|
const widthPct = Math.max(6, Math.round((s.grossValue / stageMax) * 100));
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li
|
|
|
|
|
|
key={s.stage}
|
|
|
|
|
|
className="grid grid-cols-[1fr_auto] items-center gap-x-3 gap-y-0.5 py-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<p className="truncate text-sm font-medium text-foreground">
|
|
|
|
|
|
{stageLabel(s.stage)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="mt-1 flex h-1 w-full overflow-hidden rounded-full bg-muted">
|
|
|
|
|
|
<span
|
|
|
|
|
|
aria-hidden
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'h-full rounded-full',
|
|
|
|
|
|
STAGE_BAR_CLASS[s.stage] ?? 'bg-brand-400',
|
|
|
|
|
|
)}
|
|
|
|
|
|
style={{ width: `${widthPct}%` }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-right">
|
|
|
|
|
|
<p className="text-sm font-semibold text-foreground tabular-nums">
|
|
|
|
|
|
{fmt(s.grossValue)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-[11px] text-muted-foreground tabular-nums">
|
|
|
|
|
|
{s.count} {s.count === 1 ? 'deal' : 'deals'} · {Math.round(s.weight * 100)}%
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{s.dealsMissingPrice > 0 ? (
|
|
|
|
|
|
<p
|
|
|
|
|
|
className="mt-0.5 inline-flex items-center gap-1 text-[10px] font-medium text-warning"
|
|
|
|
|
|
title={`${s.dealsMissingPrice} of ${s.count} ${s.count === 1 ? 'deal has' : 'deals have'} a berth with no price set — gross is undercounted here.`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<AlertTriangle className="size-3" aria-hidden />
|
|
|
|
|
|
{s.dealsMissingPrice === s.count
|
|
|
|
|
|
? 'berth price missing'
|
|
|
|
|
|
: `${s.dealsMissingPrice} of ${s.count} missing price`}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{forecast.data?.weightsSource === 'default' ? (
|
|
|
|
|
|
<p className="text-[11px] text-muted-foreground">
|
|
|
|
|
|
Using default stage weights. Tune them in Settings → Pipeline.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
2026-05-12 14:50:58 +02:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|