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

- Replace flat alphabetical sort with tree-order traversal so child
  categories appear directly under their parent subtotal row
- Make category hierarchy recursive (supports arbitrary depth)
- Reduce pie chart width from 1/2 to 1/3 of the dashboard
- Show pie chart labels only on hover via tooltip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
medic-bot 2026-03-09 01:10:46 -04:00
parent 8742c25945
commit dbe249783e
8 changed files with 77 additions and 117 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

@ -18,7 +18,7 @@ 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 }>( function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number }>(
rows: T[], rows: T[],
subtotalsOnTop: boolean, subtotalsOnTop: boolean,
): T[] { ): T[] {

View file

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts"; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import { Eye } from "lucide-react"; import { Eye } from "lucide-react";
import type { CategoryBreakdownItem } from "../../shared/types"; import type { CategoryBreakdownItem } from "../../shared/types";
import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns"; import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
import ChartContextMenu from "../shared/ChartContextMenu"; import ChartContextMenu from "../shared/ChartContextMenu";
interface CategoryPieChartProps { interface CategoryPieChartProps {
@ -67,7 +67,7 @@ export default function CategoryPieChart({
)} )}
<div onContextMenu={handleContextMenu}> <div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={280}> <ResponsiveContainer width="100%" height={220}>
<PieChart> <PieChart>
<ChartPatternDefs <ChartPatternDefs
prefix="cat-pie" prefix="cat-pie"
@ -79,8 +79,8 @@ export default function CategoryPieChart({
nameKey="category_name" nameKey="category_name"
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={50} innerRadius={40}
outerRadius={100} outerRadius={85}
paddingAngle={2} paddingAngle={2}
> >
{visibleData.map((item, index) => ( {visibleData.map((item, index) => (
@ -94,9 +94,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,29 +112,6 @@ export default function CategoryPieChart({
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
{data.map((item, index) => {
const isHidden = hiddenCategories.has(item.category_name);
return (
<button
key={index}
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, item });
}}
onClick={() => isHidden ? onToggleHidden(item.category_name) : undefined}
title={isHidden ? t("charts.clickToShow") : undefined}
>
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
<span className="text-[var(--muted-foreground)]">
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""}
</span>
</button>
);
})}
</div>
{contextMenu && ( {contextMenu && (
<ChartContextMenu <ChartContextMenu
x={contextMenu.x} x={contextMenu.x}

View file

@ -25,7 +25,7 @@ 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 }>( function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number }>(
rows: T[], rows: T[],
subtotalsOnTop: boolean, subtotalsOnTop: boolean,
): T[] { ): T[] {
@ -215,7 +215,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
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 isIntermediateParent = isParent && depth === 1;
const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; 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}`}

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-3 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-2">
<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,40 @@ 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) { for (const child of subChildren) {
const leaf = buildLeaf(gc, cat.id, 2); const grandchildren = childrenByParent.get(child.id) || [];
if (!isRowAllZero(leaf)) gcRows.push(leaf); 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 +363,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 +381,18 @@ 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 all children in sort order (preserves tree structure)
for (const child of inputableChildren) { for (const child of children) {
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 +400,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;