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>
This commit is contained in:
Arnas Donauskas 2026-04-16 22:50:52 +03:00
parent 57f05fb143
commit 2cc03fcdfa
No known key found for this signature in database
4 changed files with 98 additions and 64 deletions

View File

@ -380,6 +380,7 @@ export default function PipelinesPage() {
</div> </div>
) : ( ) : (
<> <>
<PipelineAnalytics stages={stages} deals={deals} />
<PipelineBoard <PipelineBoard
stages={stages} stages={stages}
deals={deals} deals={deals}
@ -387,7 +388,6 @@ export default function PipelinesPage() {
onAddDeal={handleAddDeal} onAddDeal={handleAddDeal}
onEditDeal={handleEditDeal} onEditDeal={handleEditDeal}
/> />
<PipelineAnalytics stages={stages} deals={deals} />
</> </>
)} )}

View File

@ -47,10 +47,10 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
e.stopPropagation(); e.stopPropagation();
onEdit(deal); onEdit(deal);
}} }}
className={`group relative w-full cursor-pointer rounded-xl bg-white pl-4 pr-3 py-3 text-left shadow-sm transition-all ${ className={`group relative w-full cursor-pointer rounded-xl border border-slate-700/50 bg-slate-800/70 pl-4 pr-3 py-3 text-left shadow-sm transition-all ${
isOverlay isOverlay
? "shadow-xl" ? "shadow-xl"
: "hover:-translate-y-0.5 hover:shadow-md" : "hover:-translate-y-0.5 hover:border-slate-600 hover:bg-slate-800 hover:shadow-lg"
}`} }`}
> >
{/* 4px left accent bar using stage color */} {/* 4px left accent bar using stage color */}
@ -61,17 +61,17 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
/> />
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<h4 className="flex-1 text-sm font-semibold leading-snug text-gray-900 break-words"> <h4 className="flex-1 text-sm font-semibold leading-snug text-white break-words">
{deal.title} {deal.title}
</h4> </h4>
{deal.status === "won" && ( {deal.status === "won" && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700"> <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold text-emerald-400">
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
Won Won
</span> </span>
)} )}
{deal.status === "lost" && ( {deal.status === "lost" && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-700"> <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-semibold text-red-400">
<X className="h-3 w-3" /> <X className="h-3 w-3" />
Lost Lost
</span> </span>
@ -80,18 +80,18 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
{/* Contact row */} {/* Contact row */}
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-slate-200 text-[10px] font-semibold text-slate-700"> <span className="flex h-5 w-5 items-center justify-center rounded-full bg-slate-700 text-[10px] font-semibold text-slate-200">
{initials(deal.contact?.name, deal.contact?.phone)} {initials(deal.contact?.name, deal.contact?.phone)}
</span> </span>
<span className="truncate text-xs text-gray-600">{contactLabel}</span> <span className="truncate text-xs text-slate-400">{contactLabel}</span>
</div> </div>
<div className="mt-2 flex items-center justify-between"> <div className="mt-2 flex items-center justify-between">
<span className="text-sm font-bold text-gray-900"> <span className="text-sm font-bold text-emerald-400">
{formatCurrency(deal.value, deal.currency)} {formatCurrency(deal.value, deal.currency)}
</span> </span>
{deal.expected_close_date && ( {deal.expected_close_date && (
<span className="flex items-center gap-1 text-[11px] text-gray-500"> <span className="flex items-center gap-1 text-[11px] text-slate-500">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{formatDate(deal.expected_close_date)} {formatDate(deal.expected_close_date)}
</span> </span>
@ -102,7 +102,7 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
<div className="mt-2 flex items-center justify-end"> <div className="mt-2 flex items-center justify-end">
<span <span
title={assigneeLabel} title={assigneeLabel}
className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-[10px] font-semibold text-emerald-700" className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500/15 text-[10px] font-semibold text-emerald-400"
> >
{initials(assigneeLabel)} {initials(assigneeLabel)}
</span> </span>

View File

@ -2,7 +2,21 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { Deal, PipelineStage } from "@/types"; import type { Deal, PipelineStage } from "@/types";
import { DollarSign, TrendingUp, Target, BarChart3, Trophy, XCircle } from "lucide-react"; import {
DollarSign,
TrendingUp,
Target,
BarChart3,
Trophy,
XCircle,
Info,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface PipelineAnalyticsProps { interface PipelineAnalyticsProps {
stages: PipelineStage[]; stages: PipelineStage[];
@ -20,9 +34,8 @@ function formatCurrency(value: number) {
/** /**
* Weighted pipeline value: value × per-stage probability. * Weighted pipeline value: value × per-stage probability.
* Spec rule: first stage = 10%, last stage before Won = 90%, Won = 100%. * First stage ≈ 10%, stages interpolate up to 90% before the final stage,
* Stages between are interpolated linearly. * final stage (Won) = 100%. Lost deals excluded.
* Won deals count at 100%; Lost deals are excluded.
*/ */
function computeStageProbability( function computeStageProbability(
stage: PipelineStage, stage: PipelineStage,
@ -32,11 +45,10 @@ function computeStageProbability(
if (n <= 1) return 1; if (n <= 1) return 1;
const index = sortedStages.findIndex((s) => s.id === stage.id); const index = sortedStages.findIndex((s) => s.id === stage.id);
if (index < 0) return 0; if (index < 0) return 0;
if (index === n - 1) return 1; // final stage (Won) if (index === n - 1) return 1;
// First stage → 0.10, second-to-last → 0.90. Evenly spread. const slots = n - 1;
const slots = n - 1; // number of non-final slots
if (slots <= 1) return 0.1; if (slots <= 1) return 0.1;
const t = index / (slots - 1); // 0..1 const t = index / (slots - 1);
return 0.1 + t * (0.9 - 0.1); return 0.1 + t * (0.9 - 0.1);
} }
@ -62,7 +74,6 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
return sum + Number(d.value || 0) * prob; return sum + Number(d.value || 0) * prob;
}, 0); }, 0);
// This-month won/lost counts based on updated_at (falls back to created_at).
const now = new Date(); const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const thisMonth = (d: Deal) => { const thisMonth = (d: Deal) => {
@ -87,38 +98,46 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
}, [deals, sortedStages]); }, [deals, sortedStages]);
return ( return (
<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"> <TooltipProvider>
<Metric <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">
icon={<BarChart3 className="h-4 w-4 text-slate-400" />} <Metric
label="Total Deals" icon={<BarChart3 className="h-4 w-4 text-slate-400" />}
value={String(stats.totalCount)} label="Total Deals"
/> value={String(stats.totalCount)}
<Metric tooltip="Count of every deal in this pipeline that isn't marked as Lost. Won deals are still included."
icon={<DollarSign className="h-4 w-4 text-emerald-400" />} />
label="Pipeline Value" <Metric
value={formatCurrency(stats.totalValue)} icon={<DollarSign className="h-4 w-4 text-emerald-400" />}
/> label="Pipeline Value"
<Metric value={formatCurrency(stats.totalValue)}
icon={<Target className="h-4 w-4 text-blue-400" />} tooltip="Sum of the dollar values of all deals in this pipeline, excluding deals marked as Lost."
label="Avg Deal Size" />
value={formatCurrency(stats.avgValue)} <Metric
/> icon={<Target className="h-4 w-4 text-blue-400" />}
<Metric label="Avg Deal Size"
icon={<TrendingUp className="h-4 w-4 text-purple-400" />} value={formatCurrency(stats.avgValue)}
label="Weighted Value" tooltip="Pipeline Value divided by Total Deals — the average value of a single non-lost deal."
value={formatCurrency(stats.weightedValue)} />
/> <Metric
<Metric icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
icon={<Trophy className="h-4 w-4 text-emerald-400" />} label="Weighted Value"
label="Won This Month" value={formatCurrency(stats.weightedValue)}
value={String(stats.wonThisMonth)} 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 <Metric
icon={<XCircle className="h-4 w-4 text-red-400" />} icon={<Trophy className="h-4 w-4 text-emerald-400" />}
label="Lost This Month" label="Won This Month"
value={String(stats.lostThisMonth)} value={String(stats.wonThisMonth)}
/> tooltip="Deals marked as Won since the first day of the current month."
</div> />
<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>
); );
} }
@ -126,16 +145,34 @@ function Metric({
icon, icon,
label, label,
value, value,
tooltip,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
value: string; value: string;
tooltip: string;
}) { }) {
return ( return (
<div className="rounded-lg bg-slate-800/50 p-3"> <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"> <div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-slate-400">
{icon} {icon}
{label} <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> </div>
<p className="mt-1 text-base font-semibold text-white">{value}</p> <p className="mt-1 text-base font-semibold text-white">{value}</p>
</div> </div>

View File

@ -172,35 +172,32 @@ function StageColumn({
const { setNodeRef, isOver } = useDroppable({ id: stage.id }); const { setNodeRef, isOver } = useDroppable({ id: stage.id });
return ( return (
<div <div className="flex min-w-[280px] max-w-[300px] shrink-0 flex-col rounded-xl border border-slate-800 bg-slate-900/60 p-4">
className="flex min-w-[280px] max-w-[300px] shrink-0 flex-col rounded-xl p-4" {/* 3px colored top border — sits above the column's padding */}
style={{ backgroundColor: "#f9fafb" }}
>
{/* 3px colored top border (spec) — sits above the column's padding */}
<div <div
className="-mx-4 -mt-4 h-[3px] rounded-t-xl" className="-mx-4 -mt-4 h-[3px] rounded-t-xl"
style={{ backgroundColor: stage.color }} style={{ backgroundColor: stage.color }}
/> />
<div className="flex items-center justify-between pt-3"> <div className="flex items-center justify-between pt-3">
<h3 className="truncate text-sm font-semibold text-gray-900"> <h3 className="truncate text-sm font-semibold text-white">
{stage.name} {stage.name}
</h3> </h3>
<span className="shrink-0 rounded-full bg-gray-200 px-2 py-0.5 text-[11px] font-medium text-gray-700"> <span className="shrink-0 rounded-full bg-slate-800 px-2 py-0.5 text-[11px] font-medium text-slate-300">
{deals.length} {deals.length}
</span> </span>
</div> </div>
<p className="text-xs text-gray-500">{formatCurrency(totalValue)}</p> <p className="text-xs text-slate-400">{formatCurrency(totalValue)}</p>
<div <div
ref={setNodeRef} ref={setNodeRef}
className={`mt-3 flex flex-1 flex-col gap-2 rounded-lg transition-all ${ className={`mt-3 flex flex-1 flex-col gap-2 rounded-lg transition-all ${
isOver isOver
? "bg-emerald-50 outline outline-2 outline-dashed outline-emerald-400 outline-offset-2" ? "bg-emerald-500/5 outline outline-2 outline-dashed outline-emerald-400 outline-offset-2"
: "" : ""
}`} }`}
> >
{deals.length === 0 ? ( {deals.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/40 py-10 text-xs text-gray-400"> <div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-slate-700 py-10 text-xs text-slate-500">
Drop a deal here Drop a deal here
</div> </div>
) : ( ) : (
@ -219,7 +216,7 @@ function StageColumn({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onAddDeal(stage.id)} onClick={() => onAddDeal(stage.id)}
className="mt-3 w-full justify-start border border-dashed border-gray-300 bg-transparent text-gray-500 hover:border-gray-400 hover:bg-white hover:text-gray-700" className="mt-3 w-full justify-start border border-dashed border-slate-700 bg-transparent text-slate-400 hover:border-slate-600 hover:bg-slate-800 hover:text-white"
> >
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
Add Deal Add Deal