Dashboard: add graphs (Chart.js) with category donut and 30-day cash flow; add SpendTrends provider and DI registration
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
@page
|
@page
|
||||||
@model MoneyMap.Pages.IndexModel
|
@model MoneyMap.Pages.IndexModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "MoneyMap";
|
ViewData["Title"] = "MoneyMap";
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="text-muted">Transactions</div>
|
<div class="text-muted">Transactions</div>
|
||||||
<div class="fs-3 fw-bold">@Model.Stats.TotalTransactions</div>
|
<div class="fs-3 fw-bold">@Model.Stats.TotalTransactions</div>
|
||||||
<div class="small text-muted">Credits: @Model.Stats.Credits · Debits: @Model.Stats.Debits</div>
|
<div class="small text-muted">Credits: @Model.Stats.Credits · Debits: @Model.Stats.Debits</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="small text-muted">
|
<div class="small text-muted">
|
||||||
@if (Model.Stats.Uncategorized > 0)
|
@if (Model.Stats.Uncategorized > 0)
|
||||||
{
|
{
|
||||||
<a asp-page="/Transactions" asp-route-category="(blank)" class="text-decoration-none">View uncategorized →</a>
|
<a asp-page="/Transactions" asp-route-category="(blank)" class="text-decoration-none">View uncategorized ?</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
<div class="card shadow-sm mb-3">
|
<div class="card shadow-sm mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
Top expense categories (last 90 days)
|
Top expense categories (last 90 days)
|
||||||
<small class="text-muted">· excludes transfers</small>
|
<small class="text-muted">· excludes transfers</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<table class="table table-sm mb-0 table-hover">
|
<table class="table table-sm mb-0 table-hover">
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
@if (t.ReceiptCount > 0)
|
@if (t.ReceiptCount > 0)
|
||||||
{
|
{
|
||||||
<span class="badge bg-success" title="@t.ReceiptCount receipt(s) attached">
|
<span class="badge bg-success" title="@t.ReceiptCount receipt(s) attached">
|
||||||
📄 @t.ReceiptCount
|
?? @t.ReceiptCount
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -152,3 +152,56 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const topLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TopCategories.Select(c => string.IsNullOrWhiteSpace(c.Category) ? "(uncategorized)" : c.Category)));
|
||||||
|
const topValues = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TopCategories.Select(c => c.TotalSpend)));
|
||||||
|
|
||||||
|
const trendLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TrendLabels));
|
||||||
|
const trendDebits = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TrendDebitsAbs));
|
||||||
|
const trendCredits = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TrendCredits));
|
||||||
|
const trendNet = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TrendNet));
|
||||||
|
|
||||||
|
const catCtx = document.getElementById('categoryChart');
|
||||||
|
if (catCtx && topLabels.length) {
|
||||||
|
new Chart(catCtx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: topLabels,
|
||||||
|
datasets: [{
|
||||||
|
data: topValues,
|
||||||
|
backgroundColor: ['#4e79a7','#f28e2c','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
maintainAspectRatio: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendCtx = document.getElementById('trendChart');
|
||||||
|
if (trendCtx) {
|
||||||
|
new Chart(trendCtx, {
|
||||||
|
data: {
|
||||||
|
labels: trendLabels,
|
||||||
|
datasets: [
|
||||||
|
{ type: 'bar', label: 'Debits', data: trendDebits, backgroundColor: 'rgba(225,87,89,0.6)' },
|
||||||
|
{ type: 'bar', label: 'Credits', data: trendCredits, backgroundColor: 'rgba(76,175,80,0.6)' },
|
||||||
|
{ type: 'line', label: 'Net', data: trendNet, borderColor: '#4e79a7', backgroundColor: 'transparent', tension: 0.2, yAxisID: 'y' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
},
|
||||||
|
maintainAspectRatio: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ namespace MoneyMap.Pages
|
|||||||
public DashboardStats Stats { get; set; } = new();
|
public DashboardStats Stats { get; set; } = new();
|
||||||
public List<TopCategoryRow> TopCategories { get; set; } = new();
|
public List<TopCategoryRow> TopCategories { get; set; } = new();
|
||||||
public List<RecentTxnRow> Recent { get; set; } = new();
|
public List<RecentTxnRow> Recent { get; set; } = new();
|
||||||
|
public List<string> TrendLabels { get; set; } = new();
|
||||||
|
public List<decimal> TrendDebitsAbs { get; set; } = new();
|
||||||
|
public List<decimal> TrendCredits { get; set; } = new();
|
||||||
|
public List<decimal> TrendNet { get; set; } = new();
|
||||||
|
|
||||||
public async Task OnGet()
|
public async Task OnGet()
|
||||||
{
|
{
|
||||||
@@ -29,6 +33,10 @@ namespace MoneyMap.Pages
|
|||||||
Stats = dashboard.Stats;
|
Stats = dashboard.Stats;
|
||||||
TopCategories = dashboard.TopCategories;
|
TopCategories = dashboard.TopCategories;
|
||||||
Recent = dashboard.RecentTransactions;
|
Recent = dashboard.RecentTransactions;
|
||||||
|
TrendLabels = dashboard.Trends.Labels;
|
||||||
|
TrendDebitsAbs = dashboard.Trends.DebitsAbs;
|
||||||
|
TrendCredits = dashboard.Trends.Credits;
|
||||||
|
TrendNet = dashboard.Trends.Net;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record DashboardStats(
|
public record DashboardStats(
|
||||||
@@ -74,17 +82,20 @@ namespace MoneyMap.Pages
|
|||||||
private readonly IDashboardStatsCalculator _statsCalculator;
|
private readonly IDashboardStatsCalculator _statsCalculator;
|
||||||
private readonly ITopCategoriesProvider _topCategoriesProvider;
|
private readonly ITopCategoriesProvider _topCategoriesProvider;
|
||||||
private readonly IRecentTransactionsProvider _recentTransactionsProvider;
|
private readonly IRecentTransactionsProvider _recentTransactionsProvider;
|
||||||
|
private readonly ISpendTrendsProvider _spendTrendsProvider;
|
||||||
|
|
||||||
public DashboardService(
|
public DashboardService(
|
||||||
MoneyMapContext db,
|
MoneyMapContext db,
|
||||||
IDashboardStatsCalculator statsCalculator,
|
IDashboardStatsCalculator statsCalculator,
|
||||||
ITopCategoriesProvider topCategoriesProvider,
|
ITopCategoriesProvider topCategoriesProvider,
|
||||||
IRecentTransactionsProvider recentTransactionsProvider)
|
IRecentTransactionsProvider recentTransactionsProvider,
|
||||||
|
ISpendTrendsProvider spendTrendsProvider)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_statsCalculator = statsCalculator;
|
_statsCalculator = statsCalculator;
|
||||||
_topCategoriesProvider = topCategoriesProvider;
|
_topCategoriesProvider = topCategoriesProvider;
|
||||||
_recentTransactionsProvider = recentTransactionsProvider;
|
_recentTransactionsProvider = recentTransactionsProvider;
|
||||||
|
_spendTrendsProvider = spendTrendsProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20)
|
public async Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20)
|
||||||
@@ -92,12 +103,14 @@ namespace MoneyMap.Pages
|
|||||||
var stats = await _statsCalculator.CalculateAsync();
|
var stats = await _statsCalculator.CalculateAsync();
|
||||||
var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount);
|
var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount);
|
||||||
var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount);
|
var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount);
|
||||||
|
var trends = await _spendTrendsProvider.GetDailyTrendsAsync(30);
|
||||||
|
|
||||||
return new DashboardData
|
return new DashboardData
|
||||||
{
|
{
|
||||||
Stats = stats,
|
Stats = stats,
|
||||||
TopCategories = topCategories,
|
TopCategories = topCategories,
|
||||||
RecentTransactions = recent
|
RecentTransactions = recent,
|
||||||
|
Trends = trends
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,5 +277,82 @@ namespace MoneyMap.Pages
|
|||||||
public required IndexModel.DashboardStats Stats { get; init; }
|
public required IndexModel.DashboardStats Stats { get; init; }
|
||||||
public required List<IndexModel.TopCategoryRow> TopCategories { get; init; }
|
public required List<IndexModel.TopCategoryRow> TopCategories { get; init; }
|
||||||
public required List<IndexModel.RecentTxnRow> RecentTransactions { get; init; }
|
public required List<IndexModel.RecentTxnRow> RecentTransactions { get; init; }
|
||||||
|
public required SpendTrends Trends { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Spend Trends Provider =====
|
||||||
|
|
||||||
|
public interface ISpendTrendsProvider
|
||||||
|
{
|
||||||
|
Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SpendTrendsProvider : ISpendTrendsProvider
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
|
public SpendTrendsProvider(MoneyMapContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30)
|
||||||
|
{
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
var since = today.AddDays(-(lastDays - 1));
|
||||||
|
|
||||||
|
var raw = await _db.Transactions
|
||||||
|
.Where(t => t.Date >= since)
|
||||||
|
.ExcludeTransfers()
|
||||||
|
.GroupBy(t => t.Date.Date)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Date = g.Key,
|
||||||
|
Debits = g.Where(t => t.Amount < 0).Sum(t => t.Amount),
|
||||||
|
Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount)
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var dict = raw.ToDictionary(x => x.Date, x => x);
|
||||||
|
var labels = new List<string>();
|
||||||
|
var debitsAbs = new List<decimal>();
|
||||||
|
var credits = new List<decimal>();
|
||||||
|
var net = new List<decimal>();
|
||||||
|
|
||||||
|
for (var d = since; d <= today; d = d.AddDays(1))
|
||||||
|
{
|
||||||
|
labels.Add(d.ToString("yyyy-MM-dd"));
|
||||||
|
if (dict.TryGetValue(d, out var v))
|
||||||
|
{
|
||||||
|
var debit = v.Debits;
|
||||||
|
var credit = v.Credits;
|
||||||
|
debitsAbs.Add(Math.Abs(debit));
|
||||||
|
credits.Add(credit);
|
||||||
|
net.Add(credit + debit);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
debitsAbs.Add(0);
|
||||||
|
credits.Add(0);
|
||||||
|
net.Add(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SpendTrends
|
||||||
|
{
|
||||||
|
Labels = labels,
|
||||||
|
DebitsAbs = debitsAbs,
|
||||||
|
Credits = credits,
|
||||||
|
Net = net
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SpendTrends
|
||||||
|
{
|
||||||
|
public List<string> Labels { get; set; } = new();
|
||||||
|
public List<decimal> DebitsAbs { get; set; } = new();
|
||||||
|
public List<decimal> Credits { get; set; } = new();
|
||||||
|
public List<decimal> Net { get; set; } = new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ builder.Services.AddScoped<IDashboardService, DashboardService>();
|
|||||||
builder.Services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
|
builder.Services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
|
||||||
builder.Services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
|
builder.Services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
|
||||||
builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
|
builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
|
||||||
|
builder.Services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
|
||||||
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
|
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
|
||||||
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
|
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
|
||||||
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
|
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
|
||||||
@@ -65,4 +66,4 @@ app.UseAuthorization();
|
|||||||
|
|
||||||
app.MapRazorPages();
|
app.MapRazorPages();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
Reference in New Issue
Block a user