Simpl-Resultat/src/components/budget/BudgetTable.tsx
le king fu 9c325e274b feat(budget): adopt multi-level collapse on the Budget grid
Reverse #278's deliberate no-collapse decision for the budget grid: it now
folds/unfolds at every category level like the hierarchical reports, opening
fully collapsed with a one-click "Expand all".

- BudgetTable: wire useCollapsibleGroups (defaultExpanded: false), inline
  BUDGET_COLLAPSE_ACCESSORS + BUDGET_EXPANDED_KEY (mirrors ComparePeriodTable /
  BudgetVsActualTable — no budgetTableModel.ts). Chevron + aria-expanded +
  aria-level on every parent row; groups.visible(group) before reorderRows.
- BudgetTable: section subtotal now uses the tested sumLeavesForType on the RAW
  group (drop-in for the hand-rolled loop) so folding stays purely visual.
- BudgetTable: rename STORAGE_KEY to "budget-subtotals-position", decoupling the
  subtotals-position preference from BudgetVsActualTable (they collided).
- useBudget: extract the pure buildBudgetYearRows(); the grid's rows are
  level-order (BFS), not DFS — document the invariant and pin it in a test, since
  the #288 ancestor-walk collapse is order-independent (the v1 plan assumed DFS
  and would have broken here).
- Tests: useBudget.test.ts locks the level-order emission, the DFS-killer, the
  end-to-end multi-level collapse on real builder output, and that subtotals sum
  raw rows regardless of collapse. 828 vitest pass.

Resolves #289

Generated autonomously by /autopilot run of 2026-07-15
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:16:01 -04:00

458 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { computeBudgetResults, sumLeavesForType, type BudgetTotals } from "./budgetTableResults";
const fmt = new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
const MONTH_KEYS = [
"months.jan", "months.feb", "months.mar", "months.apr",
"months.may", "months.jun", "months.jul", "months.aug",
"months.sep", "months.oct", "months.nov", "months.dec",
] as const;
// Prefixed so the "subtotals on top/bottom" preference is INDEPENDENT of the
// real-vs-budget report's (BudgetVsActualTable still uses "subtotals-position") —
// they collided before (issue #289).
const STORAGE_KEY = "budget-subtotals-position";
// Collapse groups keyed by category id; a row is hidden when any ancestor
// (walking parent_id) is collapsed, so every parent level is collapsible.
// Declared inline, mirroring ComparePeriodTable / BudgetVsActualTable (issue #289).
const BUDGET_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetYearRow> = {
keyOf: (row) => `p:${row.category_id}`,
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
depthOf: (row) => row.depth ?? 0,
isParent: (row) => row.is_parent,
};
const BUDGET_EXPANDED_KEY = "budget-grid-expanded";
interface BudgetTableProps {
rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
onSplitEvenly: (categoryId: number, annualAmount: number) => void;
}
export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: BudgetTableProps) {
const { t } = useTranslation();
const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null);
const [editingValue, setEditingValue] = useState("");
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;
});
};
// Collapse/expand at every hierarchy level — collapsed by default (issue #289),
// matching the hierarchical reports. Persisted per profile in user_preferences.
const groups = useCollapsibleGroups<BudgetYearRow>(
BUDGET_EXPANDED_KEY,
BUDGET_COLLAPSE_ACCESSORS,
{ defaultExpanded: false },
);
const inputRef = useRef<HTMLInputElement>(null);
const annualInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingCell && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingCell]);
useEffect(() => {
if (editingAnnual && annualInputRef.current) {
annualInputRef.current.focus();
annualInputRef.current.select();
}
}, [editingAnnual]);
const handleStartEdit = (categoryId: number, monthIdx: number, currentValue: number) => {
setEditingAnnual(null);
setEditingCell({ categoryId, monthIdx });
setEditingValue(currentValue === 0 ? "" : String(currentValue));
};
const handleStartEditAnnual = (categoryId: number, currentValue: number) => {
setEditingCell(null);
setEditingAnnual({ categoryId });
setEditingValue(currentValue === 0 ? "" : String(currentValue));
};
const handleSave = () => {
if (!editingCell) return;
const amount = parseFloat(editingValue) || 0;
onUpdatePlanned(editingCell.categoryId, editingCell.monthIdx + 1, amount);
setEditingCell(null);
};
const handleSaveAnnual = () => {
if (!editingAnnual) return;
const amount = parseFloat(editingValue) || 0;
onSplitEvenly(editingAnnual.categoryId, amount);
setEditingAnnual(null);
};
const handleCancel = () => {
setEditingCell(null);
setEditingAnnual(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") handleCancel();
if (e.key === "Tab") {
e.preventDefault();
if (!editingCell) return;
const amount = parseFloat(editingValue) || 0;
onUpdatePlanned(editingCell.categoryId, editingCell.monthIdx + 1, amount);
// Move to next month cell
const nextMonth = editingCell.monthIdx + (e.shiftKey ? -1 : 1);
if (nextMonth >= 0 && nextMonth < 12) {
const row = rows.find((r) => r.category_id === editingCell.categoryId && !r.is_parent);
if (row) {
handleStartEdit(editingCell.categoryId, nextMonth, row.months[nextMonth]);
}
} else {
setEditingCell(null);
}
}
};
const handleAnnualKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") handleSaveAnnual();
if (e.key === "Escape") handleCancel();
};
// Sign multiplier: expenses negative, income/transfer positive
const signFor = (type: string) => (type === "expense" ? -1 : 1);
// Group rows by type
const grouped: Record<string, BudgetYearRow[]> = {};
for (const row of rows) {
const key = row.category_type;
if (!grouped[key]) grouped[key] = [];
grouped[key].push(row);
}
// Income-statement reading order: revenue first, then expenses, then
// transfers (Issue #278) — matches the compare/trend reports' ordering.
const typeOrder = ["income", "expense", "transfer"] as const;
const typeLabelKeys: Record<string, string> = {
expense: "budget.expenses",
income: "budget.income",
transfer: "budget.transfers",
};
const typeTotalKeys: Record<string, string> = {
expense: "budget.totalExpenses",
income: "budget.totalIncome",
transfer: "budget.totalTransfers",
};
// Income-statement roll-up (Issue #278): Résultat avant transferts / net,
// computed on the budgeted + previous-year-actual leaves. Mathematically
// equivalent to the old plain grand-total (income expense + transfer over
// every leaf), now surfaced as the two labeled result rows below instead of
// a single unlabeled "Total".
const results = computeBudgetResults(rows);
const totalCols = 15; // category + prev year + annual + 12 months
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
<p>{t("budget.noCategories")}</p>
</div>
);
}
const formatSigned = (value: number) => {
if (value === 0) return <span className="text-[var(--muted-foreground)]"></span>;
const color = value > 0 ? "text-[var(--positive)]" : "text-[var(--negative)]";
return <span className={color}>{fmt.format(value)}</span>;
};
const renderRow = (row: BudgetYearRow) => {
const sign = signFor(row.category_type);
const isChild = row.parent_id !== null && !row.is_parent;
const depth = row.depth ?? (isChild ? 1 : 0);
// Unique key: parent rows and "(direct)" fake children can share the same category_id
const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`;
if (row.is_parent) {
// Parent subtotal row: read-only, bold, distinct background. Collapsible
// at every level (issue #289) — a chevron toggles its subtree.
const parentDepth = row.depth ?? 0;
const isTopParent = parentDepth === 0;
const isIntermediateParent = parentDepth >= 1;
const collapsed = groups.isCollapsed(row);
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
return (
<tr
key={rowKey}
aria-level={parentDepth + 1}
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`}
>
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}>
<button
type="button"
onClick={() => groups.toggle(row)}
aria-expanded={!collapsed}
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
</button>
</td>
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
{formatSigned(row.previousYearTotal)}
</td>
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
{formatSigned(row.annual * sign)}
</td>
{row.months.map((val, mIdx) => (
<td key={mIdx} className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
{formatSigned(val * sign)}
</td>
))}
</tr>
);
}
// Leaf / child row: editable
return (
<tr
key={rowKey}
aria-level={depth + 1}
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
>
{/* Category name - sticky */}
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth >= 3 ? "pl-20 pr-3" : depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
<div className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
<span className="truncate text-xs">{row.category_name}</span>
</div>
</td>
{/* Previous year total — read-only */}
<td className="py-2 px-2 text-right text-[var(--muted-foreground)]">
<span className="text-xs px-1 py-0.5">
{formatSigned(row.previousYearTotal)}
</span>
</td>
{/* Annual total — editable */}
<td className="py-2 px-2 text-right">
{editingAnnual?.categoryId === row.category_id ? (
<input
ref={annualInputRef}
type="number"
step="0.01"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
onBlur={handleSaveAnnual}
onKeyDown={handleAnnualKeyDown}
className="w-full text-right bg-[var(--background)] border border-[var(--border)] rounded px-1 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
) : (
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleStartEditAnnual(row.category_id, row.annual)}
title={t("budget.clickToEdit")}
className="font-medium text-xs hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer rounded px-1 py-0.5"
>
{formatSigned(row.annual * sign)}
</button>
{(() => {
const monthSum = row.months.reduce((s, v) => s + v, 0);
return row.annual !== 0 && Math.abs(row.annual - monthSum) > 0.01 ? (
<span title={t("budget.annualMismatch")} className="text-[var(--negative)]">
<AlertTriangle size={13} />
</span>
) : null;
})()}
</div>
)}
</td>
{/* 12 month cells */}
{row.months.map((val, mIdx) => (
<td key={mIdx} className="py-2 px-2 text-right">
{editingCell?.categoryId === row.category_id && editingCell.monthIdx === mIdx ? (
<input
ref={inputRef}
type="number"
step="0.01"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-right bg-[var(--background)] border border-[var(--border)] rounded px-1 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
) : (
<button
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
title={t("budget.clickToEdit")}
className="w-full text-right hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer text-xs rounded px-1 py-0.5"
>
{formatSigned(val * sign)}
</button>
)}
</td>
))}
</tr>
);
};
// One type's section: header, its (reorderable) rows, and a leaf-summed
// subtotal row. Extracted so it can be called for income/expense, then
// again for transfer once the Résultat rows are interleaved between them.
const renderTypeSection = (type: (typeof typeOrder)[number]) => {
const group = grouped[type];
if (!group || group.length === 0) return null;
// Section subtotal is summed from the RAW group via the tested
// `sumLeavesForType` (leaves only, sign applied to budgeted figures), never
// from the collapse-filtered rows — folding a parent stays purely visual and
// never moves a total (issue #289).
const sectionTotals = sumLeavesForType(group, type);
return (
<Fragment key={type}>
<tr>
<td
colSpan={totalCols}
className="py-1.5 px-3 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]"
>
{t(typeLabelKeys[type])}
</td>
</tr>
{reorderRows(groups.visible(group), subtotalsOnTop).map((row) => renderRow(row))}
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
{t(typeTotalKeys[type])}
</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionTotals.previousYearTotal)}</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionTotals.annual)}</td>
{sectionTotals.months.map((total, mIdx) => (
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
{formatSigned(total)}
</td>
))}
</tr>
</Fragment>
);
};
// A Résultat row (avant-transferts subtotal, or the net bottom line).
// `strong` mirrors the previous grand-total row's weight (bold, border-t-2);
// the softer variant mirrors the per-type section-subtotal row above.
const renderResultRow = (labelKey: string, tot: BudgetTotals, strong: boolean) => {
const rowClass = strong
? "bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"
: "bg-[var(--muted)]/40 border-b border-[var(--border)]";
const stickyBg = strong ? "bg-[var(--muted)]" : "bg-[var(--muted)]/40";
const cellWeight = strong ? "" : "font-semibold";
const pad = strong ? "py-3" : "py-2.5";
return (
<tr key={labelKey} className={rowClass}>
<td className={`${pad} px-3 sticky left-0 z-10 text-sm ${stickyBg}`}>{t(labelKey)}</td>
<td className={`${pad} px-2 text-right text-sm ${cellWeight} text-[var(--muted-foreground)]`}>
{formatSigned(tot.previousYearTotal)}
</td>
<td className={`${pad} px-2 text-right text-sm ${cellWeight}`}>{formatSigned(tot.annual)}</td>
{tot.months.map((val, mIdx) => (
<td key={mIdx} className={`${pad} px-2 text-right text-sm ${cellWeight}`}>
{formatSigned(val)}
</td>
))}
</tr>
);
};
const hasGroups = groups.groupCount(rows) > 0;
const allExpanded = groups.allExpanded(rows);
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
{hasGroups && (
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll(rows) : groups.expandAll(rows))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
)}
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
{t("budget.category")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.previousYear")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.annual")}
</th>
{MONTH_KEYS.map((key) => (
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
{t(key)}
</th>
))}
</tr>
</thead>
<tbody>
{renderTypeSection("income")}
{renderTypeSection("expense")}
{/* Operating result (revenues expenses), interleaved before the
transfers section only when transfers exist — otherwise it
equals the net result below and would just be noise. */}
{results.hasTransfers &&
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
{renderTypeSection("transfer")}
{/* Bottom line: result after netting transfers. */}
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
</tbody>
</table>
</div>
</div>
);
}