- Move CategoryMapping class to Models/CategoryMapping.cs - Add IMemoryCache with 10-minute TTL for category mappings - Add InvalidateMappingsCache() method for cache invalidation - Reduces repeated DB queries during bulk categorization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
317 lines
11 KiB
C#
317 lines
11 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using MoneyMap.Data;
|
|
using MoneyMap.Models;
|
|
using MoneyMap.Services;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace MoneyMap.Pages
|
|
{
|
|
public class CategoryMappingsModel : PageModel
|
|
{
|
|
private readonly MoneyMapContext _db;
|
|
private readonly ITransactionCategorizer _categorizer;
|
|
private readonly IMerchantService _merchantService;
|
|
|
|
public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService)
|
|
{
|
|
_db = db;
|
|
_categorizer = categorizer;
|
|
_merchantService = merchantService;
|
|
}
|
|
|
|
public List<CategoryGroup> CategoryGroups { get; set; } = new();
|
|
public int TotalMappings { get; set; }
|
|
public int TotalCategories { get; set; }
|
|
public List<string> AvailableCategories { get; set; } = new();
|
|
public List<string> AvailableMerchants { get; set; } = new();
|
|
|
|
[TempData]
|
|
public string? SuccessMessage { get; set; }
|
|
|
|
[TempData]
|
|
public string? ErrorMessage { get; set; }
|
|
|
|
public async Task OnGetAsync()
|
|
{
|
|
await LoadDataAsync();
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostSeedDefaultsAsync()
|
|
{
|
|
await _categorizer.SeedDefaultMappingsAsync();
|
|
SuccessMessage = "Default category mappings loaded successfully!";
|
|
return RedirectToPage();
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostAddMappingAsync(AddMappingModel model)
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
ErrorMessage = "Please fill in all required fields.";
|
|
await LoadDataAsync();
|
|
return Page();
|
|
}
|
|
|
|
int? merchantId = null;
|
|
if (!string.IsNullOrWhiteSpace(model.Merchant))
|
|
{
|
|
merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant);
|
|
}
|
|
|
|
var mapping = new CategoryMapping
|
|
{
|
|
Category = model.Category.Trim(),
|
|
Pattern = model.Pattern.Trim(),
|
|
MerchantId = merchantId,
|
|
Priority = model.Priority
|
|
};
|
|
|
|
_db.CategoryMappings.Add(mapping);
|
|
await _db.SaveChangesAsync();
|
|
|
|
SuccessMessage = $"Added pattern '{mapping.Pattern}' to category '{mapping.Category}'.";
|
|
return RedirectToPage();
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostUpdateMappingAsync(UpdateMappingModel model)
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
ErrorMessage = "Please fill in all required fields.";
|
|
await LoadDataAsync();
|
|
return Page();
|
|
}
|
|
|
|
var mapping = await _db.CategoryMappings.FindAsync(model.Id);
|
|
if (mapping == null)
|
|
{
|
|
ErrorMessage = "Mapping not found.";
|
|
return RedirectToPage();
|
|
}
|
|
|
|
int? merchantId = null;
|
|
if (!string.IsNullOrWhiteSpace(model.Merchant))
|
|
{
|
|
merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant);
|
|
}
|
|
|
|
mapping.Category = model.Category.Trim();
|
|
mapping.Pattern = model.Pattern.Trim();
|
|
mapping.MerchantId = merchantId;
|
|
mapping.Priority = model.Priority;
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
SuccessMessage = "Mapping updated successfully.";
|
|
return RedirectToPage();
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostDeleteMappingAsync(int id)
|
|
{
|
|
var mapping = await _db.CategoryMappings.FindAsync(id);
|
|
if (mapping == null)
|
|
{
|
|
ErrorMessage = "Mapping not found.";
|
|
return RedirectToPage();
|
|
}
|
|
|
|
_db.CategoryMappings.Remove(mapping);
|
|
await _db.SaveChangesAsync();
|
|
|
|
SuccessMessage = "Mapping deleted successfully.";
|
|
return RedirectToPage();
|
|
}
|
|
|
|
public async Task<IActionResult> OnGetExportAsync()
|
|
{
|
|
var mappings = await _db.CategoryMappings
|
|
.OrderBy(m => m.Category)
|
|
.ThenByDescending(m => m.Priority)
|
|
.ToListAsync();
|
|
|
|
var exportData = mappings.Select(m => new CategoryMappingExport
|
|
{
|
|
Category = m.Category,
|
|
Pattern = m.Pattern,
|
|
Merchant = m.Merchant?.Name,
|
|
Priority = m.Priority
|
|
}).ToList();
|
|
|
|
var json = JsonSerializer.Serialize(exportData, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
});
|
|
|
|
var bytes = Encoding.UTF8.GetBytes(json);
|
|
return File(bytes, "application/json", "category-mappings.json");
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostImportAsync(IFormFile jsonFile, bool replaceExisting)
|
|
{
|
|
if (jsonFile == null || jsonFile.Length == 0)
|
|
{
|
|
ErrorMessage = "Please select a JSON file to import.";
|
|
return RedirectToPage();
|
|
}
|
|
|
|
try
|
|
{
|
|
using var stream = jsonFile.OpenReadStream();
|
|
var importData = await JsonSerializer.DeserializeAsync<List<CategoryMappingExport>>(stream, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
if (importData == null || !importData.Any())
|
|
{
|
|
ErrorMessage = "No category mappings found in the file.";
|
|
return RedirectToPage();
|
|
}
|
|
|
|
// Validate the data
|
|
foreach (var item in importData)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(item.Category) || string.IsNullOrWhiteSpace(item.Pattern))
|
|
{
|
|
ErrorMessage = "Invalid data in JSON file. Each mapping must have a category and pattern.";
|
|
return RedirectToPage();
|
|
}
|
|
}
|
|
|
|
// Replace existing or merge
|
|
if (replaceExisting)
|
|
{
|
|
var existingMappings = await _db.CategoryMappings.ToListAsync();
|
|
_db.CategoryMappings.RemoveRange(existingMappings);
|
|
}
|
|
|
|
// Add new mappings (create merchants first if needed)
|
|
var newMappings = new List<CategoryMapping>();
|
|
foreach (var item in importData)
|
|
{
|
|
int? merchantId = null;
|
|
if (!string.IsNullOrWhiteSpace(item.Merchant))
|
|
{
|
|
merchantId = await _merchantService.GetOrCreateIdAsync(item.Merchant);
|
|
}
|
|
|
|
newMappings.Add(new CategoryMapping
|
|
{
|
|
Category = item.Category.Trim(),
|
|
Pattern = item.Pattern.Trim(),
|
|
MerchantId = merchantId,
|
|
Priority = item.Priority
|
|
});
|
|
}
|
|
|
|
_db.CategoryMappings.AddRange(newMappings);
|
|
await _db.SaveChangesAsync();
|
|
|
|
SuccessMessage = replaceExisting
|
|
? $"Successfully replaced all mappings with {importData.Count} mappings from file."
|
|
: $"Successfully imported {importData.Count} mappings from file.";
|
|
|
|
return RedirectToPage();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
ErrorMessage = "Invalid JSON file format. Please check the file and try again.";
|
|
return RedirectToPage();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ErrorMessage = $"Error importing file: {ex.Message}";
|
|
return RedirectToPage();
|
|
}
|
|
}
|
|
|
|
private async Task LoadDataAsync()
|
|
{
|
|
var mappings = await _db.CategoryMappings
|
|
.Include(m => m.Merchant)
|
|
.OrderBy(m => m.Category)
|
|
.ThenByDescending(m => m.Priority)
|
|
.ToListAsync();
|
|
|
|
CategoryGroups = mappings
|
|
.GroupBy(m => m.Category)
|
|
.Select(g => new CategoryGroup
|
|
{
|
|
Category = g.Key,
|
|
Mappings = g.OrderByDescending(m => m.Priority).ToList(),
|
|
Count = g.Count()
|
|
})
|
|
.OrderBy(g => g.Category)
|
|
.ToList();
|
|
|
|
TotalMappings = mappings.Count;
|
|
TotalCategories = CategoryGroups.Count;
|
|
|
|
// Get distinct categories for dropdown
|
|
AvailableCategories = CategoryGroups
|
|
.Select(g => g.Category)
|
|
.OrderBy(c => c)
|
|
.ToList();
|
|
|
|
// Get all merchants for dropdown
|
|
AvailableMerchants = await _db.Merchants
|
|
.OrderBy(m => m.Name)
|
|
.Select(m => m.Name)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public class CategoryGroup
|
|
{
|
|
public required string Category { get; set; }
|
|
public required List<CategoryMapping> Mappings { get; set; }
|
|
public int Count { get; set; }
|
|
}
|
|
|
|
public class AddMappingModel
|
|
{
|
|
[Required(ErrorMessage = "Category is required")]
|
|
[StringLength(100)]
|
|
public string Category { get; set; } = "";
|
|
|
|
[StringLength(100)]
|
|
public string? Merchant { get; set; }
|
|
|
|
[Required(ErrorMessage = "Pattern is required")]
|
|
[StringLength(200)]
|
|
public string Pattern { get; set; } = "";
|
|
|
|
public int Priority { get; set; } = 0;
|
|
}
|
|
|
|
public class UpdateMappingModel
|
|
{
|
|
[Required]
|
|
public int Id { get; set; }
|
|
|
|
[Required(ErrorMessage = "Category is required")]
|
|
[StringLength(100)]
|
|
public string Category { get; set; } = "";
|
|
|
|
[StringLength(100)]
|
|
public string? Merchant { get; set; }
|
|
|
|
[Required(ErrorMessage = "Pattern is required")]
|
|
[StringLength(200)]
|
|
public string Pattern { get; set; } = "";
|
|
|
|
public int Priority { get; set; } = 0;
|
|
}
|
|
|
|
public class CategoryMappingExport
|
|
{
|
|
public string Category { get; set; } = "";
|
|
public string Pattern { get; set; } = "";
|
|
public string? Merchant { get; set; }
|
|
public int Priority { get; set; } = 0;
|
|
}
|
|
}
|
|
} |