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:
393
MoneyMap/Pages/ReceiptQueue.cshtml
Normal file
393
MoneyMap/Pages/ReceiptQueue.cshtml
Normal file
@@ -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>
|
||||
}
|
||||
220
MoneyMap/Pages/ReceiptQueue.cshtml.cs
Normal file
220
MoneyMap/Pages/ReceiptQueue.cshtml.cs
Normal file
@@ -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">
|
||||
Review Mappings
|
||||
</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">
|
||||
Upload Receipt
|
||||
Upload Receipts
|
||||
</button>
|
||||
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
@@ -115,25 +118,33 @@
|
||||
</script>
|
||||
}
|
||||
|
||||
<!-- Upload Receipt Modal -->
|
||||
<!-- Upload Receipts Modal -->
|
||||
<div class="modal fade" id="uploadReceiptModal" tabindex="-1" aria-labelledby="uploadReceiptModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<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>
|
||||
</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="mb-3">
|
||||
<label for="UploadFile" class="form-label">Select Receipt File</label>
|
||||
<input type="file" asp-for="UploadFile" class="form-control" accept=".jpg,.jpeg,.png,.pdf,.gif,.heic" />
|
||||
<div class="form-text">Supported formats: JPG, PNG, PDF, GIF, HEIC (Max 10MB)</div>
|
||||
<label for="uploadFiles" class="form-label">Select Receipt Files</label>
|
||||
<input type="file" name="files" id="uploadFiles" 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>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -154,14 +165,24 @@
|
||||
<span class="text-muted">- 0 total</span>
|
||||
}
|
||||
</div>
|
||||
@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 class="d-flex gap-2">
|
||||
@if (Model.FailedParseCount > 0)
|
||||
{
|
||||
<form method="post" asp-page-handler="RetryFailedParses" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Re-queue all failed receipts for AI parsing">
|
||||
Retry @Model.FailedParseCount Failed Parse(s)
|
||||
</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 class="card-body p-0">
|
||||
@if (Model.Receipts.Any())
|
||||
@@ -487,6 +508,7 @@
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Map form validation
|
||||
document.querySelectorAll('form[data-mapform="1"]').forEach(function(form){
|
||||
form.addEventListener('submit', function(e){
|
||||
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>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Pages
|
||||
@@ -13,12 +14,15 @@ namespace MoneyMap.Pages
|
||||
private readonly IReceiptAutoMapper _autoMapper;
|
||||
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;
|
||||
_receiptManager = receiptManager;
|
||||
_autoMapper = autoMapper;
|
||||
_receiptMatchingService = receiptMatchingService;
|
||||
_parseQueue = parseQueue;
|
||||
}
|
||||
|
||||
public List<ReceiptRow> Receipts { get; set; } = new();
|
||||
@@ -53,10 +57,12 @@ namespace MoneyMap.Pages
|
||||
|
||||
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
|
||||
public bool ShowDuplicateModal { get; set; } = false;
|
||||
public int FailedParseCount { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
await LoadReceiptsAsync();
|
||||
FailedParseCount = await _db.Receipts.CountAsync(r => r.ParseStatus == ReceiptParseStatus.Failed);
|
||||
|
||||
// Show duplicate modal if warnings present
|
||||
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()
|
||||
{
|
||||
if (UploadFile == null)
|
||||
@@ -227,6 +256,30 @@ namespace MoneyMap.Pages
|
||||
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)
|
||||
{
|
||||
var success = await _receiptManager.UnmapReceiptAsync(receiptId);
|
||||
|
||||
Reference in New Issue
Block a user