From 32dae2b7b2f179a5f2b0d1276b85ce1c7eb69ad2 Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Sun, 15 Feb 2026 17:44:32 +0000 Subject: [PATCH] feat: add parent category subtotals and sign convention to budget page Parent categories now display as bold read-only subtotal rows with their children indented below. Expenses show as negative (red), income as positive (green). Inputable parents get a "(direct)" child row for their own budget entries. Co-Authored-By: Claude Opus 4.6 --- src/components/budget/BudgetTable.tsx | 215 +++++++++++++++----------- src/hooks/useBudget.ts | 138 +++++++++++++++-- src/i18n/locales/en.json | 1 + src/i18n/locales/fr.json | 1 + src/services/budgetService.ts | 7 + src/shared/types/index.ts | 2 + 6 files changed, 262 insertions(+), 102 deletions(-) diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index b8574e3..7fe3981 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -86,7 +86,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu // 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); + const row = rows.find((r) => r.category_id === editingCell.categoryId && !r.is_parent); if (row) { handleStartEdit(editingCell.categoryId, nextMonth, row.months[nextMonth]); } @@ -101,6 +101,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu 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) { @@ -116,14 +119,16 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu transfer: "budget.transfers", }; - // Column totals + // 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]; + monthTotals[m] += row.months[m] * sign; } - annualTotal += row.annual; + annualTotal += row.annual * sign; } const totalCols = 14; // category + annual + 12 months @@ -136,6 +141,122 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu ); } + 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; + // 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 + 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 (
@@ -168,97 +289,17 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {t(typeLabelKeys[type])} - {group.map((row) => ( - - {/* Category name - sticky */} - - {/* Annual total — editable */} - - {/* 12 month cells */} - {row.months.map((val, mIdx) => ( - - ))} - - ))} + {group.map((row) => renderRow(row))} ); })} {/* Totals row */} - + {monthTotals.map((total, mIdx) => ( ))} diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index cc2cbd1..58d0c6c 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -1,7 +1,7 @@ import { useReducer, useCallback, useEffect, useRef } from "react"; import type { BudgetYearRow, BudgetTemplate } from "../shared/types"; import { - getActiveCategories, + getAllActiveCategories, getBudgetEntriesForYear, upsertBudgetEntry, upsertBudgetEntriesForYear, @@ -72,8 +72,8 @@ export function useBudget() { dispatch({ type: "SET_ERROR", payload: null }); try { - const [categories, entries, templates] = await Promise.all([ - getActiveCategories(), + const [allCategories, entries, templates] = await Promise.all([ + getAllActiveCategories(), getBudgetEntriesForYear(year), getAllTemplates(), ]); @@ -87,8 +87,9 @@ export function useBudget() { entryMap.get(e.category_id)!.set(e.month, e.amount); } - const rows: BudgetYearRow[] = categories.map((cat) => { - const monthMap = entryMap.get(cat.id); + // Helper: build months array from entryMap + const buildMonths = (catId: number) => { + const monthMap = entryMap.get(catId); const months: number[] = []; let annual = 0; for (let m = 1; m <= 12; m++) { @@ -96,20 +97,126 @@ export function useBudget() { months.push(val); annual += val; } - return { - category_id: cat.id, - category_name: cat.name, - category_color: cat.color || "#9ca3af", - category_type: cat.type, - months, - annual, - }; - }); + return { months, annual }; + }; + // Index categories by id and group children by parent_id + const catById = new Map(allCategories.map((c) => [c.id, c])); + const childrenByParent = new Map(); + for (const cat of allCategories) { + if (cat.parent_id) { + if (!childrenByParent.has(cat.parent_id)) childrenByParent.set(cat.parent_id, []); + childrenByParent.get(cat.parent_id)!.push(cat); + } + } + + const rows: BudgetYearRow[] = []; + + // Identify top-level parents and standalone leaves + const topLevel = allCategories.filter((c) => !c.parent_id); + + for (const cat of topLevel) { + const children = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable); + + if (children.length === 0 && cat.is_inputable) { + // Standalone leaf (no children) — regular editable row + const { months, annual } = buildMonths(cat.id); + rows.push({ + category_id: cat.id, + category_name: cat.name, + category_color: cat.color || "#9ca3af", + category_type: cat.type, + parent_id: null, + is_parent: false, + months, + annual, + }); + } else if (children.length > 0) { + // Parent with children — build child rows first, then parent subtotal + const childRows: BudgetYearRow[] = []; + + // If parent is also inputable, create a "(direct)" fake-child row + if (cat.is_inputable) { + const { months, annual } = buildMonths(cat.id); + childRows.push({ + category_id: cat.id, + category_name: `${cat.name} (direct)`, + category_color: cat.color || "#9ca3af", + category_type: cat.type, + parent_id: cat.id, + is_parent: false, + months, + annual, + }); + } + + for (const child of children) { + const { months, annual } = buildMonths(child.id); + childRows.push({ + category_id: child.id, + category_name: child.name, + category_color: child.color || cat.color || "#9ca3af", + category_type: child.type, + parent_id: cat.id, + is_parent: false, + months, + annual, + }); + } + + // Parent subtotal row: sum of all children (+ direct if inputable) + const parentMonths = Array(12).fill(0) as number[]; + let parentAnnual = 0; + for (const cr of childRows) { + for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m]; + parentAnnual += cr.annual; + } + + rows.push({ + category_id: cat.id, + category_name: cat.name, + category_color: cat.color || "#9ca3af", + category_type: cat.type, + parent_id: null, + is_parent: true, + months: parentMonths, + annual: parentAnnual, + }); + + // Sort children alphabetically, but keep "(direct)" first + childRows.sort((a, b) => { + if (a.category_id === cat.id) return -1; + if (b.category_id === cat.id) return 1; + return a.category_name.localeCompare(b.category_name); + }); + + rows.push(...childRows); + } + // else: non-inputable parent with no inputable children — skip + } + + // Sort by type, then within each type: parent rows first (with children following), then standalone rows.sort((a, b) => { const typeA = TYPE_ORDER[a.category_type] ?? 9; const typeB = TYPE_ORDER[b.category_type] ?? 9; if (typeA !== typeB) return typeA - typeB; + // Within same type, keep parent+children groups together + const groupA = a.is_parent ? a.category_id : (a.parent_id ?? a.category_id); + const groupB = b.is_parent ? b.category_id : (b.parent_id ?? b.category_id); + if (groupA !== groupB) { + // Find the sort_order of the group's parent category + const catA = catById.get(groupA); + const catB = catById.get(groupB); + const orderA = catA?.sort_order ?? 999; + const orderB = catB?.sort_order ?? 999; + if (orderA !== orderB) return orderA - orderB; + return (catA?.name ?? "").localeCompare(catB?.name ?? ""); + } + // Same group: parent row first, then children + if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1; + // Children: "(direct)" first, then alphabetical + if (a.parent_id && a.category_id === a.parent_id) return -1; + if (b.parent_id && b.category_id === b.parent_id) return 1; return a.category_name.localeCompare(b.category_name); }); @@ -178,8 +285,9 @@ export function useBudget() { dispatch({ type: "SET_SAVING", payload: true }); try { // Save template from January values (template is a single-month snapshot) + // Exclude parent subtotal rows (they're computed, not real entries) const entries = state.rows - .filter((r) => r.months[0] !== 0) + .filter((r) => !r.is_parent && r.months[0] !== 0) .map((r) => ({ category_id: r.category_id, amount: r.months[0] })); await saveAsTemplateSvc(name, description, entries); await refreshData(state.year); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 29cf1aa..ac7c634 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -312,6 +312,7 @@ "noTemplates": "No templates saved yet.", "templateName": "Template name", "templateDescription": "Description (optional)", + "directSuffix": "(direct)", "deleteTemplateConfirm": "Delete this template?", "help": { "title": "How to use Budget", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f8096e7..16cf917 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -312,6 +312,7 @@ "noTemplates": "Aucun modèle enregistré.", "templateName": "Nom du modèle", "templateDescription": "Description (optionnel)", + "directSuffix": "(direct)", "deleteTemplateConfirm": "Supprimer ce modèle ?", "help": { "title": "Comment utiliser le Budget", diff --git a/src/services/budgetService.ts b/src/services/budgetService.ts index d2509b4..a980f76 100644 --- a/src/services/budgetService.ts +++ b/src/services/budgetService.ts @@ -20,6 +20,13 @@ export async function getActiveCategories(): Promise { ); } +export async function getAllActiveCategories(): Promise { + const db = await getDb(); + return db.select( + "SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name" + ); +} + export async function getBudgetEntriesForMonth( year: number, month: number diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 56b4a93..fa748d6 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -137,6 +137,8 @@ export interface BudgetYearRow { category_name: string; category_color: string; category_type: "expense" | "income" | "transfer"; + parent_id: number | null; + is_parent: boolean; months: number[]; // index 0-11 = Jan-Dec planned amounts annual: number; // computed sum }
-
- - {row.category_name} -
-
- {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; - })()} -
- )} -
- {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)]" - /> - ) : ( - - )} -
{t("common.total")}{fmt.format(annualTotal)}{formatSigned(annualTotal)} - {fmt.format(total)} + {formatSigned(total)}