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 (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);

View file

@ -18,18 +18,19 @@ const MONTH_KEYS = [
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[],
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<T extends { is_parent: boolean; parent_id: number | null; c
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) =>
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 (
<tr
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">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
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>
</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)}
</td>
{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)}
</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"
>
{/* 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">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"

View file

@ -41,9 +41,36 @@ export default function CategoryForm({
setForm(initialData);
}, [initialData]);
const parentOptions = categories.filter(
(c) => 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<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) => {
e.preventDefault();
@ -113,7 +140,7 @@ export default function CategoryForm({
<option value="">{t("categories.noParent")}</option>
{parentOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{c.indent > 0 ? "\u00A0\u00A0\u00A0\u00A0" : ""}{c.name}
</option>
))}
</select>

View file

@ -35,25 +35,24 @@ interface Props {
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[] {
const items: FlatItem[] = [];
for (const node of tree) {
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: 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,
});
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<Set<number>>(() => {
const ids = new Set<number>();
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<number | null>(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
if (activeItem.depth === 0) {
// Root reorder: keep as root
// Dropping onto/near a root item — same depth reorder or moving to 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);
if (activeItem.depth === 0) {
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;
}

View file

@ -25,7 +25,7 @@ interface BudgetVsActualTableProps {
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[],
subtotalsOnTop: boolean,
): T[] {
@ -33,10 +33,10 @@ function reorderRows<T extends { is_parent: boolean; parent_id: number | null; c
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);
@ -44,9 +44,34 @@ function reorderRows<T extends { is_parent: boolean; parent_id: number | null; c
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) =>
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)
</tr>
{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 (
<tr
key={`${row.category_id}-${row.is_parent}`}
key={`${row.category_id}-${row.is_parent}-${depth}`}
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="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 { 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"];
interface DynamicReportPanelProps {
@ -105,7 +105,7 @@ export default function DynamicReportPanel({ config, onChange }: DynamicReportPa
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}`);
// Context menu only shows zones where the field is NOT already assigned

View file

@ -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) {
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);
childRows.push({
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);

View file

@ -79,12 +79,15 @@ function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
function flattenTreeToCategories(tree: CategoryTreeNode[]): CategoryTreeNode[] {
const result: CategoryTreeNode[] = [];
for (const node of tree) {
function recurse(nodes: CategoryTreeNode[]) {
for (const node of nodes) {
result.push(node);
for (const child of node.children) {
result.push(child);
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<number | null>();
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);

View file

@ -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",

View file

@ -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",

View file

@ -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,51 +281,15 @@ export async function getBudgetVsActualData(
};
}
function isRowAllZero(r: BudgetVsActualRow): boolean {
return (
r.monthActual === 0 &&
r.monthBudget === 0 &&
r.ytdActual === 0 &&
r.ytdBudget === 0
);
}
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);
if (children.length === 0 && cat.is_inputable) {
// Standalone leaf
const leaf = buildLeaf(cat, null);
if (!isRowAllZero(leaf)) rows.push(leaf);
} else if (children.length > 0) {
const childRows: BudgetVsActualRow[] = [];
// If parent is also inputable, create a "(direct)" child row
if (cat.is_inputable) {
const direct = buildLeaf(cat, cat.id);
direct.category_name = `${cat.name} (direct)`;
if (!isRowAllZero(direct)) childRows.push(direct);
}
for (const child of children) {
const leaf = buildLeaf(child, cat.id);
if (!isRowAllZero(leaf)) childRows.push(leaf);
}
// Skip parent entirely if all children were filtered out
if (childRows.length === 0) continue;
// Build parent subtotal from kept children
const parent: BudgetVsActualRow = {
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: null,
parent_id: parentId,
is_parent: true,
depth,
monthActual: 0,
monthBudget: 0,
monthVariation: 0,
@ -335,27 +300,117 @@ export async function getBudgetVsActualData(
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;
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;
}
parent.monthVariationPct =
parent.monthBudget !== 0 ? parent.monthVariation / Math.abs(parent.monthBudget) : null;
parent.ytdVariationPct =
parent.ytdBudget !== 0 ? parent.ytdVariation / Math.abs(parent.ytdBudget) : null;
rows.push(parent);
function isRowAllZero(r: BudgetVsActualRow): boolean {
return (
r.monthActual === 0 &&
r.monthBudget === 0 &&
r.ytdActual === 0 &&
r.ytdBudget === 0
);
}
// Sort children: "(direct)" first, then alphabetical
childRows.sort((a, b) => {
// 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);
});
rows.push(...childRows);
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) || [];
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 (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 (inputableChildren.length > 0 || intermediateParents.length > 0) {
const allChildRows: BudgetVsActualRow[] = [];
// Direct transactions on the parent itself
if (cat.is_inputable) {
const direct = buildLeaf(cat, cat.id, 1);
direct.category_name = `${cat.name} (direct)`;
if (!isRowAllZero(direct)) allChildRows.push(direct);
}
// 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);
}
}
// Non-inputable intermediate parents at level 1
for (const ip of intermediateParents) {
const subRows = buildLevel2Group(ip, cat.id);
allChildRows.push(...subRows);
}
if (allChildRows.length === 0) continue;
// Collect only leaf rows for parent subtotal (avoid double-counting)
const leafRows = allChildRows.filter((r) => !r.is_parent);
const parent = buildSubtotal(cat, leafRows, null, 0);
rows.push(parent);
// Sort: "(direct)" first, then subtotals with their children, then alphabetical leaves
allChildRows.sort((a, b) => {
if (a.category_id === cat.id && !a.is_parent) return -1;
if (b.category_id === cat.id && !b.is_parent) return 1;
return a.category_name.localeCompare(b.category_name);
});
rows.push(...allChildRows);
}
}
@ -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);

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: {
name: string;
type: string;
@ -35,10 +51,28 @@ export async function createCategory(data: {
sort_order: number;
}): Promise<number> {
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<void> {
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<Array<{ parent_id: number | null }>>(
`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<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> {
@ -142,9 +197,15 @@ export async function getCategoryUsageCount(id: number): Promise<number> {
export async function getChildrenUsageCount(parentId: number): Promise<number> {
const db = await getDb();
// Check descendants recursively (up to 2 levels deep)
const rows = await db.select<Array<{ cnt: number }>>(
`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<void> {
);
}
// 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<void> {
["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],

View file

@ -158,12 +158,13 @@ const FIELD_SQL: Record<PivotFieldId, { select: string; alias: string }> = {
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<Array<{ val: string }>>(

View file

@ -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;