Simpl-Resultat/src/components/budget/BudgetTable.tsx
le king fu 2386486762 feat(onboarding): the Budget page's step card and its highlight
The budget grid opens collapsed and only its leaf rows take an amount,
so the card first points at "Expand all", then at the current month's
cell of the first editable row shown (D12). `budgetOnboardingTarget`
(pure, beside budgetTableResults.ts) decides it from the rows each
section shows, in the order it shows them; the collapse state stays
BudgetTable's own and the card never unfolds the grid.

BudgetTable rings that one control when the page asks
(`onboardingTarget`) and reports its kind through
`onOnboardingTargetChange`, in an effect, at mount and on change only:
the page needs it for the card's sentence before it asks for the ring.

`budgetCardModel(input, checklist)` gives the "collapsed" sentence for
"Expand all", the "flat" one for a cell, no sentence and no ring before
the grid says, and "Done." once the budget step is checked, which the
card needs to end the path. CARD_PAGES gets /budget. BudgetPage renders
the card first, outside the loading dim the grid gets after each save,
and asks for the ring only while the card shows. The Free edition never
mounts the page and never latches it: its path ends at 5/5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:21:29 -04:00

513 lines
22 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 { BudgetOnboardingTargetKind, BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { computeBudgetResults, sumLeavesForType, type BudgetTotals } from "./budgetTableResults";
import { budgetOnboardingTarget } from "./budgetOnboardingTarget";
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";
/** `data-onboarding-target` of the highlighted month cell. */
export const BUDGET_CELL_TARGET = "budget-cell";
/** `data-onboarding-target` of the highlighted "Expand all" button. */
export const BUDGET_EXPAND_ALL_TARGET = "budget-expand-all";
interface BudgetTableProps {
rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
onSplitEvenly: (categoryId: number, annualAmount: number) => void;
/**
* Highlight the control the getting-started card points at (#400): this
* month's cell of the first editable row shown, or "Expand all" while none
* is shown (`budgetOnboardingTarget`). One target at a time.
*/
onboardingTarget?: boolean;
/**
* Told which control that is, at mount and whenever it changes: the
* collapse state lives here, and the card's sentence depends on it.
*/
onOnboardingTargetChange?: (kind: BudgetOnboardingTargetKind | null) => void;
}
export default function BudgetTable({
rows,
onUpdatePlanned,
onSplitEvenly,
onboardingTarget,
onOnboardingTargetChange,
}: 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
// The rows each section shows, in the order it shows them: collapse-filtered
// (issue #289), then subtotals moved to the chosen side.
const shownByType: Record<string, BudgetYearRow[]> = {};
for (const type of typeOrder) {
shownByType[type] = reorderRows(groups.visible(grouped[type] ?? []), subtotalsOnTop);
}
const hasGroups = groups.groupCount(rows) > 0;
const allExpanded = groups.allExpanded(rows);
// The control the getting-started card points at (#400, D12): this month's
// cell of the first editable row shown, else "Expand all". Its kind goes up
// to the page, which picks the card's sentence; the grid stays as the user
// left it (the card never unfolds it).
const target = budgetOnboardingTarget(
typeOrder.flatMap((type) => shownByType[type]),
hasGroups,
new Date().getMonth(),
);
const targetKind = target?.kind ?? null;
useEffect(() => {
onOnboardingTargetChange?.(targetKind);
}, [targetKind, onOnboardingTargetChange]);
const targetCell = onboardingTarget && target?.kind === "cell" ? target : null;
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"
{...onboardingTargetAttributes(
BUDGET_CELL_TARGET,
targetCell !== null &&
targetCell.categoryId === row.category_id &&
targetCell.monthIdx === mIdx,
)}
>
{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>
{shownByType[type].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>
);
};
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"
{...onboardingTargetAttributes(
BUDGET_EXPAND_ALL_TARGET,
onboardingTarget && target?.kind === "expandAll",
)}
>
{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>
);
}