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 _logger; private readonly INotificationService _notificationService; private readonly ISubscriptionService _subscriptionService; private readonly ITenantContext _tenantContext; private readonly UserManager _userManager; private readonly IFinancialReportService _financialReports; public CustomersController( IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, INotificationService notificationService, ISubscriptionService subscriptionService, ITenantContext tenantContext, UserManager userManager, IFinancialReportService financialReports) { _unitOfWork = unitOfWork; _mapper = mapper; _logger = logger; _notificationService = notificationService; _subscriptionService = subscriptionService; _tenantContext = tenantContext; _userManager = userManager; _financialReports = financialReports; } /// /// 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. /// public async Task 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>? 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, IOrderedQueryable> 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(); var pagedResult = PagedResult.From(gridRequest, customerDtos, 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()); } } /// /// Renders the customer detail page. In addition to basic info and credit memos, runs /// four sequential queries (jobs, quotes, invoices, deposits) to build: /// (1) — aggregate KPIs for the stats card /// (2) list — last 15 events for the activity feed /// Credit memos are loaded separately because the Customer aggregate does not navigate to them. /// public async Task 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(); // CRM queries — must be sequential; EF Core's DbContext is not thread-safe var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CustomerId == id.Value && j.CompanyId == companyId, false, j => j.JobStatus)).ToList(); var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CustomerId == id.Value && q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList(); var invoices = (await _unitOfWork.Invoices.FindAsync(i => i.CustomerId == id.Value && i.CompanyId == companyId)).ToList(); var deposits = (await _unitOfWork.Deposits.FindAsync(d => d.CustomerId == id.Value && d.CompanyId == companyId)).ToList(); var pendingPickups = (await _unitOfWork.Jobs.FindAsync( j => j.CustomerId == id.Value && j.CompanyId == companyId && j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup, false, j => j.JobStatus)) .OrderBy(j => j.UpdatedAt) .ToList(); ViewBag.PendingPickups = pendingPickups; var customerNotes = (await _unitOfWork.CustomerNotes.FindAsync(n => n.CustomerId == id.Value)) .OrderByDescending(n => n.CreatedAt) .ToList(); ViewBag.CustomerNotes = customerNotes; var preferredPowders = (await _unitOfWork.CustomerPreferredPowders.FindAsync( p => p.CustomerId == id.Value, false, p => p.InventoryItem)) .ToList(); ViewBag.PreferredPowders = preferredPowders; var customerContacts = (await _unitOfWork.CustomerContacts.FindAsync(n => n.CustomerId == id.Value)) .OrderBy(c => c.FirstName) .ToList(); ViewBag.CustomerContacts = customerContacts; // Stats var nonVoided = invoices.Where(i => i.Status != InvoiceStatus.Voided).ToList(); var stats = new CustomerLifetimeStatsDto { TotalJobs = jobs.Count, ActiveJobs = jobs.Count(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus), TotalRevenue = nonVoided.Sum(i => i.Total), TotalCollected = nonVoided.Sum(i => i.AmountPaid), AverageJobValue = jobs.Count > 0 ? jobs.Average(j => j.FinalPrice) : 0, LastJobDate = jobs.Count > 0 ? jobs.Max(j => (DateTime?)j.CreatedAt) : null, LastJobId = jobs.Count > 0 ? jobs.OrderByDescending(j => j.CreatedAt).First().Id : (int?)null, TotalQuotes = quotes.Count, TotalInvoices = invoices.Count, OpenBalance = customer.CurrentBalance }; stats.DaysSinceLastJob = stats.LastJobDate.HasValue ? (int)(DateTime.UtcNow - stats.LastJobDate.Value).TotalDays : null; // Timeline: merge all event types, sort descending, cap at 15 var events = new List(); foreach (var j in jobs) events.Add(new CustomerTimelineEventDto { Date = j.CreatedAt, Icon = "bi-briefcase", BadgeColor = "primary", Title = $"Job {j.JobNumber}", Subtitle = j.Description, Amount = j.FinalPrice > 0 ? j.FinalPrice : null, EntityId = j.Id, LinkController = "Jobs", LinkAction = "Details" }); foreach (var q in quotes) events.Add(new CustomerTimelineEventDto { Date = q.QuoteDate, Icon = "bi-file-text", BadgeColor = "info", Title = $"Quote {q.QuoteNumber}", Subtitle = q.QuoteStatus?.DisplayName, Amount = q.Total > 0 ? q.Total : null, EntityId = q.Id, LinkController = "Quotes", LinkAction = "Details" }); foreach (var inv in invoices) events.Add(new CustomerTimelineEventDto { Date = inv.InvoiceDate, Icon = inv.Status == InvoiceStatus.Paid ? "bi-receipt-cutoff" : "bi-receipt", BadgeColor = inv.Status == InvoiceStatus.Paid ? "success" : "warning", Title = $"Invoice {inv.InvoiceNumber}", Subtitle = inv.Status.ToString(), Amount = inv.Total, EntityId = inv.Id, LinkController = "Invoices", LinkAction = "Details" }); foreach (var dep in deposits) events.Add(new CustomerTimelineEventDto { Date = dep.ReceivedDate, Icon = "bi-cash-coin", BadgeColor = "success", Title = "Deposit received", Subtitle = dep.ReceiptNumber, Amount = dep.Amount, EntityId = dep.JobId, LinkController = dep.JobId.HasValue ? "Jobs" : null, LinkAction = dep.JobId.HasValue ? "Details" : null }); ViewBag.CrmStats = stats; ViewBag.Timeline = events .OrderByDescending(e => e.Date) .Take(15) .ToList(); var customerDto = _mapper.Map(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)); } } /// /// 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. /// public async Task 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> 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, IOrderedQueryable> 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>(items); var pagedResult = new Application.DTOs.Common.PagedResult { 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 }); } } /// /// 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. /// public async Task 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, IOrderedQueryable> 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>(jobItems); var pagedJobs = new Application.DTOs.Common.PagedResult { Items = jobDtos, PageNumber = jobPage, PageSize = jobSize, TotalCount = jobTotal }; // --- Quotes --- Func, IOrderedQueryable> 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>(quoteItems); var pagedQuotes = new Application.DTOs.Common.PagedResult { 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 }); } } /// /// 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. /// public async Task 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, IOrderedQueryable> 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>(items); var pagedResult = new Application.DTOs.Common.PagedResult { 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 }); } } /// /// 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 so the /// dropdown is available immediately without a round-trip. /// public async Task 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(); } /// /// 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 ; the resulting /// notification log entry is then surfaced as a toast so staff can confirm delivery. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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(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 logs = await _unitOfWork.NotificationLogs.FindAsync( n => n.CustomerId == customer.Id, ignoreQueryFilters: true); var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault(); 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); } } /// /// 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. /// public async Task 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(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)); } } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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 logs = await _unitOfWork.NotificationLogs.FindAsync( n => n.CustomerId == customer.Id, ignoreQueryFilters: true); var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault(); 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); } } /// /// 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. /// public async Task 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(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)); } } /// /// 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. /// [HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] public async Task 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)); } } /// /// 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. /// [HttpGet] public async Task 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(); } } /// /// 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 /// can return them faithfully. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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 }); } } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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 }); } } /// /// 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 . /// The memo is not tied to any invoice at creation; it will be applied automatically /// when the next invoice is created for this customer. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task 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 }); } /// /// Adds a quick internal note to the customer record. Returns the rendered note HTML so /// the caller can prepend it to the notes list without a full page reload. /// [HttpPost, ValidateAntiForgeryToken] public async Task AddCustomerNote(int id, string note, bool isImportant = false) { if (string.IsNullOrWhiteSpace(note)) return Json(new { success = false, message = "Note cannot be empty." }); try { var customer = await _unitOfWork.Customers.GetByIdAsync(id); if (customer == null) return Json(new { success = false, message = "Customer not found." }); var currentUser = await _userManager.GetUserAsync(User); var entity = new PowderCoating.Core.Entities.CustomerNote { CustomerId = id, Note = note.Trim(), IsImportant = isImportant, CreatedBy = currentUser?.Email }; await _unitOfWork.CustomerNotes.AddAsync(entity); await _unitOfWork.CompleteAsync(); var displayDate = entity.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy h:mm tt"); var author = currentUser?.Email ?? "Staff"; var noteHtml = $@"
{(isImportant ? @"" : "")} {System.Web.HttpUtility.HtmlEncode(entity.Note)}
{System.Web.HttpUtility.HtmlEncode(author)} — {displayDate}
"; return Json(new { success = true, noteHtml }); } catch (Exception ex) { _logger.LogError(ex, "Error adding note to customer {CustomerId}", id); return Json(new { success = false, message = "An error occurred." }); } } /// /// Soft-deletes a single customer note. Only the owning company can delete their own notes /// (enforced via CompanyId on the entity + global query filter). /// [HttpPost, ValidateAntiForgeryToken] public async Task DeleteCustomerNote(int id, int noteId) { try { var note = await _unitOfWork.CustomerNotes.GetByIdAsync(noteId); if (note == null || note.CustomerId != id) return Json(new { success = false, message = "Note not found." }); await _unitOfWork.CustomerNotes.SoftDeleteAsync(note); await _unitOfWork.CompleteAsync(); return Json(new { success = true }); } catch (Exception ex) { _logger.LogError(ex, "Error deleting note {NoteId} for customer {CustomerId}", noteId, id); return Json(new { success = false, message = "An error occurred." }); } } /// /// Returns up to 10 inventory items matching the search term for the preferred-powder typeahead. /// Results are scoped to the current company and only include active items. /// [HttpGet] public async Task SearchInventoryItems(string term) { if (string.IsNullOrWhiteSpace(term) || term.Length < 2) return Json(Array.Empty()); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var lower = term.ToLower(); var items = (await _unitOfWork.InventoryItems.FindAsync( i => i.CompanyId == companyId && i.IsActive && (i.Name.ToLower().Contains(lower) || (i.SKU != null && i.SKU.ToLower().Contains(lower))))) .OrderBy(i => i.Name) .Take(10) .Select(i => new { i.Id, i.Name, i.ColorName, sku = i.SKU }) .ToList(); return Json(items); } /// /// Associates an inventory item as a preferred powder for a customer. /// Silently succeeds if the association already exists (idempotent). /// [HttpPost, ValidateAntiForgeryToken] public async Task AddPreferredPowder(int id, int inventoryItemId, string? notes = null) { try { var customer = await _unitOfWork.Customers.GetByIdAsync(id); if (customer == null) return Json(new { success = false, message = "Customer not found." }); var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId); if (item == null) return Json(new { success = false, message = "Inventory item not found." }); var existing = (await _unitOfWork.CustomerPreferredPowders.FindAsync( p => p.CustomerId == id && p.InventoryItemId == inventoryItemId)).FirstOrDefault(); if (existing != null) return Json(new { success = false, message = $"{item.Name} is already in preferred powders." }); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; await _unitOfWork.CustomerPreferredPowders.AddAsync(new PowderCoating.Core.Entities.CustomerPreferredPowder { CustomerId = id, InventoryItemId = inventoryItemId, Notes = notes?.Trim(), CompanyId = companyId }); await _unitOfWork.CompleteAsync(); return Json(new { success = true, itemId = inventoryItemId, itemName = item.Name, notes = notes?.Trim() }); } catch (Exception ex) { _logger.LogError(ex, "Error adding preferred powder for customer {CustomerId}", id); return Json(new { success = false, message = "An error occurred." }); } } /// /// Removes a preferred-powder association by inventory item ID. Soft-deletes the record /// so the history is preserved but it no longer appears on the customer page. /// [HttpPost, ValidateAntiForgeryToken] public async Task RemovePreferredPowder(int id, int itemId) { try { var record = (await _unitOfWork.CustomerPreferredPowders.FindAsync( p => p.CustomerId == id && p.InventoryItemId == itemId)).FirstOrDefault(); if (record == null) return Json(new { success = false, message = "Record not found." }); await _unitOfWork.CustomerPreferredPowders.SoftDeleteAsync(record); await _unitOfWork.CompleteAsync(); return Json(new { success = true }); } catch (Exception ex) { _logger.LogError(ex, "Error removing preferred powder {ItemId} for customer {CustomerId}", itemId, id); return Json(new { success = false, message = "An error occurred." }); } } // ── Customer Contacts ────────────────────────────────────────────────── /// /// Returns the JSON representation of a single contact for pre-populating the edit modal. /// [HttpGet] public async Task GetContact(int id, int contactId) { var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId); if (contact == null || contact.CustomerId != id) return Json(new { success = false }); var dto = _mapper.Map(contact); return Json(new { success = true, contact = dto }); } /// /// Adds a new contact to the customer record. Returns rendered row HTML so the /// caller can append it to the contacts table without a full reload. /// [HttpPost, ValidateAntiForgeryToken] public async Task AddContact(int id, PowderCoating.Application.DTOs.Customer.CreateCustomerContactDto dto) { if (!ModelState.IsValid) return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." }); try { var customer = await _unitOfWork.Customers.GetByIdAsync(id); if (customer == null) return Json(new { success = false, message = "Customer not found." }); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var entity = _mapper.Map(dto); entity.CustomerId = id; entity.CompanyId = companyId; await _unitOfWork.CustomerContacts.AddAsync(entity); await _unitOfWork.CompleteAsync(); var rowHtml = BuildContactRowHtml(id, entity); return Json(new { success = true, contactId = entity.Id, rowHtml }); } catch (Exception ex) { _logger.LogError(ex, "Error adding contact to customer {CustomerId}", id); return Json(new { success = false, message = "An error occurred." }); } } /// /// Updates an existing contact record in place. Returns the updated row HTML. /// [HttpPost, ValidateAntiForgeryToken] public async Task UpdateContact(int id, PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto dto) { if (!ModelState.IsValid) return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." }); try { var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(dto.Id); if (contact == null || contact.CustomerId != id) return Json(new { success = false, message = "Contact not found." }); _mapper.Map(dto, contact); contact.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CustomerContacts.UpdateAsync(contact); await _unitOfWork.CompleteAsync(); var rowHtml = BuildContactRowHtml(id, contact); return Json(new { success = true, contactId = contact.Id, rowHtml }); } catch (Exception ex) { _logger.LogError(ex, "Error updating contact {ContactId} for customer {CustomerId}", dto.Id, id); return Json(new { success = false, message = "An error occurred." }); } } /// /// Soft-deletes a contact. Only the owning company can delete their contacts /// (enforced via CompanyId + global query filter). /// [HttpPost, ValidateAntiForgeryToken] public async Task DeleteContact(int id, int contactId) { try { var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId); if (contact == null || contact.CustomerId != id) return Json(new { success = false, message = "Contact not found." }); await _unitOfWork.CustomerContacts.SoftDeleteAsync(contact); await _unitOfWork.CompleteAsync(); return Json(new { success = true }); } catch (Exception ex) { _logger.LogError(ex, "Error deleting contact {ContactId} for customer {CustomerId}", contactId, id); return Json(new { success = false, message = "An error occurred." }); } } /// /// Builds the table-row HTML for a contact. Kept server-side so the same markup is /// used for both the initial page render and the AJAX insert/replace path. /// private static string BuildContactRowHtml(int customerId, PowderCoating.Core.Entities.CustomerContact c) { var displayName = string.IsNullOrWhiteSpace(c.LastName) ? c.FirstName : $"{c.FirstName} {c.LastName}"; var titlePart = !string.IsNullOrWhiteSpace(c.Title) ? System.Web.HttpUtility.HtmlEncode(c.Title) : ""; var roleBadge = !string.IsNullOrWhiteSpace(c.ContactRole) ? $"{System.Web.HttpUtility.HtmlEncode(c.ContactRole)}" : ""; var email = !string.IsNullOrWhiteSpace(c.Email) ? $"{System.Web.HttpUtility.HtmlEncode(c.Email)}" : ""; var phone = !string.IsNullOrWhiteSpace(c.Phone ?? c.MobilePhone) ? $"{System.Web.HttpUtility.HtmlEncode(c.Phone ?? c.MobilePhone)}" : ""; return $@"
{System.Web.HttpUtility.HtmlEncode(displayName)}{roleBadge}
{(string.IsNullOrWhiteSpace(titlePart) ? "" : $"
{titlePart}
")} {email} {phone} "; } /// /// Displays or downloads a dated activity statement for a customer. /// Pass pdf=true to download the QuestPDF version; otherwise renders the HTML view. /// [HttpGet] public async Task Statement(int id, DateTime? from, DateTime? to, bool pdf = false) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); var toDate = to ?? DateTime.Today; var dto = await _financialReports.GetCustomerStatementAsync(companyId, id, fromDate, toDate); if (pdf) { var bytes = StatementPdfHelper.Generate( dto.CustomerName, dto.CompanyName, dto.CustomerAddress, dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: false); return File(bytes, "application/pdf", $"Statement-{dto.CustomerName}-{toDate:yyyyMMdd}.pdf"); } return View(dto); } /// /// Generates the next sequential credit memo number in CM-YYMM-#### format. /// Uses ignoreQueryFilters: true 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. /// private async Task GenerateCreditMemoNumberAsync() { var prefix = $"CM-{DateTime.Now:yyMM}-"; var last = (await _unitOfWork.CreditMemos.FindAsync( m => m.MemoNumber.StartsWith(prefix), ignoreQueryFilters: true)) .OrderByDescending(m => m.MemoNumber) .Select(m => m.MemoNumber) .FirstOrDefault(); int next = 1; if (last != null && int.TryParse(last[prefix.Length..], out int num)) next = num + 1; return $"{prefix}{next:D4}"; } /// /// 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. /// 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(); } }