import { Fragment, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowUpDown } from "lucide-react"; import type { BudgetVsActualRow } from "../../shared/types"; const cadFormatter = (value: number) => new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0, }).format(value); const pctFormatter = (value: number | null) => value == null ? "—" : `${(value * 100).toFixed(1)}%`; function variationColor(value: number): string { if (value > 0) return "text-[var(--positive)]"; if (value < 0) return "text-[var(--negative)]"; return ""; } interface BudgetVsActualTableProps { data: BudgetVsActualRow[]; } const STORAGE_KEY = "subtotals-position"; function reorderRows( rows: T[], subtotalsOnTop: boolean, ): T[] { if (subtotalsOnTop) return rows; const groups: { parent: T | null; children: T[] }[] = []; let current: { parent: T | null; children: T[] } | null = null; for (const row of rows) { if (row.is_parent && (row.depth ?? 0) === 0) { if (current) groups.push(current); current = { parent: row, children: [] }; } else if (current) { current.children.push(row); } else { if (current) groups.push(current); current = { parent: null, children: [row] }; } } if (current) groups.push(current); return groups.flatMap(({ parent, children }) => { if (!parent) return children; const reorderedChildren: T[] = []; let subParent: T | null = null; const subChildren: T[] = []; for (const child of children) { if (child.is_parent && (child.depth ?? 0) === 1) { if (subParent) { reorderedChildren.push(...subChildren, subParent); subChildren.length = 0; } subParent = child; } else if (subParent && child.parent_id === subParent.category_id) { subChildren.push(child); } else { if (subParent) { reorderedChildren.push(...subChildren, subParent); subParent = null; subChildren.length = 0; } reorderedChildren.push(child); } } if (subParent) { reorderedChildren.push(...subChildren, subParent); } return [...reorderedChildren, parent]; }); } export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) { const { t } = useTranslation(); 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; }); }; if (data.length === 0) { return (
{t("reports.bva.noData")}
); } // Group rows by type for section headers type SectionType = "expense" | "income" | "transfer"; const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = []; const typeLabels: Record = { expense: t("budget.expenses"), income: t("budget.income"), transfer: t("budget.transfers"), }; let currentType: SectionType | null = null; for (const row of data) { if (row.category_type !== currentType) { currentType = row.category_type; sections.push({ type: currentType, label: typeLabels[currentType], rows: [] }); } sections[sections.length - 1].rows.push(row); } // Grand totals (leaf rows only) const leaves = data.filter((r) => !r.is_parent); const totals = leaves.reduce( (acc, r) => ({ monthActual: acc.monthActual + r.monthActual, monthBudget: acc.monthBudget + r.monthBudget, monthVariation: acc.monthVariation + r.monthVariation, ytdActual: acc.ytdActual + r.ytdActual, ytdBudget: acc.ytdBudget + r.ytdBudget, ytdVariation: acc.ytdVariation + r.ytdVariation, }), { monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 } ); const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null; const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null; return (
{sections.map((section) => ( {reorderRows(section.rows, subtotalsOnTop).map((row) => { const isParent = row.is_parent; const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0); const isIntermediateParent = isParent && depth === 1; const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; return ( ); })} ))} {/* Grand totals */}
{t("budget.category")} {t("reports.bva.monthly")} {t("reports.bva.ytd")}
{t("budget.actual")} {t("budget.planned")} {t("reports.bva.dollarVar")} {t("reports.bva.pctVar")} {t("budget.actual")} {t("budget.planned")} {t("reports.bva.dollarVar")} {t("reports.bva.pctVar")}
{section.label}
{row.category_name} {cadFormatter(row.monthActual)} {cadFormatter(row.monthBudget)} {cadFormatter(row.monthVariation)} {pctFormatter(row.monthVariationPct)} {cadFormatter(row.ytdActual)} {cadFormatter(row.ytdBudget)} {cadFormatter(row.ytdVariation)} {pctFormatter(row.ytdVariationPct)}
{t("common.total")} {cadFormatter(totals.monthActual)} {cadFormatter(totals.monthBudget)} {cadFormatter(totals.monthVariation)} {pctFormatter(totalMonthPct)} {cadFormatter(totals.ytdActual)} {cadFormatter(totals.ytdBudget)} {cadFormatter(totals.ytdVariation)} {pctFormatter(totalYtdPct)}
); }