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:
@@ -7,6 +7,9 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>Receipts</h2>
|
||||
<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">
|
||||
Upload Receipt
|
||||
</button>
|
||||
|
||||
283
MoneyMap/Pages/ReviewReceipts.cshtml
Normal file
283
MoneyMap/Pages/ReviewReceipts.cshtml
Normal 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>
|
||||
195
MoneyMap/Pages/ReviewReceipts.cshtml.cs
Normal file
195
MoneyMap/Pages/ReviewReceipts.cshtml.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user