Feature: Add transaction multi-select for batch AI review

Add selection UI to Transactions page:
- Add checkbox column for selecting transactions
- Add sticky selection bar showing selected count
- Add Select All / Clear Selection controls
- Wire up form to send selected IDs to AICategorizePreview page
- Preserve row click navigation for unselected areas

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 22:53:05 -05:00
parent c43fe12124
commit f72c32c52f

View File

@@ -133,11 +133,31 @@
</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">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
@if (!string.IsNullOrWhiteSpace(Model.Category))
{
<strong>@Model.Category</strong>
@@ -149,11 +169,17 @@
<span class="text-muted">- @Model.Stats.Count total</span>
}
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="toggleSelectAll(this.checked)">
<label class="form-check-label small" for="selectAllCheckbox">Select all on page</label>
</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;"></th>
<th style="width: 70px;">ID</th>
<th style="width: 110px;">Date</th>
<th>Name</th>
@@ -166,12 +192,16 @@
<tbody>
@foreach (var t in Model.Transactions)
{
<tr style="cursor: pointer;" title="Open details" onclick="window.location.href='@Url.Page("/EditTransaction", new { id = t.Id })'">
<td class="small text-muted">#@t.Id</td>
<td>@t.Date.ToString("yyyy-MM-dd")</td>
<td>
<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">
<a asp-page="/EditTransaction" asp-route-id="@t.Id" class="text-decoration-none text-body">@t.Name</a>
<span class="text-body">@t.Name</span>
@if (t.ReceiptCount > 0)
{
<span class="badge bg-success" title="@t.ReceiptCount receipt(s) attached">
@@ -187,11 +217,11 @@
}
</div>
</td>
<td class="text-truncate" style="max-width:320px">@t.Memo</td>
<td class="text-end @(t.Amount >= 0 ? "text-success" : "")">
<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>
<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>
@@ -201,10 +231,9 @@
@t.Category
}
</td>
<td class="small">
<td class="small" style="cursor: pointer;" onclick="window.location.href='@Url.Page("/EditTransaction", new { id = t.Id })'">
@t.CardLabel
</td>
</tr>
}
</tbody>
@@ -340,6 +369,56 @@ else
});
}
})();
// 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>
}