Files
Budgeteer/BudgetApp/Components/Pages/Reports.razor
T
2026-03-02 17:00:53 +00:00

373 lines
15 KiB
Plaintext

@page "/reports"
@attribute [Authorize]
@inject TransactionService TransactionService
@inject AccountService AccountService
@inject CategoryService CategoryService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@implements IAsyncDisposable
@rendermode InteractiveServer
<PageTitle>Reports</PageTitle>
<h3 class="mb-2">Reports</h3>
<p class="text-muted mb-4">Visual breakdown of your spending and income by category for a specified time period.</p>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (accounts.Count == 0)
{
<div class="alert alert-info">
<h5>No data yet</h5>
<p>Add some <a href="/accounts">accounts</a> and <a href="/transactions">transactions</a> to see your spending reports.</p>
</div>
}
else
{
@* ── Date Range & Account Filters ── *@
<div class="card mb-4">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<h5 class="mb-0">Filters</h5>
<div class="d-flex flex-wrap align-items-center gap-2">
<div class="btn-group btn-group-sm">
<button class="btn @(preset == "7d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(7)">7d</button>
<button class="btn @(preset == "30d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(30)">30d</button>
<button class="btn @(preset == "90d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(90)">90d</button>
<button class="btn @(preset == "1y" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(365)">1y</button>
</div>
<input type="date" class="form-control form-control-sm" style="width:auto;"
title="Start date" @bind="dateFrom" @bind:after="OnDateChanged" />
<span class="text-muted">to</span>
<input type="date" class="form-control form-control-sm" style="width:auto;"
title="End date" @bind="dateTo" @bind:after="OnDateChanged" />
</div>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2">
@foreach (var account in accounts)
{
<button class="btn btn-sm @(selectedAccountIds.Contains(account.Id) ? "btn-primary" : "btn-outline-secondary")"
title="Toggle @account.Name" @onclick="() => ToggleAccount(account.Id)">
<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background-color:@account.Color;"></span>
@account.Name
</button>
}
</div>
</div>
</div>
@* ── Expense by Category (Doughnut) ── *@
<div class="row mb-4">
<div class="col-lg-6 mb-4 mb-lg-0">
<div class="card h-100">
<div class="card-header"><h5 class="mb-0">Expenses by Category</h5></div>
<div class="card-body">
@if (categoryBreakdown.Count == 0)
{
<p class="text-muted">No expenses in this period.</p>
}
else
{
<div style="position:relative;height:clamp(250px,35vw,400px);">
<canvas id="expensePieChart"></canvas>
</div>
}
</div>
</div>
</div>
@* ── Income by Category (Doughnut) ── *@
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h5 class="mb-0">Income by Category</h5></div>
<div class="card-body">
@if (incomeBreakdown.Count == 0)
{
<p class="text-muted">No income in this period.</p>
}
else
{
<div style="position:relative;height:clamp(250px,35vw,400px);">
<canvas id="incomePieChart"></canvas>
</div>
}
</div>
</div>
</div>
</div>
@* ── Monthly Income vs Expenses (Bar) ── *@
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Monthly Income vs Expenses</h5></div>
<div class="card-body">
@if (monthlyLabels.Count == 0)
{
<p class="text-muted">No transactions in this period.</p>
}
else
{
<div style="position:relative;height:clamp(250px,30vw,350px);">
<canvas id="monthlyBarChart"></canvas>
</div>
}
</div>
</div>
@* ── Category Breakdown Table ── *@
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Category Summary</h5></div>
<div class="card-body">
@if (categoryBreakdown.Count == 0 && incomeBreakdown.Count == 0)
{
<p class="text-muted">No transactions in this period.</p>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Category</th>
<th class="text-end">Income</th>
<th class="text-end">Expenses</th>
<th class="text-end">Net</th>
</tr>
</thead>
<tbody>
@foreach (var row in categorySummary)
{
<tr>
<td>@row.Icon @row.Name</td>
<td class="text-end text-success">@currencySymbol@row.Income.ToString("N2")</td>
<td class="text-end text-danger">@currencySymbol@row.Expense.ToString("N2")</td>
<td class="text-end @(row.Net >= 0 ? "text-success" : "text-danger")">
@currencySymbol@row.Net.ToString("N2")
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="fw-bold">
<td>Total</td>
<td class="text-end text-success">@currencySymbol@categorySummary.Sum(r => r.Income).ToString("N2")</td>
<td class="text-end text-danger">@currencySymbol@categorySummary.Sum(r => r.Expense).ToString("N2")</td>
@{ var totalNet = categorySummary.Sum(r => r.Net); }
<td class="text-end @(totalNet >= 0 ? "text-success" : "text-danger")">
@currencySymbol@totalNet.ToString("N2")
</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
</div>
}
@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<Account> accounts = [];
private HashSet<int> selectedAccountIds = [];
private List<Category> categories = [];
private string currencySymbol = "£";
private List<CategoryAmount> categoryBreakdown = [];
private List<CategoryAmount> incomeBreakdown = [];
private List<CategorySummaryRow> categorySummary = [];
private List<string> monthlyLabels = [];
private List<decimal> monthlyIncome = [];
private List<decimal> 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;
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;
}
}