@page "/reports" @attribute [Authorize] @inject TransactionService TransactionService @inject AccountService AccountService @inject CategoryService CategoryService @inject SalaryService SalaryService @inject AuthenticationStateProvider AuthStateProvider @inject IJSRuntime JS @implements IAsyncDisposable @rendermode InteractiveServer Reports

Reports

Visual breakdown of your spending and income by category for a specified time period.

@if (loading) {

Loading...

} else if (accounts.Count == 0) {
No data yet

Add some accounts and transactions to see your spending reports.

} else { @* ── Date Range & Account Filters ── *@
Filters
to
@foreach (var account in accounts) { }
@* ── Expense by Category (Doughnut) ── *@
Expenses by Category
@if (categoryBreakdown.Count == 0) {

No expenses in this period.

} else {
}
@* ── Income by Category (Doughnut) ── *@
Income by Category
@if (incomeBreakdown.Count == 0) {

No income in this period.

} else {
}
@* ── Monthly Income vs Expenses (Bar) ── *@
Monthly Income vs Expenses
@if (monthlyLabels.Count == 0) {

No transactions in this period.

} else {
}
@* ── Category Breakdown Table ── *@
Category Summary
@if (categoryBreakdown.Count == 0 && incomeBreakdown.Count == 0) {

No transactions in this period.

} else {
@foreach (var row in categorySummary) { } @{ var totalNet = categorySummary.Sum(r => r.Net); }
Category Income Expenses Net
@row.Icon @row.Name @currencySymbol@row.Income.ToString("N2") @currencySymbol@row.Expense.ToString("N2") @currencySymbol@row.Net.ToString("N2")
Total @currencySymbol@categorySummary.Sum(r => r.Income).ToString("N2") @currencySymbol@categorySummary.Sum(r => r.Expense).ToString("N2") @currencySymbol@totalNet.ToString("N2")
}
} @code { private bool loading = true; private string? userId; private DateTime dateFrom = DateTime.Today; private DateTime dateTo = DateTime.Today.AddDays(30); private string? preset = "30d"; private List accounts = []; private HashSet selectedAccountIds = []; private List categories = []; private string currencySymbol = "£"; private List categoryBreakdown = []; private List incomeBreakdown = []; private List categorySummary = []; private List monthlyLabels = []; private List monthlyIncome = []; private List monthlyExpenses = []; private static readonly string[] chartColors = [ "#4dc9f6", "#f67019", "#f53794", "#537bc4", "#acc236", "#166a8f", "#00a950", "#58595b", "#8549ba", "#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4", "#42d4f4", "#f032e6", "#bfef45", "#fabed4", "#469990" ]; protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; if (userId is null) return; accounts = await AccountService.GetAccountsAsync(userId); categories = await CategoryService.GetCategoriesAsync(userId); selectedAccountIds = accounts.Select(a => a.Id).ToHashSet(); currencySymbol = accounts.FirstOrDefault()?.GetSymbol() ?? "£"; await LoadReport(); loading = false; } protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender || loading) return; await RenderCharts(); } private async Task LoadReport() { if (userId is null) return; await SalaryService.MaterializeSalaryTransactionsAsync(userId, dateTo); var transactions = await TransactionService.GetTransactionsInRangeAsync(userId, dateFrom, dateTo); var filtered = transactions .Where(t => selectedAccountIds.Contains(t.AccountId)) .ToList(); categoryBreakdown = filtered .Where(t => t.Type == TransactionType.Expense) .GroupBy(t => t.CategoryId) .Select(g => { var cat = categories.FirstOrDefault(c => c.Id == g.Key); return new CategoryAmount { Name = cat?.Name ?? "Uncategorised", Icon = cat?.Icon ?? "", Total = g.Sum(t => t.Amount) }; }) .OrderByDescending(c => c.Total) .ToList(); incomeBreakdown = filtered .Where(t => t.Type == TransactionType.Income) .GroupBy(t => t.CategoryId) .Select(g => { var cat = categories.FirstOrDefault(c => c.Id == g.Key); return new CategoryAmount { Name = cat?.Name ?? "Uncategorised", Icon = cat?.Icon ?? "", Total = g.Sum(t => t.Amount) }; }) .OrderByDescending(c => c.Total) .ToList(); var allCategoryKeys = filtered .Select(t => t.CategoryId) .Distinct() .ToList(); categorySummary = allCategoryKeys.Select(key => { var cat = categories.FirstOrDefault(c => c.Id == key); var income = filtered.Where(t => t.CategoryId == key && t.Type == TransactionType.Income).Sum(t => t.Amount); var expense = filtered.Where(t => t.CategoryId == key && t.Type == TransactionType.Expense).Sum(t => t.Amount); return new CategorySummaryRow { Name = cat?.Name ?? "Uncategorised", Icon = cat?.Icon ?? "", Income = income, Expense = expense }; }) .OrderByDescending(r => r.Expense) .ToList(); // Monthly data var monthly = filtered .GroupBy(t => new { t.Date.Year, t.Date.Month }) .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month) .ToList(); monthlyLabels = monthly.Select(g => $"{g.Key.Year}-{g.Key.Month:D2}").ToList(); monthlyIncome = monthly.Select(g => g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount)).ToList(); monthlyExpenses = monthly.Select(g => g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount)).ToList(); } private async Task RenderCharts() { if (categoryBreakdown.Count > 0) { var labels = categoryBreakdown.Select(c => $"{c.Icon} {c.Name}".Trim()).ToArray(); var data = categoryBreakdown.Select(c => c.Total).ToArray(); var colors = Enumerable.Range(0, categoryBreakdown.Count).Select(i => chartColors[i % chartColors.Length]).ToArray(); await JS.InvokeVoidAsync("chartInterop.createOrUpdatePieChart", "expensePieChart", labels, data, colors, currencySymbol, "doughnut"); } if (incomeBreakdown.Count > 0) { var labels = incomeBreakdown.Select(c => $"{c.Icon} {c.Name}".Trim()).ToArray(); var data = incomeBreakdown.Select(c => c.Total).ToArray(); var colors = Enumerable.Range(0, incomeBreakdown.Count).Select(i => chartColors[i % chartColors.Length]).ToArray(); await JS.InvokeVoidAsync("chartInterop.createOrUpdatePieChart", "incomePieChart", labels, data, colors, currencySymbol, "doughnut"); } if (monthlyLabels.Count > 0) { await JS.InvokeVoidAsync("chartInterop.createOrUpdateBarChart", "monthlyBarChart", monthlyLabels.ToArray(), monthlyIncome.ToArray(), monthlyExpenses.ToArray(), currencySymbol); } } private async Task SetPreset(int days) { preset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null }; dateFrom = DateTime.Today; dateTo = DateTime.Today.AddDays(days); await Refresh(); } private async Task OnDateChanged() { preset = null; await Refresh(); } private async Task ToggleAccount(int accountId) { if (!selectedAccountIds.Remove(accountId)) selectedAccountIds.Add(accountId); await Refresh(); } private async Task Refresh() { await LoadReport(); StateHasChanged(); await Task.Yield(); await RenderCharts(); } public async ValueTask DisposeAsync() { try { await JS.InvokeVoidAsync("chartInterop.destroyChart", "expensePieChart"); await JS.InvokeVoidAsync("chartInterop.destroyChart", "incomePieChart"); await JS.InvokeVoidAsync("chartInterop.destroyChart", "monthlyBarChart"); } catch (JSDisconnectedException) { } } private sealed class CategoryAmount { public string Name { get; set; } = string.Empty; public string Icon { get; set; } = string.Empty; public decimal Total { get; set; } } private sealed class CategorySummaryRow { public string Name { get; set; } = string.Empty; public string Icon { get; set; } = string.Empty; public decimal Income { get; set; } public decimal Expense { get; set; } public decimal Net => Income - Expense; } }