feat: add previous year total column to budget table (#16) #18

Merged
maximus merged 1 commit from fix/simpl-resultat-16-budget-previous-year into main 2026-03-08 16:39:44 +00:00
7 changed files with 56 additions and 9 deletions

View file

@ -2,6 +2,9 @@
## [Non publié] ## [Non publié]
### Ajouté
- Tableau de budget : colonne du total de l'année précédente affichée comme première colonne de données pour servir de référence (#16)
## [0.6.3] ## [0.6.3]
### Ajouté ### Ajouté

View file

@ -2,6 +2,9 @@
## [Unreleased] ## [Unreleased]
### Added
- Budget table: previous year total column displayed as first data column for baseline reference (#16)
## [0.6.3] ## [0.6.3]
### Added ### Added

View file

@ -193,6 +193,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
// Column totals with sign convention (only count leaf rows to avoid double-counting parents) // 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;
let prevYearTotal = 0;
for (const row of rows) { for (const row of rows) {
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
const sign = signFor(row.category_type); const sign = signFor(row.category_type);
@ -200,9 +201,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
monthTotals[m] += row.months[m] * sign; monthTotals[m] += row.months[m] * sign;
} }
annualTotal += row.annual * sign; annualTotal += row.annual * sign;
prevYearTotal += row.previousYearTotal * sign;
} }
const totalCols = 14; // category + annual + 12 months const totalCols = 15; // category + prev year + annual + 12 months
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
@ -243,6 +245,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span> <span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
</div> </div>
</td> </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"}`}> <td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
{formatSigned(row.annual * sign)} {formatSigned(row.annual * sign)}
</td> </td>
@ -271,6 +276,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<span className="truncate text-xs">{row.category_name}</span> <span className="truncate text-xs">{row.category_name}</span>
</div> </div>
</td> </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 */} {/* Annual total — editable */}
<td className="py-2 px-2 text-right"> <td className="py-2 px-2 text-right">
{editingAnnual?.categoryId === row.category_id ? ( {editingAnnual?.categoryId === row.category_id ? (
@ -351,6 +362,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<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]"> <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")} {t("budget.category")}
</th> </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]"> <th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.annual")} {t("budget.annual")}
</th> </th>
@ -369,11 +383,13 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
const leaves = group.filter((r) => !r.is_parent); const leaves = group.filter((r) => !r.is_parent);
const sectionMonthTotals: number[] = Array(12).fill(0); const sectionMonthTotals: number[] = Array(12).fill(0);
let sectionAnnualTotal = 0; let sectionAnnualTotal = 0;
let sectionPrevYearTotal = 0;
for (const row of leaves) { for (const row of leaves) {
for (let m = 0; m < 12; m++) { for (let m = 0; m < 12; m++) {
sectionMonthTotals[m] += row.months[m] * sign; sectionMonthTotals[m] += row.months[m] * sign;
} }
sectionAnnualTotal += row.annual * sign; sectionAnnualTotal += row.annual * sign;
sectionPrevYearTotal += row.previousYearTotal * sign;
} }
return ( return (
<Fragment key={type}> <Fragment key={type}>
@ -390,6 +406,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold"> <td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
{t(typeTotalKeys[type])} {t(typeTotalKeys[type])}
</td> </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> <td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
{sectionMonthTotals.map((total, mIdx) => ( {sectionMonthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold"> <td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
@ -403,6 +420,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
{/* Totals row */} {/* Totals row */}
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"> <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-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> <td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
{monthTotals.map((total, mIdx) => ( {monthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-3 px-2 text-right text-sm"> <td key={mIdx} className="py-3 px-2 text-right text-sm">

View file

@ -72,9 +72,10 @@ export function useBudget() {
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
try { try {
const [allCategories, entries, templates] = await Promise.all([ const [allCategories, entries, prevYearEntries, templates] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getBudgetEntriesForYear(year - 1),
getAllTemplates(), getAllTemplates(),
]); ]);
@ -87,6 +88,12 @@ export function useBudget() {
entryMap.get(e.category_id)!.set(e.month, e.amount); entryMap.get(e.category_id)!.set(e.month, e.amount);
} }
// Build a map for previous year totals: categoryId -> annual total
const prevYearTotalMap = new Map<number, number>();
for (const e of prevYearEntries) {
prevYearTotalMap.set(e.category_id, (prevYearTotalMap.get(e.category_id) ?? 0) + e.amount);
}
// Helper: build months array from entryMap // Helper: build months array from entryMap
const buildMonths = (catId: number) => { const buildMonths = (catId: number) => {
const monthMap = entryMap.get(catId); const monthMap = entryMap.get(catId);
@ -97,7 +104,8 @@ export function useBudget() {
months.push(val); months.push(val);
annual += val; annual += val;
} }
return { months, annual }; const previousYearTotal = prevYearTotalMap.get(catId) ?? 0;
return { months, annual, previousYearTotal };
}; };
// Index categories by id and group children by parent_id // Index categories by id and group children by parent_id
@ -117,7 +125,7 @@ export function useBudget() {
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable); const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
if (grandchildren.length === 0 && cat.is_inputable) { if (grandchildren.length === 0 && cat.is_inputable) {
// Leaf at depth 2 // Leaf at depth 2
const { months, annual } = buildMonths(cat.id); const { months, annual, previousYearTotal } = buildMonths(cat.id);
return [{ return [{
category_id: cat.id, category_id: cat.id,
category_name: cat.name, category_name: cat.name,
@ -128,6 +136,7 @@ export function useBudget() {
depth: 2, depth: 2,
months, months,
annual, annual,
previousYearTotal,
}]; }];
} }
if (grandchildren.length === 0 && !cat.is_inputable) { if (grandchildren.length === 0 && !cat.is_inputable) {
@ -138,7 +147,7 @@ export function useBudget() {
const gcRows: BudgetYearRow[] = []; const gcRows: BudgetYearRow[] = [];
if (cat.is_inputable) { if (cat.is_inputable) {
const { months, annual } = buildMonths(cat.id); const { months, annual, previousYearTotal } = buildMonths(cat.id);
gcRows.push({ gcRows.push({
category_id: cat.id, category_id: cat.id,
category_name: `${cat.name} (direct)`, category_name: `${cat.name} (direct)`,
@ -149,10 +158,11 @@ export function useBudget() {
depth: 2, depth: 2,
months, months,
annual, annual,
previousYearTotal,
}); });
} }
for (const gc of grandchildren) { for (const gc of grandchildren) {
const { months, annual } = buildMonths(gc.id); const { months, annual, previousYearTotal } = buildMonths(gc.id);
gcRows.push({ gcRows.push({
category_id: gc.id, category_id: gc.id,
category_name: gc.name, category_name: gc.name,
@ -163,6 +173,7 @@ export function useBudget() {
depth: 2, depth: 2,
months, months,
annual, annual,
previousYearTotal,
}); });
} }
if (gcRows.length === 0) return []; if (gcRows.length === 0) return [];
@ -170,9 +181,11 @@ export function useBudget() {
// Build intermediate subtotal // Build intermediate subtotal
const subMonths = Array(12).fill(0) as number[]; const subMonths = Array(12).fill(0) as number[];
let subAnnual = 0; let subAnnual = 0;
let subPrevYear = 0;
for (const cr of gcRows) { for (const cr of gcRows) {
for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m]; for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m];
subAnnual += cr.annual; subAnnual += cr.annual;
subPrevYear += cr.previousYearTotal;
} }
const subtotal: BudgetYearRow = { const subtotal: BudgetYearRow = {
category_id: cat.id, category_id: cat.id,
@ -184,6 +197,7 @@ export function useBudget() {
depth: 1, depth: 1,
months: subMonths, months: subMonths,
annual: subAnnual, annual: subAnnual,
previousYearTotal: subPrevYear,
}; };
gcRows.sort((a, b) => { gcRows.sort((a, b) => {
if (a.category_id === cat.id) return -1; if (a.category_id === cat.id) return -1;
@ -203,7 +217,7 @@ export function useBudget() {
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) { if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
// Standalone leaf (no children) — regular editable row // Standalone leaf (no children) — regular editable row
const { months, annual } = buildMonths(cat.id); const { months, annual, previousYearTotal } = buildMonths(cat.id);
rows.push({ rows.push({
category_id: cat.id, category_id: cat.id,
category_name: cat.name, category_name: cat.name,
@ -214,13 +228,14 @@ export function useBudget() {
depth: 0, depth: 0,
months, months,
annual, annual,
previousYearTotal,
}); });
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) { } else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
const allChildRows: BudgetYearRow[] = []; const allChildRows: BudgetYearRow[] = [];
// If parent is also inputable, create a "(direct)" fake-child row // If parent is also inputable, create a "(direct)" fake-child row
if (cat.is_inputable) { if (cat.is_inputable) {
const { months, annual } = buildMonths(cat.id); const { months, annual, previousYearTotal } = buildMonths(cat.id);
allChildRows.push({ allChildRows.push({
category_id: cat.id, category_id: cat.id,
category_name: `${cat.name} (direct)`, category_name: `${cat.name} (direct)`,
@ -231,6 +246,7 @@ export function useBudget() {
depth: 1, depth: 1,
months, months,
annual, annual,
previousYearTotal,
}); });
} }
@ -238,7 +254,7 @@ export function useBudget() {
const grandchildren = childrenByParent.get(child.id) || []; const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length === 0) { if (grandchildren.length === 0) {
// Simple leaf at depth 1 // Simple leaf at depth 1
const { months, annual } = buildMonths(child.id); const { months, annual, previousYearTotal } = buildMonths(child.id);
allChildRows.push({ allChildRows.push({
category_id: child.id, category_id: child.id,
category_name: child.name, category_name: child.name,
@ -249,6 +265,7 @@ export function useBudget() {
depth: 1, depth: 1,
months, months,
annual, annual,
previousYearTotal,
}); });
} else { } else {
// Intermediate parent at depth 1 with grandchildren // Intermediate parent at depth 1 with grandchildren
@ -267,9 +284,11 @@ export function useBudget() {
const leafRows = allChildRows.filter((r) => !r.is_parent); const leafRows = allChildRows.filter((r) => !r.is_parent);
const parentMonths = Array(12).fill(0) as number[]; const parentMonths = Array(12).fill(0) as number[];
let parentAnnual = 0; let parentAnnual = 0;
let parentPrevYear = 0;
for (const cr of leafRows) { for (const cr of leafRows) {
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m]; for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
parentAnnual += cr.annual; parentAnnual += cr.annual;
parentPrevYear += cr.previousYearTotal;
} }
rows.push({ rows.push({
@ -282,6 +301,7 @@ export function useBudget() {
depth: 0, depth: 0,
months: parentMonths, months: parentMonths,
annual: parentAnnual, annual: parentAnnual,
previousYearTotal: parentPrevYear,
}); });
// Sort children alphabetically, but keep "(direct)" first // Sort children alphabetically, but keep "(direct)" first

View file

@ -319,6 +319,7 @@
"actual": "Actual", "actual": "Actual",
"difference": "Difference", "difference": "Difference",
"annual": "Annual", "annual": "Annual",
"previousYear": "Prev. Year",
"splitEvenly": "Split evenly across 12 months", "splitEvenly": "Split evenly across 12 months",
"annualMismatch": "Annual total does not match the sum of monthly amounts", "annualMismatch": "Annual total does not match the sum of monthly amounts",
"clickToEdit": "Click to edit", "clickToEdit": "Click to edit",

View file

@ -319,6 +319,7 @@
"actual": "Réel", "actual": "Réel",
"difference": "Écart", "difference": "Écart",
"annual": "Annuel", "annual": "Annuel",
"previousYear": "Année préc.",
"splitEvenly": "Répartir également sur 12 mois", "splitEvenly": "Répartir également sur 12 mois",
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels", "annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
"clickToEdit": "Cliquer pour modifier", "clickToEdit": "Cliquer pour modifier",

View file

@ -142,6 +142,7 @@ export interface BudgetYearRow {
depth?: 0 | 1 | 2; depth?: 0 | 1 | 2;
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
previousYearTotal: number; // total budget from the previous year
} }
export interface ImportConfigTemplate { export interface ImportConfigTemplate {