import React, { useMemo, useState } from "react"; import { Chart as ChartJS, CategoryScale, LinearScale, LineController, LineElement, PointElement, BarController, BarElement, Tooltip, type ChartOptions, } from "chart.js"; import { Chart } from "react-chartjs-2"; import { BarChart2, LineChart } from "lucide-react"; 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 { FALLBACK_TICK, hexToRgba, formatTooltipDate, formatMonthLabel, formatCount, ChartLegendItem, ChartPeriodButton, } from "./chart-shared"; import { ToggleGroup, ToggleGroupItem } from "./toggle-group"; ChartJS.register( CategoryScale, LinearScale, LineController, LineElement, PointElement, BarController, BarElement, Tooltip, ); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** * One month of contact-matching data showing how many contacts are in each * account-status bucket. */ export interface ContactMatchingDataPoint { /** ISO date string e.g. "2024-01-01" */ date: string; /** Total contacts across all statuses. */ total: number; /** Contacts with an active account. */ active: number; /** Contacts with a pending account. */ pending: number; /** Contacts with an inactive account. */ inactive: number; } export type ContactMatchingPeriod = 3 | 6 | 12; type ContactMatchingChartType = "line" | "bar"; export interface BackofficeContactMatchingChartProps { chartData?: ContactMatchingDataPoint[] | null; title?: string; showLegend?: boolean; legendPosition?: "top" | "bottom"; showXAxis?: boolean; showYAxis?: boolean; defaultPeriod?: ContactMatchingPeriod; height?: number; width?: number | string; className?: string; isLoading?: boolean; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PERIODS: ContactMatchingPeriod[] = [3, 6, 12]; const SLICE_COUNT: Record = { 3: 3, 6: 6, 12: 12, }; const TOTAL_COLOR = "#162029"; const ACTIVE_COLOR = "#166d42"; const PENDING_COLOR = "#EF6C00"; const INACTIVE_COLOR = "#F44336"; // --------------------------------------------------------------------------- // Legend // --------------------------------------------------------------------------- function ContactMatchingLegend() { return (
); } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function BackofficeContactMatchingChart({ chartData, title = "Client Matching History", showLegend = true, legendPosition = "top", showXAxis = true, showYAxis = false, defaultPeriod = 6, height = 200, width = "100%", className, isLoading = false, }: BackofficeContactMatchingChartProps) { const [period, setPeriod] = useState(defaultPeriod); const [chartType, setChartType] = useState("line"); const themeVars = useThemeVars(); const fontFamily: string = (themeVars["--font-sans"] as string | undefined) || "Figtree, sans-serif"; const sliced = useMemo(() => { if (!chartData?.length) return null; const count = Math.min(SLICE_COUNT[period], chartData.length); return chartData.slice(chartData.length - count); }, [chartData, period]); const labels = useMemo( () => sliced?.map((p) => formatMonthLabel(p.date)) ?? [], [sliced], ); const isBar = chartType === "bar"; const datasets = useMemo(() => { if (!sliced) return []; return [ { type: chartType, label: "All", data: sliced.map((p) => p.total), borderColor: TOTAL_COLOR, backgroundColor: isBar ? TOTAL_COLOR : hexToRgba(TOTAL_COLOR, 0.08), fill: !isBar, tension: 0.3, pointRadius: 0, borderWidth: 2, }, { type: chartType, label: "Active", data: sliced.map((p) => p.active), borderColor: ACTIVE_COLOR, backgroundColor: isBar ? ACTIVE_COLOR : hexToRgba(ACTIVE_COLOR, 0.08), fill: !isBar, tension: 0.3, pointRadius: 0, borderWidth: 2, }, { type: chartType, label: "Pending", data: sliced.map((p) => p.pending), borderColor: PENDING_COLOR, backgroundColor: isBar ? PENDING_COLOR : hexToRgba(PENDING_COLOR, 0.08), fill: !isBar, tension: 0.3, pointRadius: 0, borderWidth: 2, }, { type: chartType, label: "Inactive", data: sliced.map((p) => p.inactive), borderColor: INACTIVE_COLOR, backgroundColor: isBar ? INACTIVE_COLOR : hexToRgba(INACTIVE_COLOR, 0.08), fill: !isBar, tension: 0.3, pointRadius: 0, borderWidth: 2, }, ]; }, [sliced, chartType, isBar]); const options = useMemo>( () => ({ responsive: true, maintainAspectRatio: false, animation: { duration: 300 }, 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?.[idx]?.date) { return formatTooltipDate(sliced[idx].date, "monthly"); } return tooltipItems[0]?.label ?? ""; }, label: (ctx) => { const val = ctx.raw as number; if (val === 0) return; return ` ${ctx.dataset.label}: ${formatCount(val)}`; }, }, }, }, scales: { x: { display: showXAxis, grid: { display: false }, border: { display: false }, ticks: { maxRotation: 0, minRotation: 0, color: FALLBACK_TICK, font: { size: 10 }, }, }, y: { display: showYAxis, grid: { display: false }, border: { display: false }, beginAtZero: true, ticks: { padding: 8, maxTicksLimit: 5, color: FALLBACK_TICK, font: { size: 10 }, callback: (v) => formatCount(Number(v)), }, }, }, }), [showXAxis, showYAxis, sliced], ); return ( {title}
v[0] && setChartType(v[0] as ContactMatchingChartType) } >
{PERIODS.map((p) => ( setPeriod(p)} /> ))}
{isLoading ? ( ) : !sliced ? ( No data available ) : (
{showLegend && legendPosition === "top" && ( )}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{showLegend && legendPosition === "bottom" && ( )}
)}
); }