wacrm/src/components/pipelines/pipeline-analytics.tsx
Arnas Donauskas 2cc03fcdfa
fix(pipelines): dark-theme the board + cards, put analytics above, add metric tooltips
Three follow-ups to the Pipelines page that shipped in #18:

1. Analytics strip now sits ABOVE the Kanban board rather than below, so
   top-line numbers read first and the board flows beneath.
2. Re-theme the board to match the rest of the dark app: slate-900 columns
   with slate-800 borders, slate-800 deal cards with stage-colored accent
   bar, emerald dollar values, and slate-500/slate-400 secondary text. The
   empty-column "Drop a deal here" and drop-zone highlight stay but in
   slate/emerald tones instead of white/light-gray.
3. Each analytics metric now has an info icon + tooltip explaining how it
   is calculated in plain language (e.g. Weighted Value explains the
   10% → 90% stage interpolation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 22:50:52 +03:00

181 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo } from "react";
import type { Deal, PipelineStage } from "@/types";
import {
DollarSign,
TrendingUp,
Target,
BarChart3,
Trophy,
XCircle,
Info,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface PipelineAnalyticsProps {
stages: PipelineStage[];
deals: Deal[];
}
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}
/**
* Weighted pipeline value: value × per-stage probability.
* First stage ≈ 10%, stages interpolate up to 90% before the final stage,
* final stage (Won) = 100%. Lost deals excluded.
*/
function computeStageProbability(
stage: PipelineStage,
sortedStages: PipelineStage[],
): number {
const n = sortedStages.length;
if (n <= 1) return 1;
const index = sortedStages.findIndex((s) => s.id === stage.id);
if (index < 0) return 0;
if (index === n - 1) return 1;
const slots = n - 1;
if (slots <= 1) return 0.1;
const t = index / (slots - 1);
return 0.1 + t * (0.9 - 0.1);
}
export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
const sortedStages = useMemo(
() => [...stages].sort((a, b) => a.position - b.position),
[stages],
);
const stats = useMemo(() => {
const active = deals.filter((d) => d.status !== "lost");
const openDeals = active.filter((d) => d.status !== "won");
const totalCount = active.length;
const totalValue = active.reduce((sum, d) => sum + Number(d.value || 0), 0);
const avgValue = totalCount > 0 ? totalValue / totalCount : 0;
const stageById = new Map(sortedStages.map((s) => [s.id, s]));
const weightedValue = openDeals.reduce((sum, d) => {
const stage = stageById.get(d.stage_id);
if (!stage) return sum;
const prob = computeStageProbability(stage, sortedStages);
return sum + Number(d.value || 0) * prob;
}, 0);
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const thisMonth = (d: Deal) => {
const ts = d.updated_at ?? d.created_at;
return ts ? new Date(ts) >= monthStart : false;
};
const wonThisMonth = deals.filter(
(d) => d.status === "won" && thisMonth(d),
).length;
const lostThisMonth = deals.filter(
(d) => d.status === "lost" && thisMonth(d),
).length;
return {
totalCount,
totalValue,
avgValue,
weightedValue,
wonThisMonth,
lostThisMonth,
};
}, [deals, sortedStages]);
return (
<TooltipProvider>
<div className="grid grid-cols-2 gap-3 rounded-xl border border-slate-800 bg-slate-900/60 p-4 sm:grid-cols-3 lg:grid-cols-6">
<Metric
icon={<BarChart3 className="h-4 w-4 text-slate-400" />}
label="Total Deals"
value={String(stats.totalCount)}
tooltip="Count of every deal in this pipeline that isn't marked as Lost. Won deals are still included."
/>
<Metric
icon={<DollarSign className="h-4 w-4 text-emerald-400" />}
label="Pipeline Value"
value={formatCurrency(stats.totalValue)}
tooltip="Sum of the dollar values of all deals in this pipeline, excluding deals marked as Lost."
/>
<Metric
icon={<Target className="h-4 w-4 text-blue-400" />}
label="Avg Deal Size"
value={formatCurrency(stats.avgValue)}
tooltip="Pipeline Value divided by Total Deals — the average value of a single non-lost deal."
/>
<Metric
icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
label="Weighted Value"
value={formatCurrency(stats.weightedValue)}
tooltip="Expected revenue: each open deal's value × its stage probability. First stage ≈ 10%, stages progress up to 90%, Won = 100%. Lost deals are excluded."
/>
<Metric
icon={<Trophy className="h-4 w-4 text-emerald-400" />}
label="Won This Month"
value={String(stats.wonThisMonth)}
tooltip="Deals marked as Won since the first day of the current month."
/>
<Metric
icon={<XCircle className="h-4 w-4 text-red-400" />}
label="Lost This Month"
value={String(stats.lostThisMonth)}
tooltip="Deals marked as Lost since the first day of the current month."
/>
</div>
</TooltipProvider>
);
}
function Metric({
icon,
label,
value,
tooltip,
}: {
icon: React.ReactNode;
label: string;
value: string;
tooltip: string;
}) {
return (
<div className="rounded-lg bg-slate-800/50 p-3">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-slate-400">
{icon}
<span>{label}</span>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={`How ${label} is calculated`}
className="ml-auto text-slate-500 hover:text-slate-300 focus:outline-none"
/>
}
>
<Info className="h-3 w-3" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-left">
{tooltip}
</TooltipContent>
</Tooltip>
</div>
<p className="mt-1 text-base font-semibold text-white">{value}</p>
</div>
);
}