diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 7066584..ba58876 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -5,6 +5,14 @@ ### 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) +### 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] ### Ajouté diff --git a/CHANGELOG.md b/CHANGELOG.md index edd44d6..dd0c18a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ### Added - 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] ### Added diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index c7666c3..6ed81b7 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -18,7 +18,7 @@ const MONTH_KEYS = [ const STORAGE_KEY = "subtotals-position"; -function reorderRows( +function reorderRows( rows: T[], subtotalsOnTop: boolean, ): T[] { diff --git a/src/components/dashboard/CategoryPieChart.tsx b/src/components/dashboard/CategoryPieChart.tsx index 2fe8195..328f78f 100644 --- a/src/components/dashboard/CategoryPieChart.tsx +++ b/src/components/dashboard/CategoryPieChart.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts"; import { Eye } from "lucide-react"; import type { CategoryBreakdownItem } from "../../shared/types"; -import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns"; +import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns"; import ChartContextMenu from "../shared/ChartContextMenu"; interface CategoryPieChartProps { @@ -67,7 +67,7 @@ export default function CategoryPieChart({ )}
- + {visibleData.map((item, index) => ( @@ -94,9 +94,11 @@ export default function CategoryPieChart({ ))} - new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value)) - } + formatter={(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={{ backgroundColor: "var(--card)", border: "1px solid var(--border)", @@ -110,29 +112,6 @@ export default function CategoryPieChart({
-
- {data.map((item, index) => { - const isHidden = hiddenCategories.has(item.category_name); - return ( - - ); - })} -
- {contextMenu && ( ( +function reorderRows( rows: T[], subtotalsOnTop: boolean, ): T[] { @@ -215,7 +215,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) const isParent = row.is_parent; const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0); 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 ( -
-
+
+

{t("dashboard.expensesByCategory")}

-
+

{t("dashboard.budgetVsActual")}

diff --git a/src/services/budgetService.ts b/src/services/budgetService.ts index 8a59581..0bfcd30 100644 --- a/src/services/budgetService.ts +++ b/src/services/budgetService.ts @@ -231,7 +231,6 @@ export async function getBudgetVsActualData( } // Index categories - const catById = new Map(allCategories.map((c) => [c.id, c])); const childrenByParent = new Map(); for (const cat of allCategories) { if (cat.parent_id) { @@ -244,7 +243,7 @@ export async function getBudgetVsActualData( const signFor = (type: string) => (type === "expense" ? -1 : 1); // 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 monthMap = entryMap.get(cat.id); 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 = { category_id: cat.id, category_name: cat.name, @@ -323,35 +322,40 @@ export async function getBudgetVsActualData( ); } - // Build rows for a level-2 parent (intermediate parent with grandchildren) - function buildLevel2Group(cat: Category, grandparentId: number): BudgetVsActualRow[] { - const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable); - if (grandchildren.length === 0 && cat.is_inputable) { - // Leaf at level 2 - const leaf = buildLeaf(cat, grandparentId, 2); + // Build rows for a sub-group (recursive, supports arbitrary depth) + function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] { + const subChildren = childrenByParent.get(cat.id) || []; + const hasSubChildren = subChildren.some( + (c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0 + ); + + if (!hasSubChildren && cat.is_inputable) { + const leaf = buildLeaf(cat, groupParentId, depth); return isRowAllZero(leaf) ? [] : [leaf]; } - if (grandchildren.length === 0) return []; + if (!hasSubChildren) return []; - const gcRows: BudgetVsActualRow[] = []; + const childRows: BudgetVsActualRow[] = []; 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)`; - if (!isRowAllZero(direct)) gcRows.push(direct); + if (!isRowAllZero(direct)) childRows.push(direct); } - for (const gc of grandchildren) { - const leaf = buildLeaf(gc, cat.id, 2); - if (!isRowAllZero(leaf)) gcRows.push(leaf); + for (const child of subChildren) { + 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); - gcRows.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); - }); - return [subtotal, ...gcRows]; + const leafRows = childRows.filter((r) => !r.is_parent); + const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth); + return [subtotal, ...childRows]; } const rows: BudgetVsActualRow[] = []; @@ -359,15 +363,15 @@ export async function getBudgetVsActualData( for (const cat of topLevel) { const children = childrenByParent.get(cat.id) || []; - const inputableChildren = children.filter((c) => c.is_inputable); - // Also check for non-inputable intermediate parents that have their own children - const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0); + const hasChildren = children.some( + (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 const leaf = buildLeaf(cat, null, 0); if (!isRowAllZero(leaf)) rows.push(leaf); - } else if (inputableChildren.length > 0 || intermediateParents.length > 0) { + } else if (hasChildren) { const allChildRows: BudgetVsActualRow[] = []; // Direct transactions on the parent itself @@ -377,25 +381,18 @@ export async function getBudgetVsActualData( if (!isRowAllZero(direct)) allChildRows.push(direct); } - // Level-2 leaves (direct children that are inputable and have no children) - for (const child of inputableChildren) { + // Process all children in sort order (preserves tree structure) + for (const child of children) { 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); 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; // 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); 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); } } - // 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(); + rows.forEach((r, i) => rowOrder.set(r, i)); + 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; - // Find the top-level group id - 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 rowOrder.get(a)! - rowOrder.get(b)!; }); return rows; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index f32bef5..3c96ae7 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -139,7 +139,7 @@ export interface BudgetYearRow { category_type: "expense" | "income" | "transfer"; parent_id: number | null; is_parent: boolean; - depth?: 0 | 1 | 2; + depth?: number; months: number[]; // index 0-11 = Jan-Dec planned amounts annual: number; // computed sum previousYearTotal: number; // total budget from the previous year @@ -332,7 +332,7 @@ export interface BudgetVsActualRow { category_type: "expense" | "income" | "transfer"; parent_id: number | null; is_parent: boolean; - depth?: 0 | 1 | 2; + depth?: number; monthActual: number; monthBudget: number; monthVariation: number;