Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,169 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Customer;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class PricingTiersController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<PricingTiersController> _logger;
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
}
/// <summary>
/// Lists all pricing tiers with a live customer-count badge per tier. Sorted active-first then by discount ascending so the most relevant tiers appear at the top. Customer counts are computed in memory after a single query to avoid N+1 round-trips.
/// </summary>
public async Task<IActionResult> Index()
{
var tiers = await _unitOfWork.PricingTiers.GetAllAsync();
var customers = await _unitOfWork.Customers.GetAllAsync();
var customerCountByTier = customers
.Where(c => c.PricingTierId.HasValue)
.GroupBy(c => c.PricingTierId!.Value)
.ToDictionary(g => g.Key, g => g.Count());
var dtos = _mapper.Map<List<PricingTierDto>>(tiers);
foreach (var dto in dtos)
dto.CustomerCount = customerCountByTier.GetValueOrDefault(dto.Id, 0);
// Sort: active first, then by discount ascending
dtos = dtos.OrderByDescending(t => t.IsActive).ThenBy(t => t.DiscountPercent).ToList();
return View(dtos);
}
/// <summary>
/// Shows the create form, pre-populating IsActive to true so new tiers are immediately usable without an extra toggle.
/// </summary>
[HttpGet]
public IActionResult Create()
{
return View(new CreatePricingTierDto { IsActive = true });
}
/// <summary>
/// Persists a new pricing tier after validating that the tier name is unique within the company. Duplicate-name checks are done at the application layer because the DB unique index alone would produce a cryptic SQL error.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreatePricingTierDto dto)
{
if (!ModelState.IsValid)
return View(dto);
// Check for duplicate name
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.TierName == dto.TierName);
if (existing.Any())
{
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
return View(dto);
}
var entity = _mapper.Map<PricingTier>(dto);
await _unitOfWork.PricingTiers.AddAsync(entity);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Pricing tier '{TierName}' created (ID: {Id})", entity.TierName, entity.Id);
TempData["SuccessMessage"] = $"Pricing tier '{entity.TierName}' created successfully.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Shows the edit form for an existing pricing tier, mapping the entity to an UpdatePricingTierDto so the view is decoupled from the entity.
/// </summary>
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(id);
if (entity == null) return NotFound();
var dto = _mapper.Map<UpdatePricingTierDto>(entity);
return View(dto);
}
/// <summary>
/// Saves edits to an existing pricing tier. Duplicate-name check excludes the current record (t.Id != dto.Id) so a tier can be saved without changing its name.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(UpdatePricingTierDto dto)
{
if (!ModelState.IsValid)
return View(dto);
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(dto.Id);
if (entity == null) return NotFound();
// Check for duplicate name (excluding this record)
var duplicate = await _unitOfWork.PricingTiers.FindAsync(
t => t.TierName == dto.TierName && t.Id != dto.Id);
if (duplicate.Any())
{
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
return View(dto);
}
_mapper.Map(dto, entity);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Pricing tier '{TierName}' updated (ID: {Id})", entity.TierName, entity.Id);
TempData["SuccessMessage"] = $"Pricing tier '{entity.TierName}' updated successfully.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Soft-deletes a pricing tier. Deletion is blocked when customers are still assigned to the tier to prevent orphaned pricing references; the admin must reassign those customers first.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(id);
if (entity == null) return NotFound();
// Block delete if customers are assigned to this tier
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.PricingTierId == id);
if (assignedCustomers.Any())
{
TempData["ErrorMessage"] = $"Cannot delete '{entity.TierName}' — {assignedCustomers.Count()} customer(s) are assigned to it. Reassign them first.";
return RedirectToAction(nameof(Index));
}
await _unitOfWork.PricingTiers.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Pricing tier '{TierName}' deleted (ID: {Id})", entity.TierName, id);
TempData["SuccessMessage"] = $"Pricing tier '{entity.TierName}' deleted.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Toggles the IsActive flag on a pricing tier without a dedicated edit form round-trip. Inactive tiers remain in the database and on existing customer records but are hidden from new-customer assignment dropdowns.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleActive(int id)
{
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(id);
if (entity == null) return NotFound();
entity.IsActive = !entity.IsActive;
await _unitOfWork.CompleteAsync();
TempData["SuccessMessage"] = $"'{entity.TierName}' marked as {(entity.IsActive ? "active" : "inactive")}.";
return RedirectToAction(nameof(Index));
}
}