Move the "Select all on page" checkbox from the card header into the first column header of the transactions table, aligned with per-row checkboxes for a cleaner layout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
423 lines
19 KiB
Plaintext
423 lines
19 KiB
Plaintext
@page
|
|
@model MoneyMap.Pages.TransactionsModel
|
|
@{
|
|
ViewData["Title"] = "Transactions";
|
|
}
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h2>Transactions</h2>
|
|
<div class="d-flex gap-2">
|
|
<a asp-page="/Upload" class="btn btn-primary">Upload CSV</a>
|
|
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-body">
|
|
<form method="get" class="row g-3">
|
|
<div class="col-md-12 mb-2">
|
|
<label for="Search" class="form-label">Search</label>
|
|
<input asp-for="Search" type="text" class="form-control" placeholder="Search by name, memo, category, notes, or merchant..." />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="Category" class="form-label">Category</label>
|
|
<select asp-for="Category" class="form-select">
|
|
<option value="">All Categories</option>
|
|
@foreach (var cat in Model.AvailableCategories)
|
|
{
|
|
<option value="@(string.IsNullOrWhiteSpace(cat) ? "(blank)" : cat)">@(string.IsNullOrWhiteSpace(cat) ? "(blank)" : cat)</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="Merchant" class="form-label">Merchant</label>
|
|
<select asp-for="Merchant" class="form-select">
|
|
<option value="">All Merchants</option>
|
|
@foreach (var merchant in Model.AvailableMerchants)
|
|
{
|
|
<option value="@merchant">@merchant</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="CardId" class="form-label">Card</label>
|
|
<select asp-for="CardId" class="form-select">
|
|
<option value="">All Cards</option>
|
|
@foreach (var card in Model.AvailableCards)
|
|
{
|
|
<option value="@card.Id">@card.Owner - @card.Last4</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="StartDate" class="form-label">Start Date</label>
|
|
<input asp-for="StartDate" type="date" class="form-control" id="startDateInput" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="EndDate" class="form-label">End Date</label>
|
|
<input asp-for="EndDate" type="date" class="form-control" id="endDateInput" />
|
|
</div>
|
|
<div class="col-md-12 mb-2">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="Quick date ranges">
|
|
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(30)">Last 30 Days</button>
|
|
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(60)">Last 60 Days</button>
|
|
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(90)">Last 90 Days</button>
|
|
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(365)">Last Year</button>
|
|
<button type="button" class="btn btn-outline-secondary" onclick="setDateRangeThisMonth()">This Month</button>
|
|
<button type="button" class="btn btn-outline-secondary" onclick="setDateRangeLastMonth()">Last Month</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2 d-flex align-items-end">
|
|
<button type="submit" class="btn btn-primary w-100">Filter</button>
|
|
</div>
|
|
</form>
|
|
@if (!string.IsNullOrWhiteSpace(Model.Search) || !string.IsNullOrWhiteSpace(Model.Category) || !string.IsNullOrWhiteSpace(Model.Merchant) || !string.IsNullOrWhiteSpace(Model.CardId) || Model.StartDate.HasValue || Model.EndDate.HasValue)
|
|
{
|
|
<div class="mt-2">
|
|
<a asp-page="/Transactions" class="btn btn-sm btn-outline-secondary">Clear Filters</a>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-md-3">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<div class="text-muted small">Transactions</div>
|
|
<div class="fs-4 fw-bold">@Model.Stats.Count</div>
|
|
<div class="small text-muted">
|
|
Showing @Model.Transactions.Count on page @Model.PageNumber of @Model.TotalPages
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<div class="text-muted small">Total Spent</div>
|
|
<div class="fs-4 fw-bold text-danger">@Model.Stats.TotalDebits.ToString("C")</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<div class="text-muted small">Total Income</div>
|
|
<div class="fs-4 fw-bold text-success">@Model.Stats.TotalCredits.ToString("C")</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<div class="text-muted small">Net</div>
|
|
<div class="fs-4 fw-bold @(Model.Stats.NetAmount >= 0 ? "text-success" : "text-danger")">
|
|
@Model.Stats.NetAmount.ToString("C")
|
|
</div>
|
|
</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>
|
|
}
|
|
|
|
<!-- Selection Action Bar (hidden by default) -->
|
|
<div id="selectionBar" class="alert alert-primary d-none sticky-top" style="top: 10px; z-index: 1020;">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<strong><span id="selectedCount">0</span> transaction(s) selected</strong>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="selectAllVisible()">Select All Visible</button>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearSelection()">Clear Selection</button>
|
|
<form id="aiReviewForm" method="post" asp-page="/AICategorizePreview" asp-page-handler="StoreIds" class="d-inline">
|
|
@Html.AntiForgeryToken()
|
|
<button type="submit" class="btn btn-sm btn-success">
|
|
AI Review Categories
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Transactions Table -->
|
|
@if (Model.Transactions.Any())
|
|
{
|
|
<div class="card shadow-sm">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<div>
|
|
@if (!string.IsNullOrWhiteSpace(Model.Category))
|
|
{
|
|
<strong>@Model.Category</strong>
|
|
<span class="text-muted">- @Model.Stats.Count transactions</span>
|
|
}
|
|
else
|
|
{
|
|
<strong>All Transactions</strong>
|
|
<span class="text-muted">- @Model.Stats.Count total</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover table-sm mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 40px;">
|
|
<input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="toggleSelectAll(this.checked)" title="Select all on page">
|
|
</th>
|
|
<th style="width: 70px;">ID</th>
|
|
<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: 140px;">Payment/Account</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var t in Model.Transactions)
|
|
{
|
|
<tr>
|
|
<td onclick="event.stopPropagation();">
|
|
<input type="checkbox" class="form-check-input txn-checkbox"
|
|
value="@t.Id" onchange="updateSelection()">
|
|
</td>
|
|
<td class="small text-muted" style="cursor: pointer;" onclick="window.location.href='@Url.Page("/EditTransaction", new { id = t.Id })'">#@t.Id</td>
|
|
<td style="cursor: pointer;" onclick="window.location.href='@Url.Page("/EditTransaction", new { id = t.Id })'">@t.Date.ToString("yyyy-MM-dd")</td>
|
|
<td style="cursor: pointer;" onclick="window.location.href='@Url.Page("/EditTransaction", new { id = t.Id })'">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<span class="text-body">@t.Name</span>
|
|
@if (t.ReceiptCount > 0)
|
|
{
|
|
<span class="badge bg-success" title="@t.ReceiptCount receipt(s) attached">
|
|
@t.ReceiptCount
|
|
</span>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(t.Notes))
|
|
{
|
|
<span class="badge bg-info"
|
|
title="@t.Notes"
|
|
data-bs-toggle="tooltip"
|
|
data-bs-placement="top">Note</span>
|
|
}
|
|
</div>
|
|
</td>
|
|
<td class="text-truncate" style="max-width:320px; cursor: pointer;" onclick="window.location.href='@Url.Page("/EditTransaction", new { id = t.Id })'">@t.Memo</td>
|
|
<td class="text-end @(t.Amount >= 0 ? "text-success" : "")" style="cursor: pointer;" onclick="window.location.href='@Url.Page("/EditTransaction", new { id = t.Id })'">
|
|
@t.Amount.ToString("C")
|
|
</td>
|
|
<td style="cursor: pointer;" onclick="window.location.href='@Url.Page("/EditTransaction", new { id = t.Id })'">
|
|
@if (string.IsNullOrWhiteSpace(t.Category))
|
|
{
|
|
<span class="text-muted">(uncategorized)</span>
|
|
}
|
|
else
|
|
{
|
|
@t.Category
|
|
}
|
|
</td>
|
|
<td class="small" style="cursor: pointer;" onclick="window.location.href='@Url.Page("/EditTransaction", new { id = t.Id })'">
|
|
@t.CardLabel
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="alert alert-info">
|
|
No transactions found matching the selected filters.
|
|
</div>
|
|
}
|
|
|
|
<!-- Pagination -->
|
|
@if (Model.TotalPages > 1)
|
|
{
|
|
<nav aria-label="Transaction pagination" class="mt-3">
|
|
<ul class="pagination justify-content-center">
|
|
<!-- Previous -->
|
|
<li class="page-item @(Model.PageNumber == 1 ? "disabled" : "")">
|
|
<a class="page-link"
|
|
asp-page="/Transactions"
|
|
asp-route-pageNumber="@(Model.PageNumber - 1)"
|
|
asp-route-search="@Model.Search"
|
|
asp-route-category="@Model.Category"
|
|
asp-route-merchant="@Model.Merchant"
|
|
asp-route-cardId="@Model.CardId"
|
|
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
|
|
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
|
|
Previous
|
|
</a>
|
|
</li>
|
|
|
|
<!-- First page -->
|
|
@if (Model.PageNumber > 3)
|
|
{
|
|
<li class="page-item">
|
|
<a class="page-link"
|
|
asp-page="/Transactions"
|
|
asp-route-pageNumber="1"
|
|
asp-route-search="@Model.Search"
|
|
asp-route-category="@Model.Category"
|
|
asp-route-merchant="@Model.Merchant"
|
|
asp-route-cardId="@Model.CardId"
|
|
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
|
|
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
|
|
1
|
|
</a>
|
|
</li>
|
|
<li class="page-item disabled"><span class="page-link">...</span></li>
|
|
}
|
|
|
|
<!-- Page numbers -->
|
|
@for (int i = Math.Max(1, Model.PageNumber - 2); i <= Math.Min(Model.TotalPages, Model.PageNumber + 2); i++)
|
|
{
|
|
<li class="page-item @(i == Model.PageNumber ? "active" : "")">
|
|
<a class="page-link"
|
|
asp-page="/Transactions"
|
|
asp-route-pageNumber="@i"
|
|
asp-route-search="@Model.Search"
|
|
asp-route-category="@Model.Category"
|
|
asp-route-merchant="@Model.Merchant"
|
|
asp-route-cardId="@Model.CardId"
|
|
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
|
|
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
|
|
@i
|
|
</a>
|
|
</li>
|
|
}
|
|
|
|
<!-- Last page -->
|
|
@if (Model.PageNumber < Model.TotalPages - 2)
|
|
{
|
|
<li class="page-item disabled"><span class="page-link">...</span></li>
|
|
<li class="page-item">
|
|
<a class="page-link"
|
|
asp-page="/Transactions"
|
|
asp-route-pageNumber="@Model.TotalPages"
|
|
asp-route-search="@Model.Search"
|
|
asp-route-category="@Model.Category"
|
|
asp-route-merchant="@Model.Merchant"
|
|
asp-route-cardId="@Model.CardId"
|
|
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
|
|
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
|
|
@Model.TotalPages
|
|
</a>
|
|
</li>
|
|
}
|
|
|
|
<!-- Next -->
|
|
<li class="page-item @(Model.PageNumber >= Model.TotalPages ? "disabled" : "")">
|
|
<a class="page-link"
|
|
asp-page="/Transactions"
|
|
asp-route-pageNumber="@(Model.PageNumber + 1)"
|
|
asp-route-search="@Model.Search"
|
|
asp-route-category="@Model.Category"
|
|
asp-route-merchant="@Model.Merchant"
|
|
asp-route-cardId="@Model.CardId"
|
|
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
|
|
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
|
|
Next
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
}
|
|
|
|
@section Scripts {
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.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: ['#6366f1','#f59e0b','#ef4444','#10b981','#06b6d4','#8b5cf6','#f97316','#ec4899','#84cc16','#a78bfa']
|
|
}]
|
|
},
|
|
options: {
|
|
plugins: { legend: { position: 'bottom', labels: { color: '#64748b', font: { size: 12 } } } },
|
|
maintainAspectRatio: false
|
|
}
|
|
});
|
|
}
|
|
})();
|
|
|
|
// Transaction selection handling
|
|
function updateSelection() {
|
|
const checkboxes = document.querySelectorAll('.txn-checkbox:checked');
|
|
const count = checkboxes.length;
|
|
const selectionBar = document.getElementById('selectionBar');
|
|
const selectedCount = document.getElementById('selectedCount');
|
|
const form = document.getElementById('aiReviewForm');
|
|
|
|
selectedCount.textContent = count;
|
|
|
|
if (count > 0) {
|
|
selectionBar.classList.remove('d-none');
|
|
// Update form with selected IDs
|
|
const existingInputs = form.querySelectorAll('input[name="transactionIds"]');
|
|
existingInputs.forEach(input => input.remove());
|
|
|
|
checkboxes.forEach(cb => {
|
|
const input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = 'transactionIds';
|
|
input.value = cb.value;
|
|
form.appendChild(input);
|
|
});
|
|
} else {
|
|
selectionBar.classList.add('d-none');
|
|
}
|
|
|
|
// Update select all checkbox state
|
|
const allCheckboxes = document.querySelectorAll('.txn-checkbox');
|
|
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
|
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
|
selectAllCheckbox.indeterminate = checkboxes.length > 0 && checkboxes.length < allCheckboxes.length;
|
|
}
|
|
|
|
function toggleSelectAll(checked) {
|
|
document.querySelectorAll('.txn-checkbox').forEach(cb => cb.checked = checked);
|
|
updateSelection();
|
|
}
|
|
|
|
function selectAllVisible() {
|
|
document.querySelectorAll('.txn-checkbox').forEach(cb => cb.checked = true);
|
|
updateSelection();
|
|
}
|
|
|
|
function clearSelection() {
|
|
document.querySelectorAll('.txn-checkbox').forEach(cb => cb.checked = false);
|
|
document.getElementById('selectAllCheckbox').checked = false;
|
|
updateSelection();
|
|
}
|
|
</script>
|
|
}
|
|
|