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 <noreply@anthropic.com>
This commit is contained in:
parent
f9c6fabc13
commit
32dae2b7b2
6 changed files with 262 additions and 102 deletions
|
|
@ -86,7 +86,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
// Move to next month cell
|
// Move to next month cell
|
||||||
const nextMonth = editingCell.monthIdx + (e.shiftKey ? -1 : 1);
|
const nextMonth = editingCell.monthIdx + (e.shiftKey ? -1 : 1);
|
||||||
if (nextMonth >= 0 && nextMonth < 12) {
|
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) {
|
if (row) {
|
||||||
handleStartEdit(editingCell.categoryId, nextMonth, row.months[nextMonth]);
|
handleStartEdit(editingCell.categoryId, nextMonth, row.months[nextMonth]);
|
||||||
}
|
}
|
||||||
|
|
@ -101,6 +101,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
if (e.key === "Escape") handleCancel();
|
if (e.key === "Escape") handleCancel();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sign multiplier: expenses negative, income/transfer positive
|
||||||
|
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
||||||
|
|
||||||
// Group rows by type
|
// Group rows by type
|
||||||
const grouped: Record<string, BudgetYearRow[]> = {};
|
const grouped: Record<string, BudgetYearRow[]> = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
@ -116,14 +119,16 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
transfer: "budget.transfers",
|
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);
|
const monthTotals: number[] = Array(12).fill(0);
|
||||||
let annualTotal = 0;
|
let annualTotal = 0;
|
||||||
for (const row of rows) {
|
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++) {
|
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
|
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 <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;
|
||||||
|
// 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 (
|
||||||
|
<tr
|
||||||
|
key={rowKey}
|
||||||
|
className="border-b border-[var(--border)] bg-[var(--muted)]/30"
|
||||||
|
>
|
||||||
|
<td className="py-2 px-3 sticky left-0 bg-[var(--muted)]/30 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 font-semibold">{row.category_name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right text-xs font-semibold">
|
||||||
|
{formatSigned(row.annual * sign)}
|
||||||
|
</td>
|
||||||
|
{row.months.map((val, mIdx) => (
|
||||||
|
<td key={mIdx} className="py-2 px-2 text-right text-xs 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 ${isChild ? "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>
|
||||||
|
{/* 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"
|
||||||
|
>
|
||||||
|
{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)}
|
||||||
|
className="w-full text-right hover:text-[var(--primary)] transition-colors cursor-text text-xs"
|
||||||
|
>
|
||||||
|
{formatSigned(val * sign)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-x-auto">
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-x-auto">
|
||||||
<table className="w-full text-sm whitespace-nowrap">
|
<table className="w-full text-sm whitespace-nowrap">
|
||||||
|
|
@ -168,97 +289,17 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
{t(typeLabelKeys[type])}
|
{t(typeLabelKeys[type])}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{group.map((row) => (
|
{group.map((row) => renderRow(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>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Totals row */}
|
{/* Totals row */}
|
||||||
<tr className="bg-[var(--muted)] font-semibold">
|
<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-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>
|
<td className="py-2.5 px-2 text-right text-xs">{formatSigned(annualTotal)}</td>
|
||||||
{monthTotals.map((total, mIdx) => (
|
{monthTotals.map((total, mIdx) => (
|
||||||
<td key={mIdx} className="py-2.5 px-2 text-right text-xs">
|
<td key={mIdx} className="py-2.5 px-2 text-right text-xs">
|
||||||
{fmt.format(total)}
|
{formatSigned(total)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
|
import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
|
||||||
import {
|
import {
|
||||||
getActiveCategories,
|
getAllActiveCategories,
|
||||||
getBudgetEntriesForYear,
|
getBudgetEntriesForYear,
|
||||||
upsertBudgetEntry,
|
upsertBudgetEntry,
|
||||||
upsertBudgetEntriesForYear,
|
upsertBudgetEntriesForYear,
|
||||||
|
|
@ -72,8 +72,8 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [categories, entries, templates] = await Promise.all([
|
const [allCategories, entries, templates] = await Promise.all([
|
||||||
getActiveCategories(),
|
getAllActiveCategories(),
|
||||||
getBudgetEntriesForYear(year),
|
getBudgetEntriesForYear(year),
|
||||||
getAllTemplates(),
|
getAllTemplates(),
|
||||||
]);
|
]);
|
||||||
|
|
@ -87,8 +87,9 @@ export function useBudget() {
|
||||||
entryMap.get(e.category_id)!.set(e.month, e.amount);
|
entryMap.get(e.category_id)!.set(e.month, e.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: BudgetYearRow[] = categories.map((cat) => {
|
// Helper: build months array from entryMap
|
||||||
const monthMap = entryMap.get(cat.id);
|
const buildMonths = (catId: number) => {
|
||||||
|
const monthMap = entryMap.get(catId);
|
||||||
const months: number[] = [];
|
const months: number[] = [];
|
||||||
let annual = 0;
|
let annual = 0;
|
||||||
for (let m = 1; m <= 12; m++) {
|
for (let m = 1; m <= 12; m++) {
|
||||||
|
|
@ -96,20 +97,126 @@ export function useBudget() {
|
||||||
months.push(val);
|
months.push(val);
|
||||||
annual += val;
|
annual += val;
|
||||||
}
|
}
|
||||||
return {
|
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<number, typeof allCategories>();
|
||||||
|
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_id: cat.id,
|
||||||
category_name: cat.name,
|
category_name: cat.name,
|
||||||
category_color: cat.color || "#9ca3af",
|
category_color: cat.color || "#9ca3af",
|
||||||
category_type: cat.type,
|
category_type: cat.type,
|
||||||
|
parent_id: null,
|
||||||
|
is_parent: false,
|
||||||
months,
|
months,
|
||||||
annual,
|
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) => {
|
rows.sort((a, b) => {
|
||||||
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
||||||
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
||||||
if (typeA !== typeB) return typeA - typeB;
|
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);
|
return a.category_name.localeCompare(b.category_name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -178,8 +285,9 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
try {
|
try {
|
||||||
// Save template from January values (template is a single-month snapshot)
|
// 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
|
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] }));
|
.map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
|
||||||
await saveAsTemplateSvc(name, description, entries);
|
await saveAsTemplateSvc(name, description, entries);
|
||||||
await refreshData(state.year);
|
await refreshData(state.year);
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,7 @@
|
||||||
"noTemplates": "No templates saved yet.",
|
"noTemplates": "No templates saved yet.",
|
||||||
"templateName": "Template name",
|
"templateName": "Template name",
|
||||||
"templateDescription": "Description (optional)",
|
"templateDescription": "Description (optional)",
|
||||||
|
"directSuffix": "(direct)",
|
||||||
"deleteTemplateConfirm": "Delete this template?",
|
"deleteTemplateConfirm": "Delete this template?",
|
||||||
"help": {
|
"help": {
|
||||||
"title": "How to use Budget",
|
"title": "How to use Budget",
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,7 @@
|
||||||
"noTemplates": "Aucun modèle enregistré.",
|
"noTemplates": "Aucun modèle enregistré.",
|
||||||
"templateName": "Nom du modèle",
|
"templateName": "Nom du modèle",
|
||||||
"templateDescription": "Description (optionnel)",
|
"templateDescription": "Description (optionnel)",
|
||||||
|
"directSuffix": "(direct)",
|
||||||
"deleteTemplateConfirm": "Supprimer ce modèle ?",
|
"deleteTemplateConfirm": "Supprimer ce modèle ?",
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Comment utiliser le Budget",
|
"title": "Comment utiliser le Budget",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ export async function getActiveCategories(): Promise<Category[]> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllActiveCategories(): Promise<Category[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.select<Category[]>(
|
||||||
|
"SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBudgetEntriesForMonth(
|
export async function getBudgetEntriesForMonth(
|
||||||
year: number,
|
year: number,
|
||||||
month: number
|
month: number
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,8 @@ export interface BudgetYearRow {
|
||||||
category_name: string;
|
category_name: string;
|
||||||
category_color: string;
|
category_color: string;
|
||||||
category_type: "expense" | "income" | "transfer";
|
category_type: "expense" | "income" | "transfer";
|
||||||
|
parent_id: number | null;
|
||||||
|
is_parent: boolean;
|
||||||
months: number[]; // index 0-11 = Jan-Dec planned amounts
|
months: number[]; // index 0-11 = Jan-Dec planned amounts
|
||||||
annual: number; // computed sum
|
annual: number; // computed sum
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue