mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
175 lines
5.4 KiB
TypeScript
175 lines
5.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|