4be5658d32
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>
399 lines
19 KiB
Plaintext
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>
|
|
}
|