import { Fragment } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react"; import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types"; import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups"; import { computeOverTimeResults, type OverTimeSeries, type OverTimeType } from "./overTimeResults"; import { OVERTIME_COLLAPSE_ACCESSORS, OVERTIME_EXPANDED_STORAGE_KEY, groupOverTimeSections, type OverTimeRenderSection, } from "./overTimeTableModel"; const cadFormatter = (value: number) => new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); function formatMonth(month: string): string { const [year, m] = month.split("-"); const date = new Date(Number(year), Number(m) - 1); return date.toLocaleDateString("default", { month: "short", year: "2-digit" }); } /** Colours a result figure by sign: surplus green, deficit red, zero neutral. */ function resultColor(value: number): string { if (value > 0) return "var(--positive, #10b981)"; if (value < 0) return "var(--negative, #ef4444)"; return ""; } const SECTION_LABEL_KEY: Record = { income: "reports.compare.sections.income", expense: "reports.compare.sections.expenses", transfer: "reports.compare.sections.transfers", }; const SECTION_TOTAL_KEY: Record = { income: "reports.compare.totalIncome", expense: "reports.compare.totalExpenses", transfer: "reports.compare.totalTransfers", }; interface CategoryOverTimeTableProps { data: CategoryOverTimeData; } export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) { const { t } = useTranslation(); // Collapse/expand of sub-category groups — collapsed by default (issue #265), // persisted under a key distinct from the comparable tables. Called before the // early return so the hook order stays stable. const groups = useCollapsibleGroups( OVERTIME_EXPANDED_STORAGE_KEY, OVERTIME_COLLAPSE_ACCESSORS, ); if (data.data.length === 0) { return (
{t("dashboard.noData")}
); } // Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and // the result lines are exact, and homonym categories never collide. const analysis = computeOverTimeResults(data); const { months, hasTransfers, net, beforeTransfers } = analysis; const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree); const colSpan = months.length + 2; const hasGroups = groups.groupCount(data.tree) > 0; const allExpanded = groups.allExpanded(data.tree); // A section = its header, its (visibly) indented hierarchy rows, and a // leaf-summed subtotal row. Only top-level parents collapse (parity with the // comparable tables); the subtotal is always the reducer's leaf sum, so it // never changes when a group is folded away. const renderSection = (section: OverTimeRenderSection) => ( {/* Section header */} {t(SECTION_LABEL_KEY[section.type])} {/* Category rows — neutral leaf/parent magnitudes (not deltas) */} {groups.visible(section.rows).map((row) => { const isParent = row.is_parent; const depth = row.depth; const isTopParent = isParent && depth === 0; const isIntermediateParent = isParent && depth >= 1; const collapsed = isParent && groups.isCollapsed(row); const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; return ( {isParent ? ( ) : ( {row.categoryName} )} {months.map((month, monthIdx) => { const value = row.monthly[monthIdx] ?? 0; return ( {value ? cadFormatter(value) : "—"} ); })} {cadFormatter(row.total)} ); })} {/* Section subtotal */} {t(SECTION_TOTAL_KEY[section.type])} {months.map((month) => ( {cadFormatter(section.monthly[month])} ))} {cadFormatter(section.total)} ); // A result line (before-transfers subtotal or the net bottom line). Amounts are // coloured by sign (surplus green / deficit red). Computed from the raw tree, // so folding groups never moves these figures. const renderResultRow = (labelKey: string, series: OverTimeSeries, strong: boolean) => ( {t(labelKey)} {months.map((month) => ( {cadFormatter(series.monthly[month])} ))} {cadFormatter(series.total)} ); return (
{hasGroups && (
)}
{months.map((month) => ( ))} {nonTransferSections.map(renderSection)} {/* Operating result (revenues − expenses), interleaved before the transfers section only when transfers exist — otherwise it equals the net total below and would just be noise. */} {hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)} {transferSection && renderSection(transferSection)} {/* Bottom line: result after netting transfers. */} {renderResultRow("reports.compare.resultNet", net, true)}
{t("budget.category")} {formatMonth(month)} {t("common.total")}
); }