Add Merchants management page with modal edit/delete

Created a dedicated page for viewing and managing merchants:

**Backend (Merchants.cshtml.cs):**
- List all merchants with transaction and mapping counts
- Add new merchant with duplicate name validation
- Edit merchant name (updates all linked transactions/mappings)
- Delete merchant (unlinks from transactions/mappings via SET NULL)
- Full CRUD operations with proper validation

**Frontend (Merchants.cshtml):**
- Clean table view showing merchant name, transaction count, mapping count
- Add modal for creating new merchants
- Edit modal for renaming merchants (click row or Edit button)
- Delete with confirmation showing impact (# of transactions/mappings)
- Success/error message display
- Responsive Bootstrap layout

**Navigation:**
- Added "Merchants" link to main navigation bar
- Positioned between "Categories" and "Recategorize"

**Features:**
- Shows transaction count per merchant (useful for seeing merchant usage)
- Shows mapping count per merchant (useful for seeing pattern coverage)
- Inline edit with modal dialog (consistent with CategoryMappings UI)
- Safe delete with SET NULL (transactions/mappings remain, just unlinked)
- Duplicate name prevention on add/edit

**Benefits:**
- Easy merchant management without SQL queries
- Visual feedback on merchant usage
- Rename propagates to all transactions/mappings automatically
- Consistent UI with rest of application

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
AJ
2025-10-12 04:03:23 -04:00
parent 45077a0029
commit 1e060c10f5
3 changed files with 358 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
@page
@model MoneyMap.Pages.MerchantsModel
@{
ViewData["Title"] = "Merchants";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Merchants</h2>
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
</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>
}
<div class="row mb-3">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted">Total Merchants</div>
<div class="fs-3 fw-bold">@Model.TotalMerchants</div>
</div>
</div>
</div>
</div>
<!-- Add New Merchant Button -->
<div class="mb-3">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addModal">
+ Add New Merchant
</button>
</div>
@if (Model.Merchants.Any())
{
<div class="card shadow-sm">
<div class="card-header">
<strong>All Merchants</strong>
<small class="text-muted ms-2">Click to edit, or delete merchants</small>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Merchant Name</th>
<th class="text-end" style="width: 150px;">Transactions</th>
<th class="text-end" style="width: 150px;">Mappings</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var merchant in Model.Merchants)
{
<tr>
<td style="cursor: pointer;" onclick="openEditModal(@merchant.Id, '@Html.Raw(merchant.Name.Replace("'", "\\'"))')">
<strong>@merchant.Name</strong>
</td>
<td class="text-end">
@merchant.TransactionCount
</td>
<td class="text-end">
@merchant.MappingCount
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openEditModal(@merchant.Id, '@Html.Raw(merchant.Name.Replace("'", "\\'"))')">
Edit
</button>
<form method="post" asp-page-handler="DeleteMerchant" asp-route-id="@merchant.Id"
onsubmit="return confirm('Delete merchant \'@merchant.Name\'? This will unlink @merchant.TransactionCount transaction(s) and @merchant.MappingCount mapping(s).')" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
else
{
<div class="alert alert-info">
<h5>No merchants found</h5>
<p>Click "Add New Merchant" to create your first merchant.</p>
</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="AddMerchant">
<div class="modal-header">
<h5 class="modal-title" id="addModalLabel">Add New Merchant</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 for="addName" class="form-label">Merchant Name</label>
<input name="model.Name" id="addName" class="form-control" placeholder="e.g., Walmart" required />
<div class="form-text">Enter a friendly merchant name (e.g., "Walmart", "Target", "Amazon")</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">Add Merchant</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="UpdateMerchant">
<div class="modal-header">
<h5 class="modal-title" id="editModalLabel">Edit Merchant</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" name="model.Id" id="editId" />
<div class="mb-3">
<label for="editName" class="form-label">Merchant Name</label>
<input name="model.Name" id="editName" class="form-control" required />
<div class="form-text">Changing this will update all linked transactions and mappings</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, name) {
document.getElementById('editId').value = id;
document.getElementById('editName').value = name;
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.');
}
}
document.addEventListener('DOMContentLoaded', function() {
// Reopen modals if there are validation errors
var addNameInput = document.getElementById('addName');
var editNameInput = document.getElementById('editName');
if (addNameInput && addNameInput.classList.contains('input-validation-error')) {
var addModal = new bootstrap.Modal(document.getElementById('addModal'));
addModal.show();
}
if (editNameInput && editNameInput.classList.contains('input-validation-error')) {
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
editModal.show();
}
});
</script>
}

View File

@@ -0,0 +1,169 @@
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.Models;
namespace MoneyMap.Pages
{
public class MerchantsModel : PageModel
{
private readonly MoneyMapContext _db;
public MerchantsModel(MoneyMapContext db)
{
_db = db;
}
public List<MerchantRow> Merchants { get; set; } = new();
public int TotalMerchants { get; set; }
[TempData]
public string? SuccessMessage { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public async Task OnGetAsync()
{
await LoadDataAsync();
}
public async Task<IActionResult> OnPostAddMerchantAsync(AddMerchantModel model)
{
if (!ModelState.IsValid)
{
ErrorMessage = "Please fill in all required fields.";
await LoadDataAsync();
return Page();
}
// Check if merchant already exists
var existing = await _db.Merchants
.FirstOrDefaultAsync(m => m.Name == model.Name.Trim());
if (existing != null)
{
ErrorMessage = $"Merchant '{model.Name}' already exists.";
await LoadDataAsync();
return Page();
}
var merchant = new Merchant
{
Name = model.Name.Trim()
};
_db.Merchants.Add(merchant);
await _db.SaveChangesAsync();
SuccessMessage = $"Added merchant '{merchant.Name}'.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostUpdateMerchantAsync(UpdateMerchantModel model)
{
if (!ModelState.IsValid)
{
ErrorMessage = "Please fill in all required fields.";
await LoadDataAsync();
return Page();
}
var merchant = await _db.Merchants.FindAsync(model.Id);
if (merchant == null)
{
ErrorMessage = "Merchant not found.";
return RedirectToPage();
}
// Check if another merchant with the same name exists
var existing = await _db.Merchants
.FirstOrDefaultAsync(m => m.Name == model.Name.Trim() && m.Id != model.Id);
if (existing != null)
{
ErrorMessage = $"Merchant '{model.Name}' already exists.";
await LoadDataAsync();
return Page();
}
merchant.Name = model.Name.Trim();
await _db.SaveChangesAsync();
SuccessMessage = "Merchant updated successfully.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteMerchantAsync(int id)
{
var merchant = await _db.Merchants
.Include(m => m.Transactions)
.Include(m => m.CategoryMappings)
.FirstOrDefaultAsync(m => m.Id == id);
if (merchant == null)
{
ErrorMessage = "Merchant not found.";
return RedirectToPage();
}
var transactionCount = merchant.Transactions.Count;
var mappingCount = merchant.CategoryMappings.Count;
_db.Merchants.Remove(merchant);
await _db.SaveChangesAsync();
SuccessMessage = $"Deleted merchant '{merchant.Name}'. {transactionCount} transactions and {mappingCount} category mappings are now unlinked.";
return RedirectToPage();
}
private async Task LoadDataAsync()
{
var merchants = await _db.Merchants
.Include(m => m.Transactions)
.Include(m => m.CategoryMappings)
.OrderBy(m => m.Name)
.ToListAsync();
Merchants = merchants.Select(m => new MerchantRow
{
Id = m.Id,
Name = m.Name,
TransactionCount = m.Transactions.Count,
MappingCount = m.CategoryMappings.Count
}).ToList();
TotalMerchants = Merchants.Count;
}
public class MerchantRow
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int TransactionCount { get; set; }
public int MappingCount { get; set; }
}
public class AddMerchantModel
{
[Required(ErrorMessage = "Merchant name is required")]
[StringLength(100)]
public string Name { get; set; } = "";
}
public class UpdateMerchantModel
{
[Required]
public int Id { get; set; }
[Required(ErrorMessage = "Merchant name is required")]
[StringLength(100)]
public string Name { get; set; } = "";
}
}
}

View File

@@ -34,6 +34,9 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/CategoryMappings">Categories</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Merchants">Merchants</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Recategorize">Recategorize</a>
</li>