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..aa92e4f 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, Fragment } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangle, ArrowUpDown } from "lucide-react"; import type { BudgetYearRow } from "../../shared/types"; +import { reorderRows } from "../../utils/reorderRows"; const fmt = new Intl.NumberFormat("en-CA", { style: "currency", @@ -18,58 +19,6 @@ const MONTH_KEYS = [ const STORAGE_KEY = "subtotals-position"; -function reorderRows( - 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 { rows: BudgetYearRow[]; onUpdatePlanned: (categoryId: number, month: number, amount: number) => void; @@ -230,13 +179,15 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu if (row.is_parent) { // Parent subtotal row: read-only, bold, distinct background 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 ( - +
{/* Category name - sticky */} - + = 3 ? "pl-20 pr-3" : depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
(null); + const [isChartHovered, setIsChartHovered] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null); const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name)); @@ -36,14 +37,14 @@ export default function CategoryPieChart({ if (data.length === 0) { return ( -
-

{t("dashboard.noData")}

+
+

{t("dashboard.noData")}

); } return ( -
+
{hiddenCategories.size > 0 && (
{t("charts.hiddenCategories")}: @@ -66,8 +67,12 @@ export default function CategoryPieChart({
)} -
- +
setIsChartHovered(true)} + onMouseLeave={() => setIsChartHovered(false)} + > + {visibleData.map((item, index) => ( @@ -94,9 +99,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,13 +117,14 @@ export default function CategoryPieChart({
-
+
{data.map((item, index) => { const isHidden = hiddenCategories.has(item.category_name); + const pct = total > 0 && !isHidden ? Math.round((item.total / total) * 100) : null; return ( ); diff --git a/src/components/reports/BudgetVsActualTable.tsx b/src/components/reports/BudgetVsActualTable.tsx index 4a4fda5..a7ef69f 100644 --- a/src/components/reports/BudgetVsActualTable.tsx +++ b/src/components/reports/BudgetVsActualTable.tsx @@ -2,6 +2,7 @@ import { Fragment, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowUpDown } from "lucide-react"; import type { BudgetVsActualRow } from "../../shared/types"; +import { reorderRows } from "../../utils/reorderRows"; const cadFormatter = (value: number) => new Intl.NumberFormat("en-CA", { @@ -25,55 +26,6 @@ interface BudgetVsActualTableProps { const STORAGE_KEY = "subtotals-position"; -function reorderRows( - 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) { const { t } = useTranslation(); const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => { @@ -214,17 +166,18 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {reorderRows(section.rows, subtotalsOnTop).map((row) => { 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 isTopParent = isParent && depth === 0; + const isIntermediateParent = isParent && depth >= 1; + 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..8e4d625 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,41 @@ 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); + const sortedSubChildren = [...subChildren].sort((a, b) => a.name.localeCompare(b.name)); + for (const child of sortedSubChildren) { + 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 +364,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 +382,19 @@ 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 children in alphabetical order + const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name)); + for (const child of sortedChildren) { 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 +402,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; diff --git a/src/utils/reorderRows.ts b/src/utils/reorderRows.ts new file mode 100644 index 0000000..4ac0181 --- /dev/null +++ b/src/utils/reorderRows.ts @@ -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); +}