import React, { useMemo } from "react"; import { Chart as ChartJS, CategoryScale, LinearScale, BarController, BarElement, Tooltip, type ChartOptions, type ChartData, } from "chart.js"; import ChartDataLabels from "chartjs-plugin-datalabels"; import { Chart } from "react-chartjs-2"; import { useThemeVars } from "@/lib/theme-provider"; import { Card, CardContent, CardHeader, CardTitle } from "./card"; import { Empty, EmptyDescription } from "./empty"; import { Spinner } from "./spinner"; import { cn } from "@/lib/utils"; import { formatCurrency } from "@/lib/format-currency"; import { FALLBACK_TICK, FALLBACK_PRIMARY, FALLBACK_SECONDARY, } from "./chart-shared"; ChartJS.register( CategoryScale, LinearScale, BarController, BarElement, Tooltip, ); // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface TransactionsIncomeExpenseBarChartProps { /** Total income amount (positive number) */ totalIncome: number | null; /** Total expense amount (negative or positive — absolute value is used) */ totalExpense: number | null; title?: string; /** Chart canvas height in pixels */ height?: number; /** Width of the card */ width?: number | string; className?: string; /** Show skeleton loading state instead of the chart */ isLoading?: boolean; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function TransactionsIncomeExpenseBarChart({ totalIncome, totalExpense, title = "Transactions — Income vs Expense", height = 120, width = "100%", className, isLoading = false, }: TransactionsIncomeExpenseBarChartProps) { const themeVars = useThemeVars(); const brandPrimary = (themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY; const brandSecondary = (themeVars["--theme-secondary"] as string | undefined) || FALLBACK_SECONDARY; const fontFamily: string = (themeVars["--font-sans"] as string | undefined) || "Figtree, sans-serif"; const hasData = totalIncome != null && totalExpense != null; const incomeVal = totalIncome ?? 0; const expenseVal = Math.abs(totalExpense ?? 0); const maxVal = Math.max(incomeVal, expenseVal); const chartData = useMemo>(() => { if (!hasData) return { labels: [], datasets: [] }; return { labels: ["Incoming", "Outgoing"], datasets: [ { barThickness: 40, backgroundColor: [brandPrimary, brandSecondary], hoverBackgroundColor: [brandPrimary, brandSecondary], borderWidth: 0, borderRadius: 0, borderSkipped: false, data: [incomeVal, expenseVal], // chartjs-plugin-datalabels config — typed via plugin module augmentation datalabels: { labels: { value: { anchor: "end", align: "end", offset: 10, clamp: false, font: { weight: "bold", size: 14 }, color: FALLBACK_SECONDARY, textAlign: "left", // Returns array for multi-line: dollar value on line 1, blank on line 2 formatter: (v: number) => [formatCurrency(v, { decimals: 2 }), ""], }, name: { anchor: "end", align: "end", offset: 10, clamp: false, font: { size: 12 }, color: FALLBACK_TICK, textAlign: "left", // Returns array for multi-line: blank on line 1, bar label on line 2 // eslint-disable-next-line @typescript-eslint/no-explicit-any formatter: (_: number, ctx: any) => [ "", String(ctx.chart.data.labels?.[ctx.dataIndex] ?? ""), ], }, }, } as unknown as never, }, ], }; }, [hasData, incomeVal, expenseVal, brandPrimary, brandSecondary]); const options = useMemo>( () => ({ indexAxis: "y", responsive: true, maintainAspectRatio: false, animation: { duration: 800, easing: "easeOutQuart" }, layout: { // Right padding reserves space for the datalabels rendered outside the bar area padding: { right: 180, left: 0, top: 10, bottom: 10 }, }, plugins: { legend: { display: false }, tooltip: { enabled: false }, }, scales: { y: { display: true, grid: { display: false }, border: { display: false }, ticks: { display: false }, }, x: { display: true, suggestedMax: maxVal * 1.3, grid: { display: false }, border: { display: false }, ticks: { display: false }, }, }, }), [maxVal], ); return ( {title} {isLoading ? (
) : !hasData ? ( No data available ) : (
)}
); }