Files
MoneyMap/MoneyMap/Pages/Transactions.cshtml
AJ Isaacs 7725bdb159 Improve: Move select-all checkbox into table header row
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>
2026-02-26 19:09:05 -05:00

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>
}