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
|
||||
@{
|
||||
ViewData["Title"] = "MoneyMap";
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="card-body">
|
||||
<div class="text-muted">Transactions</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>
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="small text-muted">
|
||||
@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
|
||||
{
|
||||
@@ -63,7 +63,7 @@
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header">
|
||||
Top expense categories (last 90 days)
|
||||
<small class="text-muted">· excludes transfers</small>
|
||||
<small class="text-muted">· excludes transfers</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0 table-hover">
|
||||
@@ -125,7 +125,7 @@
|
||||
@if (t.ReceiptCount > 0)
|
||||
{
|
||||
<span class="badge bg-success" title="@t.ReceiptCount receipt(s) attached">
|
||||
📄 @t.ReceiptCount
|
||||
?? @t.ReceiptCount
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@@ -152,3 +152,56 @@
|
||||
</table>
|
||||
</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 List<TopCategoryRow> TopCategories { 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()
|
||||
{
|
||||
@@ -29,6 +33,10 @@ namespace MoneyMap.Pages
|
||||
Stats = dashboard.Stats;
|
||||
TopCategories = dashboard.TopCategories;
|
||||
Recent = dashboard.RecentTransactions;
|
||||
TrendLabels = dashboard.Trends.Labels;
|
||||
TrendDebitsAbs = dashboard.Trends.DebitsAbs;
|
||||
TrendCredits = dashboard.Trends.Credits;
|
||||
TrendNet = dashboard.Trends.Net;
|
||||
}
|
||||
|
||||
public record DashboardStats(
|
||||
@@ -74,17 +82,20 @@ namespace MoneyMap.Pages
|
||||
private readonly IDashboardStatsCalculator _statsCalculator;
|
||||
private readonly ITopCategoriesProvider _topCategoriesProvider;
|
||||
private readonly IRecentTransactionsProvider _recentTransactionsProvider;
|
||||
private readonly ISpendTrendsProvider _spendTrendsProvider;
|
||||
|
||||
public DashboardService(
|
||||
MoneyMapContext db,
|
||||
IDashboardStatsCalculator statsCalculator,
|
||||
ITopCategoriesProvider topCategoriesProvider,
|
||||
IRecentTransactionsProvider recentTransactionsProvider)
|
||||
IRecentTransactionsProvider recentTransactionsProvider,
|
||||
ISpendTrendsProvider spendTrendsProvider)
|
||||
{
|
||||
_db = db;
|
||||
_statsCalculator = statsCalculator;
|
||||
_topCategoriesProvider = topCategoriesProvider;
|
||||
_recentTransactionsProvider = recentTransactionsProvider;
|
||||
_spendTrendsProvider = spendTrendsProvider;
|
||||
}
|
||||
|
||||
public async Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20)
|
||||
@@ -92,12 +103,14 @@ namespace MoneyMap.Pages
|
||||
var stats = await _statsCalculator.CalculateAsync();
|
||||
var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount);
|
||||
var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount);
|
||||
var trends = await _spendTrendsProvider.GetDailyTrendsAsync(30);
|
||||
|
||||
return new DashboardData
|
||||
{
|
||||
Stats = stats,
|
||||
TopCategories = topCategories,
|
||||
RecentTransactions = recent
|
||||
RecentTransactions = recent,
|
||||
Trends = trends
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -264,5 +277,82 @@ namespace MoneyMap.Pages
|
||||
public required IndexModel.DashboardStats Stats { get; init; }
|
||||
public required List<IndexModel.TopCategoryRow> TopCategories { 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<ITopCategoriesProvider, TopCategoriesProvider>();
|
||||
builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
|
||||
builder.Services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
|
||||
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
|
||||
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
|
||||
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
|
||||
|
||||
Reference in New Issue
Block a user