import { useState, useRef, useEffect, Fragment } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangle, ArrowUpDown } from "lucide-react"; import type { BudgetYearRow } from "../../shared/types"; 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; const STORAGE_KEY = "subtotals-position"; function reorderRows( rows: T[], subtotalsOnTop: boolean, ): T[] { if (subtotalsOnTop) return rows; // Group depth-0 parents with all their descendants, then move subtotals to bottom 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; // Also move intermediate subtotals (depth-1 parents) to bottom of their sub-groups const reorderedChildren: T[] = []; let subParent: T | null = null; const subChildren: T[] = []; for (const child of children) { if (child.is_parent && (child.depth ?? 0) === 1) { // Flush previous sub-group 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]; }); } 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; }); }; 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); } const typeOrder = ["expense", "income", "transfer"] as const; const typeLabelKeys: Record = { expense: "budget.expenses", income: "budget.income", transfer: "budget.transfers", }; // Column totals with sign convention (only count leaf rows to avoid double-counting parents) const monthTotals: number[] = Array(12).fill(0); let annualTotal = 0; for (const row of rows) { if (row.is_parent) continue; // skip parent subtotals to avoid double-counting const sign = signFor(row.category_type); for (let m = 0; m < 12; m++) { monthTotals[m] += row.months[m] * sign; } annualTotal += row.annual * sign; } const totalCols = 14; // category + 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 const parentDepth = row.depth ?? 0; const isIntermediateParent = parentDepth === 1; return (
{row.category_name}
{formatSigned(row.annual * sign)} {row.months.map((val, mIdx) => ( {formatSigned(val * sign)} ))} ); } // Leaf / child row: editable return ( {/* Category name - sticky */}
{row.category_name}
{/* 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)]" /> ) : ( )} ))} ); }; return (
{MONTH_KEYS.map((key) => ( ))} {typeOrder.map((type) => { const group = grouped[type]; if (!group || group.length === 0) return null; return ( {reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))} ); })} {/* Totals row */} {monthTotals.map((total, mIdx) => ( ))}
{t("budget.category")} {t("budget.annual")} {t(key)}
{t(typeLabelKeys[type])}
{t("common.total")} {formatSigned(annualTotal)} {formatSigned(total)}
); }