From 444035fd72d751a9ba4cbaf258c6a31329e79285 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Feb 2026 19:14:19 -0500 Subject: [PATCH] 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 --- MoneyMap/Pages/AICategorizePreview.cshtml | 162 +++++++++---------- MoneyMap/Pages/AICategorizePreview.cshtml.cs | 79 +++++++-- MoneyMap/Pages/_ProposalTable.cshtml | 101 ++++++++++++ 3 files changed, 243 insertions(+), 99 deletions(-) create mode 100644 MoneyMap/Pages/_ProposalTable.cshtml diff --git a/MoneyMap/Pages/AICategorizePreview.cshtml b/MoneyMap/Pages/AICategorizePreview.cshtml index 3f8de13..ce2c569 100644 --- a/MoneyMap/Pages/AICategorizePreview.cshtml +++ b/MoneyMap/Pages/AICategorizePreview.cshtml @@ -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(); +
+
Review Proposals (@Model.Proposals.Count suggestions) @@ -75,89 +79,45 @@ else
-
- - - - - - - - - - - - - - @foreach (var proposal in Model.Proposals) - { - var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" : - proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary"; - - - - - - - - - - } - -
- - TransactionCurrentProposedMerchantConfidenceCreate Rule
- - -
@proposal.TransactionName
- @if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo)) - { - @proposal.TransactionMemo - } -
- @proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C") -
-
- @if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory)) - { - @proposal.CurrentCategory - } - else - { - (none) - } - - @proposal.ProposedCategory - @if (!string.IsNullOrWhiteSpace(proposal.Reasoning)) - { -
- @(proposal.Reasoning.Length > 60 ? proposal.Reasoning.Substring(0, 60) + "..." : proposal.Reasoning) -
- } -
- @if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant)) - { -
@proposal.ProposedMerchant
- } - @if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern)) - { - @proposal.ProposedPattern - } -
- @proposal.Confidence.ToString("P0") - - -
+ +
+
+ @if (highConfidence.Any()) + { + @await Html.PartialAsync("_ProposalTable", highConfidence) + } + else + { +
No high confidence proposals.
+ } +
+
+ @if (needsReview.Any()) + { + @await Html.PartialAsync("_ProposalTable", needsReview) + } + else + { +
No proposals needing review.
+ } +
-
Create Rule
-

- When checked, a category mapping rule will be created using the proposed pattern. - Future transactions matching this pattern will be automatically categorized. -

+
Rule Status
+
    +
  • Create - No existing rule; check to create a new mapping rule
  • +
  • Update - Pattern exists with a different category; check to update it
  • +
  • Exists - Rule already exists with the same category
  • +
@@ -193,9 +154,36 @@ else @section Scripts { } diff --git a/MoneyMap/Pages/AICategorizePreview.cshtml.cs b/MoneyMap/Pages/AICategorizePreview.cshtml.cs index 01db1e4..a423ab1 100644 --- a/MoneyMap/Pages/AICategorizePreview.cshtml.cs +++ b/MoneyMap/Pages/AICategorizePreview.cshtml.cs @@ -26,7 +26,17 @@ namespace MoneyMap.Pages public List 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 OnPostApplyAsync(long[] selectedIds, long[] createRules) + public async Task 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>(ProposalsJson); + var storedProposals = JsonSerializer.Deserialize>(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(); var createRulesSet = createRules?.ToHashSet() ?? new HashSet(); + 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(); 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 { $"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; } + + /// + /// True when the pattern exists but is mapped to a different category than proposed. + /// + public bool NeedsRuleUpdate => HasExistingRule && ExistingRuleCategory != ProposedCategory; } public class StoredProposal diff --git a/MoneyMap/Pages/_ProposalTable.cshtml b/MoneyMap/Pages/_ProposalTable.cshtml new file mode 100644 index 0000000..8f28307 --- /dev/null +++ b/MoneyMap/Pages/_ProposalTable.cshtml @@ -0,0 +1,101 @@ +@model List + +
+ + + + + + + + + + + + + + @foreach (var proposal in Model) + { + var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" : + proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary"; + + + + + + + + + + } + +
+ + TransactionCurrentProposedMerchantConfidenceRule
+ + +
@proposal.TransactionName
+ @if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo)) + { + @proposal.TransactionMemo + } +
+ @proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C") +
+
+ @if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory)) + { + @proposal.CurrentCategory + } + else + { + (none) + } + + @proposal.ProposedCategory + @if (!string.IsNullOrWhiteSpace(proposal.Reasoning)) + { +
@proposal.Reasoning
+ } +
+ @if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant)) + { +
@proposal.ProposedMerchant
+ } + @if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern)) + { +
Pattern: @proposal.ProposedPattern
+ } +
+ @proposal.Confidence.ToString("P0") + + @if (proposal.HasExistingRule && !proposal.NeedsRuleUpdate) + { + + Exists + + } + else if (proposal.NeedsRuleUpdate) + { +
+ + Update +
+
+ Currently: @proposal.ExistingRuleCategory +
+ } + else + { + + Create + } +
+