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:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
101
MoneyMap/Pages/_ProposalTable.cshtml
Normal file
101
MoneyMap/Pages/_ProposalTable.cshtml
Normal 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>
|
||||
Reference in New Issue
Block a user