import React, { useMemo, useState } from "react"; import { Chart as ChartJS, CategoryScale, LinearScale, BarController, BarElement, Tooltip, Legend, type ChartOptions, type ChartData, } from "chart.js"; import { Chart } from "react-chartjs-2"; import { useThemeVars } from "@/lib/theme-provider"; import { Card, CardContent, CardHeader, CardTitle, CardAction } from "./card"; import { Empty, EmptyDescription } from "./empty"; import { Skeleton } from "./skeleton"; import { cn } from "@/lib/utils"; import { hexToRgba, FALLBACK_TICK, FALLBACK_PRIMARY, FALLBACK_SECONDARY, formatTooltipDate, ChartPeriodButton, } from "./chart-shared"; ChartJS.register( CategoryScale, LinearScale, BarController, BarElement, Tooltip, Legend, ); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface CashflowDataPoint { /** ISO date string e.g. "2024-01-01" */ date: string; income: number; expenses: number; /** Positive value — ignored when overspending > 0 */ surplus: number; /** Positive value — ignored when surplus > 0 */ overspending: number; } export interface CashflowChartData { months: string[]; data: CashflowDataPoint[]; } export type CashflowPeriod = 3 | 6 | 12; export interface CashflowBarChartProps { /** Full dataset — sliced to the selected period */ cashflowData: CashflowChartData | null; title?: string; /** Show or hide the chart legend */ showLegend?: boolean; /** Show or hide X axis labels */ showXAxis?: boolean; /** Show or hide Y axis labels */ showYAxis?: boolean; /** Legend placement relative to chart */ legendPosition?: "top" | "bottom"; /** Default period selector value */ defaultPeriod?: CashflowPeriod; /** Show or hide the period selector buttons (3M / 6M / 12M) */ showPeriodSelector?: boolean; /** Chart canvas height in pixels */ height?: number; /** Width of the card in pixels */ width?: number | string; className?: string; /** Show skeleton loading state instead of the chart */ isLoading?: boolean; } // --------------------------------------------------------------------------- // HTML legend (outside canvas — full spacing control) // --------------------------------------------------------------------------- interface LegendItemProps { label: string; fillColor: string; strokeColor: string; strokeWidth?: number; } function LegendItem({ label, fillColor, strokeColor, strokeWidth = 1.5, }: LegendItemProps) { return (
0 ? `${strokeWidth}px solid ${strokeColor}` : "none", flexShrink: 0, }} /> {label}
); } function ChartLegend({ primary, secondary, }: { primary: string; secondary: string; }) { return (
); } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PERIODS: CashflowPeriod[] = [3, 6, 12]; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function CashflowBarChart({ cashflowData, title = "Cashflow", showLegend = true, showXAxis = true, showYAxis = true, legendPosition = "top", defaultPeriod = 6, showPeriodSelector = true, height = 280, width = "100%", className, isLoading = false, }: CashflowBarChartProps) { const [period, setPeriod] = useState(defaultPeriod); // Read theme colors from ThemeProvider context. // Falls back to WealthX defaults when no ThemeProvider is present. const themeVars = useThemeVars(); const brandPrimary: string = (themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY; const brandSecondary: string = (themeVars["--theme-secondary"] as string | undefined) || FALLBACK_SECONDARY; const fontFamily: string = (themeVars["--font-sans"] as string | undefined) || "Figtree, sans-serif"; const sliced = useMemo(() => { if (!cashflowData?.data?.length) return null; const count = Math.min(period, cashflowData.data.length); const start = cashflowData.data.length - count; return { months: cashflowData.months.slice(start), data: cashflowData.data.slice(start), }; }, [cashflowData, period]); const chartData = useMemo>(() => { if (!sliced) return { labels: [], datasets: [] }; return { labels: sliced.months, datasets: [ { label: "Income", data: sliced.data.map((d) => d.income), backgroundColor: hexToRgba(brandPrimary, 0.2), hoverBackgroundColor: hexToRgba(brandPrimary, 0.35), borderColor: brandPrimary, borderWidth: 1.5, borderRadius: 0, borderSkipped: false, barPercentage: 0.75, categoryPercentage: 0.7, }, { label: "Expenses and Liabilities", data: sliced.data.map((d) => d.expenses), backgroundColor: hexToRgba(brandSecondary, 0.2), hoverBackgroundColor: hexToRgba(brandSecondary, 0.35), borderColor: brandSecondary, borderWidth: 1.5, borderRadius: 0, borderSkipped: false, barPercentage: 0.75, categoryPercentage: 0.7, }, { label: "_thirdBar", data: sliced.data.map((d) => d.overspending > 0 ? d.overspending : d.surplus, ), backgroundColor: sliced.data.map((d) => d.overspending > 0 ? brandSecondary : brandPrimary, ), hoverBackgroundColor: sliced.data.map((d) => d.overspending > 0 ? hexToRgba(brandSecondary, 0.8) : hexToRgba(brandPrimary, 0.8), ), borderWidth: 0, borderRadius: 0, borderSkipped: false, barPercentage: 0.75, categoryPercentage: 0.7, }, ], }; }, [sliced, brandPrimary, brandSecondary]); const options = useMemo>( () => ({ responsive: true, maintainAspectRatio: false, animation: { duration: 800, easing: "easeOutQuart" }, layout: { padding: 0 }, plugins: { legend: { display: false }, tooltip: { mode: "index", intersect: false, padding: 12, cornerRadius: 0, titleFont: { size: 11, weight: 600 }, bodyFont: { size: 12, weight: 500 }, callbacks: { title: (tooltipItems) => { const idx = tooltipItems[0]?.dataIndex; if (idx != null && sliced?.data[idx]?.date) { return formatTooltipDate(sliced.data[idx].date, "monthly"); } return tooltipItems[0]?.label ?? ""; }, label: (ctx) => { const val = ctx.raw as number; if (val === 0) return; if (ctx.datasetIndex === 2) { const d = sliced?.data[ctx.dataIndex]; if (!d) return; const lbl = d.overspending > 0 ? "Over Spending" : "Surplus Income"; return ` ${lbl}: $${val.toLocaleString()}`; } return ` ${ctx.dataset.label}: $${val.toLocaleString()}`; }, }, }, }, scales: { x: { display: showXAxis, grid: { display: false }, border: { display: false }, ticks: { font: { size: 12, family: fontFamily }, color: FALLBACK_TICK, }, }, y: { display: showYAxis, grid: { display: false }, border: { display: false }, ticks: { font: { size: 12, family: fontFamily }, color: FALLBACK_TICK, maxTicksLimit: 5, padding: 8, callback: (v) => `$${Number(v).toLocaleString()}`, }, }, }, }), [showXAxis, showYAxis, sliced, fontFamily], ); return ( {title} {showPeriodSelector && (
{PERIODS.map((p) => ( setPeriod(p)} /> ))}
)}
{isLoading ? ( ) : !sliced ? ( No data available ) : (
{showLegend && legendPosition === "top" && ( )}
{showLegend && legendPosition === "bottom" && ( )}
)}
); }