Add card management pages and enhance category mappings UI.
Features added: - Cards page with list/create/edit/delete functionality - EditCard page for modifying card details - Enhanced CategoryMappings UI with inline add/edit/delete modals - Category dropdown autocomplete in mapping forms - Priority badges and visual indicators for high-priority mappings - Click-to-edit functionality on patterns Improvements: - TransactionCategorizer: Add TARGET.COM to online shopping patterns - TransactionCategorizer: Set Credit Card Payment to priority 200 to catch before Banking - TransactionCategorizer: Add database migration comments - Layout: Add Cards link to navigation - Layout: Fix Bootstrap JS path - ValidationScriptsPartial: Use Url.Content for script paths 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
93
MoneyMap/Pages/Cards.cshtml
Normal file
93
MoneyMap/Pages/Cards.cshtml
Normal file
@@ -0,0 +1,93 @@
|
||||
@page
|
||||
@model MoneyMap.Pages.CardsModel
|
||||
@{
|
||||
ViewData["Title"] = "Manage Cards";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>Manage Cards</h2>
|
||||
<div>
|
||||
<a asp-page="/EditCard" class="btn btn-primary">Add New Card</a>
|
||||
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
@Model.SuccessMessage
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@Model.ErrorMessage
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Cards.Any())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<strong>Your Cards (@Model.Cards.Count)</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Issuer</th>
|
||||
<th>Last 4</th>
|
||||
<th class="text-end">Transactions</th>
|
||||
<th style="width: 150px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model.Cards)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.Card.Owner</td>
|
||||
<td>@item.Card.Issuer</td>
|
||||
<td>•••• @item.Card.Last4</td>
|
||||
<td class="text-end">@item.TransactionCount</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<a asp-page="/EditCard" asp-route-id="@item.Card.Id" class="btn btn-sm btn-outline-primary">
|
||||
Edit
|
||||
</a>
|
||||
@if (item.TransactionCount == 0)
|
||||
{
|
||||
<form method="post" asp-page-handler="Delete" asp-route-id="@item.Card.Id"
|
||||
onsubmit="return confirm('Delete this card?')" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" disabled
|
||||
title="Cannot delete - card has transactions">
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<h5>No cards found</h5>
|
||||
<p>Add your first card to start tracking transactions.</p>
|
||||
<a asp-page="/EditCard" class="btn btn-primary">Add New Card</a>
|
||||
</div>
|
||||
}
|
||||
81
MoneyMap/Pages/Cards.cshtml.cs
Normal file
81
MoneyMap/Pages/Cards.cshtml.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Pages
|
||||
{
|
||||
public class CardsModel : PageModel
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public CardsModel(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public List<CardWithStats> Cards { get; set; } = new();
|
||||
|
||||
[TempData]
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var cards = await _db.Cards
|
||||
.OrderBy(c => c.Owner)
|
||||
.ThenBy(c => c.Last4)
|
||||
.ToListAsync();
|
||||
|
||||
var cardStats = new List<CardWithStats>();
|
||||
|
||||
foreach (var card in cards)
|
||||
{
|
||||
var transactionCount = await _db.Transactions.CountAsync(t => t.CardId == card.Id);
|
||||
|
||||
cardStats.Add(new CardWithStats
|
||||
{
|
||||
Card = card,
|
||||
TransactionCount = transactionCount
|
||||
});
|
||||
}
|
||||
|
||||
Cards = cardStats;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||
{
|
||||
var card = await _db.Cards.FindAsync(id);
|
||||
if (card == null)
|
||||
{
|
||||
ErrorMessage = "Card not found.";
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
var transactionCount = await _db.Transactions.CountAsync(t => t.CardId == card.Id);
|
||||
if (transactionCount > 0)
|
||||
{
|
||||
ErrorMessage = $"Cannot delete card. It has {transactionCount} transaction(s) associated with it.";
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
_db.Cards.Remove(card);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
SuccessMessage = "Card deleted successfully.";
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public class CardWithStats
|
||||
{
|
||||
public Card Card { get; set; } = null!;
|
||||
public int TransactionCount { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,14 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@Model.ErrorMessage
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
@@ -57,6 +65,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add New Mapping Button -->
|
||||
<div class="mb-3">
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addModal">
|
||||
+ Add New Pattern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (Model.CategoryGroups.Any())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
@@ -75,13 +90,22 @@
|
||||
<span class="badge bg-secondary">@group.Count</span>
|
||||
</h6>
|
||||
<div class="small">
|
||||
@foreach (var pattern in group.Patterns.Take(5))
|
||||
@foreach (var mapping in group.Mappings)
|
||||
{
|
||||
<div class="text-muted">• @pattern</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="text-muted" style="cursor: pointer;"
|
||||
onclick="openEditModal(@mapping.Id, '@Html.Raw(mapping.Category.Replace("'", "\\'"))', '@Html.Raw(mapping.Pattern.Replace("'", "\\'"))', @mapping.Priority)">
|
||||
@if (mapping.Priority > 0)
|
||||
{
|
||||
<span class="badge bg-info me-1" title="Priority @mapping.Priority">P@(mapping.Priority)</span>
|
||||
}
|
||||
@if (group.Patterns.Count > 5)
|
||||
{
|
||||
<div class="text-muted fst-italic">+ @(group.Patterns.Count - 5) more...</div>
|
||||
@mapping.Pattern
|
||||
</span>
|
||||
<form method="post" asp-page-handler="DeleteMapping" asp-route-id="@mapping.Id"
|
||||
onsubmit="return confirm('Delete pattern \'@mapping.Pattern\'?')" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger py-0">×</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,5 +131,233 @@ else
|
||||
<li>Categories in the CSV file (if present) take precedence over auto-categorization</li>
|
||||
<li>Transactions with no match will have an empty category and can be categorized manually</li>
|
||||
<li><strong>Special rule:</strong> Gas stations with purchases under $20 are categorized as "Convenience Store"</li>
|
||||
<li><strong>Priority:</strong> Higher priority patterns are checked first (useful for Banking, Income, etc.)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Add Modal -->
|
||||
<div class="modal fade" id="addModal" tabindex="-1" aria-labelledby="addModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post" asp-page-handler="AddMapping">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addModalLabel">Add New Pattern</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label asp-for="NewMapping.Category" class="form-label">Category</label>
|
||||
<div class="position-relative">
|
||||
<input asp-for="NewMapping.Category" id="addCategory" class="form-control" placeholder="e.g., Online shopping" autocomplete="off" />
|
||||
<div id="addCategoryDropdown" class="dropdown-menu" style="width: 100%; max-height: 200px; overflow-y: auto;">
|
||||
@foreach (var group in Model.CategoryGroups)
|
||||
{
|
||||
<a class="dropdown-item" href="#" data-category="@group.Category">@group.Category</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<span asp-validation-for="NewMapping.Category" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="NewMapping.Pattern" class="form-label">Pattern</label>
|
||||
<input asp-for="NewMapping.Pattern" id="addPattern" class="form-control" placeholder="e.g., TARGET.COM" />
|
||||
<span asp-validation-for="NewMapping.Pattern" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="NewMapping.Priority" class="form-label">Priority</label>
|
||||
<input asp-for="NewMapping.Priority" type="number" id="addPriority" class="form-control" value="0" />
|
||||
<div class="form-text">Higher priority = checked first (0 = normal, 100 = high, 200 = critical)</div>
|
||||
<span asp-validation-for="NewMapping.Priority" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-text">
|
||||
Pattern will match anywhere in the merchant name (case-insensitive).
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Pattern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post" asp-page-handler="UpdateMapping">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editModalLabel">Edit Pattern</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" asp-for="EditMapping.Id" id="editId" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="EditMapping.Category" class="form-label">Category</label>
|
||||
<div class="position-relative">
|
||||
<input asp-for="EditMapping.Category" id="editCategory" class="form-control" autocomplete="off" />
|
||||
<div id="editCategoryDropdown" class="dropdown-menu" style="width: 100%; max-height: 200px; overflow-y: auto;">
|
||||
@foreach (var group in Model.CategoryGroups)
|
||||
{
|
||||
<a class="dropdown-item" href="#" data-category="@group.Category">@group.Category</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<span asp-validation-for="EditMapping.Category" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="EditMapping.Pattern" class="form-label">Pattern</label>
|
||||
<input asp-for="EditMapping.Pattern" id="editPattern" class="form-control" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="EditMapping.Priority" class="form-label">Priority</label>
|
||||
<input asp-for="EditMapping.Priority" type="number" id="editPriority" class="form-control" />
|
||||
<div class="form-text">Higher priority = checked first (0 = normal, 100 = high, 200 = critical)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function openEditModal(id, category, pattern, priority) {
|
||||
console.log('Opening modal for:', id, category, pattern, priority);
|
||||
|
||||
document.getElementById('editId').value = id;
|
||||
document.getElementById('editCategory').value = category;
|
||||
document.getElementById('editPattern').value = pattern;
|
||||
document.getElementById('editPriority').value = priority;
|
||||
|
||||
var modalElement = document.getElementById('editModal');
|
||||
if (modalElement && typeof bootstrap !== 'undefined') {
|
||||
var modal = new bootstrap.Modal(modalElement);
|
||||
modal.show();
|
||||
} else {
|
||||
console.error('Bootstrap modal not available');
|
||||
alert('Error: Modal system not loaded. Please refresh the page.');
|
||||
}
|
||||
}
|
||||
|
||||
// Category dropdown functionality
|
||||
function setupCategoryDropdown(inputId, dropdownId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
|
||||
if (!input || !dropdown) return;
|
||||
|
||||
const dropdownItems = dropdown.querySelectorAll('.dropdown-item');
|
||||
|
||||
// Show dropdown on focus/click
|
||||
input.addEventListener('focus', function() {
|
||||
filterDropdown('');
|
||||
dropdown.classList.add('show');
|
||||
});
|
||||
|
||||
input.addEventListener('click', function() {
|
||||
filterDropdown('');
|
||||
dropdown.classList.add('show');
|
||||
});
|
||||
|
||||
// Filter dropdown as user types
|
||||
input.addEventListener('input', function() {
|
||||
filterDropdown(this.value);
|
||||
dropdown.classList.add('show');
|
||||
});
|
||||
|
||||
// Select category from dropdown
|
||||
dropdownItems.forEach(item => {
|
||||
item.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const category = this.getAttribute('data-category');
|
||||
input.value = category;
|
||||
dropdown.classList.remove('show');
|
||||
});
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!input.contains(e.target) && !dropdown.contains(e.target)) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Filter dropdown items based on search text
|
||||
function filterDropdown(searchText) {
|
||||
const search = searchText.toLowerCase();
|
||||
dropdownItems.forEach(item => {
|
||||
const category = item.getAttribute('data-category').toLowerCase();
|
||||
if (category.includes(search)) {
|
||||
item.style.display = '';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupCategoryDropdown('addCategory', 'addCategoryDropdown');
|
||||
setupCategoryDropdown('editCategory', 'editCategoryDropdown');
|
||||
|
||||
// Debug: Log form values before submission
|
||||
var addForm = document.querySelector('#addModal form');
|
||||
if (addForm) {
|
||||
addForm.addEventListener('submit', function(e) {
|
||||
var categoryInput = document.getElementById('addCategory');
|
||||
var patternInput = document.getElementById('addPattern');
|
||||
var priorityInput = document.getElementById('addPriority');
|
||||
|
||||
console.log('=== ADD FORM SUBMISSION ===');
|
||||
console.log('Category:', categoryInput.value, 'Name:', categoryInput.name);
|
||||
console.log('Pattern:', patternInput.value, 'Name:', patternInput.name);
|
||||
console.log('Priority:', priorityInput.value, 'Name:', priorityInput.name);
|
||||
console.log('Form Data:');
|
||||
var formData = new FormData(addForm);
|
||||
for (var pair of formData.entries()) {
|
||||
console.log(' ' + pair[0] + ': ' + pair[1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var editForm = document.querySelector('#editModal form');
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', function(e) {
|
||||
var id = document.getElementById('editId').value;
|
||||
var category = document.getElementById('editCategory').value;
|
||||
var pattern = document.getElementById('editPattern').value;
|
||||
var priority = document.getElementById('editPriority').value;
|
||||
console.log('Submitting Edit form:', { id, category, pattern, priority });
|
||||
console.log('Category input name:', document.getElementById('editCategory').name);
|
||||
});
|
||||
}
|
||||
|
||||
// Reopen modals if there are validation errors
|
||||
var addCategoryInput = document.getElementById('addCategory');
|
||||
var editCategoryInput = document.getElementById('editCategory');
|
||||
|
||||
if (addCategoryInput && addCategoryInput.classList.contains('input-validation-error')) {
|
||||
var addModal = new bootstrap.Modal(document.getElementById('addModal'));
|
||||
addModal.show();
|
||||
}
|
||||
|
||||
if (editCategoryInput && editCategoryInput.classList.contains('input-validation-error')) {
|
||||
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||
editModal.show();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Pages
|
||||
{
|
||||
public class CategoryMappingsModel : PageModel
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly ITransactionCategorizer _categorizer;
|
||||
|
||||
public CategoryMappingsModel(ITransactionCategorizer categorizer)
|
||||
public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer)
|
||||
{
|
||||
_db = db;
|
||||
_categorizer = categorizer;
|
||||
}
|
||||
|
||||
@@ -20,26 +25,21 @@ namespace MoneyMap.Pages
|
||||
public int TotalMappings { get; set; }
|
||||
public int TotalCategories { get; set; }
|
||||
|
||||
[BindProperty]
|
||||
public MappingEditModel NewMapping { get; set; } = new();
|
||||
|
||||
[BindProperty]
|
||||
public MappingEditModel EditMapping { get; set; } = new();
|
||||
|
||||
[TempData]
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var mappings = await _categorizer.GetAllMappingsAsync();
|
||||
|
||||
CategoryGroups = mappings
|
||||
.GroupBy(m => m.Category)
|
||||
.Select(g => new CategoryGroup
|
||||
{
|
||||
Category = g.Key,
|
||||
Patterns = g.Select(m => m.Pattern).ToList(),
|
||||
Count = g.Count()
|
||||
})
|
||||
.OrderBy(g => g.Category)
|
||||
.ToList();
|
||||
|
||||
TotalMappings = mappings.Count;
|
||||
TotalCategories = CategoryGroups.Count;
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSeedDefaultsAsync()
|
||||
@@ -49,11 +49,120 @@ namespace MoneyMap.Pages
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAddMappingAsync()
|
||||
{
|
||||
// Remove validation errors for EditMapping since we're not using it in this handler
|
||||
ModelState.Remove("EditMapping.Category");
|
||||
ModelState.Remove("EditMapping.Pattern");
|
||||
ModelState.Remove("EditMapping.Priority");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
ErrorMessage = "Please fill in all required fields.";
|
||||
await LoadDataAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
var mapping = new CategoryMapping
|
||||
{
|
||||
Category = NewMapping.Category.Trim(),
|
||||
Pattern = NewMapping.Pattern.Trim(),
|
||||
Priority = NewMapping.Priority
|
||||
};
|
||||
|
||||
_db.CategoryMappings.Add(mapping);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
SuccessMessage = $"Added pattern '{mapping.Pattern}' to category '{mapping.Category}'.";
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostUpdateMappingAsync()
|
||||
{
|
||||
// Remove validation errors for NewMapping since we're not using it in this handler
|
||||
ModelState.Remove("NewMapping.Category");
|
||||
ModelState.Remove("NewMapping.Pattern");
|
||||
ModelState.Remove("NewMapping.Priority");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
ErrorMessage = "Please fill in all required fields.";
|
||||
await LoadDataAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
var mapping = await _db.CategoryMappings.FindAsync(EditMapping.Id);
|
||||
if (mapping == null)
|
||||
{
|
||||
ErrorMessage = "Mapping not found.";
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
mapping.Category = EditMapping.Category.Trim();
|
||||
mapping.Pattern = EditMapping.Pattern.Trim();
|
||||
mapping.Priority = EditMapping.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();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
var mappings = await _categorizer.GetAllMappingsAsync();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public class CategoryGroup
|
||||
{
|
||||
public required string Category { get; set; }
|
||||
public required List<string> Patterns { get; set; }
|
||||
public required List<CategoryMapping> Mappings { get; set; }
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
public class MappingEditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Category { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Pattern { get; set; } = "";
|
||||
|
||||
public int Priority { get; set; } = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
81
MoneyMap/Pages/EditCard.cshtml
Normal file
81
MoneyMap/Pages/EditCard.cshtml
Normal file
@@ -0,0 +1,81 @@
|
||||
@page
|
||||
@model MoneyMap.Pages.EditCardModel
|
||||
@{
|
||||
ViewData["Title"] = Model.IsNewCard ? "Add Card" : "Edit Card";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>@(Model.IsNewCard ? "Add Card" : "Edit Card")</h2>
|
||||
<a asp-page="/Cards" class="btn btn-outline-secondary">Back to Cards</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<strong>Card Details</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<input type="hidden" asp-for="Card.Id" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Card.Owner" class="form-label">Owner</label>
|
||||
<input asp-for="Card.Owner" class="form-control" placeholder="e.g., John Smith" />
|
||||
<span asp-validation-for="Card.Owner" class="text-danger"></span>
|
||||
<div class="form-text">Who owns this card?</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Card.Issuer" class="form-label">Issuer</label>
|
||||
<input asp-for="Card.Issuer" class="form-control" placeholder="e.g., Chase, Bank of America" />
|
||||
<span asp-validation-for="Card.Issuer" class="text-danger"></span>
|
||||
<div class="form-text">Which bank or institution issued the card?</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Card.Last4" class="form-label">Last 4 Digits</label>
|
||||
<input asp-for="Card.Last4" class="form-control" placeholder="1234" maxlength="4" />
|
||||
<span asp-validation-for="Card.Last4" class="text-danger"></span>
|
||||
<div class="form-text">The last 4 digits of the card number</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@(Model.IsNewCard ? "Add Card" : "Save Changes")
|
||||
</button>
|
||||
<a asp-page="/Cards" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<strong>Tips</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li><strong>Owner:</strong> Use the cardholder's name for easy identification</li>
|
||||
<li><strong>Issuer:</strong> The bank or credit card company (e.g., Chase, Discover, Capital One)</li>
|
||||
<li><strong>Last 4:</strong> These digits help match transactions to the correct card</li>
|
||||
<li>Cards with transactions cannot be deleted, only edited</li>
|
||||
<li>Auto-imported cards will have "Unknown" as the owner - update them here</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!Model.IsNewCard)
|
||||
{
|
||||
<div class="alert alert-info mt-3">
|
||||
<strong>Note:</strong> Updating card details will not affect existing transactions, but will change how the card is displayed going forward.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
111
MoneyMap/Pages/EditCard.cshtml.cs
Normal file
111
MoneyMap/Pages/EditCard.cshtml.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Pages
|
||||
{
|
||||
public class EditCardModel : PageModel
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public EditCardModel(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[BindProperty]
|
||||
public CardEditModel Card { get; set; } = new();
|
||||
|
||||
public bool IsNewCard { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(int? id)
|
||||
{
|
||||
if (id.HasValue)
|
||||
{
|
||||
var card = await _db.Cards.FindAsync(id.Value);
|
||||
if (card == null)
|
||||
return NotFound();
|
||||
|
||||
Card = new CardEditModel
|
||||
{
|
||||
Id = card.Id,
|
||||
Owner = card.Owner,
|
||||
Issuer = card.Issuer,
|
||||
Last4 = card.Last4
|
||||
};
|
||||
|
||||
IsNewCard = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
IsNewCard = true;
|
||||
}
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
IsNewCard = Card.Id == 0;
|
||||
return Page();
|
||||
}
|
||||
|
||||
if (Card.Id == 0)
|
||||
{
|
||||
// Create new card
|
||||
var card = new Card
|
||||
{
|
||||
Owner = Card.Owner.Trim(),
|
||||
Issuer = Card.Issuer.Trim(),
|
||||
Last4 = Card.Last4.Trim()
|
||||
};
|
||||
|
||||
_db.Cards.Add(card);
|
||||
SuccessMessage = "Card added successfully!";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update existing card
|
||||
var card = await _db.Cards.FindAsync(Card.Id);
|
||||
if (card == null)
|
||||
return NotFound();
|
||||
|
||||
card.Owner = Card.Owner.Trim();
|
||||
card.Issuer = Card.Issuer.Trim();
|
||||
card.Last4 = Card.Last4.Trim();
|
||||
|
||||
SuccessMessage = "Card updated successfully!";
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return RedirectToPage("/Cards");
|
||||
}
|
||||
|
||||
public class CardEditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Owner is required")]
|
||||
[StringLength(100)]
|
||||
public string Owner { get; set; } = "";
|
||||
|
||||
[Required(ErrorMessage = "Issuer is required")]
|
||||
[StringLength(100)]
|
||||
public string Issuer { get; set; } = "";
|
||||
|
||||
[Required(ErrorMessage = "Last 4 digits are required")]
|
||||
[StringLength(4, MinimumLength = 4, ErrorMessage = "Last 4 must be exactly 4 digits")]
|
||||
[RegularExpression(@"^\d{4}$", ErrorMessage = "Last 4 must be 4 digits")]
|
||||
public string Last4 { get; set; } = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
8
MoneyMap/Pages/Privacy.cshtml
Normal file
8
MoneyMap/Pages/Privacy.cshtml
Normal file
@@ -0,0 +1,8 @@
|
||||
@page
|
||||
@model PrivacyModel
|
||||
@{
|
||||
ViewData["Title"] = "Privacy Policy";
|
||||
}
|
||||
<h1>@ViewData["Title"]</h1>
|
||||
|
||||
<p>Use this page to detail your site's privacy policy.</p>
|
||||
20
MoneyMap/Pages/Privacy.cshtml.cs
Normal file
20
MoneyMap/Pages/Privacy.cshtml.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace MoneyMap.Pages
|
||||
{
|
||||
public class PrivacyModel : PageModel
|
||||
{
|
||||
private readonly ILogger<PrivacyModel> _logger;
|
||||
|
||||
public PrivacyModel(ILogger<PrivacyModel> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,6 +25,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-page="/Transactions">Transactions</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-page="/Cards">Cards</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-page="/Upload">Upload CSV</a>
|
||||
</li>
|
||||
@@ -52,7 +55,7 @@
|
||||
</footer>
|
||||
|
||||
<script src="~/lib/jquery/jquery.min.js"></script>
|
||||
<script src="~/lib/bootstrap/bootstrap.min.js"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.min.js"></script>
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
|
||||
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
|
||||
<script src="@Url.Content("~/lib/jquery-validation/jquery.validate.min.js")"></script>
|
||||
<script src="@Url.Content("~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js")"></script>
|
||||
|
||||
@@ -101,7 +101,7 @@ namespace MoneyMap.Services
|
||||
"AMAZON MKTPL", "AMAZON.COM", "BATHANDBODYWORKS", "BATH AND BODY",
|
||||
"SEPHORA.COM", "ULTA.COM", "WWW.KOHLS.COM", "GAPOUTLET.COM",
|
||||
"NIKE.COM", "HOMEDEPOT.COM", "TEMU.COM", "APPLE.COM",
|
||||
"JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS");
|
||||
"JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS", "TARGET.COM");
|
||||
|
||||
// Walmart
|
||||
AddMappings("Walmart Online", mappings, "WALMART.COM");
|
||||
@@ -181,8 +181,8 @@ namespace MoneyMap.Services
|
||||
AddMappings("Insurance", mappings,
|
||||
"BOSTON MUTUAL", "IND FARMERS INS", "GERBER LIFE INS");
|
||||
|
||||
// Credit Card Payment
|
||||
AddMappings("Credit Card Payment", mappings,
|
||||
// Credit Card Payment (high priority to catch before Banking)
|
||||
AddMappings("Credit Card Payment", mappings, 200,
|
||||
"PAYMENT TO CREDIT CARD", "CAPITAL ONE", "MOBILE PAYMENT THANK YOU");
|
||||
|
||||
// Ice Cream
|
||||
@@ -229,4 +229,12 @@ namespace MoneyMap.Services
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Database Migration =====
|
||||
// Add this to your DbContext:
|
||||
// public DbSet<CategoryMapping> CategoryMappings { get; set; }
|
||||
//
|
||||
// Then create a migration:
|
||||
// dotnet ef migrations add AddCategoryMappings
|
||||
// dotnet ef database update
|
||||
}
|
||||
Reference in New Issue
Block a user