mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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:
parent
57f05fb143
commit
2cc03fcdfa
@ -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} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user