Improve receipt mapping with smart filtering and table UI

Enhanced the receipt mapping modal with intelligent transaction filtering
and a clean table-based selection interface.

Smart Filtering (per receipt):
- If receipt has a date: Show transactions within ±3 days
- If receipt has merchant: Filter by merchant name match
- Calculate amount matches (within $0.10 tolerance)
- Only show transactions without receipts
- Limit to 50 most recent matches
- Fallback to recent 50 transactions if no matches

UI Improvements:
- Replaced confusing listbox with formatted data table
- Proper column alignment with fixed widths
- Sticky header for scrollable table (max 400px height)
- Clickable rows with radio button selection
- Visual feedback:
  - Green highlight for amount matches
  - Blue highlight for selected row
  - Hover effect on rows
- Context-aware helper text explaining filter criteria
- Shows which transactions match the receipt date range

Features:
- Click anywhere on row to select
- Radio buttons for explicit selection
- Hidden input field stores selected transaction ID
- Manual ID entry still available as fallback
- Clear visual indicators for best matches

This provides a much better UX for manual mapping by showing only
relevant transactions and making it easy to identify likely matches.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
AJ
2025-10-12 13:31:14 -04:00
parent c6a01e120f
commit cd7ca679ac
3 changed files with 153 additions and 32 deletions

View File

@@ -2,15 +2,10 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)", "Bash(git commit:*)"
"Bash(dotnet ef migrations add:*)",
"Bash(dotnet build)",
"Bash(dotnet ef database:*)",
"Bash(dotnet ef migrations:*)",
"Bash(git reset:*)",
"Bash(already contains a definition for 'MultipleMatches'\".\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
} },
"spinnerTipsEnabled": false
} }

View File

@@ -229,22 +229,74 @@
} }
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="transactionId@(r.Id)" class="form-label">Select Transaction</label> <label class="form-label">Select Transaction</label>
<select class="form-select" id="transactionId@(r.Id)" name="transactionId" required size="10" style="font-family: monospace; font-size: 0.9rem;"> @{
<option value="" disabled selected>-- Select a transaction --</option> var matches = Model.ReceiptTransactionMatches.ContainsKey(r.Id)
@foreach (var txn in Model.RecentTransactions) ? Model.ReceiptTransactionMatches[r.Id]
{ : new List<MoneyMap.Pages.ReceiptsModel.TransactionOption>();
var displayText = $"{txn.Date:yyyy-MM-dd} | {txn.Amount,10:C} | {txn.Name}";
if (!string.IsNullOrWhiteSpace(txn.MerchantName))
{
displayText += $" ({txn.MerchantName})";
} }
<option value="@txn.Id">@displayText</option> @if (matches.Any())
{
<div class="form-text mb-2">
@if (r.ReceiptDate.HasValue)
{
<span>Showing transactions within ±3 days of receipt date (@r.ReceiptDate.Value.ToString("yyyy-MM-dd")).</span>
} }
</select> else
<div class="form-text"> {
Showing last 100 transactions without receipts. <span>Showing recent transactions without receipts.</span>
Can't find it? <a asp-page="/Transactions" target="_blank">Open Transactions page</a> to find the ID and enter it manually below. }
Rows highlighted in <span class="badge bg-success">green</span> have matching amounts.
</div>
<input type="hidden" id="transactionId@(r.Id)" name="transactionId" required />
<div style="max-height: 400px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 0.25rem;">
<table class="table table-sm table-hover mb-0" style="font-size: 0.85rem;">
<thead style="position: sticky; top: 0; background-color: white; z-index: 1;">
<tr>
<th style="width: 50px;">Select</th>
<th style="width: 100px;">Date</th>
<th style="width: 110px;" class="text-end">Amount</th>
<th>Name</th>
<th style="width: 150px;">Merchant</th>
<th style="width: 100px;">Payment</th>
</tr>
</thead>
<tbody>
@foreach (var txn in matches)
{
var rowClass = txn.IsAmountMatch ? "table-success" : "";
<tr class="@rowClass" style="cursor: pointer;"
onclick="document.getElementById('transactionId@(r.Id)').value = '@txn.Id';
document.querySelectorAll('#mapModal@(r.Id) tbody tr').forEach(tr => tr.classList.remove('table-primary'));
this.classList.add('table-primary');">
<td class="text-center">
<input type="radio" name="txnRadio@(r.Id)" value="@txn.Id"
onclick="event.stopPropagation();"
onchange="document.getElementById('transactionId@(r.Id)').value = this.value;" />
</td>
<td>@txn.Date.ToString("yyyy-MM-dd")</td>
<td class="text-end">@txn.Amount.ToString("C")</td>
<td>@txn.Name</td>
<td>@(txn.MerchantName ?? "-")</td>
<td class="small">@txn.PaymentMethod</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="alert alert-info">
No matching transactions found.
@if (r.ReceiptDate.HasValue)
{
<span>Try searching within ±3 days of @r.ReceiptDate.Value.ToString("yyyy-MM-dd").</span>
}
</div>
}
<div class="form-text mt-2">
Can't find it? <a asp-page="/Transactions" target="_blank">Open Transactions page</a> to search, then enter the ID manually below.
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">

View File

@@ -21,7 +21,7 @@ namespace MoneyMap.Pages
} }
public List<ReceiptRow> Receipts { get; set; } = new(); public List<ReceiptRow> Receipts { get; set; } = new();
public List<TransactionOption> RecentTransactions { get; set; } = new(); public Dictionary<long, List<TransactionOption>> ReceiptTransactionMatches { get; set; } = new();
[BindProperty] [BindProperty]
public IFormFile? UploadFile { get; set; } public IFormFile? UploadFile { get; set; }
@@ -170,20 +170,88 @@ namespace MoneyMap.Pages
StoragePath = r.StoragePath StoragePath = r.StoragePath
}).ToList(); }).ToList();
// Load recent transactions without receipts for mapping dropdown // Load matching transactions for each unmapped receipt
var transactionsWithReceipts = await _db.Receipts var transactionsWithReceipts = await _db.Receipts
.Where(r => r.TransactionId != null) .Where(r => r.TransactionId != null)
.Select(r => r.TransactionId!.Value) .Select(r => r.TransactionId!.Value)
.ToHashSet();
var unmappedReceipts = Receipts.Where(r => !r.TransactionId.HasValue).ToList();
foreach (var receipt in unmappedReceipts)
{
var matches = await FindMatchingTransactionsForReceipt(receipt, transactionsWithReceipts);
ReceiptTransactionMatches[receipt.Id] = matches;
}
}
private async Task<List<TransactionOption>> FindMatchingTransactionsForReceipt(ReceiptRow receipt, HashSet<long> transactionsWithReceipts)
{
var query = _db.Transactions
.Include(t => t.Card)
.Include(t => t.Account)
.Include(t => t.Merchant)
.Where(t => !transactionsWithReceipts.Contains(t.Id))
.AsQueryable();
// If receipt has a date, filter by +/- 3 days
if (receipt.ReceiptDate.HasValue)
{
var minDate = receipt.ReceiptDate.Value.AddDays(-3);
var maxDate = receipt.ReceiptDate.Value.AddDays(3);
query = query.Where(t => t.Date >= minDate && t.Date <= maxDate);
}
// If receipt has merchant, filter by merchant name
if (!string.IsNullOrWhiteSpace(receipt.Merchant))
{
query = query.Where(t =>
(t.Merchant != null && t.Merchant.Name.Contains(receipt.Merchant)) ||
t.Name.Contains(receipt.Merchant));
}
var candidates = await query
.OrderByDescending(t => t.Date)
.ThenByDescending(t => t.Id)
.Take(50) // Limit to 50 matches
.ToListAsync(); .ToListAsync();
RecentTransactions = await _db.Transactions // Calculate match scores and mark close amount matches
var options = candidates.Select(t =>
{
var option = new TransactionOption
{
Id = t.Id,
Date = t.Date,
Name = t.Name,
Amount = t.Amount,
MerchantName = t.Merchant?.Name,
PaymentMethod = t.PaymentMethodLabel,
IsAmountMatch = false
};
// Check if amount matches within tolerance
if (receipt.Total.HasValue)
{
var receiptTotal = Math.Abs(receipt.Total.Value);
var transactionAmount = Math.Abs(t.Amount);
option.IsAmountMatch = Math.Abs(transactionAmount - receiptTotal) <= 0.10m;
}
return option;
}).ToList();
// If no date-filtered matches, fall back to recent transactions
if (!options.Any() && !receipt.ReceiptDate.HasValue)
{
var fallback = await _db.Transactions
.Include(t => t.Card) .Include(t => t.Card)
.Include(t => t.Account) .Include(t => t.Account)
.Include(t => t.Merchant) .Include(t => t.Merchant)
.Where(t => !transactionsWithReceipts.Contains(t.Id)) .Where(t => !transactionsWithReceipts.Contains(t.Id))
.OrderByDescending(t => t.Date) .OrderByDescending(t => t.Date)
.ThenByDescending(t => t.Id) .ThenByDescending(t => t.Id)
.Take(100) // Last 100 transactions without receipts .Take(50)
.Select(t => new TransactionOption .Select(t => new TransactionOption
{ {
Id = t.Id, Id = t.Id,
@@ -194,6 +262,11 @@ namespace MoneyMap.Pages
PaymentMethod = t.PaymentMethodLabel PaymentMethod = t.PaymentMethodLabel
}) })
.ToListAsync(); .ToListAsync();
options = fallback;
}
return options;
} }
public class ReceiptRow public class ReceiptRow
@@ -221,6 +294,7 @@ namespace MoneyMap.Pages
public decimal Amount { get; set; } public decimal Amount { get; set; }
public string? MerchantName { get; set; } public string? MerchantName { get; set; }
public string PaymentMethod { get; set; } = ""; public string PaymentMethod { get; set; } = "";
public bool IsAmountMatch { get; set; }
} }
} }
} }