import React, { useMemo } from "react"; import { Chart as ChartJS, CategoryScale, LinearScale, LineController, LineElement, PointElement, Tooltip, type ChartOptions, type ChartData, } from "chart.js"; import { Chart } from "react-chartjs-2"; import { useThemeVars } from "@/lib/theme-provider"; import { FALLBACK_TICK, FALLBACK_PRIMARY, FALLBACK_BG, FALLBACK_FOREGROUND, FALLBACK_GRID_COLOR, ChartLegendItem, formatAbbrev, formatMonthLabel, formatTooltipDate, } from "./chart-shared"; import { Card, CardContent, CardHeader, CardTitle } from "./card"; import { Empty, EmptyDescription } from "./empty"; import { Skeleton } from "./skeleton"; import { cn } from "@/lib/utils"; import { formatCurrency } from "@/lib/format-currency"; ChartJS.register( CategoryScale, LinearScale, LineController, LineElement, PointElement, Tooltip, ); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type BorrowingSeriesType = | "current-expenses" | "optimised-estimate" | "desired-loan-amount"; /** @deprecated use BorrowingSeriesType */ export type BorrowingScenario = BorrowingSeriesType; export interface BorrowingCapacityDataPoint { /** ISO 8601 date string, e.g. "2025-01" or "2025-01-15" */ date: string; /** Amount in AUD cents (avoids float precision issues) */ amountCents: number; } export interface BorrowingCapacitySeries { type: BorrowingSeriesType; data: BorrowingCapacityDataPoint[]; } export interface BorrowingCapacityLineChartProps { series?: BorrowingCapacitySeries[] | null; title?: string; /** * Current borrowing capacity in AUD cents — displayed as a KPI value * in the top-right corner of the card header. */ kpiValue?: number; /** Chart canvas height in pixels */ height?: number; /** Card max-width */ width?: number | string; className?: string; /** Show skeleton loading state while data is in-flight */ isLoading?: boolean; showXAxis?: boolean; showYAxis?: boolean; showLegend?: boolean; /** Legend placement relative to the chart */ legendPosition?: "top" | "bottom"; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const SERIES_LABELS: Record = { "current-expenses": "Based on Current Expenses", "optimised-estimate": "Optimised Estimate", "desired-loan-amount": "Desired Loan Amount", }; const SERIES_LINE_STYLE: Record = { "current-expenses": "solid", "optimised-estimate": "dashed", "desired-loan-amount": "dashed", }; const SERIES_BORDER_WIDTH: Record = { "current-expenses": 2, "optimised-estimate": 1.5, "desired-loan-amount": 2, }; /** Chart.js render order — reference line painted first (back), current expenses last (front). */ const SERIES_RENDER_ORDER: BorrowingSeriesType[] = [ "desired-loan-amount", "optimised-estimate", "current-expenses", ]; /** Legend display order — left-to-right reading of the chart's primary-to-reference hierarchy. */ const SERIES_LEGEND_ORDER: BorrowingSeriesType[] = [ "current-expenses", "optimised-estimate", "desired-loan-amount", ]; const DASH_PATTERN: number[] = [6, 4]; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** "2025-01" or "2025-01-15" → "Jan '25" for x-axis ticks */ function formatDateLabel(iso: string): string { const normalized = `${iso.slice(0, 7)}-01T00:00:00`; return formatMonthLabel(normalized); } /** "2025-01" → "Jan 2025" for tooltip header */ function formatDateTooltip(iso: string): string { const normalized = `${iso.slice(0, 7)}-01T00:00:00`; return formatTooltipDate(normalized, "monthly"); } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function BorrowingCapacityLineChart({ series, title = "Borrowing Capacity", kpiValue, height = 280, width = "100%", className, isLoading = false, showXAxis = true, showYAxis = true, showLegend = true, legendPosition = "bottom", }: BorrowingCapacityLineChartProps) { const themeVars = useThemeVars(); const brandPrimary = (themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY; const fontFamily = (themeVars["--font-sans"] as string | undefined) || "Figtree, sans-serif"; const seriesColor = useMemo>( () => ({ "current-expenses": FALLBACK_FOREGROUND, "optimised-estimate": FALLBACK_TICK, // desired-loan-amount tracks tenant brand; no alpha conversion needed at 1.0 "desired-loan-amount": brandPrimary, }), [brandPrimary], ); const orderedSeries = useMemo(() => { if (!series?.length) return []; return SERIES_RENDER_ORDER.map((t) => series.find((s) => s.type === t), ).filter((s): s is BorrowingCapacitySeries => !!s && s.data.length > 0); }, [series]); const hasData = orderedSeries.length > 0; const labels = useMemo(() => { if (!orderedSeries.length) return []; const primary = orderedSeries.find((s) => s.type === "current-expenses") ?? orderedSeries[0]; return primary.data.map((p) => p.date); }, [orderedSeries]); const chartData = useMemo>(() => { if (!orderedSeries.length) return { labels: [], datasets: [] }; return { labels, datasets: orderedSeries.map((s) => { const color = seriesColor[s.type]; const isDashed = SERIES_LINE_STYLE[s.type] === "dashed"; return { label: SERIES_LABELS[s.type], data: s.data.map((p) => p.amountCents / 100), borderColor: color, backgroundColor: "transparent", borderWidth: SERIES_BORDER_WIDTH[s.type], borderDash: isDashed ? DASH_PATTERN : [], tension: s.type === "current-expenses" ? 0.4 : 0, pointRadius: 0, pointHoverRadius: s.type === "desired-loan-amount" ? 0 : 5, pointHoverBackgroundColor: FALLBACK_BG, pointHoverBorderColor: color, pointHoverBorderWidth: 2, pointHitRadius: 12, }; }), }; }, [labels, orderedSeries, seriesColor]); const options = useMemo>( () => ({ responsive: true, maintainAspectRatio: false, animation: { duration: 800, easing: "easeOutQuart" }, plugins: { legend: { display: false }, tooltip: { enabled: true, mode: "index", intersect: false, filter: (item) => item.dataset.label !== SERIES_LABELS["desired-loan-amount"], displayColors: true, boxWidth: 10, boxHeight: 2, padding: 12, cornerRadius: 0, titleFont: { size: 11, weight: 600, family: fontFamily }, bodyFont: { size: 12, weight: 500, family: fontFamily }, callbacks: { title: (items) => { const iso = items[0]?.label; return iso ? formatDateTooltip(iso) : ""; }, label: (ctx) => { const dollars = ctx.parsed.y ?? 0; return ` ${ctx.dataset.label}: ${formatCurrency(dollars)}`; }, }, }, }, interaction: { mode: "index", intersect: false }, scales: { x: { type: "category", display: showXAxis, grid: { display: false }, border: { display: false }, ticks: { autoSkip: true, maxTicksLimit: 7, maxRotation: 0, minRotation: 0, color: FALLBACK_TICK, font: { size: 12, family: fontFamily }, callback: function (_, index) { const iso = labels[index]; return iso ? formatDateLabel(iso) : ""; }, }, }, y: { display: showYAxis, position: "left", grid: { display: false }, border: { display: false }, ticks: { maxTicksLimit: 6, padding: 8, color: FALLBACK_TICK, font: { size: 12, family: fontFamily }, callback: (v) => formatAbbrev(Number(v)), }, }, }, }), [showXAxis, showYAxis, labels, fontFamily], ); const legendEl = useMemo(() => { if (!showLegend || !hasData) return null; const items = SERIES_LEGEND_ORDER.map((t) => orderedSeries.find((s) => s.type === t), ).filter((s): s is BorrowingCapacitySeries => !!s); return (
{items.map((s) => ( ))}
); }, [showLegend, hasData, orderedSeries, seriesColor]); const legendWrapper = legendEl ? (
{legendEl}
) : null; return (
{title} {kpiValue != null && ( {formatCurrency(kpiValue / 100)} )}
{isLoading ? ( ) : !hasData ? ( No borrowing capacity data available ) : ( <> {legendPosition === "top" && legendWrapper}
{legendPosition === "bottom" && legendWrapper} )}
); }