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:
AJ
2025-10-09 16:49:18 -04:00
parent 7ac0dd9dbc
commit ff14aed65f
12 changed files with 806 additions and 30 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)"
],
"deny": [],
"ask": []
}
}

View 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>
}

View 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; }
}
}
}

View File

@@ -19,6 +19,14 @@
</div> </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="row mb-3">
<div class="col-md-4"> <div class="col-md-4">
<div class="card shadow-sm"> <div class="card shadow-sm">
@@ -57,6 +65,13 @@
</div> </div>
</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()) @if (Model.CategoryGroups.Any())
{ {
<div class="card shadow-sm"> <div class="card shadow-sm">
@@ -75,13 +90,22 @@
<span class="badge bg-secondary">@group.Count</span> <span class="badge bg-secondary">@group.Count</span>
</h6> </h6>
<div class="small"> <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;"
@if (group.Patterns.Count > 5) onclick="openEditModal(@mapping.Id, '@Html.Raw(mapping.Category.Replace("'", "\\'"))', '@Html.Raw(mapping.Pattern.Replace("'", "\\'"))', @mapping.Priority)">
{ @if (mapping.Priority > 0)
<div class="text-muted fst-italic">+ @(group.Patterns.Count - 5) more...</div> {
<span class="badge bg-info me-1" title="Priority @mapping.Priority">P@(mapping.Priority)</span>
}
@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>
</div> </div>
@@ -107,5 +131,233 @@ else
<li>Categories in the CSV file (if present) take precedence over auto-categorization</li> <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>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>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> </ul>
</div> </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>
}

View File

@@ -1,18 +1,23 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Services; using MoneyMap.Services;
namespace MoneyMap.Pages namespace MoneyMap.Pages
{ {
public class CategoryMappingsModel : PageModel public class CategoryMappingsModel : PageModel
{ {
private readonly MoneyMapContext _db;
private readonly ITransactionCategorizer _categorizer; private readonly ITransactionCategorizer _categorizer;
public CategoryMappingsModel(ITransactionCategorizer categorizer) public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer)
{ {
_db = db;
_categorizer = categorizer; _categorizer = categorizer;
} }
@@ -20,26 +25,21 @@ namespace MoneyMap.Pages
public int TotalMappings { get; set; } public int TotalMappings { get; set; }
public int TotalCategories { get; set; } public int TotalCategories { get; set; }
[BindProperty]
public MappingEditModel NewMapping { get; set; } = new();
[BindProperty]
public MappingEditModel EditMapping { get; set; } = new();
[TempData] [TempData]
public string? SuccessMessage { get; set; } public string? SuccessMessage { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
var mappings = await _categorizer.GetAllMappingsAsync(); await LoadDataAsync();
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;
} }
public async Task<IActionResult> OnPostSeedDefaultsAsync() public async Task<IActionResult> OnPostSeedDefaultsAsync()
@@ -49,11 +49,120 @@ namespace MoneyMap.Pages
return RedirectToPage(); 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 class CategoryGroup
{ {
public required string Category { get; set; } 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 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;
}
} }
} }

View 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" />
}

View 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; } = "";
}
}
}

View 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>

View 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()
{
}
}
}

View File

@@ -25,6 +25,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-page="/Transactions">Transactions</a> <a class="nav-link text-dark" asp-page="/Transactions">Transactions</a>
</li> </li>
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Cards">Cards</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-page="/Upload">Upload CSV</a> <a class="nav-link text-dark" asp-page="/Upload">Upload CSV</a>
</li> </li>
@@ -52,7 +55,7 @@
</footer> </footer>
<script src="~/lib/jquery/jquery.min.js"></script> <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> <script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)

View File

@@ -1,2 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script> <script src="@Url.Content("~/lib/jquery-validation/jquery.validate.min.js")"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script> <script src="@Url.Content("~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js")"></script>

View File

@@ -101,7 +101,7 @@ namespace MoneyMap.Services
"AMAZON MKTPL", "AMAZON.COM", "BATHANDBODYWORKS", "BATH AND BODY", "AMAZON MKTPL", "AMAZON.COM", "BATHANDBODYWORKS", "BATH AND BODY",
"SEPHORA.COM", "ULTA.COM", "WWW.KOHLS.COM", "GAPOUTLET.COM", "SEPHORA.COM", "ULTA.COM", "WWW.KOHLS.COM", "GAPOUTLET.COM",
"NIKE.COM", "HOMEDEPOT.COM", "TEMU.COM", "APPLE.COM", "NIKE.COM", "HOMEDEPOT.COM", "TEMU.COM", "APPLE.COM",
"JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS"); "JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS", "TARGET.COM");
// Walmart // Walmart
AddMappings("Walmart Online", mappings, "WALMART.COM"); AddMappings("Walmart Online", mappings, "WALMART.COM");
@@ -181,8 +181,8 @@ namespace MoneyMap.Services
AddMappings("Insurance", mappings, AddMappings("Insurance", mappings,
"BOSTON MUTUAL", "IND FARMERS INS", "GERBER LIFE INS"); "BOSTON MUTUAL", "IND FARMERS INS", "GERBER LIFE INS");
// Credit Card Payment // Credit Card Payment (high priority to catch before Banking)
AddMappings("Credit Card Payment", mappings, AddMappings("Credit Card Payment", mappings, 200,
"PAYMENT TO CREDIT CARD", "CAPITAL ONE", "MOBILE PAYMENT THANK YOU"); "PAYMENT TO CREDIT CARD", "CAPITAL ONE", "MOBILE PAYMENT THANK YOU");
// Ice Cream // 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
} }