feat: add 3rd level of category hierarchy

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 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-25 19:54:05 -05:00
parent 0fbcbc0eca
commit a04813ced2
14 changed files with 595 additions and 239 deletions

View file

@ -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 (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 (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 (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 (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); 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 (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); 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 -- Keywords
-- ========================================== -- ==========================================
@ -132,10 +139,12 @@ INSERT INTO keywords (keyword, category_id) VALUES ('ORICOM', 29);
-- Animaux (30) -- Animaux (30)
INSERT INTO keywords (keyword, category_id) VALUES ('MONDOU', 30); INSERT INTO keywords (keyword, category_id) VALUES ('MONDOU', 30);
-- Assurances (31) -- Assurance-auto (310)
INSERT INTO keywords (keyword, category_id) VALUES ('BELAIR', 31); INSERT INTO keywords (keyword, category_id) VALUES ('BELAIR', 310);
INSERT INTO keywords (keyword, category_id) VALUES ('PRYSM', 31); -- Assurance-habitation (311)
INSERT INTO keywords (keyword, category_id) VALUES ('INS/ASS', 31); INSERT INTO keywords (keyword, category_id) VALUES ('PRYSM', 311);
-- Assurance-vie (312)
INSERT INTO keywords (keyword, category_id) VALUES ('INS/ASS', 312);
-- Pharmacie (32) -- Pharmacie (32)
INSERT INTO keywords (keyword, category_id) VALUES ('JEAN COUTU', 32); INSERT INTO keywords (keyword, category_id) VALUES ('JEAN COUTU', 32);

View file

@ -18,18 +18,19 @@ 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 }>( function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
rows: T[], rows: T[],
subtotalsOnTop: boolean, subtotalsOnTop: boolean,
): T[] { ): T[] {
if (subtotalsOnTop) return rows; if (subtotalsOnTop) return rows;
// Group depth-0 parents with all their descendants, then move subtotals to bottom
const groups: { parent: T | null; children: T[] }[] = []; const groups: { parent: T | null; children: T[] }[] = [];
let current: { parent: T | null; children: T[] } | null = null; let current: { parent: T | null; children: T[] } | null = null;
for (const row of rows) { for (const row of rows) {
if (row.is_parent) { if (row.is_parent && (row.depth ?? 0) === 0) {
if (current) groups.push(current); if (current) groups.push(current);
current = { parent: row, children: [] }; current = { parent: row, children: [] };
} else if (current && row.parent_id === current.parent?.category_id) { } else if (current) {
current.children.push(row); current.children.push(row);
} else { } else {
if (current) groups.push(current); if (current) groups.push(current);
@ -37,9 +38,36 @@ function reorderRows<T extends { is_parent: boolean; parent_id: number | null; c
} }
} }
if (current) groups.push(current); if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => return groups.flatMap(({ parent, children }) => {
parent ? [...children, parent] : children, if (!parent) return children;
); // Also move intermediate subtotals (depth-1 parents) to bottom of their sub-groups
const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
// Flush previous sub-group
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
} }
interface BudgetTableProps { interface BudgetTableProps {
@ -188,30 +216,33 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
const renderRow = (row: BudgetYearRow) => { const renderRow = (row: BudgetYearRow) => {
const sign = signFor(row.category_type); const sign = signFor(row.category_type);
const isChild = row.parent_id !== null && !row.is_parent; 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 // 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}`; const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`;
if (row.is_parent) { if (row.is_parent) {
// Parent subtotal row: read-only, bold, distinct background // Parent subtotal row: read-only, bold, distinct background
const parentDepth = row.depth ?? 0;
const isIntermediateParent = parentDepth === 1;
return ( return (
<tr <tr
key={rowKey} key={rowKey}
className="border-b border-[var(--border)] bg-[var(--muted)]/30" className={`border-b border-[var(--border)] ${isIntermediateParent ? "bg-[var(--muted)]/15" : "bg-[var(--muted)]/30"}`}
> >
<td className="py-2 px-3 sticky left-0 bg-[var(--muted)]/30 z-10"> <td className={`py-2 sticky left-0 z-10 ${isIntermediateParent ? "pl-8 pr-3 bg-[var(--muted)]/15" : "px-3 bg-[var(--muted)]/30"}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }} style={{ backgroundColor: row.category_color }}
/> />
<span className="truncate text-xs font-semibold">{row.category_name}</span> <span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
</div> </div>
</td> </td>
<td className="py-2 px-2 text-right text-xs font-semibold"> <td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
{formatSigned(row.annual * sign)} {formatSigned(row.annual * sign)}
</td> </td>
{row.months.map((val, mIdx) => ( {row.months.map((val, mIdx) => (
<td key={mIdx} className="py-2 px-2 text-right text-xs font-semibold"> <td key={mIdx} className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
{formatSigned(val * sign)} {formatSigned(val * sign)}
</td> </td>
))} ))}
@ -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" className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
> >
{/* Category name - sticky */} {/* Category name - sticky */}
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${isChild ? "pl-8 pr-3" : "px-3"}`}> <td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"

View file

@ -41,9 +41,36 @@ export default function CategoryForm({
setForm(initialData); setForm(initialData);
}, [initialData]); }, [initialData]);
const parentOptions = categories.filter( // Allow level 0 and level 1 categories as parents (but not level 2, which would create a 4th level)
(c) => c.parent_id === null // Also build indentation info
); const parentOptions: Array<CategoryTreeNode & { indent: number }> = [];
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) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -113,7 +140,7 @@ export default function CategoryForm({
<option value="">{t("categories.noParent")}</option> <option value="">{t("categories.noParent")}</option>
{parentOptions.map((c) => ( {parentOptions.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.name} {c.indent > 0 ? "\u00A0\u00A0\u00A0\u00A0" : ""}{c.name}
</option> </option>
))} ))}
</select> </select>

View file

@ -35,25 +35,24 @@ interface Props {
onMoveCategory: (id: number, newParentId: number | null, newIndex: number) => Promise<void>; onMoveCategory: (id: number, newParentId: number | null, newIndex: number) => Promise<void>;
} }
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<number>): FlatItem[] { function flattenTree(tree: CategoryTreeNode[], expandedSet: Set<number>): FlatItem[] {
const items: FlatItem[] = []; const items: FlatItem[] = [];
for (const node of tree) { function recurse(nodes: CategoryTreeNode[], depth: number, parentId: number | null) {
const hasChildren = node.children.length > 0; for (const node of nodes) {
const isExpanded = expandedSet.has(node.id); const hasChildren = node.children.length > 0;
items.push({ id: node.id, node, depth: 0, parentId: null, isExpanded, hasChildren }); const isExpanded = expandedSet.has(node.id);
if (isExpanded) { items.push({ id: node.id, node, depth, parentId, isExpanded, hasChildren });
for (const child of node.children) { if (isExpanded && hasChildren) {
items.push({ recurse(node.children, depth + 1, node.id);
id: child.id,
node: child,
depth: 1,
parentId: node.id,
isExpanded: false,
hasChildren: false,
});
} }
} }
} }
recurse(tree, 0, null);
return items; return items;
} }
@ -191,9 +190,15 @@ function SortableTreeRow({
export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) { export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) {
const [expanded, setExpanded] = useState<Set<number>>(() => { const [expanded, setExpanded] = useState<Set<number>>(() => {
const ids = new Set<number>(); const ids = new Set<number>();
for (const node of tree) { function collectExpandable(nodes: CategoryTreeNode[]) {
if (node.children.length > 0) ids.add(node.id); for (const node of nodes) {
if (node.children.length > 0) {
ids.add(node.id);
collectExpandable(node.children);
}
}
} }
collectExpandable(tree);
return ids; return ids;
}); });
const [activeId, setActiveId] = useState<number | null>(null); const [activeId, setActiveId] = useState<number | null>(null);
@ -238,40 +243,31 @@ export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategor
const activeItem = flatItems[activeIdx]; const activeItem = flatItems[activeIdx];
const overItem = flatItems[overIdx]; const overItem = flatItems[overIdx];
// Compute the depth of the active item's subtree
const activeSubtreeDepth = getSubtreeDepth(activeItem.node);
// Determine the new parent and index // Determine the new parent and index
let newParentId: number | null; let newParentId: number | null;
let newIndex: number; let newIndex: number;
if (overItem.depth === 0) { 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) { 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; newIndex = overRootIdx;
} else { } 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; newIndex = overIdx > activeIdx ? overRootIdx + 1 : overRootIdx;
} }
} else { } else {
// Dropping onto/near a child item // Dropping onto/near a non-root item — adopt same parent
if (activeItem.hasChildren) {
// Block: moving a root with children to become a child (would create 3 levels)
return;
}
newParentId = overItem.parentId; newParentId = overItem.parentId;
// Find the index within that parent's children
const siblings = flatItems.filter( 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); const overSiblingIdx = siblings.findIndex((i) => i.id === over.id);
newIndex = overIdx > activeIdx ? overSiblingIdx + 1 : overSiblingIdx; newIndex = overIdx > activeIdx ? overSiblingIdx + 1 : overSiblingIdx;
// If moving from same parent, adjust index
if (activeItem.parentId === newParentId) { if (activeItem.parentId === newParentId) {
const activeSiblingIdx = siblings.findIndex((i) => i.id === active.id); const activeSiblingIdx = siblings.findIndex((i) => i.id === active.id);
if (activeSiblingIdx < overSiblingIdx) { 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 // Validate 3-level constraint: targetDepth + subtreeDepth must be <= 2 (max index)
if (newParentId !== null && activeItem.hasChildren) { const targetDepth = newParentId === null ? 0 : overItem.depth;
if (targetDepth + activeSubtreeDepth > 2) {
return; return;
} }

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 }>( function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
rows: T[], rows: T[],
subtotalsOnTop: boolean, subtotalsOnTop: boolean,
): T[] { ): T[] {
@ -33,10 +33,10 @@ function reorderRows<T extends { is_parent: boolean; parent_id: number | null; c
const groups: { parent: T | null; children: T[] }[] = []; const groups: { parent: T | null; children: T[] }[] = [];
let current: { parent: T | null; children: T[] } | null = null; let current: { parent: T | null; children: T[] } | null = null;
for (const row of rows) { for (const row of rows) {
if (row.is_parent) { if (row.is_parent && (row.depth ?? 0) === 0) {
if (current) groups.push(current); if (current) groups.push(current);
current = { parent: row, children: [] }; current = { parent: row, children: [] };
} else if (current && row.parent_id === current.parent?.category_id) { } else if (current) {
current.children.push(row); current.children.push(row);
} else { } else {
if (current) groups.push(current); if (current) groups.push(current);
@ -44,9 +44,34 @@ function reorderRows<T extends { is_parent: boolean; parent_id: number | null; c
} }
} }
if (current) groups.push(current); if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => return groups.flatMap(({ parent, children }) => {
parent ? [...children, parent] : children, if (!parent) return children;
); const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
} }
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) { export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
@ -168,15 +193,18 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</tr> </tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => { {reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent; 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 ( return (
<tr <tr
key={`${row.category_id}-${row.is_parent}`} key={`${row.category_id}-${row.is_parent}-${depth}`}
className={`border-b border-[var(--border)]/50 ${ className={`border-b border-[var(--border)]/50 ${
isParent ? "bg-[var(--muted)]/30 font-semibold" : "" isParent && !isIntermediateParent ? "bg-[var(--muted)]/30 font-semibold" :
isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : ""
}`} }`}
> >
<td className={`px-3 py-1.5 ${isChild ? "pl-8" : ""}`}> <td className={`py-1.5 ${isParent && !isIntermediateParent ? "px-3" : paddingClass}`}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"

View file

@ -4,7 +4,7 @@ import { X } from "lucide-react";
import type { PivotConfig, PivotFieldId, PivotFilterEntry, PivotMeasureId, PivotZone } from "../../shared/types"; import type { PivotConfig, PivotFieldId, PivotFilterEntry, PivotMeasureId, PivotZone } from "../../shared/types";
import { getDynamicFilterValues } from "../../services/reportService"; import { getDynamicFilterValues } from "../../services/reportService";
const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2"]; const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2", "level3"];
const ALL_MEASURES: PivotMeasureId[] = ["periodic", "ytd"]; const ALL_MEASURES: PivotMeasureId[] = ["periodic", "ytd"];
interface DynamicReportPanelProps { interface DynamicReportPanelProps {
@ -105,7 +105,7 @@ export default function DynamicReportPanel({ config, onChange }: DynamicReportPa
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } }); onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
}; };
const fieldLabel = (id: string) => 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}`); const measureLabel = (id: string) => t(`reports.pivot.${id}`);
// Context menu only shows zones where the field is NOT already assigned // Context menu only shows zones where the field is NOT already assigned

View file

@ -112,13 +112,96 @@ export function useBudget() {
const rows: BudgetYearRow[] = []; 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 // Identify top-level parents and standalone leaves
const topLevel = allCategories.filter((c) => !c.parent_id); const topLevel = allCategories.filter((c) => !c.parent_id);
for (const cat of topLevel) { 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 // Standalone leaf (no children) — regular editable row
const { months, annual } = buildMonths(cat.id); const { months, annual } = buildMonths(cat.id);
rows.push({ rows.push({
@ -128,46 +211,63 @@ export function useBudget() {
category_type: cat.type, category_type: cat.type,
parent_id: null, parent_id: null,
is_parent: false, is_parent: false,
depth: 0,
months, months,
annual, annual,
}); });
} else if (children.length > 0) { } else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
// Parent with children — build child rows first, then parent subtotal const allChildRows: BudgetYearRow[] = [];
const childRows: BudgetYearRow[] = [];
// If parent is also inputable, create a "(direct)" fake-child row // If parent is also inputable, create a "(direct)" fake-child row
if (cat.is_inputable) { if (cat.is_inputable) {
const { months, annual } = buildMonths(cat.id); const { months, annual } = buildMonths(cat.id);
childRows.push({ allChildRows.push({
category_id: cat.id, category_id: cat.id,
category_name: `${cat.name} (direct)`, category_name: `${cat.name} (direct)`,
category_color: cat.color || "#9ca3af", category_color: cat.color || "#9ca3af",
category_type: cat.type, category_type: cat.type,
parent_id: cat.id, parent_id: cat.id,
is_parent: false, is_parent: false,
depth: 1,
months, months,
annual, annual,
}); });
} }
for (const child of children) { for (const child of inputableChildren) {
const { months, annual } = buildMonths(child.id); const grandchildren = childrenByParent.get(child.id) || [];
childRows.push({ if (grandchildren.length === 0) {
category_id: child.id, // Simple leaf at depth 1
category_name: child.name, const { months, annual } = buildMonths(child.id);
category_color: child.color || cat.color || "#9ca3af", allChildRows.push({
category_type: child.type, category_id: child.id,
parent_id: cat.id, category_name: child.name,
is_parent: false, category_color: child.color || cat.color || "#9ca3af",
months, category_type: child.type,
annual, 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[]; const parentMonths = Array(12).fill(0) as number[];
let parentAnnual = 0; 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]; for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
parentAnnual += cr.annual; parentAnnual += cr.annual;
} }
@ -179,32 +279,43 @@ export function useBudget() {
category_type: cat.type, category_type: cat.type,
parent_id: null, parent_id: null,
is_parent: true, is_parent: true,
depth: 0,
months: parentMonths, months: parentMonths,
annual: parentAnnual, annual: parentAnnual,
}); });
// Sort children alphabetically, but keep "(direct)" first // Sort children alphabetically, but keep "(direct)" first
childRows.sort((a, b) => { allChildRows.sort((a, b) => {
if (a.category_id === cat.id) return -1; if (a.category_id === cat.id && !a.is_parent) return -1;
if (b.category_id === cat.id) return 1; if (b.category_id === cat.id && !b.is_parent) return 1;
return a.category_name.localeCompare(b.category_name); return a.category_name.localeCompare(b.category_name);
}); });
rows.push(...childRows); rows.push(...allChildRows);
} }
// else: non-inputable parent with no inputable children — skip // 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) => { 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;
// Within same type, keep parent+children groups together const groupA = getTopGroupId(a);
const groupA = a.is_parent ? a.category_id : (a.parent_id ?? a.category_id); const groupB = getTopGroupId(b);
const groupB = b.is_parent ? b.category_id : (b.parent_id ?? b.category_id);
if (groupA !== groupB) { if (groupA !== groupB) {
// Find the sort_order of the group's parent category
const catA = catById.get(groupA); const catA = catById.get(groupA);
const catB = catById.get(groupB); const catB = catById.get(groupB);
const orderA = catA?.sort_order ?? 999; const orderA = catA?.sort_order ?? 999;
@ -212,9 +323,9 @@ export function useBudget() {
if (orderA !== orderB) return orderA - orderB; if (orderA !== orderB) return orderA - orderB;
return (catA?.name ?? "").localeCompare(catB?.name ?? ""); return (catA?.name ?? "").localeCompare(catB?.name ?? "");
} }
// Same group: parent row first, then children // Same group: sort by depth, then parent before children at same depth
if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1; if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
// Children: "(direct)" first, then alphabetical 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 (a.parent_id && a.category_id === a.parent_id) return -1;
if (b.parent_id && b.category_id === b.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 a.category_name.localeCompare(b.category_name);

View file

@ -79,12 +79,15 @@ function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
function flattenTreeToCategories(tree: CategoryTreeNode[]): CategoryTreeNode[] { function flattenTreeToCategories(tree: CategoryTreeNode[]): CategoryTreeNode[] {
const result: CategoryTreeNode[] = []; const result: CategoryTreeNode[] = [];
for (const node of tree) { function recurse(nodes: CategoryTreeNode[]) {
result.push(node); for (const node of nodes) {
for (const child of node.children) { result.push(node);
result.push(child); if (node.children.length > 0) {
recurse(node.children);
}
} }
} }
recurse(tree);
return result; return result;
} }
@ -263,23 +266,19 @@ export function useCategories() {
}); });
const newTree = state.tree.map(cloneNode); const newTree = state.tree.map(cloneNode);
// Find and remove the category from its current position // Recursively find and remove the category from its current position
let movedNode: CategoryTreeNode | null = null; function removeFromList(list: CategoryTreeNode[]): CategoryTreeNode | null {
const idx = list.findIndex((n) => n.id === categoryId);
// Search in roots if (idx !== -1) {
const rootIdx = newTree.findIndex((n) => n.id === categoryId); return list.splice(idx, 1)[0];
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;
}
} }
for (const node of list) {
const found = removeFromList(node.children);
if (found) return found;
}
return null;
} }
const movedNode = removeFromList(newTree);
if (!movedNode) return; if (!movedNode) return;
@ -290,7 +289,16 @@ export function useCategories() {
if (newParentId === null) { if (newParentId === null) {
newTree.splice(newIndex, 0, movedNode); newTree.splice(newIndex, 0, movedNode);
} else { } 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; if (!newParent) return;
newParent.children.splice(newIndex, 0, movedNode); newParent.children.splice(newIndex, 0, movedNode);
} }
@ -298,24 +306,16 @@ export function useCategories() {
// Optimistic update // Optimistic update
dispatch({ type: "SET_TREE", payload: newTree }); 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 }> = []; const updates: Array<{ id: number; sort_order: number; parent_id: number | null }> = [];
// Collect all affected sibling groups function collectUpdates(nodes: CategoryTreeNode[], parentId: number | null) {
const affectedGroups = new Set<number | null>(); nodes.forEach((n, i) => {
affectedGroups.add(newParentId); updates.push({ id: n.id, sort_order: i + 1, parent_id: parentId });
// Also include the old parent group (category may have moved away) collectUpdates(n.children, n.id);
// 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 });
}); });
} }
collectUpdates(newTree, null);
try { try {
await updateCategorySortOrders(updates); await updateCategorySortOrders(updates);

View file

@ -370,6 +370,7 @@
"categoryType": "Type", "categoryType": "Type",
"level1": "Category (Level 1)", "level1": "Category (Level 1)",
"level2": "Category (Level 2)", "level2": "Category (Level 2)",
"level3": "Category (Level 3)",
"periodic": "Periodic Amount", "periodic": "Periodic Amount",
"ytd": "Year-to-Date (YTD)", "ytd": "Year-to-Date (YTD)",
"subtotal": "Subtotal", "subtotal": "Subtotal",

View file

@ -370,6 +370,7 @@
"categoryType": "Type", "categoryType": "Type",
"level1": "Catégorie (Niveau 1)", "level1": "Catégorie (Niveau 1)",
"level2": "Catégorie (Niveau 2)", "level2": "Catégorie (Niveau 2)",
"level3": "Catégorie (Niveau 3)",
"periodic": "Montant périodique", "periodic": "Montant périodique",
"ytd": "Cumul annuel (YTD)", "ytd": "Cumul annuel (YTD)",
"subtotal": "Sous-total", "subtotal": "Sous-total",

View file

@ -244,7 +244,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): BudgetVsActualRow { function buildLeaf(cat: Category, parentId: number | null, depth: 0 | 1 | 2): 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;
@ -269,6 +269,7 @@ export async function getBudgetVsActualData(
category_type: cat.type, category_type: cat.type,
parent_id: parentId, parent_id: parentId,
is_parent: false, is_parent: false,
depth,
monthActual, monthActual,
monthBudget, monthBudget,
monthVariation, 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 { function isRowAllZero(r: BudgetVsActualRow): boolean {
return ( return (
r.monthActual === 0 && 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 rows: BudgetVsActualRow[] = [];
const topLevel = allCategories.filter((c) => !c.parent_id); const topLevel = allCategories.filter((c) => !c.parent_id);
for (const cat of topLevel) { 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) { if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
// Standalone leaf // Standalone leaf at level 0
const leaf = buildLeaf(cat, null); const leaf = buildLeaf(cat, null, 0);
if (!isRowAllZero(leaf)) rows.push(leaf); if (!isRowAllZero(leaf)) rows.push(leaf);
} else if (children.length > 0) { } else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
const childRows: BudgetVsActualRow[] = []; const allChildRows: BudgetVsActualRow[] = [];
// If parent is also inputable, create a "(direct)" child row // Direct transactions on the parent itself
if (cat.is_inputable) { if (cat.is_inputable) {
const direct = buildLeaf(cat, cat.id); const direct = buildLeaf(cat, cat.id, 1);
direct.category_name = `${cat.name} (direct)`; direct.category_name = `${cat.name} (direct)`;
if (!isRowAllZero(direct)) childRows.push(direct); if (!isRowAllZero(direct)) allChildRows.push(direct);
} }
for (const child of children) { // Level-2 leaves (direct children that are inputable and have no children)
const leaf = buildLeaf(child, cat.id); for (const child of inputableChildren) {
if (!isRowAllZero(leaf)) childRows.push(leaf); 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 // Non-inputable intermediate parents at level 1
if (childRows.length === 0) continue; for (const ip of intermediateParents) {
const subRows = buildLevel2Group(ip, cat.id);
// Build parent subtotal from kept children allChildRows.push(...subRows);
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;
} }
parent.monthVariationPct =
parent.monthBudget !== 0 ? parent.monthVariation / Math.abs(parent.monthBudget) : null; if (allChildRows.length === 0) continue;
parent.ytdVariationPct =
parent.ytdBudget !== 0 ? parent.ytdVariation / Math.abs(parent.ytdBudget) : null; // 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); rows.push(parent);
// Sort children: "(direct)" first, then alphabetical // Sort: "(direct)" first, then subtotals with their children, then alphabetical leaves
childRows.sort((a, b) => { allChildRows.sort((a, b) => {
if (a.category_id === cat.id) return -1; if (a.category_id === cat.id && !a.is_parent) return -1;
if (b.category_id === cat.id) return 1; if (b.category_id === cat.id && !b.is_parent) return 1;
return a.category_name.localeCompare(b.category_name); 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 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;
const groupA = a.is_parent ? a.category_id : (a.parent_id ?? a.category_id); // Find the top-level group id
const groupB = b.is_parent ? b.category_id : (b.parent_id ?? b.category_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) { if (groupA !== groupB) {
const catA = catById.get(groupA); const catA = catById.get(groupA);
const catB = catById.get(groupB); const catB = catById.get(groupB);
@ -374,7 +442,9 @@ export async function getBudgetVsActualData(
if (orderA !== orderB) return orderA - orderB; if (orderA !== orderB) return orderA - orderB;
return (catA?.name ?? "").localeCompare(catB?.name ?? ""); 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 (a.parent_id && a.category_id === a.parent_id) return -1;
if (b.parent_id && b.category_id === b.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 a.category_name.localeCompare(b.category_name);

View file

@ -26,6 +26,22 @@ export async function getAllCategoriesWithCounts(): Promise<CategoryRow[]> {
); );
} }
export async function getCategoryDepth(categoryId: number): Promise<number> {
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<Array<{ parent_id: number | null }>>(
`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: { export async function createCategory(data: {
name: string; name: string;
type: string; type: string;
@ -35,10 +51,28 @@ export async function createCategory(data: {
sort_order: number; sort_order: number;
}): Promise<number> { }): Promise<number> {
const db = await getDb(); 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( const result = await db.execute(
`INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)`, `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] [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; return result.lastInsertId as number;
} }
@ -119,16 +153,37 @@ export async function updateCategorySortOrders(
export async function deactivateCategory(id: number): Promise<void> { export async function deactivateCategory(id: number): Promise<void> {
const db = await getDb(); const db = await getDb();
// Promote children to root level so they don't become orphans // Remember the parent before deactivating
await db.execute( const rows = await db.select<Array<{ parent_id: number | null }>>(
`UPDATE categories SET parent_id = NULL WHERE parent_id = $1`, `SELECT parent_id FROM categories WHERE id = $1`,
[id] [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 // Only deactivate the target category itself
await db.execute( await db.execute(
`UPDATE categories SET is_active = 0 WHERE id = $1`, `UPDATE categories SET is_active = 0 WHERE id = $1`,
[id] [id]
); );
// Auto-manage is_inputable: if parent now has no active children, restore is_inputable
if (parentId !== null) {
const childCount = await db.select<Array<{ cnt: number }>>(
`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<number> { export async function getCategoryUsageCount(id: number): Promise<number> {
@ -142,9 +197,15 @@ export async function getCategoryUsageCount(id: number): Promise<number> {
export async function getChildrenUsageCount(parentId: number): Promise<number> { export async function getChildrenUsageCount(parentId: number): Promise<number> {
const db = await getDb(); const db = await getDb();
// Check descendants recursively (up to 2 levels deep)
const rows = await db.select<Array<{ cnt: number }>>( const rows = await db.select<Array<{ cnt: number }>>(
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id IN `SELECT COUNT(*) AS cnt FROM transactions WHERE category_id IN (
(SELECT id FROM categories WHERE parent_id = $1 AND is_active = 1)`, 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] [parentId]
); );
return rows[0]?.cnt ?? 0; return rows[0]?.cnt ?? 0;
@ -173,47 +234,61 @@ export async function reinitializeCategories(): Promise<void> {
); );
} }
// Re-seed child categories // Re-seed child categories (level 2)
const children: Array<[number, string, number, string, string, number]> = [ // Note: Assurances (31) is now a non-inputable intermediate parent with level-3 children
[10, "Paie", 1, "income", "#22c55e", 1], const children: Array<[number, string, number, string, string, number, boolean]> = [
[11, "Autres revenus", 1, "income", "#4ade80", 2], [10, "Paie", 1, "income", "#22c55e", 1, true],
[20, "Loyer", 2, "expense", "#ef4444", 1], [11, "Autres revenus", 1, "income", "#4ade80", 2, true],
[21, "Électricité", 2, "expense", "#f59e0b", 2], [20, "Loyer", 2, "expense", "#ef4444", 1, true],
[22, "Épicerie", 2, "expense", "#10b981", 3], [21, "Électricité", 2, "expense", "#f59e0b", 2, true],
[23, "Dons", 2, "expense", "#ec4899", 4], [22, "Épicerie", 2, "expense", "#10b981", 3, true],
[24, "Restaurant", 2, "expense", "#f97316", 5], [23, "Dons", 2, "expense", "#ec4899", 4, true],
[25, "Frais bancaires", 2, "expense", "#6b7280", 6], [24, "Restaurant", 2, "expense", "#f97316", 5, true],
[26, "Jeux, Films & Livres", 2, "expense", "#8b5cf6", 7], [25, "Frais bancaires", 2, "expense", "#6b7280", 6, true],
[27, "Abonnements Musique", 2, "expense", "#06b6d4", 8], [26, "Jeux, Films & Livres", 2, "expense", "#8b5cf6", 7, true],
[28, "Transport en commun", 2, "expense", "#3b82f6", 9], [27, "Abonnements Musique", 2, "expense", "#06b6d4", 8, true],
[29, "Internet & Télécom", 2, "expense", "#6366f1", 10], [28, "Transport en commun", 2, "expense", "#3b82f6", 9, true],
[30, "Animaux", 2, "expense", "#a855f7", 11], [29, "Internet & Télécom", 2, "expense", "#6366f1", 10, true],
[31, "Assurances", 2, "expense", "#14b8a6", 12], [30, "Animaux", 2, "expense", "#a855f7", 11, true],
[32, "Pharmacie", 2, "expense", "#f43f5e", 13], [31, "Assurances", 2, "expense", "#14b8a6", 12, false], // intermediate parent
[33, "Taxes municipales", 2, "expense", "#78716c", 14], [32, "Pharmacie", 2, "expense", "#f43f5e", 13, true],
[40, "Voiture", 3, "expense", "#64748b", 1], [33, "Taxes municipales", 2, "expense", "#78716c", 14, true],
[41, "Amazon", 3, "expense", "#f59e0b", 2], [40, "Voiture", 3, "expense", "#64748b", 1, true],
[42, "Électroniques", 3, "expense", "#3b82f6", 3], [41, "Amazon", 3, "expense", "#f59e0b", 2, true],
[43, "Alcool", 3, "expense", "#7c3aed", 4], [42, "Électroniques", 3, "expense", "#3b82f6", 3, true],
[44, "Cadeaux", 3, "expense", "#ec4899", 5], [43, "Alcool", 3, "expense", "#7c3aed", 4, true],
[45, "Vêtements", 3, "expense", "#d946ef", 6], [44, "Cadeaux", 3, "expense", "#ec4899", 5, true],
[46, "CPA", 3, "expense", "#0ea5e9", 7], [45, "Vêtements", 3, "expense", "#d946ef", 6, true],
[47, "Voyage", 3, "expense", "#f97316", 8], [46, "CPA", 3, "expense", "#0ea5e9", 7, true],
[48, "Sports & Plein air", 3, "expense", "#22c55e", 9], [47, "Voyage", 3, "expense", "#f97316", 8, true],
[49, "Spectacles & sorties", 3, "expense", "#e11d48", 10], [48, "Sports & Plein air", 3, "expense", "#22c55e", 9, true],
[50, "Hypothèque", 4, "expense", "#dc2626", 1], [49, "Spectacles & sorties", 3, "expense", "#e11d48", 10, true],
[51, "Achats maison", 4, "expense", "#ea580c", 2], [50, "Hypothèque", 4, "expense", "#dc2626", 1, true],
[52, "Entretien maison", 4, "expense", "#ca8a04", 3], [51, "Achats maison", 4, "expense", "#ea580c", 2, true],
[53, "Électroménagers & Meubles", 4, "expense", "#0d9488", 4], [52, "Entretien maison", 4, "expense", "#ca8a04", 3, true],
[54, "Outils", 4, "expense", "#b45309", 5], [53, "Électroménagers & Meubles", 4, "expense", "#0d9488", 4, true],
[60, "Placements", 5, "transfer", "#2563eb", 1], [54, "Outils", 4, "expense", "#b45309", 5, true],
[61, "Transferts", 5, "transfer", "#7c3aed", 2], [60, "Placements", 5, "transfer", "#2563eb", 1, true],
[70, "Impôts", 6, "expense", "#dc2626", 1], [61, "Transferts", 5, "transfer", "#7c3aed", 2, true],
[71, "Paiement CC", 6, "transfer", "#6b7280", 2], [70, "Impôts", 6, "expense", "#dc2626", 1, true],
[72, "Retrait cash", 6, "expense", "#57534e", 3], [71, "Paiement CC", 6, "transfer", "#6b7280", 2, true],
[73, "Projets", 6, "expense", "#0ea5e9", 4], [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( await db.execute(
"INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES ($1, $2, $3, $4, $5, $6)", "INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES ($1, $2, $3, $4, $5, $6)",
[id, name, parentId, type, color, sort] [id, name, parentId, type, color, sort]
@ -237,7 +312,7 @@ export async function reinitializeCategories(): Promise<void> {
["GARE CENTRALE", 28], ["REM", 28], ["GARE CENTRALE", 28], ["REM", 28],
["VIDEOTRON", 29], ["ORICOM", 29], ["VIDEOTRON", 29], ["ORICOM", 29],
["MONDOU", 30], ["MONDOU", 30],
["BELAIR", 31], ["PRYSM", 31], ["INS/ASS", 31], ["BELAIR", 310], ["PRYSM", 311], ["INS/ASS", 312],
["JEAN COUTU", 32], ["FAMILIPRIX", 32], ["PHARMAPRIX", 32], ["JEAN COUTU", 32], ["FAMILIPRIX", 32], ["PHARMAPRIX", 32],
["M-ST-HILAIRE TX", 33], ["CSS PATRIOT", 33], ["M-ST-HILAIRE TX", 33], ["CSS PATRIOT", 33],
["SHELL", 40], ["ESSO", 40], ["ULTRAMAR", 40], ["PETRO-CANADA", 40], ["SHELL", 40], ["ESSO", 40], ["ULTRAMAR", 40], ["PETRO-CANADA", 40],

View file

@ -158,12 +158,13 @@ const FIELD_SQL: Record<PivotFieldId, { select: string; alias: string }> = {
year: { select: "strftime('%Y', t.date)", alias: "year" }, year: { select: "strftime('%Y', t.date)", alias: "year" },
month: { select: "strftime('%Y-%m', t.date)", alias: "month" }, month: { select: "strftime('%Y-%m', t.date)", alias: "month" },
type: { select: "COALESCE(c.type, 'expense')", alias: "type" }, type: { select: "COALESCE(c.type, 'expense')", alias: "type" },
level1: { select: "COALESCE(parent_cat.name, c.name, 'Uncategorized')", alias: "level1" }, level1: { select: "COALESCE(grandparent_cat.name, 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" }, 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 { 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( export async function getDynamicReportData(
@ -231,7 +232,8 @@ export async function getDynamicReportData(
const joinSQL = useCatJoin const joinSQL = useCatJoin
? `LEFT JOIN categories c ON t.category_id = c.id ? `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(", ")} const sql = `SELECT ${selectParts.join(", ")}
@ -308,6 +310,7 @@ export async function getDynamicReportData(
type: "Type", type: "Type",
level1: "Catégorie (Niveau 1)", level1: "Catégorie (Niveau 1)",
level2: "Catégorie (Niveau 2)", level2: "Catégorie (Niveau 2)",
level3: "Catégorie (Niveau 3)",
periodic: "Montant périodique", periodic: "Montant périodique",
ytd: "Cumul annuel (YTD)", ytd: "Cumul annuel (YTD)",
}; };
@ -324,7 +327,8 @@ export async function getDynamicFilterValues(
const joinSQL = useCatJoin const joinSQL = useCatJoin
? `LEFT JOIN categories c ON t.category_id = c.id ? `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<Array<{ val: string }>>( const rows = await db.select<Array<{ val: string }>>(

View file

@ -139,6 +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;
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
} }
@ -278,7 +279,7 @@ export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual"
// --- Pivot / Dynamic Report Types --- // --- 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 PivotMeasureId = "periodic" | "ytd";
export type PivotZone = "rows" | "columns" | "filters" | "values"; export type PivotZone = "rows" | "columns" | "filters" | "values";
@ -330,6 +331,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;
monthActual: number; monthActual: number;
monthBudget: number; monthBudget: number;
monthVariation: number; monthVariation: number;