Files
MoneyMap/MoneyMap/Pages/ReceiptQueue.cshtml.cs
T
aj 5eb27319e1 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>
2026-02-15 19:14:09 -05:00

221 lines
8.2 KiB
C#

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; }
}
}
}