diff --git a/MoneyMap/Pages/Receipts.cshtml b/MoneyMap/Pages/Receipts.cshtml
index 6e4f88f..04a6872 100644
--- a/MoneyMap/Pages/Receipts.cshtml
+++ b/MoneyMap/Pages/Receipts.cshtml
@@ -7,6 +7,9 @@
Receipts
+
+ Review Mappings
+
diff --git a/MoneyMap/Pages/ReviewReceipts.cshtml b/MoneyMap/Pages/ReviewReceipts.cshtml
new file mode 100644
index 0000000..d991ae3
--- /dev/null
+++ b/MoneyMap/Pages/ReviewReceipts.cshtml
@@ -0,0 +1,283 @@
+@page
+@model MoneyMap.Pages.ReviewReceiptsModel
+@{
+ ViewData["Title"] = "Review Receipts";
+}
+
+
+
+@if (!string.IsNullOrWhiteSpace(Model.Message))
+{
+
+ @Model.Message
+
+
+}
+
+
+
+
+
+ @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";
+
+
+
+
+
+ @if (receipt.ContentType.StartsWith("image/"))
+ {
+
🖼️
+ }
+ else
+ {
+
📄
+ }
+
+
@receipt.FileName
+
+ Uploaded: @receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt")
+
+ @if (!string.IsNullOrWhiteSpace(receipt.Merchant))
+ {
+
Merchant: @receipt.Merchant
+ }
+ @if (receipt.ReceiptDate.HasValue)
+ {
+
Date: @receipt.ReceiptDate.Value.ToString("yyyy-MM-dd")
+ }
+ @if (receipt.DueDate.HasValue)
+ {
+
Due: @receipt.DueDate.Value.ToString("yyyy-MM-dd")
+ }
+ @if (receipt.Total.HasValue)
+ {
+
Total: @receipt.Total.Value.ToString("C")
+ }
+
+
+
+
+
+
+
+ @if (item.ScoredCandidates.Any())
+ {
+
+ Best Match: @(item.BestScore.ToString("P0"))
+ @if (item.BestScore >= 0.85)
+ {
+ High confidence - will auto-map
+ }
+ else if (item.BestScore >= 0.50)
+ {
+ Medium confidence - LLM will review
+ }
+ else
+ {
+ Low confidence - manual review needed
+ }
+
+
+
+
+
+ | Score |
+ Date |
+ Amount |
+ Name |
+ Action |
+
+
+
+ @foreach (var candidate in item.ScoredCandidates)
+ {
+ var rowClass = candidate.Score >= 0.85 ? "table-success" :
+ candidate.Score >= 0.50 ? "table-warning" : "";
+
+ |
+ = 0.50 ? "bg-warning text-dark" : "bg-secondary")">
+ @(candidate.Score.ToString("P0"))
+
+ |
+ @candidate.Transaction.Date.ToString("yyyy-MM-dd") |
+ @candidate.Transaction.Amount.ToString("C") |
+
+ @candidate.Transaction.Name
+ @if (candidate.Transaction.Merchant != null)
+ {
+ @candidate.Transaction.Merchant.Name
+ }
+ |
+
+
+ View
+ |
+
+ }
+
+
+
+ }
+ else
+ {
+
+ No matching transactions found within the date range.
+
+ }
+
+
+
+
+ }
+ }
+ else
+ {
+
+ No unmapped receipts need review. All parsed receipts are mapped!
+
+ }
+
+
+
+
+
+
+
+ @if (Model.RecentlyMappedReceipts.Any())
+ {
+
+
+
+
+ | Receipt |
+ Mapped To |
+ Match |
+ Actions |
+
+
+
+ @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;
+
+
+
+
+ @if (receipt.ContentType.StartsWith("image/"))
+ {
+ 🖼️
+ }
+ else
+ {
+ 📄
+ }
+
+ @receipt.FileName
+
+ @(receipt.Merchant ?? "Unknown") |
+ @(receipt.ReceiptDate?.ToString("yyyy-MM-dd") ?? "No date") |
+ @(receipt.Total?.ToString("C") ?? "No amount")
+
+
+
+ |
+
+ @if (transaction != null)
+ {
+ @transaction.Name
+
+ @transaction.Date.ToString("yyyy-MM-dd") |
+ @transaction.Amount.ToString("C")
+ @if (transaction.Merchant != null)
+ {
+ | @transaction.Merchant.Name
+ }
+
+ }
+ |
+
+ @if (amountMatch && dateMatch)
+ {
+ Good
+ }
+ else if (amountMatch || dateMatch)
+ {
+ Partial
+ }
+ else
+ {
+ Check
+ }
+ |
+
+
+ View
+
+
+ |
+
+ }
+
+
+
+ }
+ else
+ {
+
+ No receipts mapped in the last 7 days.
+
+ }
+
+
diff --git a/MoneyMap/Pages/ReviewReceipts.cshtml.cs b/MoneyMap/Pages/ReviewReceipts.cshtml.cs
new file mode 100644
index 0000000..178e1af
--- /dev/null
+++ b/MoneyMap/Pages/ReviewReceipts.cshtml.cs
@@ -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
UnmappedReceipts { get; set; } = new();
+ public List 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 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 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 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 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(),
+ BestScore = 0
+ });
+ }
+ }
+
+ public class ReceiptReviewItem
+ {
+ public required Receipt Receipt { get; set; }
+ public List ScoredCandidates { get; set; } = new();
+ public double BestScore { get; set; }
+ }
+ }
+}