Files
MoneyMap/MoneyMap/Pages/Upload.cshtml
AJ c44929afe1 Add pagination to transaction preview for large imports
When uploading files with more than 100 transactions, the preview now displays transactions in pages of 100 rows at a time. This prevents browser freezing when rendering thousands of form inputs and dramatically improves page load performance.

- Show first 100 transactions by default with pagination controls
- All transactions remain in DOM but hidden for instant page switching
- Update counters and form submission to work across all pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:17:46 -04:00

351 lines
16 KiB
Plaintext

@page
@model MoneyMap.Pages.UploadModel
@{
ViewData["Title"] = "Upload Transactions";
ViewData["FullWidth"] = Model.PreviewTransactions.Any();
}
<h2>Upload Transactions</h2>
@if (Model.PreviewTransactions.Any())
{
<div class="alert alert-info">
<strong>Preview:</strong> Review the transactions below before importing. Duplicates are marked in gray and will be skipped.
</div>
<form method="post" asp-page-handler="Confirm" id="confirmForm" onsubmit="return prepareFormData()">
<input type="hidden" name="selectedIndices" id="selectedIndices" />
<input type="hidden" name="paymentData" id="paymentData" />
<!-- Account Selection for All Transactions -->
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-3">
<label class="form-label fw-bold mb-0">Account for all transactions:</label>
</div>
<div class="col-md-6">
<select id="globalAccountId" class="form-select" onchange="updateAllTransactionAccounts()">
<option value="">-- choose account --</option>
@foreach (var a in Model.Accounts)
{
var isSelected = Model.PreviewTransactions.FirstOrDefault()?.Transaction.AccountId == a.Id;
<option value="@a.Id" selected="@isSelected">@a.DisplayLabel</option>
}
</select>
</div>
<div class="col-md-3">
<small class="text-muted">All transactions in this file belong to this account</small>
</div>
</div>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Transaction Preview (<span id="selectedCount">@Model.PreviewTransactions.Count(p => !p.IsDuplicate)</span> selected, @Model.PreviewTransactions.Count(p => p.IsDuplicate) duplicates)</strong>
@if (Model.PreviewTransactions.Count > 100)
{
<div class="d-flex align-items-center gap-2">
<small class="text-muted">Showing <span id="pageStart">1</span>-<span id="pageEnd">100</span> of @Model.PreviewTransactions.Count</small>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="previousPage()">Previous</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="nextPage()">Next</button>
</div>
}
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-hover mb-0">
<thead class="sticky-top bg-white">
<tr>
<th style="width: 50px;">
<input type="checkbox" id="selectAll" class="form-check-input" onchange="toggleAllCheckboxes()" checked />
</th>
<th style="width: 100px;">Date</th>
<th>Merchant</th>
<th>Memo</th>
<th style="width: 100px;" class="text-end">Amount</th>
<th style="width: 150px;">Category</th>
<th style="width: 180px;">Card Used (optional)</th>
<th style="width: 80px;">Status</th>
</tr>
</thead>
<tbody id="transactionTableBody">
@for (int i = 0; i < Model.PreviewTransactions.Count; i++)
{
var preview = Model.PreviewTransactions[i];
var displayStyle = i >= 100 ? "display: none;" : "";
<tr class="@(preview.IsDuplicate ? "table-secondary text-muted" : "") transaction-row" data-index="@i" style="@displayStyle">
<td>
<input type="checkbox" data-index="@i"
class="form-check-input transaction-checkbox"
checked="@(!preview.IsDuplicate)"
onchange="updateSelectedCount()" />
</td>
<td>@preview.Transaction.Date.ToString("MM/dd/yy")</td>
<td>@preview.Transaction.Name</td>
<td class="text-truncate" style="max-width: 200px;" title="@preview.Transaction.Memo">
@preview.Transaction.Memo
</td>
<td class="text-end @(preview.Transaction.Amount >= 0 ? "text-success" : "text-danger")">
@preview.Transaction.Amount.ToString("C")
</td>
<td>
<input type="text"
data-index="@i"
class="form-control form-control-sm category-input"
value="@preview.Transaction.Category"
placeholder="(uncategorized)"
list="categoryList" />
@if (!string.IsNullOrWhiteSpace(preview.SuggestedCategory))
{
<small class="badge bg-info" title="Auto-suggested">Auto</small>
}
</td>
<td>
<select data-index="@i" class="form-select form-select-sm card-select">
<option value="">-- none / direct debit --</option>
@foreach (var c in Model.Cards)
{
<option value="@c.Id" selected="@(preview.Transaction.CardId == c.Id)">@c.DisplayLabel</option>
}
</select>
</td>
<td>
@if (preview.IsDuplicate)
{
<span class="badge bg-warning">Duplicate</span>
}
else
{
<span class="badge bg-success">New</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<!-- Category autocomplete datalist -->
<datalist id="categoryList">
@foreach (var cat in Model.PreviewTransactions.Select(p => p.Transaction.Category).Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().OrderBy(c => c))
{
<option value="@cat">@cat</option>
}
</datalist>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success btn-lg">
<strong>Confirm Import</strong> (<span id="selectedCountButton">@Model.PreviewTransactions.Count(p => !p.IsDuplicate)</span> transactions)
</button>
<a asp-page="/Upload" class="btn btn-secondary btn-lg">Cancel</a>
</div>
</form>
}
else if (Model.Result != null)
{
<div class="alert alert-success">
<h4>Import Complete!</h4>
<p>
<strong>@Model.Result.Total</strong> total rows processed<br />
<strong class="text-success">@Model.Result.Inserted</strong> new transactions imported<br />
<strong class="text-warning">@Model.Result.Skipped</strong> duplicates skipped
</p>
<a asp-page="/Transactions" class="btn btn-primary">View Transactions</a>
<a asp-page="/Upload" class="btn btn-outline-secondary">Upload More</a>
</div>
}
else
{
<form method="post" enctype="multipart/form-data" class="vstack gap-3">
@Html.AntiForgeryToken()
<div>
<label class="form-label">CSV file</label>
<input asp-for="Csv" type="file" class="form-control" accept=".csv" />
<span asp-validation-for="Csv" class="text-danger"></span>
</div>
<fieldset class="border rounded p-3">
<legend class="float-none w-auto fs-6">Payment Method Assignment</legend>
<div class="mb-3">
<label class="form-label fw-bold">Mode</label>
<div class="form-check">
<input class="form-check-input" type="radio" asp-for="PaymentMode" value="Auto" id="modeAuto" onchange="togglePaymentSelection()" checked />
<label class="form-check-label" for="modeAuto">
Automatically determine from CSV
</label>
<div class="form-text small">Extracts card info from memo or filename</div>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" asp-for="PaymentMode" value="Card" id="modeCard" onchange="togglePaymentSelection()" />
<label class="form-check-label" for="modeCard">
Assign all to a specific card
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" asp-for="PaymentMode" value="Account" id="modeAccount" onchange="togglePaymentSelection()" />
<label class="form-check-label" for="modeAccount">
Assign all to a bank account
</label>
<div class="form-text small">For checking/savings account transactions</div>
</div>
</div>
<div id="cardSelectRow" class="mt-3" style="display: none;">
<label class="form-label">Select Card</label>
<select asp-for="SelectedCardId" class="form-select">
<option value="">-- choose card --</option>
@foreach (var c in Model.Cards)
{
<option value="@c.Id">@c.DisplayLabel</option>
}
</select>
<span asp-validation-for="SelectedCardId" class="text-danger"></span>
</div>
<div id="accountSelectRow" class="mt-3" style="display: none;">
<label class="form-label">Select Account</label>
<select asp-for="SelectedAccountId" class="form-select">
<option value="">-- choose account --</option>
@foreach (var a in Model.Accounts)
{
<option value="@a.Id">@a.DisplayLabel</option>
}
</select>
<span asp-validation-for="SelectedAccountId" class="text-danger"></span>
@if (!Model.Accounts.Any())
{
<div class="alert alert-warning mt-2 mb-0">
No accounts available. <a asp-page="/EditAccount">Create an account first</a>.
</div>
}
</div>
</fieldset>
<button type="submit" class="btn btn-primary">Preview Transactions</button>
<div asp-validation-summary="All" class="text-danger"></div>
</form>
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
let currentPage = 0;
const pageSize = 100;
const totalRows = @Model.PreviewTransactions.Count;
function togglePaymentSelection() {
const mode = document.querySelector('input[name="PaymentMode"]:checked').value;
const cardRow = document.getElementById('cardSelectRow');
const accountRow = document.getElementById('accountSelectRow');
cardRow.style.display = mode === 'Card' ? 'block' : 'none';
accountRow.style.display = mode === 'Account' ? 'block' : 'none';
}
function toggleAllCheckboxes() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.transaction-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateSelectedCount();
}
function updateSelectedCount() {
const checkboxes = document.querySelectorAll('.transaction-checkbox:checked');
const count = checkboxes.length;
const selectedCountEl = document.getElementById('selectedCount');
const selectedCountButtonEl = document.getElementById('selectedCountButton');
if (selectedCountEl) selectedCountEl.textContent = count;
if (selectedCountButtonEl) selectedCountButtonEl.textContent = count;
}
function updateAllTransactionAccounts() {
// Called when the global account dropdown changes
// No action needed - we'll read the global account on form submit
}
function showPage(pageNum) {
const rows = document.querySelectorAll('.transaction-row');
const start = pageNum * pageSize;
const end = start + pageSize;
rows.forEach((row, index) => {
row.style.display = (index >= start && index < end) ? '' : 'none';
});
// Update pagination display
const pageStartEl = document.getElementById('pageStart');
const pageEndEl = document.getElementById('pageEnd');
if (pageStartEl && pageEndEl) {
pageStartEl.textContent = start + 1;
pageEndEl.textContent = Math.min(end, totalRows);
}
currentPage = pageNum;
}
function nextPage() {
const maxPage = Math.ceil(totalRows / pageSize) - 1;
if (currentPage < maxPage) {
showPage(currentPage + 1);
}
}
function previousPage() {
if (currentPage > 0) {
showPage(currentPage - 1);
}
}
function prepareFormData() {
// Collect selected indices
const selectedIndices = [];
document.querySelectorAll('.transaction-checkbox:checked').forEach(cb => {
selectedIndices.push(cb.getAttribute('data-index'));
});
document.getElementById('selectedIndices').value = selectedIndices.join(',');
// Get the global account ID
const globalAccountId = document.getElementById('globalAccountId')?.value;
if (!globalAccountId) {
alert('Please select an account for all transactions');
return false;
}
// Collect payment data (account + optional card + category per transaction)
const paymentData = {};
document.querySelectorAll('.card-select').forEach((select) => {
const index = select.getAttribute('data-index');
const cardId = select.value ? parseInt(select.value) : null;
// Get category for this transaction
const categoryInput = document.querySelector(`.category-input[data-index="${index}"]`);
const category = categoryInput ? categoryInput.value.trim() : '';
paymentData[index] = {
AccountId: parseInt(globalAccountId),
CardId: cardId,
Category: category
};
});
document.getElementById('paymentData').value = JSON.stringify(paymentData);
return true; // Allow form submission
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
togglePaymentSelection();
if (totalRows > pageSize) {
showPage(0);
}
});
</script>
}