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:
2026-02-15 19:14:09 -05:00
parent 705f4ea201
commit 5eb27319e1
4 changed files with 735 additions and 17 deletions

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

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

View File

@@ -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>

View File

@@ -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);