Files
MoneyMap/MoneyMap/Pages/ReceiptQueue.cshtml
T
aj 4be5658d32 Improve: Overhaul navigation with grouped dropdowns, breadcrumbs, and quick-actions
Restructure the flat 7-item navbar into logical dropdown groups (Transactions,
Receipts, Accounts), add a prominent Upload button, settings gear icon, breadcrumb
navigation on 11 deep pages, and dashboard quick-action cards with hover effects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:41:56 -05:00

399 lines
19 KiB
Plaintext

@page
@model MoneyMap.Pages.ReceiptQueueModel
@{
ViewData["Title"] = "Receipt Queue";
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
{
("Receipts", Url.Page("/Receipts")),
("Parse Queue", null)
};
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Receipt Queue</h2>
<div>
<a asp-page="/Receipts" class="btn btn-outline-secondary">Back to Receipts</a>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<div class="alert @(Model.IsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
@Model.Message
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<!-- Upload Form -->
<div class="card shadow-sm mb-4">
<div class="card-header">
<strong>Upload Receipts</strong>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload" id="uploadForm">
<div class="mb-3">
<label for="files" class="form-label">Select Receipt Files</label>
<input type="file" name="files" id="fileInput" class="form-control" multiple
accept=".jpg,.jpeg,.png,.pdf,.gif,.heic" />
<div class="form-text">
Supported: JPG, PNG, PDF, GIF, HEIC (Max 10MB each). Select multiple files at once.
</div>
</div>
<div id="filePreview" class="mb-3" style="display:none;">
<h6>Selected Files:</h6>
<ul id="fileList" class="list-group list-group-flush small"></ul>
</div>
<button type="submit" class="btn btn-primary" id="uploadBtn" disabled>
<span id="uploadBtnText">Upload</span>
<span id="uploadSpinner" class="spinner-border spinner-border-sm ms-1" role="status" style="display:none;"></span>
</button>
</form>
</div>
</div>
<!-- Currently Processing -->
<div id="processingCard" class="card shadow-sm mb-4 border-info" style="display:@(Model.CurrentlyProcessing != null ? "block" : "none");">
<div class="card-header bg-info text-white d-flex align-items-center">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
<strong>Currently Processing</strong>
</div>
<div class="card-body" id="processingBody">
@if (Model.CurrentlyProcessing != null)
{
<div>
<a asp-page="/ViewReceipt" asp-route-id="@Model.CurrentlyProcessing.ReceiptId">
@Model.CurrentlyProcessing.FileName
</a>
<span class="text-muted ms-2">
(uploaded @Model.CurrentlyProcessing.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))
</span>
</div>
}
</div>
</div>
<!-- Queue Dashboard Tabs -->
<div class="card shadow-sm">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" id="queueTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="queued-tab" data-bs-toggle="tab" data-bs-target="#queuedPane"
type="button" role="tab">
Queued <span class="badge bg-warning text-dark ms-1" id="queuedBadge">@Model.QueuedItems.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="completed-tab" data-bs-toggle="tab" data-bs-target="#completedPane"
type="button" role="tab">
Completed <span class="badge bg-success ms-1" id="completedBadge">@Model.CompletedItems.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="failed-tab" data-bs-toggle="tab" data-bs-target="#failedPane"
type="button" role="tab">
Failed <span class="badge bg-danger ms-1" id="failedBadge">@Model.FailedItems.Count</span>
</button>
</li>
</ul>
</div>
<div class="card-body p-0">
<div class="tab-content" id="queueTabContent">
<!-- Queued Tab -->
<div class="tab-pane fade show active" id="queuedPane" role="tabpanel">
@if (Model.QueuedItems.Any())
{
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th style="width:60px;">#</th>
<th>File Name</th>
<th style="width:160px;">Uploaded</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="queuedBody">
@foreach (var item in Model.QueuedItems)
{
<tr>
<td><span class="badge bg-warning text-dark">@item.QueuePosition</span></td>
<td>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId">@item.FileName</a>
</td>
<td class="small text-muted">@item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</td>
<td>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId" class="btn btn-sm btn-outline-primary">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="p-3 text-center text-muted" id="queuedEmpty">No items in queue.</div>
}
</div>
<!-- Completed Tab -->
<div class="tab-pane fade" id="completedPane" role="tabpanel">
@if (Model.CompletedItems.Any())
{
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>File Name</th>
<th>Merchant</th>
<th class="text-end">Total</th>
<th class="text-center">Confidence</th>
<th class="text-center">Items</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="completedBody">
@foreach (var item in Model.CompletedItems)
{
<tr>
<td>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId">@item.FileName</a>
</td>
<td>@(item.Merchant ?? "-")</td>
<td class="text-end">@(item.Total?.ToString("C") ?? "-")</td>
<td class="text-center">
@if (item.Confidence.HasValue)
{
var pct = item.Confidence.Value * 100;
var cls = pct >= 80 ? "success" : pct >= 50 ? "warning" : "danger";
<span class="badge bg-@cls">@pct.ToString("F0")%</span>
}
else
{
<span class="text-muted">-</span>
}
</td>
<td class="text-center">@item.LineItemCount</td>
<td>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId" class="btn btn-sm btn-outline-primary">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="p-3 text-center text-muted" id="completedEmpty">No completed items.</div>
}
</div>
<!-- Failed Tab -->
<div class="tab-pane fade" id="failedPane" role="tabpanel">
@if (Model.FailedItems.Any())
{
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>File Name</th>
<th>Error</th>
<th style="width:160px;">Uploaded</th>
<th style="width:140px;">Actions</th>
</tr>
</thead>
<tbody id="failedBody">
@foreach (var item in Model.FailedItems)
{
<tr>
<td>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId">@item.FileName</a>
</td>
<td class="small text-danger">@(item.ErrorMessage ?? "Unknown error")</td>
<td class="small text-muted">@item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</td>
<td>
<form method="post" asp-page-handler="Retry" asp-route-receiptId="@item.ReceiptId" style="display:inline;">
<button type="submit" class="btn btn-sm btn-outline-warning">Retry</button>
</form>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId" class="btn btn-sm btn-outline-primary">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="p-3 text-center text-muted" id="failedEmpty">No failed items.</div>
}
</div>
</div>
</div>
</div>
@section Scripts {
<script>
// File input preview
document.getElementById('fileInput').addEventListener('change', function () {
var preview = document.getElementById('filePreview');
var list = document.getElementById('fileList');
var btn = document.getElementById('uploadBtn');
list.innerHTML = '';
if (this.files.length > 0) {
preview.style.display = 'block';
btn.disabled = false;
for (var i = 0; i < this.files.length; i++) {
var li = document.createElement('li');
li.className = 'list-group-item py-1';
var sizeKB = (this.files[i].size / 1024).toFixed(1);
li.textContent = this.files[i].name + ' (' + sizeKB + ' KB)';
list.appendChild(li);
}
} else {
preview.style.display = 'none';
btn.disabled = true;
}
});
// Upload spinner
document.getElementById('uploadForm').addEventListener('submit', function () {
document.getElementById('uploadBtn').disabled = true;
document.getElementById('uploadBtnText').textContent = 'Uploading...';
document.getElementById('uploadSpinner').style.display = 'inline-block';
});
// AJAX polling for queue status
var pollInterval = null;
var hasActiveItems = @((Model.CurrentlyProcessing != null || Model.QueuedItems.Any()) ? "true" : "false");
function startPolling() {
if (pollInterval) return;
pollInterval = setInterval(fetchQueueStatus, 3000);
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
function fetchQueueStatus() {
fetch('?handler=QueueStatus', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
updateDashboard(data);
if (!data.currentlyProcessing && data.queued.length === 0) {
stopPolling();
}
})
.catch(err => console.error('Poll error:', err));
}
function updateDashboard(data) {
// Processing card
var procCard = document.getElementById('processingCard');
var procBody = document.getElementById('processingBody');
if (data.currentlyProcessing) {
procCard.style.display = 'block';
procBody.innerHTML = '<div><a href="/ViewReceipt/' + data.currentlyProcessing.receiptId + '">' +
escapeHtml(data.currentlyProcessing.fileName) + '</a></div>';
} else {
procCard.style.display = 'none';
}
// Badges
document.getElementById('queuedBadge').textContent = data.queued.length;
document.getElementById('completedBadge').textContent = data.completed.length;
document.getElementById('failedBadge').textContent = data.failed.length;
// Queued table
var queuedPane = document.getElementById('queuedPane');
if (data.queued.length > 0) {
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0">' +
'<thead><tr><th style="width:60px;">#</th><th>File Name</th><th style="width:160px;">Uploaded</th><th style="width:80px;">Action</th></tr></thead><tbody>';
data.queued.forEach(function(item) {
html += '<tr><td><span class="badge bg-warning text-dark">' + item.queuePosition + '</span></td>' +
'<td><a href="/ViewReceipt/' + item.receiptId + '">' + escapeHtml(item.fileName) + '</a></td>' +
'<td class="small text-muted">' + formatDate(item.uploadedAtUtc) + '</td>' +
'<td><a href="/ViewReceipt/' + item.receiptId + '" class="btn btn-sm btn-outline-primary">View</a></td></tr>';
});
html += '</tbody></table></div>';
queuedPane.innerHTML = html;
} else {
queuedPane.innerHTML = '<div class="p-3 text-center text-muted">No items in queue.</div>';
}
// Completed table
var completedPane = document.getElementById('completedPane');
if (data.completed.length > 0) {
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0">' +
'<thead><tr><th>File Name</th><th>Merchant</th><th class="text-end">Total</th><th class="text-center">Confidence</th><th class="text-center">Items</th><th style="width:80px;">Action</th></tr></thead><tbody>';
data.completed.forEach(function(item) {
var confHtml = '-';
if (item.confidence != null) {
var pct = item.confidence * 100;
var cls = pct >= 80 ? 'success' : pct >= 50 ? 'warning' : 'danger';
confHtml = '<span class="badge bg-' + cls + '">' + pct.toFixed(0) + '%</span>';
}
html += '<tr><td><a href="/ViewReceipt/' + item.receiptId + '">' + escapeHtml(item.fileName) + '</a></td>' +
'<td>' + escapeHtml(item.merchant || '-') + '</td>' +
'<td class="text-end">' + (item.total != null ? '$' + item.total.toFixed(2) : '-') + '</td>' +
'<td class="text-center">' + confHtml + '</td>' +
'<td class="text-center">' + item.lineItemCount + '</td>' +
'<td><a href="/ViewReceipt/' + item.receiptId + '" class="btn btn-sm btn-outline-primary">View</a></td></tr>';
});
html += '</tbody></table></div>';
completedPane.innerHTML = html;
} else {
completedPane.innerHTML = '<div class="p-3 text-center text-muted">No completed items.</div>';
}
// Failed table
var failedPane = document.getElementById('failedPane');
if (data.failed.length > 0) {
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0">' +
'<thead><tr><th>File Name</th><th>Error</th><th style="width:160px;">Uploaded</th><th style="width:140px;">Actions</th></tr></thead><tbody>';
data.failed.forEach(function(item) {
html += '<tr><td><a href="/ViewReceipt/' + item.receiptId + '">' + escapeHtml(item.fileName) + '</a></td>' +
'<td class="small text-danger">' + escapeHtml(item.errorMessage || 'Unknown error') + '</td>' +
'<td class="small text-muted">' + formatDate(item.uploadedAtUtc) + '</td>' +
'<td><form method="post" action="?handler=Retry&receiptId=' + item.receiptId + '" style="display:inline;">' +
'<input type="hidden" name="__RequestVerificationToken" value="' + getAntiForgeryToken() + '" />' +
'<button type="submit" class="btn btn-sm btn-outline-warning">Retry</button></form> ' +
'<a href="/ViewReceipt/' + item.receiptId + '" class="btn btn-sm btn-outline-primary">View</a></td></tr>';
});
html += '</tbody></table></div>';
failedPane.innerHTML = html;
} else {
failedPane.innerHTML = '<div class="p-3 text-center text-muted">No failed items.</div>';
}
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(utcStr) {
var d = new Date(utcStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function getAntiForgeryToken() {
var el = document.querySelector('input[name="__RequestVerificationToken"]');
return el ? el.value : '';
}
if (hasActiveItems) {
startPolling();
}
</script>
}