Refactor: AICategorizePreview with tabbed proposals and rule status

Split proposals into High Confidence / Needs Review tabs. Extract
proposal table into _ProposalTable partial view. Show rule status
(Create/Update/Exists) based on existing category mappings. Persist
proposals in hidden form field to survive app restarts. Add per-tab
select-all and improved error reporting on apply.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 19:14:19 -05:00
parent 6e3589f7da
commit 444035fd72
3 changed files with 243 additions and 99 deletions

View File

@@ -65,7 +65,11 @@
}
else
{
var highConfidence = Model.Proposals.Where(p => p.Confidence >= 0.8m).ToList();
var needsReview = Model.Proposals.Where(p => p.Confidence < 0.8m).ToList();
<form method="post" asp-page-handler="Apply">
<input type="hidden" name="proposalsData" value="@Model.ProposalsJson" />
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Review Proposals (@Model.Proposals.Count suggestions)</strong>
@@ -75,89 +79,45 @@ else
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="selectAll(this.checked)" checked>
</th>
<th>Transaction</th>
<th>Current</th>
<th>Proposed</th>
<th>Merchant</th>
<th style="width: 100px;">Confidence</th>
<th style="width: 100px;">Create Rule</th>
</tr>
</thead>
<tbody>
@foreach (var proposal in Model.Proposals)
{
var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" :
proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary";
<tr>
<td>
<input type="checkbox" class="form-check-input proposal-checkbox"
name="selectedIds" value="@proposal.TransactionId" checked>
</td>
<td>
<div class="fw-bold">@proposal.TransactionName</div>
@if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo))
{
<small class="text-muted">@proposal.TransactionMemo</small>
}
<div class="small text-muted">
@proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C")
</div>
</td>
<td>
@if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory))
{
<span class="badge bg-secondary">@proposal.CurrentCategory</span>
}
else
{
<span class="text-muted small">(none)</span>
}
</td>
<td>
<span class="badge bg-primary">@proposal.ProposedCategory</span>
@if (!string.IsNullOrWhiteSpace(proposal.Reasoning))
{
<div class="small text-muted mt-1" title="@proposal.Reasoning">
@(proposal.Reasoning.Length > 60 ? proposal.Reasoning.Substring(0, 60) + "..." : proposal.Reasoning)
</div>
}
</td>
<td>
@if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant))
{
<div>@proposal.ProposedMerchant</div>
}
@if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern))
{
<code class="small">@proposal.ProposedPattern</code>
}
</td>
<td>
<span class="badge @confidenceClass">@proposal.Confidence.ToString("P0")</span>
</td>
<td>
<input type="checkbox" class="form-check-input"
name="createRules" value="@proposal.TransactionId"
@(proposal.CreateRule ? "checked" : "")
title="Create a mapping rule for this pattern">
</td>
</tr>
}
</tbody>
</table>
<ul class="nav nav-tabs px-3 pt-3" id="proposalTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="high-confidence-tab" data-bs-toggle="tab"
data-bs-target="#high-confidence" type="button" role="tab">
High Confidence <span class="badge bg-success ms-1">@highConfidence.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="needs-review-tab" data-bs-toggle="tab"
data-bs-target="#needs-review" type="button" role="tab">
Needs Review <span class="badge bg-warning text-dark ms-1">@needsReview.Count</span>
</button>
</li>
</ul>
<div class="tab-content" id="proposalTabContent">
<div class="tab-pane fade show active" id="high-confidence" role="tabpanel">
@if (highConfidence.Any())
{
@await Html.PartialAsync("_ProposalTable", highConfidence)
}
else
{
<div class="p-4 text-center text-muted">No high confidence proposals.</div>
}
</div>
<div class="tab-pane fade" id="needs-review" role="tabpanel">
@if (needsReview.Any())
{
@await Html.PartialAsync("_ProposalTable", needsReview)
}
else
{
<div class="p-4 text-center text-muted">No proposals needing review.</div>
}
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between">
<form method="post" asp-page-handler="Cancel" class="d-inline">
<button type="submit" class="btn btn-outline-secondary">Cancel</button>
</form>
<a asp-page="/Recategorize" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-success">
Apply Selected Categorizations
</button>
@@ -180,11 +140,12 @@ else
</ul>
</div>
<div class="col-md-6">
<h6>Create Rule</h6>
<p class="small text-muted mb-0">
When checked, a category mapping rule will be created using the proposed pattern.
Future transactions matching this pattern will be automatically categorized.
</p>
<h6>Rule Status</h6>
<ul class="list-unstyled mb-0">
<li><span class="small text-muted">Create</span> - No existing rule; check to create a new mapping rule</li>
<li><span class="badge bg-warning text-dark">Update</span> - Pattern exists with a different category; check to update it</li>
<li><span class="badge bg-info text-dark">Exists</span> - Rule already exists with the same category</li>
</ul>
</div>
</div>
</div>
@@ -193,9 +154,36 @@ else
@section Scripts {
<script>
// Select/deselect all proposals across both tabs
function selectAll(checked) {
document.querySelectorAll('.proposal-checkbox').forEach(cb => cb.checked = checked);
document.getElementById('selectAllCheckbox').checked = checked;
document.querySelectorAll('.proposal-checkbox').forEach(cb => {
cb.checked = checked;
if (!checked) {
var ruleCheckbox = cb.closest('tr').querySelector('.create-rule-checkbox');
if (ruleCheckbox) ruleCheckbox.checked = false;
}
});
document.querySelectorAll('.select-all-tab').forEach(cb => cb.checked = checked);
}
// Select/deselect all within a single tab
function selectAllInTab(headerCheckbox) {
var table = headerCheckbox.closest('table');
table.querySelectorAll('.proposal-checkbox').forEach(cb => {
cb.checked = headerCheckbox.checked;
if (!headerCheckbox.checked) {
var ruleCheckbox = cb.closest('tr').querySelector('.create-rule-checkbox');
if (ruleCheckbox) ruleCheckbox.checked = false;
}
});
}
// When a proposal checkbox is unchecked, also uncheck its create-rule checkbox
document.addEventListener('change', function (e) {
if (e.target.classList.contains('proposal-checkbox') && !e.target.checked) {
var ruleCheckbox = e.target.closest('tr').querySelector('.create-rule-checkbox');
if (ruleCheckbox) ruleCheckbox.checked = false;
}
});
</script>
}

View File

@@ -26,7 +26,17 @@ namespace MoneyMap.Pages
public List<ProposalViewModel> Proposals { get; set; } = new();
public string ModelUsed { get; set; } = "";
public string AIProvider => _config["AI:CategorizationProvider"] ?? "OpenAI";
public string AIProvider
{
get
{
var model = SelectedModel;
if (model.StartsWith("llamacpp:", StringComparison.OrdinalIgnoreCase)) return "LlamaCpp";
if (model.StartsWith("ollama:", StringComparison.OrdinalIgnoreCase)) return "Ollama";
if (model.StartsWith("claude-", StringComparison.OrdinalIgnoreCase)) return "Anthropic";
return "OpenAI";
}
}
public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
[TempData]
@@ -188,26 +198,37 @@ namespace MoneyMap.Pages
return RedirectToPage();
}
public async Task<IActionResult> OnPostApplyAsync(long[] selectedIds, long[] createRules)
public async Task<IActionResult> OnPostApplyAsync(long[] selectedIds, long[] createRules, string? proposalsData)
{
if (string.IsNullOrEmpty(ProposalsJson))
// Read proposals from the hidden form field (not TempData, which can be lost on app restart)
var json = proposalsData ?? ProposalsJson;
if (string.IsNullOrEmpty(json))
{
ErrorMessage = "No proposals to apply. Please generate new suggestions.";
return RedirectToPage("/Recategorize");
}
var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(ProposalsJson);
var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(json);
if (storedProposals == null || storedProposals.Count == 0)
{
ErrorMessage = "No proposals to apply.";
ErrorMessage = "No proposals to apply (deserialization returned empty).";
return RedirectToPage("/Recategorize");
}
var selectedSet = selectedIds?.ToHashSet() ?? new HashSet<long>();
var createRulesSet = createRules?.ToHashSet() ?? new HashSet<long>();
if (selectedSet.Count == 0)
{
ErrorMessage = $"No transactions were selected. ({storedProposals.Count} proposals available but 0 selectedIds received from form)";
return RedirectToPage("/Recategorize");
}
int applied = 0;
int rulesCreated = 0;
int rulesUpdated = 0;
var errors = new List<string>();
foreach (var stored in storedProposals)
{
@@ -226,7 +247,7 @@ namespace MoneyMap.Pages
CreateRule = stored.CreateRule
};
// Check if user wants to create rule for this one
// Check if user wants to create/update rule for this one
var shouldCreateRule = createRulesSet.Contains(stored.TransactionId);
var result = await _aiCategorizer.ApplyProposalAsync(stored.TransactionId, proposal, shouldCreateRule);
@@ -235,15 +256,25 @@ namespace MoneyMap.Pages
applied++;
if (result.RuleCreated)
rulesCreated++;
if (result.RuleUpdated)
rulesUpdated++;
}
else
{
errors.Add($"ID {stored.TransactionId}: {result.ErrorMessage}");
}
}
SuccessMessage = $"Applied {applied} categorizations. Created {rulesCreated} new mapping rules.";
return RedirectToPage("/Recategorize");
}
var parts = new List<string> { $"Applied {applied} of {selectedSet.Count} selected categorizations" };
if (rulesCreated > 0) parts.Add($"created {rulesCreated} new rules");
if (rulesUpdated > 0) parts.Add($"updated {rulesUpdated} existing rules");
SuccessMessage = string.Join(". ", parts) + ".";
if (errors.Count > 0)
{
ErrorMessage = "Some proposals failed: " + string.Join("; ", errors);
}
public IActionResult OnPostCancel()
{
return RedirectToPage("/Recategorize");
}
@@ -254,10 +285,25 @@ namespace MoneyMap.Pages
.Where(t => transactionIds.Contains(t.Id))
.ToDictionaryAsync(t => t.Id);
// Look up existing rules for all proposed patterns
var proposedPatterns = storedProposals
.Where(p => !string.IsNullOrWhiteSpace(p.Pattern))
.Select(p => p.Pattern!)
.Distinct()
.ToList();
var existingRules = await _db.CategoryMappings
.Where(m => proposedPatterns.Contains(m.Pattern))
.ToDictionaryAsync(m => m.Pattern, m => m.Category);
foreach (var stored in storedProposals)
{
if (transactions.TryGetValue(stored.TransactionId, out var txn))
{
string? existingCategory = null;
var hasExisting = !string.IsNullOrWhiteSpace(stored.Pattern)
&& existingRules.TryGetValue(stored.Pattern!, out existingCategory);
Proposals.Add(new ProposalViewModel
{
TransactionId = stored.TransactionId,
@@ -271,7 +317,9 @@ namespace MoneyMap.Pages
ProposedPattern = stored.Pattern,
Confidence = stored.Confidence,
Reasoning = stored.Reasoning,
CreateRule = stored.CreateRule
CreateRule = stored.CreateRule,
HasExistingRule = hasExisting,
ExistingRuleCategory = existingCategory
});
}
}
@@ -294,6 +342,13 @@ namespace MoneyMap.Pages
public decimal Confidence { get; set; }
public string? Reasoning { get; set; }
public bool CreateRule { get; set; }
public bool HasExistingRule { get; set; }
public string? ExistingRuleCategory { get; set; }
/// <summary>
/// True when the pattern exists but is mapped to a different category than proposed.
/// </summary>
public bool NeedsRuleUpdate => HasExistingRule && ExistingRuleCategory != ProposedCategory;
}
public class StoredProposal

View File

@@ -0,0 +1,101 @@
@model List<MoneyMap.Pages.AICategorizePreviewModel.ProposalViewModel>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input select-all-tab" onchange="selectAllInTab(this)" checked>
</th>
<th>Transaction</th>
<th>Current</th>
<th>Proposed</th>
<th>Merchant</th>
<th style="width: 100px;">Confidence</th>
<th style="width: 140px;">Rule</th>
</tr>
</thead>
<tbody>
@foreach (var proposal in Model)
{
var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" :
proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary";
<tr>
<td>
<input type="checkbox" class="form-check-input proposal-checkbox"
name="selectedIds" value="@proposal.TransactionId" checked>
</td>
<td>
<div class="fw-bold">@proposal.TransactionName</div>
@if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo))
{
<small class="text-muted">@proposal.TransactionMemo</small>
}
<div class="small text-muted">
@proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C")
</div>
</td>
<td>
@if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory))
{
<span class="badge bg-secondary">@proposal.CurrentCategory</span>
}
else
{
<span class="text-muted small">(none)</span>
}
</td>
<td>
<span class="badge bg-primary">@proposal.ProposedCategory</span>
@if (!string.IsNullOrWhiteSpace(proposal.Reasoning))
{
<div class="small text-muted mt-1">@proposal.Reasoning</div>
}
</td>
<td>
@if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant))
{
<div>@proposal.ProposedMerchant</div>
}
@if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern))
{
<div class="small text-muted mt-1">Pattern: <code>@proposal.ProposedPattern</code></div>
}
</td>
<td>
<span class="badge @confidenceClass">@proposal.Confidence.ToString("P0")</span>
</td>
<td>
@if (proposal.HasExistingRule && !proposal.NeedsRuleUpdate)
{
<span class="badge bg-info text-dark" title="Rule already exists for pattern '@proposal.ProposedPattern' with same category">
Exists
</span>
}
else if (proposal.NeedsRuleUpdate)
{
<div>
<input type="checkbox" class="form-check-input create-rule-checkbox"
name="createRules" value="@proposal.TransactionId"
@(proposal.CreateRule ? "checked" : "")
title="Update existing rule from '@proposal.ExistingRuleCategory' to '@proposal.ProposedCategory'">
<span class="badge bg-warning text-dark">Update</span>
</div>
<div class="small text-muted mt-1">
Currently: <code>@proposal.ExistingRuleCategory</code>
</div>
}
else
{
<input type="checkbox" class="form-check-input create-rule-checkbox"
name="createRules" value="@proposal.TransactionId"
@(proposal.CreateRule ? "checked" : "")
title="Create a new mapping rule for this pattern">
<span class="small text-muted">Create</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>