Initial commit
This commit is contained in:
@@ -0,0 +1,988 @@
|
||||
using AutoMapper;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Customer;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Web.Helpers;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageCustomers)]
|
||||
public class CustomersController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ILogger<CustomersController> _logger;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public CustomersController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
ILogger<CustomersController> logger,
|
||||
INotificationService notificationService,
|
||||
ISubscriptionService subscriptionService,
|
||||
ITenantContext tenantContext,
|
||||
ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_logger = logger;
|
||||
_notificationService = notificationService;
|
||||
_subscriptionService = subscriptionService;
|
||||
_tenantContext = tenantContext;
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the paginated, searchable customer list. Sanitizes the search term before
|
||||
/// building the EF filter expression to guard against injection; sorting is resolved
|
||||
/// server-side via a switch so the column name never reaches raw SQL.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(
|
||||
string? searchTerm,
|
||||
string? sortColumn,
|
||||
string sortDirection = "asc",
|
||||
int pageNumber = 1,
|
||||
int pageSize = 25)
|
||||
{
|
||||
try
|
||||
{
|
||||
// SECURITY: Sanitize search input to prevent injection attacks
|
||||
searchTerm = SecurityHelper.SanitizeSearchTerm(searchTerm);
|
||||
|
||||
// Create and validate grid request
|
||||
var gridRequest = new GridRequest
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize,
|
||||
SortColumn = sortColumn ?? "CompanyName",
|
||||
SortDirection = sortDirection,
|
||||
SearchTerm = searchTerm
|
||||
};
|
||||
gridRequest.Validate();
|
||||
|
||||
// Build search filter
|
||||
System.Linq.Expressions.Expression<Func<Customer, bool>>? filter = null;
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
filter = c => c.CompanyName.ToLower().Contains(search)
|
||||
|| (c.ContactFirstName != null && c.ContactFirstName.ToLower().Contains(search))
|
||||
|| (c.ContactLastName != null && c.ContactLastName.ToLower().Contains(search))
|
||||
|| (c.Email != null && c.Email.ToLower().Contains(search))
|
||||
|| (c.Phone != null && c.Phone.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
// Build orderBy function
|
||||
Func<IQueryable<Customer>, IOrderedQueryable<Customer>> orderBy = gridRequest.SortColumn switch
|
||||
{
|
||||
"CompanyName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CompanyName) : q.OrderByDescending(c => c.CompanyName),
|
||||
"ContactName" => q => gridRequest.SortDirection == "asc"
|
||||
? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName)
|
||||
: q.OrderByDescending(c => c.ContactFirstName).ThenByDescending(c => c.ContactLastName),
|
||||
"Email" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.Email) : q.OrderByDescending(c => c.Email),
|
||||
"Phone" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.Phone) : q.OrderByDescending(c => c.Phone),
|
||||
"CurrentBalance" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CurrentBalance) : q.OrderByDescending(c => c.CurrentBalance),
|
||||
"IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.IsActive) : q.OrderByDescending(c => c.IsActive),
|
||||
"LastContactDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.LastContactDate) : q.OrderByDescending(c => c.LastContactDate),
|
||||
_ => q => q.OrderBy(c => c.CompanyName)
|
||||
};
|
||||
|
||||
// Get paged data
|
||||
var (items, totalCount) = await _unitOfWork.Customers.GetPagedAsync(
|
||||
gridRequest.PageNumber,
|
||||
gridRequest.PageSize,
|
||||
filter,
|
||||
orderBy);
|
||||
|
||||
// Map to DTOs
|
||||
var customerDtos = items.Select(c => new CustomerListDto
|
||||
{
|
||||
Id = c.Id,
|
||||
CompanyName = c.CompanyName,
|
||||
ContactName = !string.IsNullOrEmpty(c.ContactFirstName) || !string.IsNullOrEmpty(c.ContactLastName)
|
||||
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
|
||||
: string.Empty,
|
||||
Phone = c.Phone,
|
||||
Email = c.Email,
|
||||
IsCommercial = c.IsCommercial,
|
||||
CurrentBalance = c.CurrentBalance,
|
||||
IsActive = c.IsActive,
|
||||
LastContactDate = c.LastContactDate
|
||||
}).ToList();
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<CustomerListDto>
|
||||
{
|
||||
Items = customerDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
|
||||
// Set ViewBag for sorting
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||
|
||||
return View(pagedResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving customers");
|
||||
this.ToastError("An error occurred while loading customers.");
|
||||
return View(new PagedResult<CustomerListDto>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the customer detail page, including the 10 most-recent non-voided credit memos.
|
||||
/// Credit memos are loaded separately (not via eager loading) because the customer entity
|
||||
/// does not navigate to CreditMemo; this keeps the Customer aggregate lean.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var creditMemos = await _unitOfWork.CreditMemos.FindAsync(
|
||||
cm => cm.CustomerId == id.Value && cm.Status != CreditMemoStatus.Voided);
|
||||
ViewBag.CreditMemos = creditMemos
|
||||
.OrderByDescending(cm => cm.IssueDate)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
var customerDto = _mapper.Map<CustomerDto>(customer);
|
||||
return View(customerDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving customer {CustomerId}", id);
|
||||
this.ToastError("An error occurred while loading the customer.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the full paginated job history for a single customer. Defaults to descending
|
||||
/// by creation date so the newest jobs appear first. JobStatus and JobPriority are
|
||||
/// eagerly loaded so their display names and sort orders are available without N+1 queries.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> JobHistory(
|
||||
int? id,
|
||||
string? searchTerm,
|
||||
string? sortColumn,
|
||||
string sortDirection = "desc",
|
||||
int pageNumber = 1,
|
||||
int pageSize = 25)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var gridRequest = new GridRequest
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize,
|
||||
SortColumn = sortColumn ?? "CreatedAt",
|
||||
SortDirection = sortDirection,
|
||||
SearchTerm = searchTerm
|
||||
};
|
||||
gridRequest.Validate();
|
||||
|
||||
System.Linq.Expressions.Expression<Func<Core.Entities.Job, bool>> filter;
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
filter = j => j.CustomerId == id.Value
|
||||
&& (j.JobNumber.ToLower().Contains(search)
|
||||
|| j.Description.ToLower().Contains(search)
|
||||
|| (j.CustomerPO != null && j.CustomerPO.ToLower().Contains(search))
|
||||
|| j.JobStatus.DisplayName.ToLower().Contains(search)
|
||||
|| j.JobPriority.DisplayName.ToLower().Contains(search));
|
||||
}
|
||||
else
|
||||
{
|
||||
filter = j => j.CustomerId == id.Value;
|
||||
}
|
||||
|
||||
Func<IQueryable<Core.Entities.Job>, IOrderedQueryable<Core.Entities.Job>> orderBy = gridRequest.SortColumn switch
|
||||
{
|
||||
"JobNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobNumber) : q.OrderByDescending(j => j.JobNumber),
|
||||
"Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobStatus.DisplayOrder) : q.OrderByDescending(j => j.JobStatus.DisplayOrder),
|
||||
"Priority" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobPriority.DisplayOrder) : q.OrderByDescending(j => j.JobPriority.DisplayOrder),
|
||||
"ScheduledDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.ScheduledDate) : q.OrderByDescending(j => j.ScheduledDate),
|
||||
"DueDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.DueDate) : q.OrderByDescending(j => j.DueDate),
|
||||
"FinalPrice" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.FinalPrice) : q.OrderByDescending(j => j.FinalPrice),
|
||||
_ => q => q.OrderByDescending(j => j.CreatedAt)
|
||||
};
|
||||
|
||||
var (items, totalCount) = await _unitOfWork.Jobs.GetPagedAsync(
|
||||
gridRequest.PageNumber,
|
||||
gridRequest.PageSize,
|
||||
filter,
|
||||
orderBy,
|
||||
j => j.JobStatus,
|
||||
j => j.JobPriority);
|
||||
|
||||
// Use AutoMapper to map jobs to DTOs
|
||||
var jobDtos = _mapper.Map<List<Application.DTOs.Job.JobListDto>>(items);
|
||||
|
||||
var pagedResult = new Application.DTOs.Common.PagedResult<Application.DTOs.Job.JobListDto>
|
||||
{
|
||||
Items = jobDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
|
||||
ViewBag.CustomerId = id.Value;
|
||||
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
|
||||
? customer.CompanyName
|
||||
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||
|
||||
return View(pagedResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving job history for customer {CustomerId}", id);
|
||||
this.ToastError("An error occurred while loading the job history.");
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the customer activity tab-panel, showing jobs and quotes in independent
|
||||
/// paginated grids. Each grid carries its own sort/page state in the query string so
|
||||
/// both can be navigated without resetting the other. Page sizes are clamped to
|
||||
/// [10, 100] server-side to prevent accidental full-table loads.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Activity(
|
||||
int? id,
|
||||
string activeTab = "jobs",
|
||||
string jobSort = "CreatedAt",
|
||||
string jobDir = "desc",
|
||||
int jobPage = 1,
|
||||
int jobSize = 25,
|
||||
string quoteSort = "QuoteDate",
|
||||
string quoteDir = "desc",
|
||||
int quotePage = 1,
|
||||
int quoteSize = 25)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var customerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
|
||||
? customer.CompanyName
|
||||
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
|
||||
|
||||
// Clamp page sizes
|
||||
jobSize = Math.Clamp(jobSize, 10, 100);
|
||||
quoteSize = Math.Clamp(quoteSize, 10, 100);
|
||||
if (jobPage < 1) jobPage = 1;
|
||||
if (quotePage < 1) quotePage = 1;
|
||||
|
||||
// --- Jobs ---
|
||||
Func<IQueryable<Core.Entities.Job>, IOrderedQueryable<Core.Entities.Job>> jobOrderBy = jobSort switch
|
||||
{
|
||||
"JobNumber" => q => jobDir == "asc" ? q.OrderBy(j => j.JobNumber) : q.OrderByDescending(j => j.JobNumber),
|
||||
"Status" => q => jobDir == "asc" ? q.OrderBy(j => j.JobStatus.DisplayOrder) : q.OrderByDescending(j => j.JobStatus.DisplayOrder),
|
||||
"Priority" => q => jobDir == "asc" ? q.OrderBy(j => j.JobPriority.DisplayOrder) : q.OrderByDescending(j => j.JobPriority.DisplayOrder),
|
||||
"DueDate" => q => jobDir == "asc" ? q.OrderBy(j => j.DueDate) : q.OrderByDescending(j => j.DueDate),
|
||||
"FinalPrice"=> q => jobDir == "asc" ? q.OrderBy(j => j.FinalPrice) : q.OrderByDescending(j => j.FinalPrice),
|
||||
_ => q => jobDir == "asc" ? q.OrderBy(j => j.CreatedAt) : q.OrderByDescending(j => j.CreatedAt)
|
||||
};
|
||||
|
||||
var (jobItems, jobTotal) = await _unitOfWork.Jobs.GetPagedAsync(
|
||||
jobPage, jobSize,
|
||||
j => j.CustomerId == id.Value,
|
||||
jobOrderBy,
|
||||
j => j.JobStatus,
|
||||
j => j.JobPriority);
|
||||
|
||||
var jobDtos = _mapper.Map<List<Application.DTOs.Job.JobListDto>>(jobItems);
|
||||
var pagedJobs = new Application.DTOs.Common.PagedResult<Application.DTOs.Job.JobListDto>
|
||||
{
|
||||
Items = jobDtos,
|
||||
PageNumber = jobPage,
|
||||
PageSize = jobSize,
|
||||
TotalCount = jobTotal
|
||||
};
|
||||
|
||||
// --- Quotes ---
|
||||
Func<IQueryable<Core.Entities.Quote>, IOrderedQueryable<Core.Entities.Quote>> quoteOrderBy = quoteSort switch
|
||||
{
|
||||
"QuoteNumber" => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteNumber) : q.OrderByDescending(x => x.QuoteNumber),
|
||||
"Status" => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteStatus.DisplayOrder) : q.OrderByDescending(x => x.QuoteStatus.DisplayOrder),
|
||||
"Total" => q => quoteDir == "asc" ? q.OrderBy(x => x.Total) : q.OrderByDescending(x => x.Total),
|
||||
"Expiration" => q => quoteDir == "asc" ? q.OrderBy(x => x.ExpirationDate) : q.OrderByDescending(x => x.ExpirationDate),
|
||||
_ => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteDate) : q.OrderByDescending(x => x.QuoteDate)
|
||||
};
|
||||
|
||||
var (quoteItems, quoteTotal) = await _unitOfWork.Quotes.GetPagedAsync(
|
||||
quotePage, quoteSize,
|
||||
q => q.CustomerId == id.Value,
|
||||
quoteOrderBy,
|
||||
q => q.QuoteStatus);
|
||||
|
||||
var quoteDtos = _mapper.Map<List<Application.DTOs.Quote.QuoteListDto>>(quoteItems);
|
||||
var pagedQuotes = new Application.DTOs.Common.PagedResult<Application.DTOs.Quote.QuoteListDto>
|
||||
{
|
||||
Items = quoteDtos,
|
||||
PageNumber = quotePage,
|
||||
PageSize = quoteSize,
|
||||
TotalCount = quoteTotal
|
||||
};
|
||||
|
||||
ViewBag.CustomerId = id.Value;
|
||||
ViewBag.CustomerName = customerName;
|
||||
ViewBag.ActiveTab = activeTab;
|
||||
ViewBag.Jobs = pagedJobs;
|
||||
ViewBag.JobSort = jobSort;
|
||||
ViewBag.JobDir = jobDir;
|
||||
ViewBag.Quotes = pagedQuotes;
|
||||
ViewBag.QuoteSort = quoteSort;
|
||||
ViewBag.QuoteDir = quoteDir;
|
||||
|
||||
return View();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving activity for customer {CustomerId}", id);
|
||||
this.ToastError("An error occurred while loading customer activity.");
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the paginated invoice list scoped to a single customer. Defaults to
|
||||
/// descending invoice date so the most recent invoice is shown first, matching the
|
||||
/// typical accounts-receivable workflow of reviewing the latest outstanding balance.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Invoices(
|
||||
int? id,
|
||||
string? sortColumn,
|
||||
string sortDirection = "desc",
|
||||
int pageNumber = 1,
|
||||
int pageSize = 25)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var gridRequest = new GridRequest
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize,
|
||||
SortColumn = sortColumn ?? "InvoiceDate",
|
||||
SortDirection = sortDirection
|
||||
};
|
||||
gridRequest.Validate();
|
||||
|
||||
Func<IQueryable<Core.Entities.Invoice>, IOrderedQueryable<Core.Entities.Invoice>> orderBy = gridRequest.SortColumn switch
|
||||
{
|
||||
"InvoiceNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.InvoiceNumber) : q.OrderByDescending(i => i.InvoiceNumber),
|
||||
"Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Status) : q.OrderByDescending(i => i.Status),
|
||||
"DueDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.DueDate) : q.OrderByDescending(i => i.DueDate),
|
||||
"Total" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Total) : q.OrderByDescending(i => i.Total),
|
||||
"BalanceDue" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.BalanceDue) : q.OrderByDescending(i => i.BalanceDue),
|
||||
_ => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.InvoiceDate) : q.OrderByDescending(i => i.InvoiceDate)
|
||||
};
|
||||
|
||||
var (items, totalCount) = await _unitOfWork.Invoices.GetPagedAsync(
|
||||
gridRequest.PageNumber,
|
||||
gridRequest.PageSize,
|
||||
i => i.CustomerId == id.Value,
|
||||
orderBy);
|
||||
|
||||
var invoiceDtos = _mapper.Map<List<Application.DTOs.Invoice.InvoiceListDto>>(items);
|
||||
|
||||
var pagedResult = new Application.DTOs.Common.PagedResult<Application.DTOs.Invoice.InvoiceListDto>
|
||||
{
|
||||
Items = invoiceDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
|
||||
ViewBag.CustomerId = id.Value;
|
||||
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
|
||||
? customer.CompanyName
|
||||
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
|
||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||
|
||||
return View(pagedResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving invoices for customer {CustomerId}", id);
|
||||
this.ToastError("An error occurred while loading the invoices.");
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the customer creation form. Checks the subscription plan limit before
|
||||
/// allowing access; redirects with an error if the company has reached its customer cap.
|
||||
/// Pricing tiers are pre-loaded via <see cref="PopulatePricingTiersAsync"/> so the
|
||||
/// dropdown is available immediately without a round-trip.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
if (!await _subscriptionService.CanAddCustomerAsync(companyId))
|
||||
{
|
||||
var (used, max) = await _subscriptionService.GetCustomerCountAsync(companyId);
|
||||
TempData["Error"] = $"You have reached your plan limit of {max} customers. " +
|
||||
"Please upgrade your plan to add more customers.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
await PopulatePricingTiersAsync();
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a new customer record. Re-validates the subscription plan limit on POST
|
||||
/// to guard against concurrent session exploitation. If the staff member records
|
||||
/// verbal SMS consent, sets NotifyBySms, stamps SmsConsentedAt, and immediately
|
||||
/// sends a confirmation SMS via <see cref="INotificationService"/>; the resulting
|
||||
/// notification log entry is then surfaced as a toast so staff can confirm delivery.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateCustomerDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulatePricingTiersAsync();
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
// Subscription limit check
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
if (!await _subscriptionService.CanAddCustomerAsync(companyId))
|
||||
{
|
||||
var (used, max) = await _subscriptionService.GetCustomerCountAsync(companyId);
|
||||
ModelState.AddModelError(string.Empty,
|
||||
$"You have reached your plan limit of {max} customers. " +
|
||||
"Please upgrade your plan to add more customers.");
|
||||
await PopulatePricingTiersAsync();
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var customer = _mapper.Map<Customer>(dto);
|
||||
customer.CreatedAt = DateTime.UtcNow;
|
||||
customer.IsActive = true;
|
||||
|
||||
// SMS consent: only enable if staff has checked the consent box
|
||||
if (dto.SmsConsentGranted && !string.IsNullOrWhiteSpace(dto.MobilePhone ?? dto.Phone))
|
||||
{
|
||||
customer.NotifyBySms = true;
|
||||
customer.SmsConsentedAt = DateTime.UtcNow;
|
||||
customer.SmsConsentMethod = "Staff-recorded verbal consent";
|
||||
}
|
||||
else
|
||||
{
|
||||
customer.NotifyBySms = false;
|
||||
}
|
||||
|
||||
await _unitOfWork.Customers.AddAsync(customer);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Send welcome/confirmation SMS after the customer record is saved
|
||||
if (customer.NotifyBySms)
|
||||
{
|
||||
try { await _notificationService.NotifySmsConsentGrantedAsync(customer); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); }
|
||||
|
||||
var smsLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.CustomerId == customer.Id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
this.SetNotificationResultToast(smsLog);
|
||||
}
|
||||
|
||||
this.ToastSuccess("Customer created successfully.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating customer");
|
||||
this.ToastError("An error occurred while creating the customer.");
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the customer edit form, pre-populated via AutoMapper from the stored entity.
|
||||
/// Pricing tiers are loaded so the dropdown reflects current active tiers even if
|
||||
/// the tier list has changed since the customer was created.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Edit(int? id)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dto = _mapper.Map<UpdateCustomerDto>(customer);
|
||||
await PopulatePricingTiersAsync();
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving customer {CustomerId} for edit", id);
|
||||
this.ToastError("An error occurred while loading the customer.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists edits to an existing customer. Special handling covers two sensitive fields:
|
||||
/// (1) Tax-exempt certificate bytes are captured before AutoMapper runs and restored after,
|
||||
/// preventing the Edit form from inadvertently clearing an already-uploaded file.
|
||||
/// (2) SMS consent is append-only: newly granted consent stamps SmsConsentedAt and sends
|
||||
/// a confirmation SMS; existing consent is not re-granted, but NotifyBySms can be
|
||||
/// toggled on/off after the first grant to pause or resume messages.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, UpdateCustomerDto dto)
|
||||
{
|
||||
if (id != dto.Id)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulatePricingTiersAsync();
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Capture consent state before mapping (mapping ignores SmsConsentedAt/Method)
|
||||
var hadConsentBefore = customer.SmsConsentedAt.HasValue;
|
||||
|
||||
// Preserve cert data before mapping (mapper ignores these, but save explicitly for safety)
|
||||
var certData = customer.TaxExemptCertificateData;
|
||||
var certContentType = customer.TaxExemptCertificateContentType;
|
||||
var certFileName = customer.TaxExemptCertificateFileName;
|
||||
|
||||
_mapper.Map(dto, customer);
|
||||
customer.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Restore cert data (defensive: ensure Edit form never clears uploaded certificates)
|
||||
customer.TaxExemptCertificateData = certData;
|
||||
customer.TaxExemptCertificateContentType = certContentType;
|
||||
customer.TaxExemptCertificateFileName = certFileName;
|
||||
|
||||
// If consent was not previously granted and staff has now checked the consent box
|
||||
var newlyGranted = !hadConsentBefore && dto.SmsConsentGranted
|
||||
&& !string.IsNullOrWhiteSpace(customer.MobilePhone ?? customer.Phone);
|
||||
|
||||
if (newlyGranted)
|
||||
{
|
||||
customer.NotifyBySms = true;
|
||||
customer.SmsConsentedAt = DateTime.UtcNow;
|
||||
customer.SmsConsentMethod = "Staff-recorded verbal consent";
|
||||
}
|
||||
else if (!hadConsentBefore)
|
||||
{
|
||||
// No consent exists and none newly granted — keep SMS off
|
||||
customer.NotifyBySms = false;
|
||||
}
|
||||
// If hadConsentBefore: NotifyBySms is mapped from the toggle as-is (allow pause/resume)
|
||||
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Send welcome SMS if consent was just granted
|
||||
if (newlyGranted)
|
||||
{
|
||||
try { await _notificationService.NotifySmsConsentGrantedAsync(customer); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); }
|
||||
|
||||
var smsLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.CustomerId == customer.Id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
this.SetNotificationResultToast(smsLog);
|
||||
}
|
||||
|
||||
this.ToastSuccess("Customer updated successfully.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating customer {CustomerId}", id);
|
||||
this.ToastError("An error occurred while updating the customer.");
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the delete confirmation page for a customer. The view shows a full
|
||||
/// summary so the user can verify they are about to remove the correct record
|
||||
/// before submitting the confirmation POST.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Delete(int? id)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var customerDto = _mapper.Map<CustomerDto>(customer);
|
||||
return View(customerDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving customer {CustomerId} for delete", id);
|
||||
this.ToastError("An error occurred while loading the customer.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs a soft delete on the customer record. A soft delete sets IsDeleted = true
|
||||
/// rather than removing the row, preserving referential integrity with historical jobs,
|
||||
/// quotes, and invoices. The record is filtered from all normal queries by the global
|
||||
/// EF query filter, and can only be seen by SuperAdmins with ignoreQueryFilters.
|
||||
/// </summary>
|
||||
[HttpPost, ActionName("Delete")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteConfirmed(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await _unitOfWork.Customers.SoftDeleteAsync(customer);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
this.ToastSuccess("Customer deleted successfully.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting customer {CustomerId}", id);
|
||||
this.ToastError("An error occurred while deleting the customer.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams the tax-exempt certificate file stored as binary in the Customer entity back
|
||||
/// to the browser using the original content-type and filename. Certificates are stored
|
||||
/// in-database (not on disk) to keep them tenant-isolated without requiring blob storage
|
||||
/// configuration in every deployment environment.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> TaxExemptCertificate(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
||||
if (customer == null || customer.TaxExemptCertificateData == null || customer.TaxExemptCertificateData.Length == 0)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var contentType = customer.TaxExemptCertificateContentType ?? "application/pdf";
|
||||
var fileName = customer.TaxExemptCertificateFileName ?? "tax-exempt-certificate.pdf";
|
||||
|
||||
return File(customer.TaxExemptCertificateData, contentType, fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving tax exempt certificate for customer {CustomerId}", id);
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts a tax-exempt certificate upload (PDF, JPG, or PNG, max 10 MB) and stores the
|
||||
/// raw bytes directly on the Customer entity. Storing in-database avoids the need for a
|
||||
/// shared file system or Azure Blob container in single-tenant or on-premise deployments.
|
||||
/// Content-type and original filename are stored alongside the bytes so
|
||||
/// <see cref="TaxExemptCertificate"/> can return them faithfully.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> UploadTaxExemptCertificate(int id, IFormFile certificateFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (certificateFile == null || certificateFile.Length == 0)
|
||||
{
|
||||
this.ToastError("Please select a file to upload.");
|
||||
return RedirectToAction(nameof(Edit), new { id });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
var allowedContentTypes = new[] { "application/pdf", "image/jpeg", "image/jpg", "image/png" };
|
||||
if (!allowedContentTypes.Contains(certificateFile.ContentType, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
this.ToastError("Invalid file type. Only PDF and image files (JPG, PNG) are allowed.");
|
||||
return RedirectToAction(nameof(Edit), new { id });
|
||||
}
|
||||
|
||||
// Validate file size (10 MB max)
|
||||
const long maxSize = 10 * 1024 * 1024;
|
||||
if (certificateFile.Length > maxSize)
|
||||
{
|
||||
this.ToastError("File size exceeds the 10 MB limit.");
|
||||
return RedirectToAction(nameof(Edit), new { id });
|
||||
}
|
||||
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Read file data
|
||||
using var ms = new MemoryStream();
|
||||
await certificateFile.CopyToAsync(ms);
|
||||
customer.TaxExemptCertificateData = ms.ToArray();
|
||||
customer.TaxExemptCertificateContentType = certificateFile.ContentType;
|
||||
customer.TaxExemptCertificateFileName = certificateFile.FileName;
|
||||
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
this.ToastSuccess("Tax exempt certificate uploaded successfully.");
|
||||
return RedirectToAction(nameof(Edit), new { id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error uploading tax exempt certificate for customer {CustomerId}", id);
|
||||
this.ToastError("An error occurred while uploading the certificate.");
|
||||
return RedirectToAction(nameof(Edit), new { id });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the tax-exempt certificate from the customer record by nulling all three
|
||||
/// certificate fields (Data, ContentType, FileName). A separate action (rather than
|
||||
/// embedding the delete in Edit) avoids race conditions where the Edit form could
|
||||
/// clear the certificate bytes if the user saves without re-uploading.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteTaxExemptCertificate(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
customer.TaxExemptCertificateData = null;
|
||||
customer.TaxExemptCertificateContentType = null;
|
||||
customer.TaxExemptCertificateFileName = null;
|
||||
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
this.ToastSuccess("Tax exempt certificate deleted successfully.");
|
||||
return RedirectToAction(nameof(Edit), new { id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting tax exempt certificate for customer {CustomerId}", id);
|
||||
this.ToastError("An error occurred while deleting the certificate.");
|
||||
return RedirectToAction(nameof(Edit), new { id });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a standalone credit memo and increments the customer's CreditBalance.
|
||||
/// Restricted to CompanyAdmin because credits affect the financial ledger. The memo
|
||||
/// number follows the CM-YYMM-#### format generated by <see cref="GenerateCreditMemoNumberAsync"/>.
|
||||
/// The memo is not tied to any invoice at creation; it will be applied automatically
|
||||
/// when the next invoice is created for this customer.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> AddCredit(int id, AddCreditDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
TempData["Error"] = "Invalid credit amount or missing reason.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
||||
if (customer == null) return NotFound();
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var memoNumber = await GenerateCreditMemoNumberAsync();
|
||||
|
||||
var memo = new CreditMemo
|
||||
{
|
||||
MemoNumber = memoNumber,
|
||||
CustomerId = id,
|
||||
OriginalInvoiceId = null, // not tied to an invoice
|
||||
Amount = dto.Amount,
|
||||
AmountApplied = 0,
|
||||
IssueDate = DateTime.UtcNow,
|
||||
ExpiryDate = dto.ExpiryDate,
|
||||
Reason = dto.Reason,
|
||||
Notes = dto.Notes,
|
||||
Status = CreditMemoStatus.Active,
|
||||
IssuedById = currentUser?.Id,
|
||||
CompanyId = customer.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = currentUser?.Email
|
||||
};
|
||||
|
||||
await _unitOfWork.CreditMemos.AddAsync(memo);
|
||||
|
||||
customer.CreditBalance += dto.Amount;
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Credit of {dto.Amount:C} added to {customer.CompanyName ?? customer.ContactFirstName}'s account (Memo {memoNumber}).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error adding credit to customer {CustomerId}", id);
|
||||
TempData["Error"] = "An error occurred while adding the credit.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential credit memo number in CM-YYMM-#### format.
|
||||
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
|
||||
/// soft-deleted records are included in the sequence check and their numbers are
|
||||
/// never reused, maintaining an audit-friendly gap-free numbering history.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateCreditMemoNumberAsync()
|
||||
{
|
||||
var allMemos = await _unitOfWork.CreditMemos.GetAllAsync(true);
|
||||
var prefix = $"CM-{DateTime.Now:yyMM}-";
|
||||
var maxNum = allMemos
|
||||
.Where(m => m.MemoNumber.StartsWith(prefix))
|
||||
.Select(m => { int.TryParse(m.MemoNumber.Replace(prefix, ""), out int n); return n; })
|
||||
.DefaultIfEmpty(0).Max();
|
||||
return $"{prefix}{(maxNum + 1):D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads all active pricing tiers into ViewBag.PricingTiers for the Create and Edit
|
||||
/// dropdowns. Tiers are sorted alphabetically and include the discount percentage in
|
||||
/// the label text so staff can see the benefit without opening the tier detail page.
|
||||
/// </summary>
|
||||
private async Task PopulatePricingTiersAsync()
|
||||
{
|
||||
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.IsActive);
|
||||
ViewBag.PricingTiers = tiers
|
||||
.OrderBy(t => t.TierName)
|
||||
.Select(t => new SelectListItem
|
||||
{
|
||||
Value = t.Id.ToString(),
|
||||
Text = t.DiscountPercent > 0
|
||||
? $"{t.TierName} ({t.DiscountPercent:0.##}% discount)"
|
||||
: t.TierName
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user