fix: display level 4+ categories under their parent in dashboard (#23) #28

Merged
maximus merged 4 commits from fix/simpl-resultat-23-dashboard-category-ordering into main 2026-03-10 01:26:20 +00:00
9 changed files with 139 additions and 206 deletions

View file

@ -5,6 +5,14 @@
### Ajouté ### 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) - 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)
### Corrigé
- Tableau de bord : les catégories de niveau 4+ apparaissent maintenant sous leur parent au lieu du bas de la section (#23)
- Tableau de bord : la hiérarchie de catégories supporte maintenant une profondeur de niveaux arbitraire (#23)
### Modifié
- Tableau de bord : le graphique circulaire prend 1/3 de la largeur au lieu de 1/2, donnant plus d'espace au tableau budget (#23)
- Tableau de bord : les étiquettes du graphique circulaire s'affichent uniquement au survol via le tooltip (#23)
## [0.6.3] ## [0.6.3]
### Ajouté ### Ajouté

View file

@ -5,6 +5,14 @@
### Added ### Added
- Budget table: previous year total column displayed as first data column for baseline reference (#16) - Budget table: previous year total column displayed as first data column for baseline reference (#16)
### Fixed
- Dashboard: level 4+ categories now appear under their parent instead of at the bottom of the section (#23)
- Dashboard: category hierarchy now supports arbitrary nesting depth (#23)
### Changed
- Dashboard: pie chart takes 1/3 width instead of 1/2, giving more space to the budget table (#23)
- Dashboard: pie chart labels now shown only on hover via tooltip instead of permanent legend (#23)
## [0.6.3] ## [0.6.3]
### Added ### Added

View file

@ -2,6 +2,7 @@ import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown } from "lucide-react"; import { AlertTriangle, ArrowUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types"; import type { BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
const fmt = new Intl.NumberFormat("en-CA", { const fmt = new Intl.NumberFormat("en-CA", {
style: "currency", style: "currency",
@ -18,58 +19,6 @@ const MONTH_KEYS = [
const STORAGE_KEY = "subtotals-position"; const STORAGE_KEY = "subtotals-position";
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
rows: T[],
subtotalsOnTop: boolean,
): T[] {
if (subtotalsOnTop) return rows;
// Group depth-0 parents with all their descendants, then move subtotals to bottom
const groups: { parent: T | null; children: T[] }[] = [];
let current: { parent: T | null; children: T[] } | null = null;
for (const row of rows) {
if (row.is_parent && (row.depth ?? 0) === 0) {
if (current) groups.push(current);
current = { parent: row, children: [] };
} else if (current) {
current.children.push(row);
} else {
if (current) groups.push(current);
current = { parent: null, children: [row] };
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => {
if (!parent) return children;
// Also move intermediate subtotals (depth-1 parents) to bottom of their sub-groups
const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
// Flush previous sub-group
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
}
interface BudgetTableProps { interface BudgetTableProps {
rows: BudgetYearRow[]; rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void; onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
@ -230,13 +179,15 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
if (row.is_parent) { if (row.is_parent) {
// Parent subtotal row: read-only, bold, distinct background // Parent subtotal row: read-only, bold, distinct background
const parentDepth = row.depth ?? 0; const parentDepth = row.depth ?? 0;
const isIntermediateParent = parentDepth === 1; const isTopParent = parentDepth === 0;
const isIntermediateParent = parentDepth >= 1;
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
return ( return (
<tr <tr
key={rowKey} key={rowKey}
className={`border-b border-[var(--border)] ${isIntermediateParent ? "bg-[var(--muted)]/15" : "bg-[var(--muted)]/30"}`} className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`}
> >
<td className={`py-2 sticky left-0 z-10 ${isIntermediateParent ? "pl-8 pr-3 bg-[var(--muted)]/15" : "px-3 bg-[var(--muted)]/30"}`}> <td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
@ -267,7 +218,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors" className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
> >
{/* Category name - sticky */} {/* Category name - sticky */}
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}> <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"> <div className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"

View file

@ -23,6 +23,7 @@ export default function CategoryPieChart({
}: CategoryPieChartProps) { }: CategoryPieChartProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const hoveredRef = useRef<CategoryBreakdownItem | null>(null); const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
const [isChartHovered, setIsChartHovered] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name)); const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
@ -36,14 +37,14 @@ export default function CategoryPieChart({
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]"> <div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p> <p className="text-center text-[var(--muted-foreground)] py-6">{t("dashboard.noData")}</p>
</div> </div>
); );
} }
return ( return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]"> <div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
{hiddenCategories.size > 0 && ( {hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-3"> <div className="flex flex-wrap items-center gap-2 mb-3">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span> <span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
@ -66,8 +67,12 @@ export default function CategoryPieChart({
</div> </div>
)} )}
<div onContextMenu={handleContextMenu}> <div
<ResponsiveContainer width="100%" height={280}> onContextMenu={handleContextMenu}
onMouseEnter={() => setIsChartHovered(true)}
onMouseLeave={() => setIsChartHovered(false)}
>
<ResponsiveContainer width="100%" height={180}>
<PieChart> <PieChart>
<ChartPatternDefs <ChartPatternDefs
prefix="cat-pie" prefix="cat-pie"
@ -79,8 +84,8 @@ export default function CategoryPieChart({
nameKey="category_name" nameKey="category_name"
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={50} innerRadius={35}
outerRadius={100} outerRadius={75}
paddingAngle={2} paddingAngle={2}
> >
{visibleData.map((item, index) => ( {visibleData.map((item, index) => (
@ -94,9 +99,11 @@ export default function CategoryPieChart({
))} ))}
</Pie> </Pie>
<Tooltip <Tooltip
formatter={(value) => formatter={(value) => {
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value)) const formatted = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value));
} const pct = total > 0 ? ` (${Math.round((Number(value) / total) * 100)}%)` : "";
return `${formatted}${pct}`;
}}
contentStyle={{ contentStyle={{
backgroundColor: "var(--card)", backgroundColor: "var(--card)",
border: "1px solid var(--border)", border: "1px solid var(--border)",
@ -110,13 +117,14 @@ export default function CategoryPieChart({
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2"> <div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{data.map((item, index) => { {data.map((item, index) => {
const isHidden = hiddenCategories.has(item.category_name); const isHidden = hiddenCategories.has(item.category_name);
const pct = total > 0 && !isHidden ? Math.round((item.total / total) * 100) : null;
return ( return (
<button <button
key={index} key={index}
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`} className={`flex items-center gap-1 text-xs ${isHidden ? "opacity-40" : ""}`}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, item }); setContextMenu({ x: e.clientX, y: e.clientY, item });
@ -126,7 +134,7 @@ export default function CategoryPieChart({
> >
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" /> <PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
<span className="text-[var(--muted-foreground)]"> <span className="text-[var(--muted-foreground)]">
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""} {item.category_name}{isChartHovered && pct != null ? ` ${pct}%` : ""}
</span> </span>
</button> </button>
); );

View file

@ -2,6 +2,7 @@ import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react"; import { ArrowUpDown } from "lucide-react";
import type { BudgetVsActualRow } from "../../shared/types"; import type { BudgetVsActualRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { new Intl.NumberFormat("en-CA", {
@ -25,55 +26,6 @@ interface BudgetVsActualTableProps {
const STORAGE_KEY = "subtotals-position"; const STORAGE_KEY = "subtotals-position";
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
rows: T[],
subtotalsOnTop: boolean,
): T[] {
if (subtotalsOnTop) return rows;
const groups: { parent: T | null; children: T[] }[] = [];
let current: { parent: T | null; children: T[] } | null = null;
for (const row of rows) {
if (row.is_parent && (row.depth ?? 0) === 0) {
if (current) groups.push(current);
current = { parent: row, children: [] };
} else if (current) {
current.children.push(row);
} else {
if (current) groups.push(current);
current = { parent: null, children: [row] };
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => {
if (!parent) return children;
const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
}
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) { export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => { const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
@ -214,17 +166,18 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
{reorderRows(section.rows, subtotalsOnTop).map((row) => { {reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent; const isParent = row.is_parent;
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0); const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
const isIntermediateParent = isParent && depth === 1; const isTopParent = isParent && depth === 0;
const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; const isIntermediateParent = isParent && depth >= 1;
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
<tr <tr
key={`${row.category_id}-${row.is_parent}-${depth}`} key={`${row.category_id}-${row.is_parent}-${depth}`}
className={`border-b border-[var(--border)]/50 ${ className={`border-b border-[var(--border)]/50 ${
isParent && !isIntermediateParent ? "bg-[var(--muted)]/30 font-semibold" : isTopParent ? "bg-[var(--muted)]/30 font-semibold" :
isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : "" isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : ""
}`} }`}
> >
<td className={`py-1.5 ${isParent && !isIntermediateParent ? "px-3" : paddingClass}`}> <td className={`py-1.5 ${isTopParent ? "px-3" : paddingClass}`}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"

View file

@ -126,8 +126,8 @@ export default function DashboardPage() {
))} ))}
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
<div> <div className="lg:col-span-1">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2> <h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
<CategoryPieChart <CategoryPieChart
data={categoryBreakdown} data={categoryBreakdown}
@ -137,7 +137,7 @@ export default function DashboardPage() {
onViewDetails={viewDetails} onViewDetails={viewDetails}
/> />
</div> </div>
<div> <div className="lg:col-span-3">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.budgetVsActual")}</h2> <h2 className="text-lg font-semibold mb-3">{t("dashboard.budgetVsActual")}</h2>
<BudgetVsActualTable data={budgetVsActual} /> <BudgetVsActualTable data={budgetVsActual} />
</div> </div>

View file

@ -231,7 +231,6 @@ export async function getBudgetVsActualData(
} }
// Index categories // Index categories
const catById = new Map(allCategories.map((c) => [c.id, c]));
const childrenByParent = new Map<number, Category[]>(); const childrenByParent = new Map<number, Category[]>();
for (const cat of allCategories) { for (const cat of allCategories) {
if (cat.parent_id) { if (cat.parent_id) {
@ -244,7 +243,7 @@ export async function getBudgetVsActualData(
const signFor = (type: string) => (type === "expense" ? -1 : 1); const signFor = (type: string) => (type === "expense" ? -1 : 1);
// Compute leaf row values // Compute leaf row values
function buildLeaf(cat: Category, parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow { function buildLeaf(cat: Category, parentId: number | null, depth: number): BudgetVsActualRow {
const sign = signFor(cat.type); const sign = signFor(cat.type);
const monthMap = entryMap.get(cat.id); const monthMap = entryMap.get(cat.id);
const rawMonthBudget = monthMap?.get(month) ?? 0; const rawMonthBudget = monthMap?.get(month) ?? 0;
@ -281,7 +280,7 @@ export async function getBudgetVsActualData(
}; };
} }
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow { function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: number): BudgetVsActualRow {
const row: BudgetVsActualRow = { const row: BudgetVsActualRow = {
category_id: cat.id, category_id: cat.id,
category_name: cat.name, category_name: cat.name,
@ -323,35 +322,41 @@ export async function getBudgetVsActualData(
); );
} }
// Build rows for a level-2 parent (intermediate parent with grandchildren) // Build rows for a sub-group (recursive, supports arbitrary depth)
function buildLevel2Group(cat: Category, grandparentId: number): BudgetVsActualRow[] { function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] {
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable); const subChildren = childrenByParent.get(cat.id) || [];
if (grandchildren.length === 0 && cat.is_inputable) { const hasSubChildren = subChildren.some(
// Leaf at level 2 (c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
const leaf = buildLeaf(cat, grandparentId, 2); );
if (!hasSubChildren && cat.is_inputable) {
const leaf = buildLeaf(cat, groupParentId, depth);
return isRowAllZero(leaf) ? [] : [leaf]; return isRowAllZero(leaf) ? [] : [leaf];
} }
if (grandchildren.length === 0) return []; if (!hasSubChildren) return [];
const gcRows: BudgetVsActualRow[] = []; const childRows: BudgetVsActualRow[] = [];
if (cat.is_inputable) { if (cat.is_inputable) {
const direct = buildLeaf(cat, cat.id, 2); const direct = buildLeaf(cat, cat.id, depth + 1);
direct.category_name = `${cat.name} (direct)`; direct.category_name = `${cat.name} (direct)`;
if (!isRowAllZero(direct)) gcRows.push(direct); if (!isRowAllZero(direct)) childRows.push(direct);
} }
for (const gc of grandchildren) { const sortedSubChildren = [...subChildren].sort((a, b) => a.name.localeCompare(b.name));
const leaf = buildLeaf(gc, cat.id, 2); for (const child of sortedSubChildren) {
if (!isRowAllZero(leaf)) gcRows.push(leaf); const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length > 0) {
const subRows = buildSubGroup(child, cat.id, depth + 1);
childRows.push(...subRows);
} else if (child.is_inputable) {
const leaf = buildLeaf(child, cat.id, depth + 1);
if (!isRowAllZero(leaf)) childRows.push(leaf);
}
} }
if (gcRows.length === 0) return []; if (childRows.length === 0) return [];
const subtotal = buildSubtotal(cat, gcRows, grandparentId, 1); const leafRows = childRows.filter((r) => !r.is_parent);
gcRows.sort((a, b) => { const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth);
if (a.category_id === cat.id) return -1; return [subtotal, ...childRows];
if (b.category_id === cat.id) return 1;
return a.category_name.localeCompare(b.category_name);
});
return [subtotal, ...gcRows];
} }
const rows: BudgetVsActualRow[] = []; const rows: BudgetVsActualRow[] = [];
@ -359,15 +364,15 @@ export async function getBudgetVsActualData(
for (const cat of topLevel) { for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || []; const children = childrenByParent.get(cat.id) || [];
const inputableChildren = children.filter((c) => c.is_inputable); const hasChildren = children.some(
// Also check for non-inputable intermediate parents that have their own children (c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0); );
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) { if (!hasChildren && cat.is_inputable) {
// Standalone leaf at level 0 // Standalone leaf at level 0
const leaf = buildLeaf(cat, null, 0); const leaf = buildLeaf(cat, null, 0);
if (!isRowAllZero(leaf)) rows.push(leaf); if (!isRowAllZero(leaf)) rows.push(leaf);
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) { } else if (hasChildren) {
const allChildRows: BudgetVsActualRow[] = []; const allChildRows: BudgetVsActualRow[] = [];
// Direct transactions on the parent itself // Direct transactions on the parent itself
@ -377,25 +382,19 @@ export async function getBudgetVsActualData(
if (!isRowAllZero(direct)) allChildRows.push(direct); if (!isRowAllZero(direct)) allChildRows.push(direct);
} }
// Level-2 leaves (direct children that are inputable and have no children) // Process children in alphabetical order
for (const child of inputableChildren) { const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name));
for (const child of sortedChildren) {
const grandchildren = childrenByParent.get(child.id) || []; const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length === 0) { if (grandchildren.length > 0) {
const subRows = buildSubGroup(child, cat.id, 1);
allChildRows.push(...subRows);
} else if (child.is_inputable) {
const leaf = buildLeaf(child, cat.id, 1); const leaf = buildLeaf(child, cat.id, 1);
if (!isRowAllZero(leaf)) allChildRows.push(leaf); if (!isRowAllZero(leaf)) allChildRows.push(leaf);
} else {
// This child has its own children — it's an intermediate parent at level 1
const subRows = buildLevel2Group(child, cat.id);
allChildRows.push(...subRows);
} }
} }
// Non-inputable intermediate parents at level 1
for (const ip of intermediateParents) {
const subRows = buildLevel2Group(ip, cat.id);
allChildRows.push(...subRows);
}
if (allChildRows.length === 0) continue; if (allChildRows.length === 0) continue;
// Collect only leaf rows for parent subtotal (avoid double-counting) // Collect only leaf rows for parent subtotal (avoid double-counting)
@ -403,51 +402,19 @@ export async function getBudgetVsActualData(
const parent = buildSubtotal(cat, leafRows, null, 0); const parent = buildSubtotal(cat, leafRows, null, 0);
rows.push(parent); rows.push(parent);
// Sort: "(direct)" first, then subtotals with their children, then alphabetical leaves
allChildRows.sort((a, b) => {
if (a.category_id === cat.id && !a.is_parent) return -1;
if (b.category_id === cat.id && !b.is_parent) return 1;
return a.category_name.localeCompare(b.category_name);
});
rows.push(...allChildRows); rows.push(...allChildRows);
} }
} }
// Sort by type, then within same type keep parent+children groups together // Sort by type only, preserving tree order within groups (already built correctly)
const rowOrder = new Map<BudgetVsActualRow, number>();
rows.forEach((r, i) => rowOrder.set(r, i));
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;
// Find the top-level group id return rowOrder.get(a)! - rowOrder.get(b)!;
function getGroupId(r: BudgetVsActualRow): number {
if (r.depth === 0) return r.category_id;
if (r.is_parent && r.parent_id === null) return r.category_id;
// Walk up to find the root
let pid = r.parent_id;
while (pid !== null) {
const pCat = catById.get(pid);
if (!pCat || !pCat.parent_id) return pid;
pid = pCat.parent_id;
}
return r.category_id;
}
const groupA = getGroupId(a);
const groupB = getGroupId(b);
if (groupA !== groupB) {
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 ?? "");
}
// Within same group: sort by depth, then parent before children
if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
if ((a.depth ?? 0) !== (b.depth ?? 0)) return (a.depth ?? 0) - (b.depth ?? 0);
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 rows; return rows;

View file

@ -139,7 +139,7 @@ export interface BudgetYearRow {
category_type: "expense" | "income" | "transfer"; category_type: "expense" | "income" | "transfer";
parent_id: number | null; parent_id: number | null;
is_parent: boolean; is_parent: boolean;
depth?: 0 | 1 | 2; depth?: number;
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 previousYearTotal: number; // total budget from the previous year
@ -332,7 +332,7 @@ export interface BudgetVsActualRow {
category_type: "expense" | "income" | "transfer"; category_type: "expense" | "income" | "transfer";
parent_id: number | null; parent_id: number | null;
is_parent: boolean; is_parent: boolean;
depth?: 0 | 1 | 2; depth?: number;
monthActual: number; monthActual: number;
monthBudget: number; monthBudget: number;
monthVariation: number; monthVariation: number;

38
src/utils/reorderRows.ts Normal file
View file

@ -0,0 +1,38 @@
/**
* Shared utility for reordering budget table rows.
* Recursively moves subtotal (parent) rows below their children
* at every depth level when "subtotals on bottom" is enabled.
*/
export function reorderRows<
T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number },
>(rows: T[], subtotalsOnTop: boolean): T[] {
if (subtotalsOnTop) return rows;
function reorderGroup(groupRows: T[], parentDepth: number): T[] {
const result: T[] = [];
let currentParent: T | null = null;
let currentChildren: T[] = [];
for (const row of groupRows) {
if (row.is_parent && (row.depth ?? 0) === parentDepth) {
// Flush previous group
if (currentParent) {
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
currentChildren = [];
}
currentParent = row;
} else if (currentParent) {
currentChildren.push(row);
} else {
result.push(row);
}
}
// Flush last group
if (currentParent) {
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
}
return result;
}
return reorderGroup(rows, 0);
}