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:
parent
0fbcbc0eca
commit
a04813ced2
14 changed files with 595 additions and 239 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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 }>>(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue