wacrm/src/components/pipelines/pipeline-analytics.tsx
Arnas Donauskas 3abecb433b
feat(deals): make default deal currency configurable per account (#218)
wacrm hardcoded USD across every deal-value formatter, the new-deal
form, and automation-created deals, making deal tracking unhelpful for
non-US self-hosted users. Add a per-account default currency.

- Migration 021: accounts.default_currency TEXT NOT NULL DEFAULT 'USD'
  with a 3-letter-code CHECK. The existing admin-only accounts_update
  RLS policy already gates writes.
- New src/lib/currency.ts: single source of truth (CURRENCIES list,
  formatCurrency, formatCurrencyShort), replacing ~6 duplicated
  USD-hardcoded helpers.
- useAuth exposes defaultCurrency (joined from accounts), falling back
  to USD while loading / on pre-021 schemas.
- Settings → Deals tab to pick the currency (admin-only, read-only for
  others to match RLS).
- Deal form defaults new deals to the account currency and sources its
  dropdown from CURRENCIES; existing deals keep their saved currency.
- Pipeline board/analytics, dashboard "Open Deals Value", and the
  pipeline donut format totals in the account currency. Individual
  deal cards still show each deal's own currency.
- Automation create_deal sets the new deal's currency from the
  account default instead of the static DB default.

One currency per account (no FX conversion) per the issue's
recommended first pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:59:48 +03:00

175 lines
5.4 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";
import { useAuth } from "@/hooks/use-auth";
import { formatCurrency } from "@/lib/currency";
interface PipelineAnalyticsProps {
stages: PipelineStage[];
deals: Deal[];
}
/**
* 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 { defaultCurrency } = useAuth();
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 xl: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-primary" />}
label="Pipeline Value"
value={formatCurrency(stats.totalValue, defaultCurrency)}
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, defaultCurrency)}
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, defaultCurrency)}
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-primary" />}
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>
);
}