From 5c269b48a0723217f82df572069bc3f6c3b85dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Fri, 3 Jul 2026 17:27:25 +0200 Subject: [PATCH 1/2] Render category analysis as a full recursive tree at any depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can wrap categories under Einnahmen/Ausgaben umbrellas, making the tree 3+ levels deep. The detail view only rendered two levels, and the donut/treemap charted top-level categories only — so with a single umbrella root they collapsed to one meaningless box. - Controller now emits a full recursive node tree (own + descendant totals, empty subtrees pruned) instead of a flattened two-level structure. - Detail view uses a PrimeVue TreeTable so every level expands. - Charts descend past single wrapper roots to the first level with a real breakdown, so they work regardless of how the tree is grouped. --- .../CategoryAnalysisController.php | 91 ++++------ resources/js/Pages/Categories/Analysis.vue | 156 +++++++----------- .../CategoryAnalysisControllerTest.php | 2 + 3 files changed, 97 insertions(+), 152 deletions(-) diff --git a/app/Http/Controllers/CategoryAnalysisController.php b/app/Http/Controllers/CategoryAnalysisController.php index ee4a158..0b85c06 100644 --- a/app/Http/Controllers/CategoryAnalysisController.php +++ b/app/Http/Controllers/CategoryAnalysisController.php @@ -54,84 +54,55 @@ public function __invoke(Request $request) $totalExpenses = $categoryTotals->sum('expense_total'); $totalIncome = $categoryTotals->sum('income_total'); - // Sum a category's own totals plus every descendant's, recursively. - $subtreeTotals = function (Category $category) use (&$subtreeTotals, $categoryTotals, $childrenByParent): array { + // Build a full recursive node (own totals + every descendant's), pruning + // subtrees with no activity. Works at any nesting depth. + $buildNode = function (Category $category) use (&$buildNode, $categoryTotals, $childrenByParent, $totalExpenses, $totalIncome): ?array { $expense = (float) ($categoryTotals[$category->id]?->expense_total ?? 0); $income = (float) ($categoryTotals[$category->id]?->income_total ?? 0); $count = (int) ($categoryTotals[$category->id]?->transaction_count ?? 0); + $children = []; foreach ($childrenByParent[$category->id] ?? [] as $child) { - $childTotals = $subtreeTotals($child); - $expense += $childTotals['expense']; - $income += $childTotals['income']; - $count += $childTotals['count']; + $childNode = $buildNode($child); + if ($childNode !== null) { + $expense += $childNode['expense']; + $income += $childNode['income']; + $count += $childNode['transactionCount']; + $children[] = $childNode; + } + } + + if ($expense <= 0 && $income <= 0) { + return null; } - return ['expense' => $expense, 'income' => $income, 'count' => $count]; + return [ + 'id' => $category->id, + 'name' => $category->name, + 'type' => $category->type, + 'expense' => round($expense, 2), + 'income' => round($income, 2), + 'transactionCount' => $count, + 'expensePercent' => $totalExpenses > 0 ? round(($expense / $totalExpenses) * 100, 1) : 0, + 'incomePercent' => $totalIncome > 0 ? round(($income / $totalIncome) * 100, 1) : 0, + 'budget' => $category->budget_monthly, + 'children' => $children, + ]; }; $hierarchy = []; - $treemapData = []; - - foreach ($allCategories->whereNull('parent_id') as $parent) { - $parentTotals = $subtreeTotals($parent); - $parentExpense = $parentTotals['expense']; - $parentIncome = $parentTotals['income']; - $parentTxCount = $parentTotals['count']; - - $children = []; - foreach ($childrenByParent[$parent->id] ?? [] as $child) { - $childTotals = $subtreeTotals($child); - $childExpense = $childTotals['expense']; - $childIncome = $childTotals['income']; - $childTxCount = $childTotals['count']; - - if ($childExpense > 0 || $childIncome > 0) { - $children[] = [ - 'id' => $child->id, - 'name' => $child->name, - 'type' => $child->type, - 'expense' => round($childExpense, 2), - 'income' => round($childIncome, 2), - 'transactionCount' => $childTxCount, - 'expensePercent' => $totalExpenses > 0 ? round(($childExpense / $totalExpenses) * 100, 1) : 0, - 'incomePercent' => $totalIncome > 0 ? round(($childIncome / $totalIncome) * 100, 1) : 0, - 'budget' => $child->budget_monthly, - ]; - } - } - - if ($parentExpense > 0 || $parentIncome > 0) { - $hierarchy[] = [ - 'id' => $parent->id, - 'name' => $parent->name, - 'type' => $parent->type, - 'expense' => round($parentExpense, 2), - 'income' => round($parentIncome, 2), - 'transactionCount' => $parentTxCount, - 'expensePercent' => $totalExpenses > 0 ? round(($parentExpense / $totalExpenses) * 100, 1) : 0, - 'incomePercent' => $totalIncome > 0 ? round(($parentIncome / $totalIncome) * 100, 1) : 0, - 'budget' => $parent->budget_monthly, - 'children' => $children, - ]; - - if ($parentExpense > 0) { - $treemapData[] = [ - 'x' => $parent->name, - 'y' => round($parentExpense, 2), - ]; - } + foreach ($allCategories->whereNull('parent_id') as $root) { + $node = $buildNode($root); + if ($node !== null) { + $hierarchy[] = $node; } } - usort($treemapData, fn ($a, $b) => $b['y'] <=> $a['y']); - return Inertia::render('Categories/Analysis', [ 'selectedMonth' => $selectedMonth, 'prevMonth' => $prevMonth, 'nextMonth' => $nextMonth, 'hierarchy' => $hierarchy, - 'treemapData' => $treemapData, 'totalExpenses' => round($totalExpenses, 2), 'totalIncome' => round($totalIncome, 2), ]); diff --git a/resources/js/Pages/Categories/Analysis.vue b/resources/js/Pages/Categories/Analysis.vue index dc753d0..460a7dc 100644 --- a/resources/js/Pages/Categories/Analysis.vue +++ b/resources/js/Pages/Categories/Analysis.vue @@ -9,10 +9,9 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue'; import { router } from '@inertiajs/vue3'; import Button from 'primevue/button'; import DatePicker from 'primevue/datepicker'; -import DataTable from 'primevue/datatable'; +import TreeTable from 'primevue/treetable'; import Column from 'primevue/column'; import SelectButton from 'primevue/selectbutton'; -import Tag from 'primevue/tag'; const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts')); @@ -26,7 +25,6 @@ const props = defineProps({ prevMonth: { type: String, default: null }, nextMonth: { type: String, default: null }, hierarchy: { type: Array, default: () => [] }, - treemapData: { type: Array, default: () => [] }, totalExpenses: { type: Number, default: 0 }, totalIncome: { type: Number, default: 0 }, }); @@ -52,15 +50,45 @@ const viewOptions = [ ]; const activeView = ref('expense'); -const expandedRows = ref({}); - const amountKey = computed(() => activeView.value === 'expense' ? 'expense' : 'income'); -const currentData = computed(() => { +function getAmount(node) { + return node[amountKey.value]; +} + +function getPercent(node) { + return activeView.value === 'expense' ? node.expensePercent : node.incomePercent; +} + +// Recursively turn the category tree into PrimeVue TreeTable nodes, keeping only +// branches that have a value for the active view, largest first — any depth. +function toTreeNodes(nodes) { const key = amountKey.value; - return props.hierarchy - .filter(row => row[key] > 0) - .sort((a, b) => b[key] - a[key]); + return nodes + .filter((n) => n[key] > 0) + .map((n) => ({ + key: String(n.id), + data: n, + children: toTreeNodes(n.children || []), + })) + .sort((a, b) => b.data[key] - a.data[key]); +} + +const treeNodes = computed(() => toTreeNodes(props.hierarchy)); + +// For the charts, descend past single "wrapper" roots (e.g. an Ausgaben / +// Einnahmen umbrella) to the first level that actually has a breakdown. +const chartNodes = computed(() => { + const key = amountKey.value; + let nodes = props.hierarchy.filter((n) => n[key] > 0); + while (nodes.length === 1) { + const children = (nodes[0].children || []).filter((n) => n[key] > 0); + if (children.length === 0) { + break; + } + nodes = children; + } + return [...nodes].sort((a, b) => b[key] - a[key]); }); const currentTotal = computed(() => @@ -69,10 +97,13 @@ const currentTotal = computed(() => const hasData = computed(() => props.hierarchy.length > 0); -function visibleChildren(row) { - const key = amountKey.value; - return (row.children || []).filter(child => child[key] > 0); -} +// Expand the top level by default so the breakdown is visible immediately. +const expandedKeys = ref({}); +watch(treeNodes, (nodes) => { + const keys = {}; + nodes.forEach((n) => { keys[n.key] = true; }); + expandedKeys.value = keys; +}, { immediate: true }); function navigateMonth(month) { router.get('/categories/analysis', { month }, { preserveState: true }); @@ -88,7 +119,7 @@ const donutColors = ['#3b82f6', '#ef4444', '#f59e0b', '#22c55e', '#8b5cf6', '#ec const donutOptions = computed(() => ({ chart: { type: 'donut', height: 300, fontFamily: 'Inter, sans-serif', background: 'transparent' }, theme: { mode: isDark.value ? 'dark' : 'light' }, - labels: currentData.value.map(d => d.name), + labels: chartNodes.value.map((n) => n.name), colors: donutColors, legend: { position: 'bottom', labels: { colors: chartTextColor.value } }, tooltip: { @@ -98,9 +129,7 @@ const donutOptions = computed(() => ({ dataLabels: { enabled: false }, })); -const donutSeries = computed(() => - currentData.value.map(d => activeView.value === 'expense' ? d.expense : d.income) -); +const donutSeries = computed(() => chartNodes.value.map((n) => getAmount(n))); const treemapOptions = computed(() => ({ chart: { type: 'treemap', height: 300, toolbar: { show: false }, fontFamily: 'Inter, sans-serif', background: 'transparent' }, @@ -118,16 +147,8 @@ const treemapOptions = computed(() => ({ })); const treemapSeries = computed(() => [{ - data: props.treemapData, + data: chartNodes.value.map((n) => ({ x: n.name, y: getAmount(n) })), }]); - -function getAmount(row) { - return activeView.value === 'expense' ? row.expense : row.income; -} - -function getPercent(row) { - return activeView.value === 'expense' ? row.expensePercent : row.incomePercent; -}