Simpl-Resultat/src/components/budget/BudgetTable.tsx
Le-King-Fu ccdab1f06a feat: add import config templates, budget/category fixes (v0.2.5)
Add reusable import config templates so users can save and apply CSV
parsing configurations across different import sources. Includes
database table, service, hook integration, and template UI in the
source config panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 15:06:44 +00:00

269 lines
11 KiB
TypeScript

import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle } 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;
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 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);
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();
};
// 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",
};
// Column totals
const monthTotals: number[] = Array(12).fill(0);
let annualTotal = 0;
for (const row of rows) {
for (let m = 0; m < 12; m++) {
monthTotals[m] += row.months[m];
}
annualTotal += row.annual;
}
const totalCols = 14; // category + 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>
);
}
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-x-auto">
<table className="w-full text-sm whitespace-nowrap">
<thead>
<tr className="border-b border-[var(--border)]">
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-10 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.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;
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>
{group.map((row) => (
<tr
key={row.category_id}
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
>
{/* Category name - sticky */}
<td className="py-2 px-3 sticky left-0 bg-[var(--card)] z-10">
<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>
{/* 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)}
className="font-medium text-xs hover:text-[var(--primary)] transition-colors cursor-text"
>
{row.annual === 0 ? (
<span className="text-[var(--muted-foreground)]"></span>
) : (
fmt.format(row.annual)
)}
</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)}
className="w-full text-right hover:text-[var(--primary)] transition-colors cursor-text text-xs"
>
{val === 0 ? (
<span className="text-[var(--muted-foreground)]"></span>
) : (
fmt.format(val)
)}
</button>
)}
</td>
))}
</tr>
))}
</Fragment>
);
})}
{/* Totals row */}
<tr className="bg-[var(--muted)] font-semibold">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)] z-10 text-xs">{t("common.total")}</td>
<td className="py-2.5 px-2 text-right text-xs">{fmt.format(annualTotal)}</td>
{monthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-2.5 px-2 text-right text-xs">
{fmt.format(total)}
</td>
))}
</tr>
</tbody>
</table>
</div>
);
}