94a89ee175
- 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>
1425 lines
61 KiB
C#
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"">★</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)} — {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\">—</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\">—</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();
|
|
}
|
|
}
|