Feature: Add ReviewReceipts page for receipt-to-transaction mapping

New page shows unmapped receipts with scored transaction candidates,
allowing manual mapping or bulk auto-map. Displays confidence scores
and match quality indicators. Also shows recently mapped receipts
for verification with unmap option.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 16:54:15 -05:00
parent f3b847cc68
commit de5ee33a77
3 changed files with 481 additions and 0 deletions

View File

@@ -7,6 +7,9 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h2>Receipts</h2> <h2>Receipts</h2>
<div> <div>
<a asp-page="/ReviewReceipts" class="btn btn-warning me-2">
Review Mappings
</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 Receipt
</button> </button>

View File

@@ -0,0 +1,283 @@
@page
@model MoneyMap.Pages.ReviewReceiptsModel
@{
ViewData["Title"] = "Review Receipts";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Review Receipts</h2>
<div>
@if (Model.UnmappedReceipts.Any())
{
<form method="post" asp-page-handler="AutoMapAll" class="d-inline">
<button type="submit" class="btn btn-success me-2">
Auto-Map All Unmapped
</button>
</form>
}
<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-warning") alert-dismissible fade show" role="alert">
@Model.Message
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<!-- Unmapped Receipts Section -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-warning text-dark">
<strong>Unmapped Receipts Needing Review</strong>
<span class="badge bg-dark ms-2">@Model.UnmappedReceipts.Count</span>
</div>
<div class="card-body p-0">
@if (Model.UnmappedReceipts.Any())
{
@foreach (var item in Model.UnmappedReceipts)
{
var receipt = item.Receipt;
var scoreClass = item.BestScore >= 0.85 ? "bg-success" :
item.BestScore >= 0.50 ? "bg-warning text-dark" : "bg-secondary";
<div class="border-bottom p-3">
<div class="row">
<!-- Receipt Info -->
<div class="col-md-4">
<div class="d-flex align-items-start gap-2">
@if (receipt.ContentType.StartsWith("image/"))
{
<span class="fs-4">🖼️</span>
}
else
{
<span class="fs-4">📄</span>
}
<div>
<div class="fw-bold">@receipt.FileName</div>
<div class="small text-muted">
Uploaded: @receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt")
</div>
@if (!string.IsNullOrWhiteSpace(receipt.Merchant))
{
<div class="mt-1"><strong>Merchant:</strong> @receipt.Merchant</div>
}
@if (receipt.ReceiptDate.HasValue)
{
<div><strong>Date:</strong> @receipt.ReceiptDate.Value.ToString("yyyy-MM-dd")</div>
}
@if (receipt.DueDate.HasValue)
{
<div class="text-warning"><strong>Due:</strong> @receipt.DueDate.Value.ToString("yyyy-MM-dd")</div>
}
@if (receipt.Total.HasValue)
{
<div><strong>Total:</strong> @receipt.Total.Value.ToString("C")</div>
}
<div class="mt-2">
<a asp-page="/ViewReceipt" asp-route-id="@receipt.Id" class="btn btn-sm btn-outline-primary" target="_blank">
View Receipt
</a>
</div>
</div>
</div>
</div>
<!-- Candidate Transactions -->
<div class="col-md-8">
@if (item.ScoredCandidates.Any())
{
<div class="mb-2">
<span class="badge @scoreClass">Best Match: @(item.BestScore.ToString("P0"))</span>
@if (item.BestScore >= 0.85)
{
<span class="text-success small ms-2">High confidence - will auto-map</span>
}
else if (item.BestScore >= 0.50)
{
<span class="text-warning small ms-2">Medium confidence - LLM will review</span>
}
else
{
<span class="text-secondary small ms-2">Low confidence - manual review needed</span>
}
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-2" style="font-size: 0.85rem;">
<thead class="table-light">
<tr>
<th style="width: 60px;">Score</th>
<th style="width: 100px;">Date</th>
<th class="text-end" style="width: 100px;">Amount</th>
<th>Name</th>
<th style="width: 120px;">Action</th>
</tr>
</thead>
<tbody>
@foreach (var candidate in item.ScoredCandidates)
{
var rowClass = candidate.Score >= 0.85 ? "table-success" :
candidate.Score >= 0.50 ? "table-warning" : "";
<tr class="@rowClass">
<td>
<span class="badge @(candidate.Score >= 0.85 ? "bg-success" : candidate.Score >= 0.50 ? "bg-warning text-dark" : "bg-secondary")">
@(candidate.Score.ToString("P0"))
</span>
</td>
<td>@candidate.Transaction.Date.ToString("yyyy-MM-dd")</td>
<td class="text-end">@candidate.Transaction.Amount.ToString("C")</td>
<td>
<div>@candidate.Transaction.Name</div>
@if (candidate.Transaction.Merchant != null)
{
<small class="text-muted">@candidate.Transaction.Merchant.Name</small>
}
</td>
<td>
<form method="post" asp-page-handler="MapToTransaction" class="d-inline">
<input type="hidden" name="receiptId" value="@receipt.Id" />
<input type="hidden" name="transactionId" value="@candidate.Transaction.Id" />
<button type="submit" class="btn btn-sm btn-success">Map</button>
</form>
<a asp-page="/EditTransaction" asp-route-id="@candidate.Transaction.Id"
class="btn btn-sm btn-outline-secondary" target="_blank">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="alert alert-secondary mb-2">
No matching transactions found within the date range.
</div>
}
<form method="post" asp-page-handler="AutoMap" class="d-inline">
<input type="hidden" name="receiptId" value="@receipt.Id" />
<button type="submit" class="btn btn-sm btn-primary">
Run Auto-Map
</button>
</form>
</div>
</div>
</div>
}
}
else
{
<div class="p-4 text-center text-muted">
No unmapped receipts need review. All parsed receipts are mapped!
</div>
}
</div>
</div>
<!-- Recently Mapped Section -->
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<strong>Recently Mapped (Last 7 Days)</strong>
<span class="badge bg-light text-dark ms-2">@Model.RecentlyMappedReceipts.Count</span>
</div>
<div class="card-body p-0">
@if (Model.RecentlyMappedReceipts.Any())
{
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead>
<tr>
<th>Receipt</th>
<th>Mapped To</th>
<th style="width: 100px;">Match</th>
<th style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.RecentlyMappedReceipts)
{
var receipt = item.Receipt;
var transaction = receipt.Transaction;
// Calculate if amounts match
var amountMatch = receipt.Total.HasValue && transaction != null
? Math.Abs((double)(receipt.Total.Value - Math.Abs(transaction.Amount)) / (double)receipt.Total.Value) <= 0.02
: false;
var dateMatch = receipt.ReceiptDate.HasValue && transaction != null
? Math.Abs((transaction.Date - receipt.ReceiptDate.Value).TotalDays) <= 3
: false;
<tr>
<td>
<div class="d-flex align-items-center gap-2">
@if (receipt.ContentType.StartsWith("image/"))
{
<span>🖼️</span>
}
else
{
<span>📄</span>
}
<div>
<div>@receipt.FileName</div>
<small class="text-muted">
@(receipt.Merchant ?? "Unknown") |
@(receipt.ReceiptDate?.ToString("yyyy-MM-dd") ?? "No date") |
@(receipt.Total?.ToString("C") ?? "No amount")
</small>
</div>
</div>
</td>
<td>
@if (transaction != null)
{
<div>@transaction.Name</div>
<small class="text-muted">
@transaction.Date.ToString("yyyy-MM-dd") |
@transaction.Amount.ToString("C")
@if (transaction.Merchant != null)
{
<span>| @transaction.Merchant.Name</span>
}
</small>
}
</td>
<td>
@if (amountMatch && dateMatch)
{
<span class="badge bg-success">Good</span>
}
else if (amountMatch || dateMatch)
{
<span class="badge bg-warning text-dark">Partial</span>
}
else
{
<span class="badge bg-danger">Check</span>
}
</td>
<td>
<a asp-page="/ViewReceipt" asp-route-id="@receipt.Id" class="btn btn-sm btn-outline-primary" target="_blank">
View
</a>
<form method="post" asp-page-handler="Unmap" class="d-inline"
onsubmit="return confirm('Unmap this receipt? You can re-run auto-mapping after.');">
<input type="hidden" name="receiptId" value="@receipt.Id" />
<button type="submit" class="btn btn-sm btn-outline-warning">Unmap</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="p-4 text-center text-muted">
No receipts mapped in the last 7 days.
</div>
}
</div>
</div>

View File

@@ -0,0 +1,195 @@
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 ReviewReceiptsModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly IReceiptManager _receiptManager;
private readonly IReceiptAutoMapper _autoMapper;
public ReviewReceiptsModel(
MoneyMapContext db,
IReceiptManager receiptManager,
IReceiptAutoMapper autoMapper)
{
_db = db;
_receiptManager = receiptManager;
_autoMapper = autoMapper;
}
public List<ReceiptReviewItem> UnmappedReceipts { get; set; } = new();
public List<ReceiptReviewItem> RecentlyMappedReceipts { get; set; } = new();
[TempData]
public string? Message { get; set; }
[TempData]
public bool IsSuccess { get; set; }
public async Task OnGetAsync()
{
await LoadReceiptsAsync();
}
public async Task<IActionResult> OnPostAutoMapAsync(long receiptId)
{
var result = await _autoMapper.AutoMapReceiptAsync(receiptId);
switch (result.Status)
{
case AutoMapStatus.Success:
Message = $"Receipt auto-mapped to transaction #{result.TransactionId}.";
IsSuccess = true;
break;
case AutoMapStatus.AlreadyMapped:
Message = "Receipt is already mapped.";
IsSuccess = false;
break;
case AutoMapStatus.MultipleMatches:
Message = $"Found {result.MultipleMatches.Count} potential matches. Please select manually.";
IsSuccess = false;
break;
case AutoMapStatus.NoMatch:
Message = "No matching transaction found.";
IsSuccess = false;
break;
case AutoMapStatus.NotParsed:
Message = "Receipt has not been parsed yet. Parse it first.";
IsSuccess = false;
break;
default:
Message = result.Message ?? "Auto-mapping failed.";
IsSuccess = false;
break;
}
return RedirectToPage();
}
public async Task<IActionResult> OnPostMapToTransactionAsync(long receiptId, long transactionId)
{
if (transactionId <= 0)
{
Message = "Please select a transaction.";
IsSuccess = false;
return RedirectToPage();
}
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, transactionId);
if (success)
{
Message = $"Receipt mapped to transaction #{transactionId}.";
IsSuccess = true;
}
else
{
Message = "Failed to map receipt.";
IsSuccess = false;
}
return RedirectToPage();
}
public async Task<IActionResult> OnPostUnmapAsync(long receiptId)
{
var success = await _receiptManager.UnmapReceiptAsync(receiptId);
if (success)
{
Message = "Receipt unmapped. You can now re-run auto-mapping.";
IsSuccess = true;
}
else
{
Message = "Failed to unmap receipt.";
IsSuccess = false;
}
return RedirectToPage();
}
public async Task<IActionResult> OnPostAutoMapAllAsync()
{
var result = await _autoMapper.AutoMapUnmappedReceiptsAsync();
if (result.MappedCount > 0)
{
Message = $"Auto-mapped {result.MappedCount} receipt(s). " +
$"{result.NoMatchCount} had no match. " +
$"{result.MultipleMatchesCount} need manual review.";
IsSuccess = true;
}
else if (result.TotalProcessed == 0)
{
Message = "No unmapped receipts to process.";
IsSuccess = false;
}
else
{
Message = $"Unable to auto-map any receipts. {result.NoMatchCount} had no match. " +
$"{result.MultipleMatchesCount} need manual review.";
IsSuccess = false;
}
return RedirectToPage();
}
private async Task LoadReceiptsAsync()
{
// Get unmapped receipts with their scored candidates
var unmapped = await _db.Receipts
.Where(r => r.TransactionId == null)
.Where(r => r.Merchant != null || r.ReceiptDate != null || r.Total != null)
.OrderByDescending(r => r.UploadedAtUtc)
.Take(50)
.ToListAsync();
foreach (var receipt in unmapped)
{
var candidates = await _autoMapper.GetScoredCandidatesAsync(receipt.Id);
UnmappedReceipts.Add(new ReceiptReviewItem
{
Receipt = receipt,
ScoredCandidates = candidates.Take(5).ToList(),
BestScore = candidates.FirstOrDefault()?.Score ?? 0
});
}
// Get recently mapped receipts (last 7 days) for verification
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
var recentlyMapped = await _db.Receipts
.Include(r => r.Transaction)
.ThenInclude(t => t!.Merchant)
.Where(r => r.TransactionId != null)
.Where(r => r.UploadedAtUtc >= sevenDaysAgo)
.OrderByDescending(r => r.UploadedAtUtc)
.Take(20)
.ToListAsync();
foreach (var receipt in recentlyMapped)
{
RecentlyMappedReceipts.Add(new ReceiptReviewItem
{
Receipt = receipt,
ScoredCandidates = new List<ScoredCandidate>(),
BestScore = 0
});
}
}
public class ReceiptReviewItem
{
public required Receipt Receipt { get; set; }
public List<ScoredCandidate> ScoredCandidates { get; set; } = new();
public double BestScore { get; set; }
}
}
}