From 5eb27319e1a8835d2aea3b9aeace13ca85e90c21 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Feb 2026 19:14:09 -0500 Subject: [PATCH] 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 --- MoneyMap/Pages/ReceiptQueue.cshtml | 393 ++++++++++++++++++++++++++ MoneyMap/Pages/ReceiptQueue.cshtml.cs | 220 ++++++++++++++ MoneyMap/Pages/Receipts.cshtml | 84 ++++-- MoneyMap/Pages/Receipts.cshtml.cs | 55 +++- 4 files changed, 735 insertions(+), 17 deletions(-) create mode 100644 MoneyMap/Pages/ReceiptQueue.cshtml create mode 100644 MoneyMap/Pages/ReceiptQueue.cshtml.cs diff --git a/MoneyMap/Pages/ReceiptQueue.cshtml b/MoneyMap/Pages/ReceiptQueue.cshtml new file mode 100644 index 0000000..04765e5 --- /dev/null +++ b/MoneyMap/Pages/ReceiptQueue.cshtml @@ -0,0 +1,393 @@ +@page +@model MoneyMap.Pages.ReceiptQueueModel +@{ + ViewData["Title"] = "Receipt Queue"; +} + +
+

Receipt Queue

+
+ Back to Receipts +
+
+ +@if (!string.IsNullOrWhiteSpace(Model.Message)) +{ + +} + + +
+
+ Upload Receipts +
+
+
+
+ + +
+ Supported: JPG, PNG, PDF, GIF, HEIC (Max 10MB each). Select multiple files at once. +
+
+ + +
+
+
+ + +
+
+ + Currently Processing +
+
+ @if (Model.CurrentlyProcessing != null) + { +
+ + @Model.CurrentlyProcessing.FileName + + + (uploaded @Model.CurrentlyProcessing.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")) + +
+ } +
+
+ + +
+
+ +
+
+
+ +
+ @if (Model.QueuedItems.Any()) + { +
+ + + + + + + + + + + @foreach (var item in Model.QueuedItems) + { + + + + + + + } + +
#File NameUploadedAction
@item.QueuePosition + @item.FileName + @item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm") + View +
+
+ } + else + { +
No items in queue.
+ } +
+ + +
+ @if (Model.CompletedItems.Any()) + { +
+ + + + + + + + + + + + + @foreach (var item in Model.CompletedItems) + { + + + + + + + + + } + +
File NameMerchantTotalConfidenceItemsAction
+ @item.FileName + @(item.Merchant ?? "-")@(item.Total?.ToString("C") ?? "-") + @if (item.Confidence.HasValue) + { + var pct = item.Confidence.Value * 100; + var cls = pct >= 80 ? "success" : pct >= 50 ? "warning" : "danger"; + @pct.ToString("F0")% + } + else + { + - + } + @item.LineItemCount + View +
+
+ } + else + { +
No completed items.
+ } +
+ + +
+ @if (Model.FailedItems.Any()) + { +
+ + + + + + + + + + + @foreach (var item in Model.FailedItems) + { + + + + + + + } + +
File NameErrorUploadedActions
+ @item.FileName + @(item.ErrorMessage ?? "Unknown error")@item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm") +
+ +
+ View +
+
+ } + else + { +
No failed items.
+ } +
+
+
+
+ +@section Scripts { + +} diff --git a/MoneyMap/Pages/ReceiptQueue.cshtml.cs b/MoneyMap/Pages/ReceiptQueue.cshtml.cs new file mode 100644 index 0000000..af95e0a --- /dev/null +++ b/MoneyMap/Pages/ReceiptQueue.cshtml.cs @@ -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 QueuedItems { get; set; } = new(); + public List CompletedItems { get; set; } = new(); + public List 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 OnPostUploadAsync(List 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(); + 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 OnGetQueueStatusAsync() + { + await LoadQueueDashboardAsync(); + + var data = new + { + currentlyProcessing = CurrentlyProcessing, + queued = QueuedItems, + completed = CompletedItems, + failed = FailedItems + }; + + return new JsonResult(data); + } + + public async Task 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; } + } + } +} diff --git a/MoneyMap/Pages/Receipts.cshtml b/MoneyMap/Pages/Receipts.cshtml index 04a6872..7bd81a9 100644 --- a/MoneyMap/Pages/Receipts.cshtml +++ b/MoneyMap/Pages/Receipts.cshtml @@ -10,8 +10,11 @@ Review Mappings + + Parse Queue + Back to Dashboard @@ -115,25 +118,33 @@ } - +
@if (Model.Receipts.Any()) @@ -487,6 +508,7 @@ diff --git a/MoneyMap/Pages/Receipts.cshtml.cs b/MoneyMap/Pages/Receipts.cshtml.cs index af7de9e..effb9ad 100644 --- a/MoneyMap/Pages/Receipts.cshtml.cs +++ b/MoneyMap/Pages/Receipts.cshtml.cs @@ -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 Receipts { get; set; } = new(); @@ -53,10 +57,12 @@ namespace MoneyMap.Pages public List 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 OnPostUploadToQueueAsync(List 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(); + 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 OnPostUploadAsync() { if (UploadFile == null) @@ -227,6 +256,30 @@ namespace MoneyMap.Pages return RedirectToPage(); } + public async Task 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 OnPostUnmapAsync(long receiptId) { var success = await _receiptManager.UnmapReceiptAsync(receiptId);