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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user