From a04813ced20d95002b98ce13577d8d07675db207 Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 25 Feb 2026 19:54:05 -0500 Subject: [PATCH] feat: add 3rd level of category hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support up to 3 levels of categories (e.g., Dépenses récurrentes → Assurances → Assurance-auto) while keeping SQL JOINs bounded and existing 2-level branches fully compatible. Changes across 14 files: - Types: add "level3" pivot field, depth property on budget row types - Reports: grandparent JOIN for 3-level resolution in dynamic reports - Categories: depth validation (max 3), auto is_inputable management, recursive tree operations, 3-level drag-drop with subtree validation - Budget: 3-level grouping with intermediate subtotals, leaf-only aggregation, depth-based indentation (pl-8/pl-14) - Seed data: Assurances split into Assurance-auto/habitation/vie - i18n: level3 translations for FR and EN Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/database/seed_categories.sql | 19 +- src/components/budget/BudgetTable.tsx | 55 ++++-- src/components/categories/CategoryForm.tsx | 35 +++- src/components/categories/CategoryTree.tsx | 69 ++++--- .../reports/BudgetVsActualTable.tsx | 48 ++++- src/components/reports/DynamicReportPanel.tsx | 4 +- src/hooks/useBudget.ts | 175 ++++++++++++++---- src/hooks/useCategories.ts | 68 +++---- src/i18n/locales/en.json | 1 + src/i18n/locales/fr.json | 1 + src/services/budgetService.ts | 174 +++++++++++------ src/services/categoryService.ts | 167 ++++++++++++----- src/services/reportService.ts | 14 +- src/shared/types/index.ts | 4 +- 14 files changed, 595 insertions(+), 239 deletions(-) diff --git a/src-tauri/src/database/seed_categories.sql b/src-tauri/src/database/seed_categories.sql index aeba40c..0582449 100644 --- a/src-tauri/src/database/seed_categories.sql +++ b/src-tauri/src/database/seed_categories.sql @@ -35,7 +35,7 @@ INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (27 INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (28, 'Transport en commun', 2, 'expense', '#3b82f6', 9); INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (29, 'Internet & Télécom', 2, 'expense', '#6366f1', 10); INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (30, 'Animaux', 2, 'expense', '#a855f7', 11); -INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (31, 'Assurances', 2, 'expense', '#14b8a6', 12); +INSERT INTO categories (id, name, parent_id, type, color, sort_order, is_inputable) VALUES (31, 'Assurances', 2, 'expense', '#14b8a6', 12, 0); INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (32, 'Pharmacie', 2, 'expense', '#f43f5e', 13); INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (33, 'Taxes municipales', 2, 'expense', '#78716c', 14); @@ -68,6 +68,13 @@ INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (71 INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (72, 'Retrait cash', 6, 'expense', '#57534e', 3); INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (73, 'Projets', 6, 'expense', '#0ea5e9', 4); +-- ========================================== +-- Grandchild categories (Level 3 — under Assurances) +-- ========================================== +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (310, 'Assurance-auto', 31, 'expense', '#14b8a6', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (311, 'Assurance-habitation', 31, 'expense', '#0d9488', 2); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (312, 'Assurance-vie', 31, 'expense', '#0f766e', 3); + -- ========================================== -- Keywords -- ========================================== @@ -132,10 +139,12 @@ INSERT INTO keywords (keyword, category_id) VALUES ('ORICOM', 29); -- Animaux (30) INSERT INTO keywords (keyword, category_id) VALUES ('MONDOU', 30); --- Assurances (31) -INSERT INTO keywords (keyword, category_id) VALUES ('BELAIR', 31); -INSERT INTO keywords (keyword, category_id) VALUES ('PRYSM', 31); -INSERT INTO keywords (keyword, category_id) VALUES ('INS/ASS', 31); +-- Assurance-auto (310) +INSERT INTO keywords (keyword, category_id) VALUES ('BELAIR', 310); +-- Assurance-habitation (311) +INSERT INTO keywords (keyword, category_id) VALUES ('PRYSM', 311); +-- Assurance-vie (312) +INSERT INTO keywords (keyword, category_id) VALUES ('INS/ASS', 312); -- Pharmacie (32) INSERT INTO keywords (keyword, category_id) VALUES ('JEAN COUTU', 32); diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index 4e6386b..d68acf0 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -18,18 +18,19 @@ const MONTH_KEYS = [ const STORAGE_KEY = "subtotals-position"; -function reorderRows( +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) { + if (row.is_parent && (row.depth ?? 0) === 0) { if (current) groups.push(current); current = { parent: row, children: [] }; - } else if (current && row.parent_id === current.parent?.category_id) { + } else if (current) { current.children.push(row); } else { if (current) groups.push(current); @@ -37,9 +38,36 @@ function reorderRows - parent ? [...children, parent] : children, - ); + 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 { @@ -188,30 +216,33 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu const renderRow = (row: BudgetYearRow) => { const sign = signFor(row.category_type); const isChild = row.parent_id !== null && !row.is_parent; + const depth = row.depth ?? (isChild ? 1 : 0); // Unique key: parent rows and "(direct)" fake children can share the same category_id const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`; if (row.is_parent) { // Parent subtotal row: read-only, bold, distinct background + const parentDepth = row.depth ?? 0; + const isIntermediateParent = parentDepth === 1; return ( - +
- {row.category_name} + {row.category_name}
- + {formatSigned(row.annual * sign)} {row.months.map((val, mIdx) => ( - + {formatSigned(val * sign)} ))} @@ -226,7 +257,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" > {/* Category name - sticky */} - +
c.parent_id === null - ); + // Allow level 0 and level 1 categories as parents (but not level 2, which would create a 4th level) + // Also build indentation info + const parentOptions: Array = []; + for (const cat of categories) { + if (cat.parent_id === null) { + // Level 0 — always allowed as parent + parentOptions.push({ ...cat, indent: 0 }); + } + } + for (const cat of categories) { + if (cat.parent_id !== null) { + // Check if this category's parent is a root (making this level 1) + const parent = categories.find((c) => c.id === cat.parent_id); + if (parent && parent.parent_id === null) { + // Level 1 — allowed as parent (would create level 3 children) + parentOptions.push({ ...cat, indent: 1 }); + } + // Level 2 categories are NOT shown (would create level 4) + } + } + // Sort to keep hierarchy order: group by root parent sort_order + parentOptions.sort((a, b) => { + const rootA = a.indent === 0 ? a : categories.find((c) => c.id === a.parent_id); + const rootB = b.indent === 0 ? b : categories.find((c) => c.id === b.parent_id); + const orderA = rootA?.sort_order ?? 999; + const orderB = rootB?.sort_order ?? 999; + if (orderA !== orderB) return orderA - orderB; + if (a.indent !== b.indent) return a.indent - b.indent; + return a.sort_order - b.sort_order; + }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -113,7 +140,7 @@ export default function CategoryForm({ {parentOptions.map((c) => ( ))} diff --git a/src/components/categories/CategoryTree.tsx b/src/components/categories/CategoryTree.tsx index 4cfc3c1..68d4287 100644 --- a/src/components/categories/CategoryTree.tsx +++ b/src/components/categories/CategoryTree.tsx @@ -35,25 +35,24 @@ interface Props { onMoveCategory: (id: number, newParentId: number | null, newIndex: number) => Promise; } +function getSubtreeDepth(node: CategoryTreeNode): number { + if (node.children.length === 0) return 0; + return 1 + Math.max(...node.children.map(getSubtreeDepth)); +} + function flattenTree(tree: CategoryTreeNode[], expandedSet: Set): FlatItem[] { const items: FlatItem[] = []; - for (const node of tree) { - const hasChildren = node.children.length > 0; - const isExpanded = expandedSet.has(node.id); - items.push({ id: node.id, node, depth: 0, parentId: null, isExpanded, hasChildren }); - if (isExpanded) { - for (const child of node.children) { - items.push({ - id: child.id, - node: child, - depth: 1, - parentId: node.id, - isExpanded: false, - hasChildren: false, - }); + function recurse(nodes: CategoryTreeNode[], depth: number, parentId: number | null) { + for (const node of nodes) { + const hasChildren = node.children.length > 0; + const isExpanded = expandedSet.has(node.id); + items.push({ id: node.id, node, depth, parentId, isExpanded, hasChildren }); + if (isExpanded && hasChildren) { + recurse(node.children, depth + 1, node.id); } } } + recurse(tree, 0, null); return items; } @@ -191,9 +190,15 @@ function SortableTreeRow({ export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) { const [expanded, setExpanded] = useState>(() => { const ids = new Set(); - for (const node of tree) { - if (node.children.length > 0) ids.add(node.id); + function collectExpandable(nodes: CategoryTreeNode[]) { + for (const node of nodes) { + if (node.children.length > 0) { + ids.add(node.id); + collectExpandable(node.children); + } + } } + collectExpandable(tree); return ids; }); const [activeId, setActiveId] = useState(null); @@ -238,40 +243,31 @@ export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategor const activeItem = flatItems[activeIdx]; const overItem = flatItems[overIdx]; + // Compute the depth of the active item's subtree + const activeSubtreeDepth = getSubtreeDepth(activeItem.node); + // Determine the new parent and index let newParentId: number | null; let newIndex: number; if (overItem.depth === 0) { - // Dropping onto/near a root item + // Dropping onto/near a root item — same depth reorder or moving to root + newParentId = null; + const rootItems = flatItems.filter((i) => i.depth === 0); + const overRootIdx = rootItems.findIndex((i) => i.id === over.id); if (activeItem.depth === 0) { - // Root reorder: keep as root - newParentId = null; - // Count the root index of the over item - const rootItems = flatItems.filter((i) => i.depth === 0); - const overRootIdx = rootItems.findIndex((i) => i.id === over.id); newIndex = overRootIdx; } else { - // Child moving to root level - newParentId = null; - const rootItems = flatItems.filter((i) => i.depth === 0); - const overRootIdx = rootItems.findIndex((i) => i.id === over.id); newIndex = overIdx > activeIdx ? overRootIdx + 1 : overRootIdx; } } else { - // Dropping onto/near a child item - if (activeItem.hasChildren) { - // Block: moving a root with children to become a child (would create 3 levels) - return; - } + // Dropping onto/near a non-root item — adopt same parent newParentId = overItem.parentId; - // Find the index within that parent's children const siblings = flatItems.filter( - (i) => i.depth === 1 && i.parentId === overItem.parentId + (i) => i.depth === overItem.depth && i.parentId === overItem.parentId ); const overSiblingIdx = siblings.findIndex((i) => i.id === over.id); newIndex = overIdx > activeIdx ? overSiblingIdx + 1 : overSiblingIdx; - // If moving from same parent, adjust index if (activeItem.parentId === newParentId) { const activeSiblingIdx = siblings.findIndex((i) => i.id === active.id); if (activeSiblingIdx < overSiblingIdx) { @@ -282,8 +278,9 @@ export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategor } } - // Validate 2-level constraint: can't drop a root with children into a child position - if (newParentId !== null && activeItem.hasChildren) { + // Validate 3-level constraint: targetDepth + subtreeDepth must be <= 2 (max index) + const targetDepth = newParentId === null ? 0 : overItem.depth; + if (targetDepth + activeSubtreeDepth > 2) { return; } diff --git a/src/components/reports/BudgetVsActualTable.tsx b/src/components/reports/BudgetVsActualTable.tsx index 3fdf4ac..2237e44 100644 --- a/src/components/reports/BudgetVsActualTable.tsx +++ b/src/components/reports/BudgetVsActualTable.tsx @@ -25,7 +25,7 @@ interface BudgetVsActualTableProps { const STORAGE_KEY = "subtotals-position"; -function reorderRows( +function reorderRows( rows: T[], subtotalsOnTop: boolean, ): T[] { @@ -33,10 +33,10 @@ function reorderRows - parent ? [...children, parent] : children, - ); + 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) { @@ -168,15 +193,18 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {reorderRows(section.rows, subtotalsOnTop).map((row) => { const isParent = row.is_parent; - const isChild = row.parent_id !== null && !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"; return ( - + t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "type" ? "categoryType" : id}`); + const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "level3" ? "level3" : id === "type" ? "categoryType" : id}`); const measureLabel = (id: string) => t(`reports.pivot.${id}`); // Context menu only shows zones where the field is NOT already assigned diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index 58d0c6c..d78ed17 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -112,13 +112,96 @@ export function useBudget() { const rows: BudgetYearRow[] = []; + // Build rows for an intermediate parent (level 1 or 2 with children) + function buildLevel2Group(cat: typeof allCategories[0], grandparentId: number): BudgetYearRow[] { + const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable); + if (grandchildren.length === 0 && cat.is_inputable) { + // Leaf at depth 2 + const { months, annual } = buildMonths(cat.id); + return [{ + category_id: cat.id, + category_name: cat.name, + category_color: cat.color || "#9ca3af", + category_type: cat.type, + parent_id: grandparentId, + is_parent: false, + depth: 2, + months, + annual, + }]; + } + if (grandchildren.length === 0 && !cat.is_inputable) { + // Also check if it has non-inputable intermediate children with their own children + // This shouldn't happen at depth 3 (max 3 levels), but handle gracefully + return []; + } + + const gcRows: BudgetYearRow[] = []; + if (cat.is_inputable) { + const { months, annual } = buildMonths(cat.id); + gcRows.push({ + category_id: cat.id, + category_name: `${cat.name} (direct)`, + category_color: cat.color || "#9ca3af", + category_type: cat.type, + parent_id: cat.id, + is_parent: false, + depth: 2, + months, + annual, + }); + } + for (const gc of grandchildren) { + const { months, annual } = buildMonths(gc.id); + gcRows.push({ + category_id: gc.id, + category_name: gc.name, + category_color: gc.color || cat.color || "#9ca3af", + category_type: gc.type, + parent_id: cat.id, + is_parent: false, + depth: 2, + months, + annual, + }); + } + if (gcRows.length === 0) return []; + + // Build intermediate subtotal + const subMonths = Array(12).fill(0) as number[]; + let subAnnual = 0; + for (const cr of gcRows) { + for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m]; + subAnnual += cr.annual; + } + const subtotal: BudgetYearRow = { + category_id: cat.id, + category_name: cat.name, + category_color: cat.color || "#9ca3af", + category_type: cat.type, + parent_id: grandparentId, + is_parent: true, + depth: 1, + months: subMonths, + annual: subAnnual, + }; + 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]; + } + // Identify top-level parents and standalone leaves const topLevel = allCategories.filter((c) => !c.parent_id); for (const cat of topLevel) { - const children = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable); + const children = childrenByParent.get(cat.id) || []; + const inputableChildren = children.filter((c) => c.is_inputable); + const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0); - if (children.length === 0 && cat.is_inputable) { + if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) { // Standalone leaf (no children) — regular editable row const { months, annual } = buildMonths(cat.id); rows.push({ @@ -128,46 +211,63 @@ export function useBudget() { category_type: cat.type, parent_id: null, is_parent: false, + depth: 0, months, annual, }); - } else if (children.length > 0) { - // Parent with children — build child rows first, then parent subtotal - const childRows: BudgetYearRow[] = []; + } else if (inputableChildren.length > 0 || intermediateParents.length > 0) { + const allChildRows: BudgetYearRow[] = []; // If parent is also inputable, create a "(direct)" fake-child row if (cat.is_inputable) { const { months, annual } = buildMonths(cat.id); - childRows.push({ + allChildRows.push({ category_id: cat.id, category_name: `${cat.name} (direct)`, category_color: cat.color || "#9ca3af", category_type: cat.type, parent_id: cat.id, is_parent: false, + depth: 1, months, annual, }); } - for (const child of children) { - const { months, annual } = buildMonths(child.id); - childRows.push({ - category_id: child.id, - category_name: child.name, - category_color: child.color || cat.color || "#9ca3af", - category_type: child.type, - parent_id: cat.id, - is_parent: false, - months, - annual, - }); + for (const child of inputableChildren) { + const grandchildren = childrenByParent.get(child.id) || []; + if (grandchildren.length === 0) { + // Simple leaf at depth 1 + const { months, annual } = buildMonths(child.id); + allChildRows.push({ + category_id: child.id, + category_name: child.name, + category_color: child.color || cat.color || "#9ca3af", + category_type: child.type, + parent_id: cat.id, + is_parent: false, + depth: 1, + months, + annual, + }); + } else { + // Intermediate parent at depth 1 with grandchildren + allChildRows.push(...buildLevel2Group(child, cat.id)); + } } - // Parent subtotal row: sum of all children (+ direct if inputable) + // Non-inputable intermediate parents + for (const ip of intermediateParents) { + allChildRows.push(...buildLevel2Group(ip, cat.id)); + } + + if (allChildRows.length === 0) continue; + + // Parent subtotal row: sum of leaf rows only (avoid double-counting) + const leafRows = allChildRows.filter((r) => !r.is_parent); const parentMonths = Array(12).fill(0) as number[]; let parentAnnual = 0; - for (const cr of childRows) { + for (const cr of leafRows) { for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m]; parentAnnual += cr.annual; } @@ -179,32 +279,43 @@ export function useBudget() { category_type: cat.type, parent_id: null, is_parent: true, + depth: 0, months: parentMonths, annual: parentAnnual, }); // Sort children alphabetically, but keep "(direct)" first - childRows.sort((a, b) => { - if (a.category_id === cat.id) return -1; - if (b.category_id === cat.id) return 1; + 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(...childRows); + rows.push(...allChildRows); } // else: non-inputable parent with no inputable children — skip } - // Sort by type, then within each type: parent rows first (with children following), then standalone + // Sort by type, then within each type: keep hierarchy groups together + function getTopGroupId(r: BudgetYearRow): number { + if ((r.depth ?? 0) === 0) return r.category_id; + if (r.is_parent && r.parent_id === null) return r.category_id; + 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; + } + 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; - // Within same type, keep parent+children groups together - const groupA = a.is_parent ? a.category_id : (a.parent_id ?? a.category_id); - const groupB = b.is_parent ? b.category_id : (b.parent_id ?? b.category_id); + const groupA = getTopGroupId(a); + const groupB = getTopGroupId(b); if (groupA !== groupB) { - // Find the sort_order of the group's parent category const catA = catById.get(groupA); const catB = catById.get(groupB); const orderA = catA?.sort_order ?? 999; @@ -212,9 +323,9 @@ export function useBudget() { if (orderA !== orderB) return orderA - orderB; return (catA?.name ?? "").localeCompare(catB?.name ?? ""); } - // Same group: parent row first, then children - if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1; - // Children: "(direct)" first, then alphabetical + // Same group: sort by depth, then parent before children at same depth + 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); diff --git a/src/hooks/useCategories.ts b/src/hooks/useCategories.ts index 4edfefa..7217d47 100644 --- a/src/hooks/useCategories.ts +++ b/src/hooks/useCategories.ts @@ -79,12 +79,15 @@ function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] { function flattenTreeToCategories(tree: CategoryTreeNode[]): CategoryTreeNode[] { const result: CategoryTreeNode[] = []; - for (const node of tree) { - result.push(node); - for (const child of node.children) { - result.push(child); + function recurse(nodes: CategoryTreeNode[]) { + for (const node of nodes) { + result.push(node); + if (node.children.length > 0) { + recurse(node.children); + } } } + recurse(tree); return result; } @@ -263,23 +266,19 @@ export function useCategories() { }); const newTree = state.tree.map(cloneNode); - // Find and remove the category from its current position - let movedNode: CategoryTreeNode | null = null; - - // Search in roots - const rootIdx = newTree.findIndex((n) => n.id === categoryId); - if (rootIdx !== -1) { - movedNode = newTree.splice(rootIdx, 1)[0]; - } else { - // Search in children - for (const parent of newTree) { - const childIdx = parent.children.findIndex((c) => c.id === categoryId); - if (childIdx !== -1) { - movedNode = parent.children.splice(childIdx, 1)[0]; - break; - } + // Recursively find and remove the category from its current position + function removeFromList(list: CategoryTreeNode[]): CategoryTreeNode | null { + const idx = list.findIndex((n) => n.id === categoryId); + if (idx !== -1) { + return list.splice(idx, 1)[0]; } + for (const node of list) { + const found = removeFromList(node.children); + if (found) return found; + } + return null; } + const movedNode = removeFromList(newTree); if (!movedNode) return; @@ -290,7 +289,16 @@ export function useCategories() { if (newParentId === null) { newTree.splice(newIndex, 0, movedNode); } else { - const newParent = newTree.find((n) => n.id === newParentId); + // Find parent anywhere in the tree + function findNode(list: CategoryTreeNode[], id: number): CategoryTreeNode | null { + for (const n of list) { + if (n.id === id) return n; + const found = findNode(n.children, id); + if (found) return found; + } + return null; + } + const newParent = findNode(newTree, newParentId); if (!newParent) return; newParent.children.splice(newIndex, 0, movedNode); } @@ -298,24 +306,16 @@ export function useCategories() { // Optimistic update dispatch({ type: "SET_TREE", payload: newTree }); - // Compute batch updates for affected sibling groups + // Compute batch updates for all nodes in the tree (3 levels) const updates: Array<{ id: number; sort_order: number; parent_id: number | null }> = []; - // Collect all affected sibling groups - const affectedGroups = new Set(); - affectedGroups.add(newParentId); - // Also include the old parent group (category may have moved away) - // We recompute all roots and all children groups to be safe - // Roots - newTree.forEach((n, i) => { - updates.push({ id: n.id, sort_order: i + 1, parent_id: null }); - }); - // Children - for (const parent of newTree) { - parent.children.forEach((c, i) => { - updates.push({ id: c.id, sort_order: i + 1, parent_id: parent.id }); + function collectUpdates(nodes: CategoryTreeNode[], parentId: number | null) { + nodes.forEach((n, i) => { + updates.push({ id: n.id, sort_order: i + 1, parent_id: parentId }); + collectUpdates(n.children, n.id); }); } + collectUpdates(newTree, null); try { await updateCategorySortOrders(updates); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 27f91dc..d1d9370 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -370,6 +370,7 @@ "categoryType": "Type", "level1": "Category (Level 1)", "level2": "Category (Level 2)", + "level3": "Category (Level 3)", "periodic": "Periodic Amount", "ytd": "Year-to-Date (YTD)", "subtotal": "Subtotal", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 8f7c843..e68a346 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -370,6 +370,7 @@ "categoryType": "Type", "level1": "Catégorie (Niveau 1)", "level2": "Catégorie (Niveau 2)", + "level3": "Catégorie (Niveau 3)", "periodic": "Montant périodique", "ytd": "Cumul annuel (YTD)", "subtotal": "Sous-total", diff --git a/src/services/budgetService.ts b/src/services/budgetService.ts index 78ad19c..8a59581 100644 --- a/src/services/budgetService.ts +++ b/src/services/budgetService.ts @@ -244,7 +244,7 @@ export async function getBudgetVsActualData( const signFor = (type: string) => (type === "expense" ? -1 : 1); // Compute leaf row values - function buildLeaf(cat: Category, parentId: number | null): BudgetVsActualRow { + function buildLeaf(cat: Category, parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow { const sign = signFor(cat.type); const monthMap = entryMap.get(cat.id); const rawMonthBudget = monthMap?.get(month) ?? 0; @@ -269,6 +269,7 @@ export async function getBudgetVsActualData( category_type: cat.type, parent_id: parentId, is_parent: false, + depth, monthActual, monthBudget, monthVariation, @@ -280,6 +281,39 @@ export async function getBudgetVsActualData( }; } + function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow { + const row: BudgetVsActualRow = { + category_id: cat.id, + category_name: cat.name, + category_color: cat.color || "#9ca3af", + category_type: cat.type, + parent_id: parentId, + is_parent: true, + depth, + monthActual: 0, + monthBudget: 0, + monthVariation: 0, + monthVariationPct: null, + ytdActual: 0, + ytdBudget: 0, + ytdVariation: 0, + ytdVariationPct: null, + }; + for (const cr of childRows) { + row.monthActual += cr.monthActual; + row.monthBudget += cr.monthBudget; + row.monthVariation += cr.monthVariation; + row.ytdActual += cr.ytdActual; + row.ytdBudget += cr.ytdBudget; + row.ytdVariation += cr.ytdVariation; + } + row.monthVariationPct = + row.monthBudget !== 0 ? row.monthVariation / Math.abs(row.monthBudget) : null; + row.ytdVariationPct = + row.ytdBudget !== 0 ? row.ytdVariation / Math.abs(row.ytdBudget) : null; + return row; + } + function isRowAllZero(r: BudgetVsActualRow): boolean { return ( r.monthActual === 0 && @@ -289,73 +323,94 @@ 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); + return isRowAllZero(leaf) ? [] : [leaf]; + } + if (grandchildren.length === 0) return []; + + const gcRows: BudgetVsActualRow[] = []; + if (cat.is_inputable) { + const direct = buildLeaf(cat, cat.id, 2); + direct.category_name = `${cat.name} (direct)`; + if (!isRowAllZero(direct)) gcRows.push(direct); + } + for (const gc of grandchildren) { + const leaf = buildLeaf(gc, cat.id, 2); + if (!isRowAllZero(leaf)) gcRows.push(leaf); + } + if (gcRows.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 rows: BudgetVsActualRow[] = []; const topLevel = allCategories.filter((c) => !c.parent_id); for (const cat of topLevel) { - const children = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable); + 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); - if (children.length === 0 && cat.is_inputable) { - // Standalone leaf - const leaf = buildLeaf(cat, null); + if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) { + // Standalone leaf at level 0 + const leaf = buildLeaf(cat, null, 0); if (!isRowAllZero(leaf)) rows.push(leaf); - } else if (children.length > 0) { - const childRows: BudgetVsActualRow[] = []; + } else if (inputableChildren.length > 0 || intermediateParents.length > 0) { + const allChildRows: BudgetVsActualRow[] = []; - // If parent is also inputable, create a "(direct)" child row + // Direct transactions on the parent itself if (cat.is_inputable) { - const direct = buildLeaf(cat, cat.id); + const direct = buildLeaf(cat, cat.id, 1); direct.category_name = `${cat.name} (direct)`; - if (!isRowAllZero(direct)) childRows.push(direct); + if (!isRowAllZero(direct)) allChildRows.push(direct); } - for (const child of children) { - const leaf = buildLeaf(child, cat.id); - if (!isRowAllZero(leaf)) childRows.push(leaf); + // Level-2 leaves (direct children that are inputable and have no children) + for (const child of inputableChildren) { + const grandchildren = childrenByParent.get(child.id) || []; + if (grandchildren.length === 0) { + 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); + } } - // Skip parent entirely if all children were filtered out - if (childRows.length === 0) continue; - - // Build parent subtotal from kept children - const parent: BudgetVsActualRow = { - category_id: cat.id, - category_name: cat.name, - category_color: cat.color || "#9ca3af", - category_type: cat.type, - parent_id: null, - is_parent: true, - monthActual: 0, - monthBudget: 0, - monthVariation: 0, - monthVariationPct: null, - ytdActual: 0, - ytdBudget: 0, - ytdVariation: 0, - ytdVariationPct: null, - }; - for (const cr of childRows) { - parent.monthActual += cr.monthActual; - parent.monthBudget += cr.monthBudget; - parent.monthVariation += cr.monthVariation; - parent.ytdActual += cr.ytdActual; - parent.ytdBudget += cr.ytdBudget; - parent.ytdVariation += cr.ytdVariation; + // Non-inputable intermediate parents at level 1 + for (const ip of intermediateParents) { + const subRows = buildLevel2Group(ip, cat.id); + allChildRows.push(...subRows); } - parent.monthVariationPct = - parent.monthBudget !== 0 ? parent.monthVariation / Math.abs(parent.monthBudget) : null; - parent.ytdVariationPct = - parent.ytdBudget !== 0 ? parent.ytdVariation / Math.abs(parent.ytdBudget) : null; + + if (allChildRows.length === 0) continue; + + // Collect only leaf rows for parent subtotal (avoid double-counting) + const leafRows = allChildRows.filter((r) => !r.is_parent); + const parent = buildSubtotal(cat, leafRows, null, 0); rows.push(parent); - // Sort children: "(direct)" first, then alphabetical - childRows.sort((a, b) => { - if (a.category_id === cat.id) return -1; - if (b.category_id === cat.id) return 1; + // 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(...childRows); + rows.push(...allChildRows); } } @@ -364,8 +419,21 @@ export async function getBudgetVsActualData( const typeA = TYPE_ORDER[a.category_type] ?? 9; const typeB = TYPE_ORDER[b.category_type] ?? 9; if (typeA !== typeB) return typeA - typeB; - const groupA = a.is_parent ? a.category_id : (a.parent_id ?? a.category_id); - const groupB = b.is_parent ? b.category_id : (b.parent_id ?? b.category_id); + // 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); @@ -374,7 +442,9 @@ export async function getBudgetVsActualData( if (orderA !== orderB) return orderA - orderB; return (catA?.name ?? "").localeCompare(catB?.name ?? ""); } - if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1; + // 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); diff --git a/src/services/categoryService.ts b/src/services/categoryService.ts index a2175a0..f36837e 100644 --- a/src/services/categoryService.ts +++ b/src/services/categoryService.ts @@ -26,6 +26,22 @@ export async function getAllCategoriesWithCounts(): Promise { ); } +export async function getCategoryDepth(categoryId: number): Promise { + const db = await getDb(); + let depth = 0; + let currentId: number | null = categoryId; + while (currentId !== null) { + const parentRows: Array<{ parent_id: number | null }> = await db.select>( + `SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1`, + [currentId] + ); + if (parentRows.length === 0 || parentRows[0].parent_id === null) break; + currentId = parentRows[0].parent_id; + depth++; + } + return depth; +} + export async function createCategory(data: { name: string; type: string; @@ -35,10 +51,28 @@ export async function createCategory(data: { sort_order: number; }): Promise { const db = await getDb(); + + // Validate max depth: parent at depth 2 would create a 4th level + if (data.parent_id !== null) { + const parentDepth = await getCategoryDepth(data.parent_id); + if (parentDepth >= 2) { + throw new Error("Cannot create category: maximum depth of 3 levels reached"); + } + } + const result = await db.execute( `INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)`, [data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order] ); + + // Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0 + if (data.parent_id !== null) { + await db.execute( + `UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1`, + [data.parent_id] + ); + } + return result.lastInsertId as number; } @@ -119,16 +153,37 @@ export async function updateCategorySortOrders( export async function deactivateCategory(id: number): Promise { const db = await getDb(); - // Promote children to root level so they don't become orphans - await db.execute( - `UPDATE categories SET parent_id = NULL WHERE parent_id = $1`, + // Remember the parent before deactivating + const rows = await db.select>( + `SELECT parent_id FROM categories WHERE id = $1`, [id] ); + const parentId = rows[0]?.parent_id ?? null; + + // Promote children to parent level so they don't become orphans + await db.execute( + `UPDATE categories SET parent_id = $1 WHERE parent_id = $2`, + [parentId, id] + ); // Only deactivate the target category itself await db.execute( `UPDATE categories SET is_active = 0 WHERE id = $1`, [id] ); + + // Auto-manage is_inputable: if parent now has no active children, restore is_inputable + if (parentId !== null) { + const childCount = await db.select>( + `SELECT COUNT(*) AS cnt FROM categories WHERE parent_id = $1 AND is_active = 1`, + [parentId] + ); + if ((childCount[0]?.cnt ?? 0) === 0) { + await db.execute( + `UPDATE categories SET is_inputable = 1 WHERE id = $1`, + [parentId] + ); + } + } } export async function getCategoryUsageCount(id: number): Promise { @@ -142,9 +197,15 @@ export async function getCategoryUsageCount(id: number): Promise { export async function getChildrenUsageCount(parentId: number): Promise { const db = await getDb(); + // Check descendants recursively (up to 2 levels deep) const rows = await db.select>( - `SELECT COUNT(*) AS cnt FROM transactions WHERE category_id IN - (SELECT id FROM categories WHERE parent_id = $1 AND is_active = 1)`, + `SELECT COUNT(*) AS cnt FROM transactions WHERE category_id IN ( + SELECT id FROM categories WHERE parent_id = $1 AND is_active = 1 + UNION + SELECT id FROM categories WHERE parent_id IN ( + SELECT id FROM categories WHERE parent_id = $1 AND is_active = 1 + ) AND is_active = 1 + )`, [parentId] ); return rows[0]?.cnt ?? 0; @@ -173,47 +234,61 @@ export async function reinitializeCategories(): Promise { ); } - // Re-seed child categories - const children: Array<[number, string, number, string, string, number]> = [ - [10, "Paie", 1, "income", "#22c55e", 1], - [11, "Autres revenus", 1, "income", "#4ade80", 2], - [20, "Loyer", 2, "expense", "#ef4444", 1], - [21, "Électricité", 2, "expense", "#f59e0b", 2], - [22, "Épicerie", 2, "expense", "#10b981", 3], - [23, "Dons", 2, "expense", "#ec4899", 4], - [24, "Restaurant", 2, "expense", "#f97316", 5], - [25, "Frais bancaires", 2, "expense", "#6b7280", 6], - [26, "Jeux, Films & Livres", 2, "expense", "#8b5cf6", 7], - [27, "Abonnements Musique", 2, "expense", "#06b6d4", 8], - [28, "Transport en commun", 2, "expense", "#3b82f6", 9], - [29, "Internet & Télécom", 2, "expense", "#6366f1", 10], - [30, "Animaux", 2, "expense", "#a855f7", 11], - [31, "Assurances", 2, "expense", "#14b8a6", 12], - [32, "Pharmacie", 2, "expense", "#f43f5e", 13], - [33, "Taxes municipales", 2, "expense", "#78716c", 14], - [40, "Voiture", 3, "expense", "#64748b", 1], - [41, "Amazon", 3, "expense", "#f59e0b", 2], - [42, "Électroniques", 3, "expense", "#3b82f6", 3], - [43, "Alcool", 3, "expense", "#7c3aed", 4], - [44, "Cadeaux", 3, "expense", "#ec4899", 5], - [45, "Vêtements", 3, "expense", "#d946ef", 6], - [46, "CPA", 3, "expense", "#0ea5e9", 7], - [47, "Voyage", 3, "expense", "#f97316", 8], - [48, "Sports & Plein air", 3, "expense", "#22c55e", 9], - [49, "Spectacles & sorties", 3, "expense", "#e11d48", 10], - [50, "Hypothèque", 4, "expense", "#dc2626", 1], - [51, "Achats maison", 4, "expense", "#ea580c", 2], - [52, "Entretien maison", 4, "expense", "#ca8a04", 3], - [53, "Électroménagers & Meubles", 4, "expense", "#0d9488", 4], - [54, "Outils", 4, "expense", "#b45309", 5], - [60, "Placements", 5, "transfer", "#2563eb", 1], - [61, "Transferts", 5, "transfer", "#7c3aed", 2], - [70, "Impôts", 6, "expense", "#dc2626", 1], - [71, "Paiement CC", 6, "transfer", "#6b7280", 2], - [72, "Retrait cash", 6, "expense", "#57534e", 3], - [73, "Projets", 6, "expense", "#0ea5e9", 4], + // Re-seed child categories (level 2) + // Note: Assurances (31) is now a non-inputable intermediate parent with level-3 children + const children: Array<[number, string, number, string, string, number, boolean]> = [ + [10, "Paie", 1, "income", "#22c55e", 1, true], + [11, "Autres revenus", 1, "income", "#4ade80", 2, true], + [20, "Loyer", 2, "expense", "#ef4444", 1, true], + [21, "Électricité", 2, "expense", "#f59e0b", 2, true], + [22, "Épicerie", 2, "expense", "#10b981", 3, true], + [23, "Dons", 2, "expense", "#ec4899", 4, true], + [24, "Restaurant", 2, "expense", "#f97316", 5, true], + [25, "Frais bancaires", 2, "expense", "#6b7280", 6, true], + [26, "Jeux, Films & Livres", 2, "expense", "#8b5cf6", 7, true], + [27, "Abonnements Musique", 2, "expense", "#06b6d4", 8, true], + [28, "Transport en commun", 2, "expense", "#3b82f6", 9, true], + [29, "Internet & Télécom", 2, "expense", "#6366f1", 10, true], + [30, "Animaux", 2, "expense", "#a855f7", 11, true], + [31, "Assurances", 2, "expense", "#14b8a6", 12, false], // intermediate parent + [32, "Pharmacie", 2, "expense", "#f43f5e", 13, true], + [33, "Taxes municipales", 2, "expense", "#78716c", 14, true], + [40, "Voiture", 3, "expense", "#64748b", 1, true], + [41, "Amazon", 3, "expense", "#f59e0b", 2, true], + [42, "Électroniques", 3, "expense", "#3b82f6", 3, true], + [43, "Alcool", 3, "expense", "#7c3aed", 4, true], + [44, "Cadeaux", 3, "expense", "#ec4899", 5, true], + [45, "Vêtements", 3, "expense", "#d946ef", 6, true], + [46, "CPA", 3, "expense", "#0ea5e9", 7, true], + [47, "Voyage", 3, "expense", "#f97316", 8, true], + [48, "Sports & Plein air", 3, "expense", "#22c55e", 9, true], + [49, "Spectacles & sorties", 3, "expense", "#e11d48", 10, true], + [50, "Hypothèque", 4, "expense", "#dc2626", 1, true], + [51, "Achats maison", 4, "expense", "#ea580c", 2, true], + [52, "Entretien maison", 4, "expense", "#ca8a04", 3, true], + [53, "Électroménagers & Meubles", 4, "expense", "#0d9488", 4, true], + [54, "Outils", 4, "expense", "#b45309", 5, true], + [60, "Placements", 5, "transfer", "#2563eb", 1, true], + [61, "Transferts", 5, "transfer", "#7c3aed", 2, true], + [70, "Impôts", 6, "expense", "#dc2626", 1, true], + [71, "Paiement CC", 6, "transfer", "#6b7280", 2, true], + [72, "Retrait cash", 6, "expense", "#57534e", 3, true], + [73, "Projets", 6, "expense", "#0ea5e9", 4, true], ]; - for (const [id, name, parentId, type, color, sort] of children) { + for (const [id, name, parentId, type, color, sort, inputable] of children) { + await db.execute( + "INSERT INTO categories (id, name, parent_id, type, color, sort_order, is_inputable) VALUES ($1, $2, $3, $4, $5, $6, $7)", + [id, name, parentId, type, color, sort, inputable ? 1 : 0] + ); + } + + // Re-seed grandchild categories (level 3) — under Assurances (31) + const grandchildren: Array<[number, string, number, string, string, number]> = [ + [310, "Assurance-auto", 31, "expense", "#14b8a6", 1], + [311, "Assurance-habitation", 31, "expense", "#0d9488", 2], + [312, "Assurance-vie", 31, "expense", "#0f766e", 3], + ]; + for (const [id, name, parentId, type, color, sort] of grandchildren) { await db.execute( "INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES ($1, $2, $3, $4, $5, $6)", [id, name, parentId, type, color, sort] @@ -237,7 +312,7 @@ export async function reinitializeCategories(): Promise { ["GARE CENTRALE", 28], ["REM", 28], ["VIDEOTRON", 29], ["ORICOM", 29], ["MONDOU", 30], - ["BELAIR", 31], ["PRYSM", 31], ["INS/ASS", 31], + ["BELAIR", 310], ["PRYSM", 311], ["INS/ASS", 312], ["JEAN COUTU", 32], ["FAMILIPRIX", 32], ["PHARMAPRIX", 32], ["M-ST-HILAIRE TX", 33], ["CSS PATRIOT", 33], ["SHELL", 40], ["ESSO", 40], ["ULTRAMAR", 40], ["PETRO-CANADA", 40], diff --git a/src/services/reportService.ts b/src/services/reportService.ts index 16ca28c..777fa12 100644 --- a/src/services/reportService.ts +++ b/src/services/reportService.ts @@ -158,12 +158,13 @@ const FIELD_SQL: Record = { year: { select: "strftime('%Y', t.date)", alias: "year" }, month: { select: "strftime('%Y-%m', t.date)", alias: "month" }, type: { select: "COALESCE(c.type, 'expense')", alias: "type" }, - level1: { select: "COALESCE(parent_cat.name, c.name, 'Uncategorized')", alias: "level1" }, - level2: { select: "COALESCE(CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE NULL END, 'Uncategorized')", alias: "level2" }, + level1: { select: "COALESCE(grandparent_cat.name, parent_cat.name, c.name, 'Uncategorized')", alias: "level1" }, + level2: { select: "CASE WHEN grandparent_cat.id IS NOT NULL THEN parent_cat.name WHEN parent_cat.id IS NOT NULL THEN c.name ELSE NULL END", alias: "level2" }, + level3: { select: "CASE WHEN grandparent_cat.id IS NOT NULL THEN c.name ELSE NULL END", alias: "level3" }, }; function needsCategoryJoin(fields: PivotFieldId[]): boolean { - return fields.some((f) => f === "type" || f === "level1" || f === "level2"); + return fields.some((f) => f === "type" || f === "level1" || f === "level2" || f === "level3"); } export async function getDynamicReportData( @@ -231,7 +232,8 @@ export async function getDynamicReportData( const joinSQL = useCatJoin ? `LEFT JOIN categories c ON t.category_id = c.id - LEFT JOIN categories parent_cat ON c.parent_id = parent_cat.id` + LEFT JOIN categories parent_cat ON c.parent_id = parent_cat.id + LEFT JOIN categories grandparent_cat ON parent_cat.parent_id = grandparent_cat.id` : ""; const sql = `SELECT ${selectParts.join(", ")} @@ -308,6 +310,7 @@ export async function getDynamicReportData( type: "Type", level1: "Catégorie (Niveau 1)", level2: "Catégorie (Niveau 2)", + level3: "Catégorie (Niveau 3)", periodic: "Montant périodique", ytd: "Cumul annuel (YTD)", }; @@ -324,7 +327,8 @@ export async function getDynamicFilterValues( const joinSQL = useCatJoin ? `LEFT JOIN categories c ON t.category_id = c.id - LEFT JOIN categories parent_cat ON c.parent_id = parent_cat.id` + LEFT JOIN categories parent_cat ON c.parent_id = parent_cat.id + LEFT JOIN categories grandparent_cat ON parent_cat.parent_id = grandparent_cat.id` : ""; const rows = await db.select>( diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index c585d22..bff52b3 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -139,6 +139,7 @@ export interface BudgetYearRow { category_type: "expense" | "income" | "transfer"; parent_id: number | null; is_parent: boolean; + depth?: 0 | 1 | 2; months: number[]; // index 0-11 = Jan-Dec planned amounts annual: number; // computed sum } @@ -278,7 +279,7 @@ export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual" // --- Pivot / Dynamic Report Types --- -export type PivotFieldId = "year" | "month" | "type" | "level1" | "level2"; +export type PivotFieldId = "year" | "month" | "type" | "level1" | "level2" | "level3"; export type PivotMeasureId = "periodic" | "ytd"; export type PivotZone = "rows" | "columns" | "filters" | "values"; @@ -330,6 +331,7 @@ export interface BudgetVsActualRow { category_type: "expense" | "income" | "transfer"; parent_id: number | null; is_parent: boolean; + depth?: 0 | 1 | 2; monthActual: number; monthBudget: number; monthVariation: number;