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 alert-matching data showing how many contacts fall into each * alert-status bucket. */ export interface AlertMatchingDataPoint { /** ISO date string e.g. "2024-01-01" */ date: string; /** Contacts with no active alerts (healthy). */ healthy: number; /** Contacts matching at least one Watch-level alert. */ watch: number; /** Contacts matching at least one Need-Action alert. */ needAction: number; } export type AlertMatchingPeriod = 3 | 6 | 12; type AlertMatchingChartType = "line" | "bar"; export interface BackofficeAlertMatchingChartProps { chartData?: AlertMatchingDataPoint[] | null; title?: string; showLegend?: boolean; legendPosition?: "top" | "bottom"; showXAxis?: boolean; showYAxis?: boolean; defaultPeriod?: AlertMatchingPeriod; height?: number; width?: number | string; className?: string; isLoading?: boolean; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PERIODS: AlertMatchingPeriod[] = [3, 6, 12]; const SLICE_COUNT: Record = { 3: 3, 6: 6, 12: 12 }; const HEALTHY_COLOR = "#166d42"; const WATCH_COLOR = "#EF6C00"; const NEED_ACTION_COLOR = "#F44336"; // --------------------------------------------------------------------------- // Legend // --------------------------------------------------------------------------- function AlertMatchingLegend() { return (
); } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function BackofficeAlertMatchingChart({ chartData, title = "Alert Matching History", showLegend = true, legendPosition = "top", showXAxis = true, showYAxis = false, defaultPeriod = 6, height = 200, width = "100%", className, isLoading = false, }: BackofficeAlertMatchingChartProps) { 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: "Healthy", data: sliced.map((p) => p.healthy), borderColor: HEALTHY_COLOR, backgroundColor: isBar ? HEALTHY_COLOR : hexToRgba(HEALTHY_COLOR, 0.08), fill: !isBar, tension: 0.3, pointRadius: 0, borderWidth: 2, }, { type: chartType, label: "Watch", data: sliced.map((p) => p.watch), borderColor: WATCH_COLOR, backgroundColor: isBar ? WATCH_COLOR : hexToRgba(WATCH_COLOR, 0.08), fill: !isBar, tension: 0.3, pointRadius: 0, borderWidth: 2, }, { type: chartType, label: "Need Action", data: sliced.map((p) => p.needAction), borderColor: NEED_ACTION_COLOR, backgroundColor: isBar ? NEED_ACTION_COLOR : hexToRgba(NEED_ACTION_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 AlertMatchingChartType) } >
{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" && ( )}
)}
); }