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 = { 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( BUDGET_EXPANDED_KEY, BUDGET_COLLAPSE_ACCESSORS, { defaultExpanded: false }, ); const inputRef = useRef(null); const annualInputRef = useRef(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 = {}; 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 = { expense: "budget.expenses", income: "budget.income", transfer: "budget.transfers", }; const typeTotalKeys: Record = { 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 (

{t("budget.noCategories")}

); } const formatSigned = (value: number) => { if (value === 0) return ; const color = value > 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"; return {fmt.format(value)}; }; 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 ( {formatSigned(row.previousYearTotal)} {formatSigned(row.annual * sign)} {row.months.map((val, mIdx) => ( {formatSigned(val * sign)} ))} ); } // Leaf / child row: editable return ( {/* Category name - sticky */} = 3 ? "pl-20 pr-3" : depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
{row.category_name}
{/* Previous year total — read-only */} {formatSigned(row.previousYearTotal)} {/* Annual total — editable */} {editingAnnual?.categoryId === row.category_id ? ( 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)]" /> ) : (
{(() => { const monthSum = row.months.reduce((s, v) => s + v, 0); return row.annual !== 0 && Math.abs(row.annual - monthSum) > 0.01 ? ( ) : null; })()}
)} {/* 12 month cells */} {row.months.map((val, mIdx) => ( {editingCell?.categoryId === row.category_id && editingCell.monthIdx === mIdx ? ( 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)]" /> ) : ( )} ))} ); }; // 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 ( {t(typeLabelKeys[type])} {reorderRows(groups.visible(group), subtotalsOnTop).map((row) => renderRow(row))} {t(typeTotalKeys[type])} {formatSigned(sectionTotals.previousYearTotal)} {formatSigned(sectionTotals.annual)} {sectionTotals.months.map((total, mIdx) => ( {formatSigned(total)} ))} ); }; // 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 ( {t(labelKey)} {formatSigned(tot.previousYearTotal)} {formatSigned(tot.annual)} {tot.months.map((val, mIdx) => ( {formatSigned(val)} ))} ); }; const hasGroups = groups.groupCount(rows) > 0; const allExpanded = groups.allExpanded(rows); return (
{hasGroups && ( )}
{MONTH_KEYS.map((key) => ( ))} {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)}
{t("budget.category")} {t("budget.previousYear")} {t("budget.annual")} {t(key)}
); }