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 _logger; private readonly ITenantContext _tenantContext; public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, ITenantContext tenantContext) { _unitOfWork = unitOfWork; _mapper = mapper; _logger = logger; _tenantContext = tenantContext; } /// /// 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. /// public async Task Index() { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId); var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); var customerCountByTier = customers .Where(c => c.PricingTierId.HasValue) .GroupBy(c => c.PricingTierId!.Value) .ToDictionary(g => g.Key, g => g.Count()); var dtos = _mapper.Map>(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); } /// /// Shows the create form, pre-populating IsActive to true so new tiers are immediately usable without an extra toggle. /// [HttpGet] public IActionResult Create() { return View(new CreatePricingTierDto { IsActive = true }); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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(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)); } /// /// Shows the edit form for an existing pricing tier, mapping the entity to an UpdatePricingTierDto so the view is decoupled from the entity. /// [HttpGet] public async Task Edit(int id) { var entity = await _unitOfWork.PricingTiers.GetByIdAsync(id); if (entity == null) return NotFound(); var dto = _mapper.Map(entity); return View(dto); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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)); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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)); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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)); } }