import { ResponsiveBar } from "@nivo/bar"; import { format, startOfMonth, subMonths, addMonths } from "date-fns"; import { DollarSign } from "lucide-react"; import { useGetList, useLocaleState, useTranslate } from "ra-core"; import { memo, useMemo } from "react"; import { getDateFnsLocale } from "@/i18n/date-fns"; import { Skeleton } from "@/components/ds/ui/skeleton"; import type { Deal } from "../types"; const stageMultiplier: Record = { opportunity: 0.2, "proposal-sent": 0.5, "in-negotiation": 0.8, "in-negociation": 0.8, // Legacy spelling support delayed: 0.3, }; const DEFAULT_LOCALE = "en-US"; const CURRENCY = "USD"; export const DealsChart = memo(() => { const translate = useTranslate(); const [locale] = useLocaleState(); const dateFnsLocale = getDateFnsLocale(locale); const acceptedLanguages = navigator ? navigator.languages || [navigator.language] : [DEFAULT_LOCALE]; // Rolling window: 3 months past to 6 months future const dateRange = useMemo(() => { const now = new Date(); return { start: subMonths(now, 3).toISOString(), end: addMonths(now, 6).toISOString(), }; }, []); const { data, isPending } = useGetList("deals", { pagination: { perPage: 200, page: 1 }, sort: { field: "expected_closing_date", order: "ASC", }, filter: { "expected_closing_date@gte": dateRange.start, "expected_closing_date@lte": dateRange.end, }, }); const months = useMemo(() => { if (!data) return []; const dealsByMonth = data.reduce((acc, deal) => { const month = startOfMonth( deal.expected_closing_date ?? new Date(), ).toISOString(); if (!acc[month]) { acc[month] = []; } acc[month].push(deal); return acc; }, {} as any); const amountByMonth = Object.keys(dealsByMonth).map((month) => { // Calculate positive values (above zero line) const wonAmount = dealsByMonth[month] .filter((deal: Deal) => deal.stage === "won") .reduce((acc: number, deal: Deal) => { const amount = deal.amount ?? 0; return !isNaN(amount) ? acc + amount : acc; }, 0); const pendingAmount = dealsByMonth[month] .filter((deal: Deal) => !["won", "lost"].includes(deal.stage)) .reduce((acc: number, deal: Deal) => { const amount = deal.amount ?? 0; const mult = stageMultiplier[deal.stage] ?? 0; return !isNaN(amount) && !isNaN(mult) ? acc + amount * mult : acc; }, 0); // Calculate negative value (below zero line) - store as negative for diverging chart const lostAmount = dealsByMonth[month] .filter((deal: Deal) => deal.stage === "lost") .reduce((acc: number, deal: Deal) => { const amount = deal.amount ?? 0; return !isNaN(amount) ? acc + amount : acc; }, 0); return { date: format(month, "MMM", { locale: dateFnsLocale }), won: wonAmount, pending: pendingAmount, lost: -lostAmount, // Negative for diverging chart (renders below zero) }; }); // Filter out any months with NaN values return amountByMonth.filter( (month) => !isNaN(month.won) && !isNaN(month.pending) && !isNaN(month.lost), ); }, [data, dateFnsLocale]); if (isPending) { return (
); } if (months.length === 0) return null; // No data to display const range = months.reduce( (acc, month) => { acc.min = Math.min(acc.min, month.lost); acc.max = Math.max(acc.max, month.won + month.pending); return acc; }, { min: 0, max: 0 }, ); // Ensure we have a valid range for the chart const chartRange = { min: range.min === 0 && range.max === 0 ? -100 : range.min * 1.2, max: range.min === 0 && range.max === 0 ? 100 : range.max * 1.2, }; return (

{translate("crm.dashboard.upcoming_deal_revenue")}

{ // won and pending are green shades, lost is red if (id === "won") return "#61cdbb"; if (id === "pending") return "#97e3d5"; return "#e25c3b"; }} margin={{ top: 30, right: 50, bottom: 30, left: 0 }} padding={0.3} valueScale={{ type: "linear", min: chartRange.min, max: chartRange.max, }} indexScale={{ type: "band", round: true }} enableGridX={true} enableGridY={false} enableLabel={false} tooltip={({ value, indexValue }) => (
{indexValue}:  {value > 0 ? "+" : ""} {value.toLocaleString( locale ?? acceptedLanguages.at(0) ?? DEFAULT_LOCALE, { style: "currency", currency: CURRENCY, }, )}
)} axisTop={{ tickSize: 0, tickPadding: 12, style: { ticks: { text: { fill: "var(--color-muted-foreground)", }, }, legend: { text: { fill: "var(--color-muted-foreground)", }, }, }, }} axisBottom={{ legendPosition: "middle", legendOffset: 50, tickSize: 0, tickPadding: 12, style: { ticks: { text: { fill: "var(--color-muted-foreground)", }, }, legend: { text: { fill: "var(--color-muted-foreground)", }, }, }, }} axisLeft={null} axisRight={{ format: (v: any) => `${Math.abs(v / 1000)}k`, tickValues: 8, style: { ticks: { text: { fill: "var(--color-muted-foreground)", }, }, legend: { text: { fill: "var(--color-muted-foreground)", }, }, }, }} markers={ [ { axis: "y", value: 0, lineStyle: { strokeOpacity: 0 }, textStyle: { fill: "#2ebca6" }, legend: translate("crm.dashboard.deals_chart.won"), legendPosition: "top-left", legendOrientation: "vertical", }, { axis: "y", value: 0, lineStyle: { stroke: "#f47560", strokeWidth: 1, }, textStyle: { fill: "#e25c3b" }, legend: translate("crm.dashboard.deals_chart.lost"), legendPosition: "bottom-left", legendOrientation: "vertical", }, ] as any } />
); });