import React, { useMemo, useState } from "react"; import { Chart as ChartJS, CategoryScale, LinearScale, BarController, BarElement, Tooltip, 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 { FALLBACK_TICK, FALLBACK_PRIMARY, formatTooltipDate, formatMonthLabel, formatCount, hexToRgba, ChartLegendItem, ChartPeriodButton, } from "./chart-shared"; ChartJS.register( CategoryScale, LinearScale, BarController, BarElement, Tooltip, ); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ContactHistoryDataPoint { /** ISO date string e.g. "2024-01-01" */ date: string; calls: number; emails: number; meetings: number; notes?: number; } export type ContactHistoryPeriod = 3 | 6 | 12; export interface BackofficeContactHistoryChartProps { chartData?: ContactHistoryDataPoint[] | null; title?: string; /** Show or hide the chart legend */ showLegend?: boolean; /** Legend placement relative to the chart */ legendPosition?: "top" | "bottom"; /** Show or hide X axis labels */ showXAxis?: boolean; /** Show or hide Y axis labels */ showYAxis?: boolean; /** Default period selector value */ defaultPeriod?: ContactHistoryPeriod; /** Chart canvas height in pixels */ height?: number; /** Card max-width in pixels or CSS string */ width?: number | string; className?: string; /** Show skeleton loading state instead of the chart */ isLoading?: boolean; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const CONTACT_PERIODS: ContactHistoryPeriod[] = [3, 6, 12]; const SLICE_COUNT: Record = { 3: 3, 6: 6, 12: 12, }; // --------------------------------------------------------------------------- // Legend // --------------------------------------------------------------------------- function ChartLegend({ callColor, emailColor, meetingColor, noteColor, showNotes, }: { callColor: string; emailColor: string; meetingColor: string; noteColor: string; showNotes: boolean; }) { return (
{showNotes && }
); } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function BackofficeContactHistoryChart({ chartData, title = "Client History", showLegend = true, legendPosition = "top", showXAxis = true, showYAxis = false, defaultPeriod = 6, height = 200, width = "100%", className, isLoading = false, }: BackofficeContactHistoryChartProps) { const [period, setPeriod] = useState(defaultPeriod); const themeVars = useThemeVars(); const brandPrimary: string = (themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY; const fontFamily: string = (themeVars["--font-sans"] as string | undefined) || "Figtree, sans-serif"; // Derive 4 shades from the brand primary const callColor = brandPrimary; const emailColor = hexToRgba(brandPrimary, 0.65); const meetingColor = hexToRgba(brandPrimary, 0.4); const noteColor = hexToRgba(brandPrimary, 0.22); 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 showNotes = useMemo( () => !!sliced?.some((p) => (p.notes ?? 0) > 0), [sliced], ); const labels = useMemo( () => sliced?.map((p) => formatMonthLabel(p.date)) ?? [], [sliced], ); const data = useMemo>(() => { if (!sliced) return { labels: [], datasets: [] }; return { labels, datasets: [ { label: "Calls", data: sliced.map((p) => p.calls), backgroundColor: callColor, borderWidth: 0, stack: "contacts", }, { label: "Emails", data: sliced.map((p) => p.emails), backgroundColor: emailColor, borderWidth: 0, stack: "contacts", }, { label: "Meetings", data: sliced.map((p) => p.meetings), backgroundColor: meetingColor, borderWidth: 0, stack: "contacts", }, ...(showNotes ? [ { label: "Notes", data: sliced.map((p) => p.notes ?? 0), backgroundColor: noteColor, borderWidth: 0, stack: "contacts", }, ] : []), ], }; }, [ sliced, labels, callColor, emailColor, meetingColor, noteColor, showNotes, ]); const options = useMemo>( () => ({ responsive: true, maintainAspectRatio: false, animation: { duration: 600, 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?.[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: { stacked: true, display: showXAxis, grid: { display: false }, border: { display: false }, ticks: { maxRotation: 0, minRotation: 0, color: FALLBACK_TICK, font: { size: 10 }, }, }, y: { stacked: true, display: showYAxis, grid: { display: false }, border: { display: false }, ticks: { padding: 8, maxTicksLimit: 5, color: FALLBACK_TICK, font: { size: 10 }, callback: (v) => formatCount(Number(v)), }, }, }, }), [showXAxis, showYAxis, sliced], ); return ( {title}
{CONTACT_PERIODS.map((p) => ( setPeriod(p)} /> ))}
{isLoading ? ( ) : !sliced ? ( No data available ) : (
{showLegend && legendPosition === "top" && ( )}
{showLegend && legendPosition === "bottom" && ( )}
)}
); }