diff --git a/app/Http/Controllers/CategoryAnalysisController.php b/app/Http/Controllers/CategoryAnalysisController.php index ee4a158..4c00604 100644 --- a/app/Http/Controllers/CategoryAnalysisController.php +++ b/app/Http/Controllers/CategoryAnalysisController.php @@ -47,6 +47,26 @@ public function __invoke(Request $request) ->get() ->keyBy('category_id'); + // Cumulative totals up to (and including) the selected month, for the + // monthly average. Averaged over the months from the first transaction + // to the selected month. + $cumulativeTotals = Transaction::select( + 'category_id', + DB::raw('SUM(CASE WHEN amount < 0 THEN ABS(amount) ELSE 0 END) as expense_total'), + DB::raw('SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as income_total') + ) + ->whereNotNull('category_id') + ->whereDoesntHave('category', fn ($q) => $q->where('type', 'transfer')) + ->where('date', '<=', $monthEnd) + ->groupBy('category_id') + ->get() + ->keyBy('category_id'); + + $monthsElapsed = 1; + if ($selectedMonth >= $firstMonth) { + $monthsElapsed = Carbon::createFromFormat('Y-m', $firstMonth)->startOfMonth()->diffInMonths($selectedDate) + 1; + } + // Load the full category tree so aggregation works at any nesting depth. $allCategories = Category::orderBy('sort_order')->orderBy('name')->get(); $childrenByParent = $allCategories->groupBy('parent_id'); @@ -54,84 +74,62 @@ 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, $cumulativeTotals, $monthsElapsed, $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); + // Monthly averages are additive across the subtree (constant divisor). + $avgMonthlyExpense = (float) ($cumulativeTotals[$category->id]?->expense_total ?? 0) / $monthsElapsed; + $avgMonthlyIncome = (float) ($cumulativeTotals[$category->id]?->income_total ?? 0) / $monthsElapsed; + $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']; + $avgMonthlyExpense += $childNode['avgMonthlyExpense']; + $avgMonthlyIncome += $childNode['avgMonthlyIncome']; + $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, + 'avgMonthlyExpense' => round($avgMonthlyExpense, 2), + 'avgMonthlyIncome' => round($avgMonthlyIncome, 2), + '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/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php index a339479..45434cc 100644 --- a/app/Http/Controllers/CategoryController.php +++ b/app/Http/Controllers/CategoryController.php @@ -3,7 +3,10 @@ namespace App\Http\Controllers; use App\Models\Category; +use App\Models\Transaction; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Inertia\Inertia; class CategoryController extends Controller @@ -17,7 +20,27 @@ public function index() ->orderBy('name') ->get(); - $tree = $categories->map(fn ($cat) => $this->mapTreeNode($cat))->toArray(); + // Per-category monthly average: each category's own total (matching the + // direct Buchungen count) over the months from the first transaction to now. + $now = now(); + $firstMonth = Transaction::selectRaw("strftime('%Y-%m', MIN(date)) as m")->value('m'); + $monthsElapsed = 1; + if ($firstMonth) { + $monthsElapsed = Carbon::createFromFormat('Y-m', $firstMonth)->startOfMonth()->diffInMonths($now->copy()->startOfMonth()) + 1; + } + + $totals = Transaction::select( + 'category_id', + DB::raw('SUM(CASE WHEN amount < 0 THEN ABS(amount) ELSE 0 END) as expense_total'), + DB::raw('SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as income_total') + ) + ->whereNotNull('category_id') + ->where('date', '<=', $now->toDateString()) + ->groupBy('category_id') + ->get() + ->keyBy('category_id'); + + $tree = $categories->map(fn ($cat) => $this->mapTreeNode($cat, $totals, $monthsElapsed))->toArray(); return Inertia::render('Categories/Index', [ 'categories' => $tree, @@ -66,8 +89,12 @@ public function destroy(Category $category) return redirect()->back()->with('success', 'Kategorie gelöscht.'); } - private function mapTreeNode(Category $category): array + private function mapTreeNode(Category $category, $totals, int $monthsElapsed): array { + $amount = $category->type === 'income' + ? (float) ($totals[$category->id]->income_total ?? 0) + : (float) ($totals[$category->id]->expense_total ?? 0); + $node = [ 'key' => $category->id, 'data' => [ @@ -78,12 +105,13 @@ private function mapTreeNode(Category $category): array 'parent_id' => $category->parent_id, 'budget_monthly' => $category->budget_monthly, 'transactionsCount' => $category->transactions_count ?? 0, + 'monthlyAverage' => round($amount / $monthsElapsed, 2), ], 'children' => [], ]; if ($category->children->isNotEmpty()) { - $node['children'] = $category->children->map(fn ($child) => $this->mapTreeNode($child))->toArray(); + $node['children'] = $category->children->map(fn ($child) => $this->mapTreeNode($child, $totals, $monthsElapsed))->toArray(); } return $node; diff --git a/resources/js/Pages/Categories/Analysis.vue b/resources/js/Pages/Categories/Analysis.vue index dc753d0..4383130 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,49 @@ 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; +} + +function getMonthlyAverage(node) { + return activeView.value === 'expense' ? node.avgMonthlyExpense : node.avgMonthlyIncome; +} + +// 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 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; - return props.hierarchy - .filter(row => row[key] > 0) - .sort((a, b) => b[key] - a[key]); + 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 +101,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 +123,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 +133,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 +151,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; -}