226 lines
9.3 KiB
Plaintext
226 lines
9.3 KiB
Plaintext
@page
|
|
@model MoneyMap.Pages.IndexModel
|
|
@{
|
|
ViewData["Title"] = "MoneyMap";
|
|
}
|
|
|
|
<div class="row g-3">
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="card shadow-sm">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<div class="text-muted">Uncategorized</div>
|
|
<div class="fs-3 fw-bold">@Model.Stats.Uncategorized</div>
|
|
<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>
|
|
}
|
|
else
|
|
{
|
|
<span>All categorized!</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<div class="text-muted">Receipts</div>
|
|
<div class="fs-3 fw-bold">@Model.Stats.Receipts</div>
|
|
<div class="small text-muted">Linked files</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<div class="text-muted">Cards</div>
|
|
<div class="fs-3 fw-bold">@Model.Stats.Cards</div>
|
|
<div class="small text-muted">Tracked accounts</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="my-3 d-flex gap-2">
|
|
<a class="btn btn-primary" asp-page="/Upload">Upload CSV</a>
|
|
<a class="btn btn-outline-secondary" asp-page="/Transactions">View All Transactions</a>
|
|
<a class="btn btn-outline-secondary" asp-page="/CategoryMappings">Categories</a>
|
|
</div>
|
|
<div class="row g-3 my-2">
|
|
<div class="col-lg-6">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header">Spending by category (last 90 days)</div>
|
|
<div class="card-body">
|
|
<canvas id="categoryChart" height="220"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-6">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header">Cash flow (last 30 days)</div>
|
|
<div class="card-body">
|
|
<canvas id="trendChart" height="220"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if (Model.TopCategories.Any())
|
|
{
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-header">
|
|
Top expense categories (last 90 days)
|
|
<small class="text-muted">· excludes transfers</small>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<table class="table table-sm mb-0 table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>Category</th>
|
|
<th class="text-end">Total Spend</th>
|
|
<th class="text-end">Avg Per Txn</th>
|
|
<th class="text-end">% of Total</th>
|
|
<th class="text-end">Txns</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var c in Model.TopCategories)
|
|
{
|
|
<tr style="cursor: pointer;" onclick="window.location.href='@Url.Page("/Transactions", new { category = string.IsNullOrWhiteSpace(c.Category) ? "(blank)" : c.Category })'">
|
|
<td>
|
|
<a asp-page="/Transactions" asp-route-category="@(string.IsNullOrWhiteSpace(c.Category) ? "(blank)" : c.Category)" class="text-decoration-none text-dark">
|
|
@(string.IsNullOrWhiteSpace(c.Category) ? "(uncategorized)" : c.Category)
|
|
</a>
|
|
</td>
|
|
<td class="text-end">@c.TotalSpend.ToString("C")</td>
|
|
<td class="text-end text-muted">@c.AveragePerTransaction.ToString("C")</td>
|
|
<td class="text-end">
|
|
<span class="badge bg-secondary">@c.PercentageOfTotal.ToString("F1")%</span>
|
|
</td>
|
|
<td class="text-end">@c.Count</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div class="card shadow-sm">
|
|
<div class="card-header">Recent transactions</div>
|
|
<div class="card-body p-0">
|
|
<table class="table table-hover table-sm mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 110px;">Date</th>
|
|
<th>Name</th>
|
|
<th>Memo</th>
|
|
<th style="width: 110px;" class="text-end">Amount</th>
|
|
<th style="width: 160px;">Category</th>
|
|
<th style="width: 110px;">Card</th>
|
|
<th style="width: 90px;">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var t in Model.Recent)
|
|
{
|
|
<tr style="cursor: pointer;" title="View details" onclick="window.location.href='@Url.Page("/EditTransaction", new { id = t.Id })'">
|
|
<td>@t.Date.ToString("yyyy-MM-dd")</td>
|
|
<td>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<a asp-page="/EditTransaction" asp-route-id="@t.Id" class="text-decoration-none text-dark">@t.Name</a>
|
|
@if (t.ReceiptCount > 0)
|
|
{
|
|
<span class="badge bg-success" title="@t.ReceiptCount receipt(s) attached">
|
|
?? @t.ReceiptCount
|
|
</span>
|
|
}
|
|
</div>
|
|
</td>
|
|
<td class="text-truncate" style="max-width:320px">@t.Memo</td>
|
|
<td class="text-end">@t.Amount.ToString("C")</td>
|
|
<td>
|
|
@if (string.IsNullOrWhiteSpace(t.Category))
|
|
{
|
|
<a asp-page="/Transactions" asp-route-category="(blank)" class="text-decoration-none text-muted">(uncategorized)</a>
|
|
}
|
|
else
|
|
{
|
|
<a asp-page="/Transactions" asp-route-category="@t.Category" class="text-decoration-none text-dark">@t.Category</a>
|
|
}
|
|
</td>
|
|
<td>@t.CardLabel</td>
|
|
<td>
|
|
<a asp-page="/EditTransaction" asp-route-id="@t.Id" class="btn btn-sm btn-outline-secondary">Open</a>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</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>
|