Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/CustomersController.cs
T
spouliot 94a89ee175 Add CRM features: Additional Contacts, Lead Source, Ship-To Address; update Help docs
- New CustomerContact entity + migration (AddCustomerContactsAndCrmFields)
- Customer.LeadSource + ShipToAddress/City/State/ZipCode/Country fields
- Additional Contacts card on Customer Details with AJAX add/edit/delete
- Lead Source dropdown on Create/Edit; Ship-To section on Create/Edit
- Customer Details: side-by-side billing/ship-to when ship-to is set
- Help docs: Customers (contacts, ship-to, lead source, preferred powders, outstanding pickups)
- Help docs: Jobs (clone job, project name), Quotes (project name), Invoices (project name), Inventory (low stock clickable filter)
- HelpKnowledgeBase.cs updated for all features above

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:46:08 -04:00

1425 lines
61 KiB
C#

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 UserManager<ApplicationUser> _userManager;
private readonly IFinancialReportService _financialReports;
public CustomersController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILogger<CustomersController> logger,
INotificationService notificationService,
ISubscriptionService subscriptionService,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
IFinancialReportService financialReports)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
_notificationService = notificationService;
_subscriptionService = subscriptionService;
_tenantContext = tenantContext;
_userManager = userManager;
_financialReports = financialReports;
}
/// <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();
var pagedResult = PagedResult<CustomerListDto>.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<CustomerListDto>());
}
}
/// <summary>
/// Renders the customer detail page. In addition to basic info and credit memos, runs
/// four sequential queries (jobs, quotes, invoices, deposits) to build:
/// (1) <see cref="CustomerLifetimeStatsDto"/> — aggregate KPIs for the stats card
/// (2) <see cref="CustomerTimelineEventDto"/> list — last 15 events for the activity feed
/// Credit memos are loaded separately because the Customer aggregate does not navigate to them.
/// </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();
// 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<CustomerTimelineEventDto>();
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<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 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);
}
}
/// <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 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);
}
}
/// <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>
/// 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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 = $@"<div class=""customer-note-item d-flex gap-2 py-2 border-bottom"" data-note-id=""{entity.Id}"">
<div class=""flex-grow-1"">
{(isImportant ? @"<span class=""text-warning me-1"" title=""Important"">&#9733;</span>" : "")}
<span class=""note-text"">{System.Web.HttpUtility.HtmlEncode(entity.Note)}</span>
<div class=""text-muted"" style=""font-size:0.75rem;"">{System.Web.HttpUtility.HtmlEncode(author)} &mdash; {displayDate}</div>
</div>
<button type=""button"" class=""btn btn-sm btn-link text-danger p-0 flex-shrink-0""
onclick=""deleteCustomerNote({id}, {entity.Id})"" title=""Delete note"">
<i class=""bi bi-trash""></i>
</button>
</div>";
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." });
}
}
/// <summary>
/// Soft-deletes a single customer note. Only the owning company can delete their own notes
/// (enforced via CompanyId on the entity + global query filter).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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." });
}
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> SearchInventoryItems(string term)
{
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
return Json(Array.Empty<object>());
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);
}
/// <summary>
/// Associates an inventory item as a preferred powder for a customer.
/// Silently succeeds if the association already exists (idempotent).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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." });
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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 ──────────────────────────────────────────────────
/// <summary>
/// Returns the JSON representation of a single contact for pre-populating the edit modal.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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<PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto>(contact);
return Json(new { success = true, contact = dto });
}
/// <summary>
/// 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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<PowderCoating.Core.Entities.CustomerContact>(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." });
}
}
/// <summary>
/// Updates an existing contact record in place. Returns the updated row HTML.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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." });
}
}
/// <summary>
/// Soft-deletes a contact. Only the owning company can delete their contacts
/// (enforced via CompanyId + global query filter).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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." });
}
}
/// <summary>
/// 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.
/// </summary>
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)
? $"<span class=\"badge bg-secondary bg-opacity-10 text-secondary ms-1\">{System.Web.HttpUtility.HtmlEncode(c.ContactRole)}</span>"
: "";
var email = !string.IsNullOrWhiteSpace(c.Email)
? $"<a href=\"mailto:{System.Web.HttpUtility.HtmlEncode(c.Email)}\" class=\"text-decoration-none small\">{System.Web.HttpUtility.HtmlEncode(c.Email)}</a>"
: "<span class=\"text-muted small\">&mdash;</span>";
var phone = !string.IsNullOrWhiteSpace(c.Phone ?? c.MobilePhone)
? $"<span class=\"small\">{System.Web.HttpUtility.HtmlEncode(c.Phone ?? c.MobilePhone)}</span>"
: "<span class=\"text-muted small\">&mdash;</span>";
return $@"<tr data-contact-id=""{c.Id}"">
<td>
<div class=""fw-semibold"">{System.Web.HttpUtility.HtmlEncode(displayName)}{roleBadge}</div>
{(string.IsNullOrWhiteSpace(titlePart) ? "" : $"<div class=\"text-muted\" style=\"font-size:0.75rem;\">{titlePart}</div>")}
</td>
<td>{email}</td>
<td>{phone}</td>
<td class=""text-end"">
<button type=""button"" class=""btn btn-sm btn-outline-secondary me-1""
onclick=""editContact({customerId}, {c.Id})"" title=""Edit"">
<i class=""bi bi-pencil""></i>
</button>
<button type=""button"" class=""btn btn-sm btn-outline-danger""
onclick=""deleteContact({customerId}, {c.Id})"" title=""Delete"">
<i class=""bi bi-trash""></i>
</button>
</td>
</tr>";
}
/// <summary>
/// Displays or downloads a dated activity statement for a customer.
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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);
}
/// <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 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}";
}
/// <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();
}
}