import { Fragment, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowUpDown } from "lucide-react"; import type { CategoryDelta } from "../../shared/types"; import { reorderRows } from "../../utils/reorderRows"; export interface ComparePeriodTableProps { rows: CategoryDelta[]; /** Label for the "previous" monthly column (e.g. "March 2026" or "2025"). */ previousLabel: string; /** Label for the "current" monthly column (e.g. "April 2026" or "2026"). */ currentLabel: string; /** Optional label for the previous cumulative window (YTD). Falls back to previousLabel. */ cumulativePreviousLabel?: string; /** Optional label for the current cumulative window (YTD). Falls back to currentLabel. */ cumulativeCurrentLabel?: string; } function formatCurrency(amount: number, language: string): string { return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0, }).format(amount); } function formatSignedCurrency(amount: number, language: string): string { return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0, signDisplay: "always", }).format(amount); } function formatPct(pct: number | null, language: string): string { if (pct === null) return "—"; return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { style: "percent", maximumFractionDigits: 1, signDisplay: "always", }).format(pct / 100); } function variationColor(value: number): string { // Compare report deals with expenses (abs values): spending more is negative // for the user, spending less is positive. Mirror that colour convention // consistently so the eye parses the delta sign at a glance. if (value > 0) return "var(--negative, #ef4444)"; if (value < 0) return "var(--positive, #10b981)"; return ""; } const STORAGE_KEY = "compare-subtotals-position"; type SectionType = "expense" | "income" | "transfer"; /** Aggregate of the 6 comparable figures across a set of leaf rows. */ interface Totals { monthCurrent: number; monthPrevious: number; monthDelta: number; ytdCurrent: number; ytdPrevious: number; ytdDelta: number; } function sumLeaves(rows: CategoryDelta[]): Totals { return rows .filter((r) => !r.is_parent) .reduce( (acc, r) => ({ monthCurrent: acc.monthCurrent + r.currentAmount, monthPrevious: acc.monthPrevious + r.previousAmount, monthDelta: acc.monthDelta + r.deltaAbs, ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount, ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount, ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs, }), { monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 }, ); } function pct(delta: number, previous: number): number | null { return previous !== 0 ? (delta / Math.abs(previous)) * 100 : null; } export default function ComparePeriodTable({ rows, previousLabel, currentLabel, cumulativePreviousLabel, cumulativeCurrentLabel, }: ComparePeriodTableProps) { const { t, i18n } = useTranslation(); const lang = i18n.language; const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => { const stored = localStorage.getItem(STORAGE_KEY); return stored === null ? true : stored === "top"; }); const toggleSubtotals = () => { setSubtotalsOnTop((prev) => { const next = !prev; localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom"); return next; }); }; const monthPrevLabel = previousLabel; const monthCurrLabel = currentLabel; const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel; const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel; // Group rows into contiguous type sections (the service already type-sorts). const sectionLabels: Record = { expense: t("reports.compare.sections.expenses"), income: t("reports.compare.sections.income"), transfer: t("reports.compare.sections.transfers"), }; const sectionTotalKeys: Record = { expense: "reports.compare.totalExpenses", income: "reports.compare.totalIncome", transfer: "reports.compare.totalTransfers", }; const sections: { type: SectionType; rows: CategoryDelta[] }[] = []; let currentType: SectionType | null = null; for (const row of rows) { const type = (row.category_type ?? "expense") as SectionType; if (type !== currentType) { currentType = type; sections.push({ type, rows: [] }); } sections[sections.length - 1].rows.push(row); } // Grand totals across every leaf. const totals = sumLeaves(rows); return (
{rows.length === 0 ? ( ) : ( <> {sections.map((section) => { const sectionTotals = sumLeaves(section.rows); return ( {reorderRows(section.rows, subtotalsOnTop).map((row) => { const isParent = row.is_parent ?? false; const depth = row.depth ?? 0; const isTopParent = isParent && depth === 0; const isIntermediateParent = isParent && depth >= 1; const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; return ( {/* Monthly block */} {/* Cumulative YTD block */} ); })} {/* Section net total */} ); })} {/* Grand totals row */} )}
{t("reports.highlights.category")} {t("reports.bva.monthly")} {t("reports.bva.ytd")}
{t("reports.compare.currentAmount")}
{monthCurrLabel}
{t("reports.compare.previousAmount")}
{monthPrevLabel}
{t("reports.bva.dollarVar")} {t("reports.bva.pctVar")}
{t("reports.compare.currentAmount")}
{ytdCurrLabel}
{t("reports.compare.previousAmount")}
{ytdPrevLabel}
{t("reports.bva.dollarVar")} {t("reports.bva.pctVar")}
{t("reports.empty.noData")}
{sectionLabels[section.type]}
{row.categoryName} {formatCurrency(row.currentAmount, lang)} {formatCurrency(row.previousAmount, lang)} {formatSignedCurrency(row.deltaAbs, lang)} {formatPct(row.deltaPct, lang)} {formatCurrency(row.cumulativeCurrentAmount, lang)} {formatCurrency(row.cumulativePreviousAmount, lang)} {formatSignedCurrency(row.cumulativeDeltaAbs, lang)} {formatPct(row.cumulativeDeltaPct, lang)}
{t(sectionTotalKeys[section.type])} {formatCurrency(sectionTotals.monthCurrent, lang)} {formatCurrency(sectionTotals.monthPrevious, lang)} {formatSignedCurrency(sectionTotals.monthDelta, lang)} {formatPct(pct(sectionTotals.monthDelta, sectionTotals.monthPrevious), lang)} {formatCurrency(sectionTotals.ytdCurrent, lang)} {formatCurrency(sectionTotals.ytdPrevious, lang)} {formatSignedCurrency(sectionTotals.ytdDelta, lang)} {formatPct(pct(sectionTotals.ytdDelta, sectionTotals.ytdPrevious), lang)}
{t("reports.compare.totalRow")} {formatCurrency(totals.monthCurrent, lang)} {formatCurrency(totals.monthPrevious, lang)} {formatSignedCurrency(totals.monthDelta, lang)} {formatPct(pct(totals.monthDelta, totals.monthPrevious), lang)} {formatCurrency(totals.ytdCurrent, lang)} {formatCurrency(totals.ytdPrevious, lang)} {formatSignedCurrency(totals.ytdDelta, lang)} {formatPct(pct(totals.ytdDelta, totals.ytdPrevious), lang)}
); }