Refactor. Add data layer
This commit is contained in:
1 parent
3b7e4e6ec5
commit
b6b794f0b4
82 files changed
+2930
-2811
No files matched your search
@@ -1,11 +1,11 @@
|
||||
@page "/reports"
|
||||
@attribute [Authorize]
|
||||
@inject TransactionService TransactionService
|
||||
@inject AccountService AccountService
|
||||
@inject CategoryService CategoryService
|
||||
@inject SalaryService SalaryService
|
||||
@inject ITransactionRepository TransactionService
|
||||
@inject IAccountRepository AccountService
|
||||
@inject ICategoryRepository CategoryService
|
||||
@inject ISalaryRepository SalaryService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime JS
|
||||
@inject IJSRuntime Js
|
||||
@implements IAsyncDisposable
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
<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)
|
||||
@if (_loading)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (accounts.Count == 0)
|
||||
else if (_accounts.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<h5>No data yet</h5>
|
||||
@@ -33,23 +33,23 @@ else
|
||||
<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>
|
||||
<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" />
|
||||
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" />
|
||||
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)
|
||||
@foreach (var account in _accounts)
|
||||
{
|
||||
<button class="btn btn-sm @(selectedAccountIds.Contains(account.Id) ? "btn-primary" : "btn-outline-secondary")"
|
||||
<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
|
||||
@@ -65,7 +65,7 @@ else
|
||||
<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)
|
||||
@if (_categoryBreakdown.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No expenses in this period.</p>
|
||||
}
|
||||
@@ -84,7 +84,7 @@ else
|
||||
<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)
|
||||
@if (_incomeBreakdown.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No income in this period.</p>
|
||||
}
|
||||
@@ -103,7 +103,7 @@ else
|
||||
<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)
|
||||
@if (_monthlyLabels.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No transactions in this period.</p>
|
||||
}
|
||||
@@ -120,7 +120,7 @@ else
|
||||
<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)
|
||||
@if (_categoryBreakdown.Count == 0 && _incomeBreakdown.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No transactions in this period.</p>
|
||||
}
|
||||
@@ -137,14 +137,14 @@ else
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in categorySummary)
|
||||
@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 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")
|
||||
@_currencySymbol@row.Net.ToString("N2")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -152,11 +152,11 @@ else
|
||||
<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 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")
|
||||
@_currencySymbol@totalNet.ToString("N2")
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -165,210 +165,4 @@ else
|
||||
}
|
||||
</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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user