This commit is contained in:
rossmill76 committed 2026-03-02 17:00:53 +00:00
1 parent 8313d8e85a
commit f220a034b1
13 files changed
+504 -7

No files matched your search

@@ -39,6 +39,12 @@
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="reports">
<span class="bi bi-bar-chart-nav-menu" aria-hidden="true"></span> Reports
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="settings">
<span class="bi bi-gear-nav-menu" aria-hidden="true"></span> Settings
@@ -54,6 +54,10 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-tag-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 1a1 1 0 0 0-1 1v4.586a1 1 0 0 0 .293.707l7 7a1 1 0 0 0 1.414 0l4.586-4.586a1 1 0 0 0 0-1.414l-7-7A1 1 0 0 0 6.586 1H2zm4 3.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z'/%3E%3C/svg%3E");
}
.bi-bar-chart-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-bar-chart-fill' viewBox='0 0 16 16'%3E%3Cpath d='M1 11a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3zm5-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7zm5-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V2z'/%3E%3C/svg%3E");
}
.bi-gear-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-gear-fill' viewBox='0 0 16 16'%3E%3Cpath d='M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z'/%3E%3C/svg%3E");
}
+2 -1
View File
@@ -7,7 +7,8 @@
<PageTitle>Accounts</PageTitle>
<h3 class="mb-4">Accounts</h3>
<h3 class="mb-2">Accounts</h3>
<p class="text-muted mb-4">Manage your financial accounts, balances and currencies.</p>
<div class="card mb-4">
<div class="card-header">
+2 -1
View File
@@ -6,7 +6,8 @@
<PageTitle>Categories</PageTitle>
<h3 class="mb-4">Categories</h3>
<h3 class="mb-2">Categories</h3>
<p class="text-muted mb-4">Create and organise categories to classify your transactions.</p>
<div class="card mb-4">
<div class="card-header">
+2 -1
View File
@@ -3,7 +3,8 @@
<PageTitle>Help</PageTitle>
<h3 class="mb-4">Help</h3>
<h3 class="mb-2">Help</h3>
<p class="text-muted mb-4">Guides and tips to help you get the most out of Budgeteer.</p>
<div class="row">
<div class="col-lg-8">
+2 -1
View File
@@ -9,7 +9,8 @@
<PageTitle>Dashboard</PageTitle>
<h3 class="mb-4">Dashboard</h3>
<h3 class="mb-2">Dashboard</h3>
<p class="text-muted mb-4">An overview of your balances, recent and upcoming transactions.</p>
@if (data is null)
{
+372
View File
@@ -0,0 +1,372 @@
@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;
}
}
+2 -1
View File
@@ -12,10 +12,11 @@
<PageTitle>Settings</PageTitle>
<h3 class="mb-4">
<h3 class="mb-2">
Settings
<span class="badge bg-warning text-dark ms-2" style="font-size: 0.4em; vertical-align: middle;">beta</span>
</h3>
<p class="text-muted mb-4">Change your password, reset your data or delete your account.</p>
@* ── Change Password ── *@
<div class="card mb-4">
@@ -8,7 +8,8 @@
<PageTitle>Transactions</PageTitle>
<h3 class="mb-4">Transactions</h3>
<h3 class="mb-2">Transactions</h3>
<p class="text-muted mb-4">Record, edit and manage your income and expenses.</p>
<div class="card mb-4">
<div class="card-header">