Merge pull request #19 from ArnasDon/fix/pipelines-dark-theme-and-tooltips

fix(pipelines): dark theme, analytics above board, metric tooltips
This commit is contained in:
Arnas Donauskas 2026-04-16 22:52:01 +03:00 committed by GitHub
commit 4229c94ec5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 98 additions and 64 deletions

View File

@ -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} />
</>
)}

View File

@ -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>

View File

@ -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 (
<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)}
/>
<Metric
icon={<DollarSign className="h-4 w-4 text-emerald-400" />}
label="Pipeline Value"
value={formatCurrency(stats.totalValue)}
/>
<Metric
icon={<Target className="h-4 w-4 text-blue-400" />}
label="Avg Deal Size"
value={formatCurrency(stats.avgValue)}
/>
<Metric
icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
label="Weighted Value"
value={formatCurrency(stats.weightedValue)}
/>
<Metric
icon={<Trophy className="h-4 w-4 text-emerald-400" />}
label="Won This Month"
value={String(stats.wonThisMonth)}
/>
<Metric
icon={<XCircle className="h-4 w-4 text-red-400" />}
label="Lost This Month"
value={String(stats.lostThisMonth)}
/>
</div>
<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>

View File

@ -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