Feature: Add category pie chart and case-insensitive search
- Add doughnut chart showing spending by category - Fix search to use EF.Functions.Like for explicit case-insensitivity - Include Chart.js CDN for chart rendering 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pie Chart -->
|
||||||
|
@if (Model.CategoryBreakdowns.Any())
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header">Spending by category</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="categoryChart" height="220"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Transactions Table -->
|
<!-- Transactions Table -->
|
||||||
@if (Model.Transactions.Any())
|
@if (Model.Transactions.Any())
|
||||||
{
|
{
|
||||||
@@ -304,6 +315,31 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script src="~/js/transactions.js"></script>
|
<script src="~/js/transactions.js"></script>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const categoryLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.CategoryBreakdowns.Select(c => string.IsNullOrWhiteSpace(c.Category) ? "(uncategorized)" : c.Category)));
|
||||||
|
const categoryValues = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.CategoryBreakdowns.Select(c => c.TotalSpend)));
|
||||||
|
|
||||||
|
const catCtx = document.getElementById('categoryChart');
|
||||||
|
if (catCtx && categoryLabels.length) {
|
||||||
|
new Chart(catCtx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: categoryLabels,
|
||||||
|
datasets: [{
|
||||||
|
data: categoryValues,
|
||||||
|
backgroundColor: ['#4e79a7','#f28e2c','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ab']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
maintainAspectRatio: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ namespace MoneyMap.Pages
|
|||||||
public List<string> AvailableMerchants { get; set; } = new();
|
public List<string> AvailableMerchants { get; set; } = new();
|
||||||
public List<Card> AvailableCards { get; set; } = new();
|
public List<Card> AvailableCards { get; set; } = new();
|
||||||
public TransactionStats Stats { get; set; } = new();
|
public TransactionStats Stats { get; set; } = new();
|
||||||
|
public List<CategoryBreakdown> CategoryBreakdowns { get; set; } = new();
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
public async Task OnGetAsync()
|
||||||
{
|
{
|
||||||
@@ -61,15 +62,16 @@ namespace MoneyMap.Pages
|
|||||||
.Include(t => t.Merchant)
|
.Include(t => t.Merchant)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters (case-insensitive search using EF.Functions.Like)
|
||||||
if (!string.IsNullOrWhiteSpace(Search))
|
if (!string.IsNullOrWhiteSpace(Search))
|
||||||
{
|
{
|
||||||
|
var searchPattern = $"%{Search}%";
|
||||||
query = query.Where(t =>
|
query = query.Where(t =>
|
||||||
t.Name.Contains(Search) ||
|
EF.Functions.Like(t.Name, searchPattern) ||
|
||||||
(t.Memo != null && t.Memo.Contains(Search)) ||
|
(t.Memo != null && EF.Functions.Like(t.Memo, searchPattern)) ||
|
||||||
(t.Category != null && t.Category.Contains(Search)) ||
|
(t.Category != null && EF.Functions.Like(t.Category, searchPattern)) ||
|
||||||
(t.Notes != null && t.Notes.Contains(Search)) ||
|
(t.Notes != null && EF.Functions.Like(t.Notes, searchPattern)) ||
|
||||||
(t.Merchant != null && t.Merchant.Name.Contains(Search)));
|
(t.Merchant != null && EF.Functions.Like(t.Merchant.Name, searchPattern)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(Category))
|
if (!string.IsNullOrWhiteSpace(Category))
|
||||||
@@ -154,6 +156,21 @@ namespace MoneyMap.Pages
|
|||||||
// Calculate stats for filtered results (all pages, not just current)
|
// Calculate stats for filtered results (all pages, not just current)
|
||||||
Stats = await _statsService.CalculateStatsAsync(query);
|
Stats = await _statsService.CalculateStatsAsync(query);
|
||||||
|
|
||||||
|
// Calculate category breakdown for pie chart (only expenses)
|
||||||
|
var expenseQuery = query.Where(t => t.Amount < 0).ExcludeTransfers();
|
||||||
|
var categoryGroups = await expenseQuery
|
||||||
|
.GroupBy(t => t.Category ?? "")
|
||||||
|
.Select(g => new CategoryBreakdown
|
||||||
|
{
|
||||||
|
Category = g.Key,
|
||||||
|
TotalSpend = g.Sum(x => -x.Amount),
|
||||||
|
Count = g.Count()
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.TotalSpend)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
CategoryBreakdowns = categoryGroups;
|
||||||
|
|
||||||
// Get available categories for filter dropdown
|
// Get available categories for filter dropdown
|
||||||
AvailableCategories = await _referenceDataService.GetAvailableCategoriesAsync();
|
AvailableCategories = await _referenceDataService.GetAvailableCategoriesAsync();
|
||||||
|
|
||||||
@@ -178,5 +195,12 @@ namespace MoneyMap.Pages
|
|||||||
public string AccountLabel { get; set; } = "";
|
public string AccountLabel { get; set; } = "";
|
||||||
public int ReceiptCount { get; set; }
|
public int ReceiptCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class CategoryBreakdown
|
||||||
|
{
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public decimal TotalSpend { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user