Feature: Receipt queue dashboard and multi-file upload UI
Add ReceiptQueue page with tabbed dashboard (queued/completed/failed), AJAX polling for live status updates, and per-receipt retry. Update Receipts page with multi-file upload modal, file preview, upload spinner, and bulk retry for failed parses. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,393 @@
|
|||||||
|
@page
|
||||||
|
@model MoneyMap.Pages.ReceiptQueueModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Receipt Queue";
|
||||||
|
}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Models;
|
||||||
|
using MoneyMap.Services;
|
||||||
|
|
||||||
|
namespace MoneyMap.Pages
|
||||||
|
{
|
||||||
|
public class ReceiptQueueModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
private readonly IReceiptManager _receiptManager;
|
||||||
|
private readonly IReceiptParseQueue _parseQueue;
|
||||||
|
|
||||||
|
public ReceiptQueueModel(
|
||||||
|
MoneyMapContext db,
|
||||||
|
IReceiptManager receiptManager,
|
||||||
|
IReceiptParseQueue parseQueue)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_receiptManager = receiptManager;
|
||||||
|
_parseQueue = parseQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<QueueItemViewModel> QueuedItems { get; set; } = new();
|
||||||
|
public List<QueueItemViewModel> CompletedItems { get; set; } = new();
|
||||||
|
public List<QueueItemViewModel> FailedItems { get; set; } = new();
|
||||||
|
public QueueItemViewModel? CurrentlyProcessing { get; set; }
|
||||||
|
|
||||||
|
[TempData]
|
||||||
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
[TempData]
|
||||||
|
public bool IsSuccess { get; set; }
|
||||||
|
|
||||||
|
public async Task OnGetAsync()
|
||||||
|
{
|
||||||
|
await LoadQueueDashboardAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostUploadAsync(List<IFormFile> files)
|
||||||
|
{
|
||||||
|
if (files == null || files.Count == 0)
|
||||||
|
{
|
||||||
|
Message = "Please select files to upload.";
|
||||||
|
IsSuccess = false;
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _receiptManager.UploadManyUnmappedReceiptsAsync(files);
|
||||||
|
|
||||||
|
var messages = new List<string>();
|
||||||
|
if (result.Uploaded.Count > 0)
|
||||||
|
messages.Add($"{result.Uploaded.Count} receipt(s) uploaded and queued for parsing.");
|
||||||
|
if (result.Failed.Count > 0)
|
||||||
|
messages.Add($"{result.Failed.Count} failed: " +
|
||||||
|
string.Join("; ", result.Failed.Select(f => $"{f.FileName}: {f.ErrorMessage}")));
|
||||||
|
|
||||||
|
Message = string.Join(" ", messages);
|
||||||
|
IsSuccess = result.Failed.Count == 0;
|
||||||
|
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetQueueStatusAsync()
|
||||||
|
{
|
||||||
|
await LoadQueueDashboardAsync();
|
||||||
|
|
||||||
|
var data = new
|
||||||
|
{
|
||||||
|
currentlyProcessing = CurrentlyProcessing,
|
||||||
|
queued = QueuedItems,
|
||||||
|
completed = CompletedItems,
|
||||||
|
failed = FailedItems
|
||||||
|
};
|
||||||
|
|
||||||
|
return new JsonResult(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostRetryAsync(long receiptId)
|
||||||
|
{
|
||||||
|
var receipt = await _db.Receipts.FindAsync(receiptId);
|
||||||
|
if (receipt == null)
|
||||||
|
{
|
||||||
|
Message = "Receipt not found.";
|
||||||
|
IsSuccess = false;
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt.ParseStatus = ReceiptParseStatus.Queued;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await _parseQueue.EnqueueAsync(receiptId);
|
||||||
|
|
||||||
|
Message = $"Receipt \"{receipt.FileName}\" re-queued for parsing.";
|
||||||
|
IsSuccess = true;
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadQueueDashboardAsync()
|
||||||
|
{
|
||||||
|
var currentId = _parseQueue.CurrentlyProcessingId;
|
||||||
|
|
||||||
|
// Load all non-NotRequested receipts (recent first, limit to keep things manageable)
|
||||||
|
var recentReceipts = await _db.Receipts
|
||||||
|
.Where(r => r.ParseStatus != ReceiptParseStatus.NotRequested)
|
||||||
|
.OrderByDescending(r => r.UploadedAtUtc)
|
||||||
|
.Take(200)
|
||||||
|
.Select(r => new QueueItemViewModel
|
||||||
|
{
|
||||||
|
ReceiptId = r.Id,
|
||||||
|
FileName = r.FileName,
|
||||||
|
UploadedAtUtc = r.UploadedAtUtc,
|
||||||
|
ParseStatus = r.ParseStatus,
|
||||||
|
Merchant = r.Merchant,
|
||||||
|
Total = r.Total,
|
||||||
|
LineItemCount = r.LineItems.Count
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Get error messages from latest parse log for failed items
|
||||||
|
var failedIds = recentReceipts
|
||||||
|
.Where(r => r.ParseStatus == ReceiptParseStatus.Failed)
|
||||||
|
.Select(r => r.ReceiptId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (failedIds.Count > 0)
|
||||||
|
{
|
||||||
|
var errorLogs = await _db.ReceiptParseLogs
|
||||||
|
.Where(l => failedIds.Contains(l.ReceiptId) && !l.Success)
|
||||||
|
.GroupBy(l => l.ReceiptId)
|
||||||
|
.Select(g => new { ReceiptId = g.Key, Error = g.OrderByDescending(l => l.StartedAtUtc).First().Error })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var errorMap = errorLogs.ToDictionary(e => e.ReceiptId, e => e.Error);
|
||||||
|
|
||||||
|
foreach (var item in recentReceipts.Where(r => r.ParseStatus == ReceiptParseStatus.Failed))
|
||||||
|
{
|
||||||
|
item.ErrorMessage = errorMap.GetValueOrDefault(item.ReceiptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get confidence from latest successful parse log
|
||||||
|
var completedIds = recentReceipts
|
||||||
|
.Where(r => r.ParseStatus == ReceiptParseStatus.Completed)
|
||||||
|
.Select(r => r.ReceiptId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (completedIds.Count > 0)
|
||||||
|
{
|
||||||
|
var confidenceLogs = await _db.ReceiptParseLogs
|
||||||
|
.Where(l => completedIds.Contains(l.ReceiptId) && l.Success)
|
||||||
|
.GroupBy(l => l.ReceiptId)
|
||||||
|
.Select(g => new { ReceiptId = g.Key, Confidence = g.OrderByDescending(l => l.StartedAtUtc).First().Confidence })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var confidenceMap = confidenceLogs.ToDictionary(c => c.ReceiptId, c => c.Confidence);
|
||||||
|
|
||||||
|
foreach (var item in recentReceipts.Where(r => r.ParseStatus == ReceiptParseStatus.Completed))
|
||||||
|
{
|
||||||
|
item.Confidence = confidenceMap.GetValueOrDefault(item.ReceiptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign queue positions for queued items
|
||||||
|
var queuedList = recentReceipts
|
||||||
|
.Where(r => r.ParseStatus == ReceiptParseStatus.Queued)
|
||||||
|
.OrderBy(r => r.UploadedAtUtc)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
for (int i = 0; i < queuedList.Count; i++)
|
||||||
|
queuedList[i].QueuePosition = i + 1;
|
||||||
|
|
||||||
|
QueuedItems = queuedList;
|
||||||
|
CompletedItems = recentReceipts
|
||||||
|
.Where(r => r.ParseStatus == ReceiptParseStatus.Completed)
|
||||||
|
.OrderByDescending(r => r.UploadedAtUtc)
|
||||||
|
.ToList();
|
||||||
|
FailedItems = recentReceipts
|
||||||
|
.Where(r => r.ParseStatus == ReceiptParseStatus.Failed)
|
||||||
|
.OrderByDescending(r => r.UploadedAtUtc)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (currentId.HasValue)
|
||||||
|
{
|
||||||
|
CurrentlyProcessing = recentReceipts
|
||||||
|
.FirstOrDefault(r => r.ReceiptId == currentId.Value);
|
||||||
|
|
||||||
|
// If currently processing item isn't in our recent list, load it
|
||||||
|
if (CurrentlyProcessing == null)
|
||||||
|
{
|
||||||
|
CurrentlyProcessing = await _db.Receipts
|
||||||
|
.Where(r => r.Id == currentId.Value)
|
||||||
|
.Select(r => new QueueItemViewModel
|
||||||
|
{
|
||||||
|
ReceiptId = r.Id,
|
||||||
|
FileName = r.FileName,
|
||||||
|
UploadedAtUtc = r.UploadedAtUtc,
|
||||||
|
ParseStatus = r.ParseStatus
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class QueueItemViewModel
|
||||||
|
{
|
||||||
|
public long ReceiptId { get; set; }
|
||||||
|
public string FileName { get; set; } = "";
|
||||||
|
public DateTime UploadedAtUtc { get; set; }
|
||||||
|
public ReceiptParseStatus ParseStatus { get; set; }
|
||||||
|
public int QueuePosition { get; set; }
|
||||||
|
public string? Merchant { get; set; }
|
||||||
|
public decimal? Total { get; set; }
|
||||||
|
public decimal? Confidence { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public int LineItemCount { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,11 @@
|
|||||||
<a asp-page="/ReviewReceipts" class="btn btn-warning me-2">
|
<a asp-page="/ReviewReceipts" class="btn btn-warning me-2">
|
||||||
Review Mappings
|
Review Mappings
|
||||||
</a>
|
</a>
|
||||||
|
<a asp-page="/ReceiptQueue" class="btn btn-info me-2">
|
||||||
|
Parse Queue
|
||||||
|
</a>
|
||||||
<button type="button" class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#uploadReceiptModal">
|
<button type="button" class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#uploadReceiptModal">
|
||||||
Upload Receipt
|
Upload Receipts
|
||||||
</button>
|
</button>
|
||||||
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
|
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,25 +118,33 @@
|
|||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Upload Receipt Modal -->
|
<!-- Upload Receipts Modal -->
|
||||||
<div class="modal fade" id="uploadReceiptModal" tabindex="-1" aria-labelledby="uploadReceiptModalLabel" aria-hidden="true">
|
<div class="modal fade" id="uploadReceiptModal" tabindex="-1" aria-labelledby="uploadReceiptModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="uploadReceiptModalLabel">Upload Receipt</h5>
|
<h5 class="modal-title" id="uploadReceiptModalLabel">Upload Receipts</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload">
|
<form method="post" enctype="multipart/form-data" asp-page-handler="UploadToQueue" id="uploadForm">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="UploadFile" class="form-label">Select Receipt File</label>
|
<label for="uploadFiles" class="form-label">Select Receipt Files</label>
|
||||||
<input type="file" asp-for="UploadFile" class="form-control" accept=".jpg,.jpeg,.png,.pdf,.gif,.heic" />
|
<input type="file" name="files" id="uploadFiles" class="form-control" multiple
|
||||||
<div class="form-text">Supported formats: JPG, PNG, PDF, GIF, HEIC (Max 10MB)</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary">Upload</button>
|
<button type="submit" class="btn btn-primary" id="uploadBtn" disabled>
|
||||||
|
<span id="uploadBtnText">Upload & Parse</span>
|
||||||
|
<span id="uploadSpinner" class="spinner-border spinner-border-sm ms-1" role="status" style="display:none;"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,14 +165,24 @@
|
|||||||
<span class="text-muted">- 0 total</span>
|
<span class="text-muted">- 0 total</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (Model.Receipts.Any(r => !r.TransactionId.HasValue && (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue)))
|
<div class="d-flex gap-2">
|
||||||
{
|
@if (Model.FailedParseCount > 0)
|
||||||
<form method="post" asp-page-handler="AutoMapUnmapped" style="display: inline;">
|
{
|
||||||
<button type="submit" class="btn btn-sm btn-success" title="Automatically map unmapped receipts to matching transactions">
|
<form method="post" asp-page-handler="RetryFailedParses" style="display: inline;">
|
||||||
🔗 Auto-Map Unmapped Receipts
|
<button type="submit" class="btn btn-sm btn-danger" title="Re-queue all failed receipts for AI parsing">
|
||||||
</button>
|
Retry @Model.FailedParseCount Failed Parse(s)
|
||||||
</form>
|
</button>
|
||||||
}
|
</form>
|
||||||
|
}
|
||||||
|
@if (Model.Receipts.Any(r => !r.TransactionId.HasValue && (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue)))
|
||||||
|
{
|
||||||
|
<form method="post" asp-page-handler="AutoMapUnmapped" style="display: inline;">
|
||||||
|
<button type="submit" class="btn btn-sm btn-success" title="Automatically map unmapped receipts to matching transactions">
|
||||||
|
Auto-Map Unmapped Receipts
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (Model.Receipts.Any())
|
@if (Model.Receipts.Any())
|
||||||
@@ -487,6 +508,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Map form validation
|
||||||
document.querySelectorAll('form[data-mapform="1"]').forEach(function(form){
|
document.querySelectorAll('form[data-mapform="1"]').forEach(function(form){
|
||||||
form.addEventListener('submit', function(e){
|
form.addEventListener('submit', function(e){
|
||||||
var hidden = form.querySelector('input[type="hidden"][name="transactionId"]');
|
var hidden = form.querySelector('input[type="hidden"][name="transactionId"]');
|
||||||
@@ -498,6 +520,36 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upload file preview
|
||||||
|
document.getElementById('uploadFiles').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';
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Services;
|
using MoneyMap.Services;
|
||||||
|
|
||||||
namespace MoneyMap.Pages
|
namespace MoneyMap.Pages
|
||||||
@@ -13,12 +14,15 @@ namespace MoneyMap.Pages
|
|||||||
private readonly IReceiptAutoMapper _autoMapper;
|
private readonly IReceiptAutoMapper _autoMapper;
|
||||||
private readonly IReceiptMatchingService _receiptMatchingService;
|
private readonly IReceiptMatchingService _receiptMatchingService;
|
||||||
|
|
||||||
public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptAutoMapper autoMapper, IReceiptMatchingService receiptMatchingService)
|
private readonly IReceiptParseQueue _parseQueue;
|
||||||
|
|
||||||
|
public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptAutoMapper autoMapper, IReceiptMatchingService receiptMatchingService, IReceiptParseQueue parseQueue)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_receiptManager = receiptManager;
|
_receiptManager = receiptManager;
|
||||||
_autoMapper = autoMapper;
|
_autoMapper = autoMapper;
|
||||||
_receiptMatchingService = receiptMatchingService;
|
_receiptMatchingService = receiptMatchingService;
|
||||||
|
_parseQueue = parseQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ReceiptRow> Receipts { get; set; } = new();
|
public List<ReceiptRow> Receipts { get; set; } = new();
|
||||||
@@ -53,10 +57,12 @@ namespace MoneyMap.Pages
|
|||||||
|
|
||||||
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
|
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
|
||||||
public bool ShowDuplicateModal { get; set; } = false;
|
public bool ShowDuplicateModal { get; set; } = false;
|
||||||
|
public int FailedParseCount { get; set; }
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
public async Task OnGetAsync()
|
||||||
{
|
{
|
||||||
await LoadReceiptsAsync();
|
await LoadReceiptsAsync();
|
||||||
|
FailedParseCount = await _db.Receipts.CountAsync(r => r.ParseStatus == ReceiptParseStatus.Failed);
|
||||||
|
|
||||||
// Show duplicate modal if warnings present
|
// Show duplicate modal if warnings present
|
||||||
if (!string.IsNullOrWhiteSpace(DuplicateWarningsJson))
|
if (!string.IsNullOrWhiteSpace(DuplicateWarningsJson))
|
||||||
@@ -66,6 +72,29 @@ namespace MoneyMap.Pages
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostUploadToQueueAsync(List<IFormFile> files)
|
||||||
|
{
|
||||||
|
if (files == null || files.Count == 0)
|
||||||
|
{
|
||||||
|
Message = "Please select files to upload.";
|
||||||
|
IsSuccess = false;
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _receiptManager.UploadManyUnmappedReceiptsAsync(files);
|
||||||
|
|
||||||
|
var messages = new List<string>();
|
||||||
|
if (result.Uploaded.Count > 0)
|
||||||
|
messages.Add($"{result.Uploaded.Count} receipt(s) uploaded and queued for parsing.");
|
||||||
|
if (result.Failed.Count > 0)
|
||||||
|
messages.Add($"{result.Failed.Count} failed: " +
|
||||||
|
string.Join("; ", result.Failed.Select(f => $"{f.FileName}: {f.ErrorMessage}")));
|
||||||
|
|
||||||
|
Message = string.Join(" ", messages);
|
||||||
|
IsSuccess = result.Failed.Count == 0;
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostUploadAsync()
|
public async Task<IActionResult> OnPostUploadAsync()
|
||||||
{
|
{
|
||||||
if (UploadFile == null)
|
if (UploadFile == null)
|
||||||
@@ -227,6 +256,30 @@ namespace MoneyMap.Pages
|
|||||||
return RedirectToPage();
|
return RedirectToPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostRetryFailedParsesAsync()
|
||||||
|
{
|
||||||
|
var failedReceipts = await _db.Receipts
|
||||||
|
.Where(r => r.ParseStatus == ReceiptParseStatus.Failed)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (failedReceipts.Count == 0)
|
||||||
|
{
|
||||||
|
Message = "No failed receipts to retry.";
|
||||||
|
IsSuccess = false;
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var receipt in failedReceipts)
|
||||||
|
receipt.ParseStatus = ReceiptParseStatus.Queued;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await _parseQueue.EnqueueManyAsync(failedReceipts.Select(r => r.Id));
|
||||||
|
|
||||||
|
Message = $"Re-queued {failedReceipts.Count} failed receipt(s) for parsing.";
|
||||||
|
IsSuccess = true;
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostUnmapAsync(long receiptId)
|
public async Task<IActionResult> OnPostUnmapAsync(long receiptId)
|
||||||
{
|
{
|
||||||
var success = await _receiptManager.UnmapReceiptAsync(receiptId);
|
var success = await _receiptManager.UnmapReceiptAsync(receiptId);
|
||||||
|
|||||||
Reference in New Issue
Block a user