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:
186
MoneyMap/Pages/Merchants.cshtml
Normal file
186
MoneyMap/Pages/Merchants.cshtml
Normal 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>
|
||||
}
|
||||
169
MoneyMap/Pages/Merchants.cshtml.cs
Normal file
169
MoneyMap/Pages/Merchants.cshtml.cs
Normal 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; } = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user