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>
|
||||
) : (
|
||||
<>
|
||||
<PipelineAnalytics stages={stages} deals={deals} />
|
||||
<PipelineBoard
|
||||
stages={stages}
|
||||
deals={deals}
|
||||
@ -387,7 +388,6 @@ export default function PipelinesPage() {
|
||||
onAddDeal={handleAddDeal}
|
||||
onEditDeal={handleEditDeal}
|
||||
/>
|
||||
<PipelineAnalytics stages={stages} deals={deals} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@ -47,10 +47,10 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
|
||||
e.stopPropagation();
|
||||
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
|
||||
? "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 */}
|
||||
@ -61,17 +61,17 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
|
||||
/>
|
||||
|
||||
<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}
|
||||
</h4>
|
||||
{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" />
|
||||
Won
|
||||
</span>
|
||||
)}
|
||||
{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" />
|
||||
Lost
|
||||
</span>
|
||||
@ -80,18 +80,18 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
|
||||
|
||||
{/* Contact row */}
|
||||
<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)}
|
||||
</span>
|
||||
<span className="truncate text-xs text-gray-600">{contactLabel}</span>
|
||||
<span className="truncate text-xs text-slate-400">{contactLabel}</span>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
</span>
|
||||
{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" />
|
||||
{formatDate(deal.expected_close_date)}
|
||||
</span>
|
||||
@ -102,7 +102,7 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
|
||||
<div className="mt-2 flex items-center justify-end">
|
||||
<span
|
||||
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)}
|
||||
</span>
|
||||
|
||||
@ -2,7 +2,21 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
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 {
|
||||
stages: PipelineStage[];
|
||||
@ -20,9 +34,8 @@ function formatCurrency(value: number) {
|
||||
|
||||
/**
|
||||
* Weighted pipeline value: value × per-stage probability.
|
||||
* Spec rule: first stage = 10%, last stage before Won = 90%, Won = 100%.
|
||||
* Stages between are interpolated linearly.
|
||||
* Won deals count at 100%; Lost deals are excluded.
|
||||
* First stage ≈ 10%, stages interpolate up to 90% before the final stage,
|
||||
* final stage (Won) = 100%. Lost deals excluded.
|
||||
*/
|
||||
function computeStageProbability(
|
||||
stage: PipelineStage,
|
||||
@ -32,11 +45,10 @@ function computeStageProbability(
|
||||
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; // final stage (Won)
|
||||
// First stage → 0.10, second-to-last → 0.90. Evenly spread.
|
||||
const slots = n - 1; // number of non-final slots
|
||||
if (index === n - 1) return 1;
|
||||
const slots = n - 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);
|
||||
}
|
||||
|
||||
@ -62,7 +74,6 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
|
||||
return sum + Number(d.value || 0) * prob;
|
||||
}, 0);
|
||||
|
||||
// This-month won/lost counts based on updated_at (falls back to created_at).
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const thisMonth = (d: Deal) => {
|
||||
@ -87,38 +98,46 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -126,16 +145,34 @@ 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}
|
||||
{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>
|
||||
<p className="mt-1 text-base font-semibold text-white">{value}</p>
|
||||
</div>
|
||||
|
||||
@ -172,35 +172,32 @@ function StageColumn({
|
||||
const { setNodeRef, isOver } = useDroppable({ id: stage.id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-[280px] max-w-[300px] shrink-0 flex-col rounded-xl p-4"
|
||||
style={{ backgroundColor: "#f9fafb" }}
|
||||
>
|
||||
{/* 3px colored top border (spec) — sits above the column's padding */}
|
||||
<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">
|
||||
{/* 3px colored top border — sits above the column's padding */}
|
||||
<div
|
||||
className="-mx-4 -mt-4 h-[3px] rounded-t-xl"
|
||||
style={{ backgroundColor: stage.color }}
|
||||
/>
|
||||
<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}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{formatCurrency(totalValue)}</p>
|
||||
<p className="text-xs text-slate-400">{formatCurrency(totalValue)}</p>
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`mt-3 flex flex-1 flex-col gap-2 rounded-lg transition-all ${
|
||||
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 ? (
|
||||
<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
|
||||
</div>
|
||||
) : (
|
||||
@ -219,7 +216,7 @@ function StageColumn({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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" />
|
||||
Add Deal
|
||||
|
||||
Loading…
Reference in New Issue
Block a user