- Extract reorderRows into shared utility (src/utils/reorderRows.ts) to deduplicate identical function in BudgetTable and BudgetVsActualTable - Restore alphabetical sorting of children in budgetService.ts - Fix styling for intermediate parent rows at depth 2+ (was only handling depth 0-1) - Reduce pie chart size (height 220->180, radii reduced) and padding to give more space to the table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
387 lines
16 KiB
TypeScript
387 lines
16 KiB
TypeScript
import { useState, useRef, useEffect, Fragment } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
|
import type { BudgetYearRow } from "../../shared/types";
|
|
import { reorderRows } from "../../utils/reorderRows";
|
|
|
|
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";
|
|
|
|
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<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);
|
|
}
|
|
|
|
const typeOrder = ["expense", "income", "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",
|
|
};
|
|
|
|
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
|
|
const monthTotals: number[] = Array(12).fill(0);
|
|
let annualTotal = 0;
|
|
let prevYearTotal = 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;
|
|
prevYearTotal += row.previousYearTotal * sign;
|
|
}
|
|
|
|
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
|
|
const parentDepth = row.depth ?? 0;
|
|
const isTopParent = parentDepth === 0;
|
|
const isIntermediateParent = parentDepth >= 1;
|
|
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}
|
|
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`}`}>
|
|
<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 ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
|
|
</div>
|
|
</td>
|
|
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
|
|
{formatSigned(row.previousYearTotal * sign)}
|
|
</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}
|
|
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 * sign)}
|
|
</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>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
|
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
|
<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>
|
|
{typeOrder.map((type) => {
|
|
const group = grouped[type];
|
|
if (!group || group.length === 0) return null;
|
|
const sign = signFor(type);
|
|
const leaves = group.filter((r) => !r.is_parent);
|
|
const sectionMonthTotals: number[] = Array(12).fill(0);
|
|
let sectionAnnualTotal = 0;
|
|
let sectionPrevYearTotal = 0;
|
|
for (const row of leaves) {
|
|
for (let m = 0; m < 12; m++) {
|
|
sectionMonthTotals[m] += row.months[m] * sign;
|
|
}
|
|
sectionAnnualTotal += row.annual * sign;
|
|
sectionPrevYearTotal += row.previousYearTotal * sign;
|
|
}
|
|
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(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(sectionPrevYearTotal)}</td>
|
|
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
|
|
{sectionMonthTotals.map((total, mIdx) => (
|
|
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
|
|
{formatSigned(total)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
</Fragment>
|
|
);
|
|
})}
|
|
{/* Totals row */}
|
|
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
|
|
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
|
|
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
|
|
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
|
|
{monthTotals.map((total, mIdx) => (
|
|
<td key={mIdx} className="py-3 px-2 text-right text-sm">
|
|
{formatSigned(total)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|