using AutoMapper; using PowderCoating.Shared.Constants; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Vendor; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Web.Helpers; namespace PowderCoating.Web.Controllers; /// /// Manages vendor (supplier) records: company details, payment terms, preferred-vendor flag, and the /// default expense account used when AP bills are created for this vendor. Vendors link to inventory /// items (indicating their source supplier) and purchase orders. Deletion is blocked when live /// inventory items reference the vendor, protecting referential integrity without resorting to a /// cascade delete that would silently break historical records. /// [Authorize(Policy = AppConstants.Policies.CanManageVendors)] public class VendorsController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IFinancialReportService _financialReports; private readonly ITenantContext _tenantContext; public VendorsController( IUnitOfWork unitOfWork, IMapper mapper, UserManager userManager, ILogger logger, IFinancialReportService financialReports, ITenantContext tenantContext) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _logger = logger; _financialReports = financialReports; _tenantContext = tenantContext; } /// /// Displays a paginated, sortable, searchable vendor list. Search covers company name, contact /// name, email, phone, and city so a user can locate a vendor by any of its common identifiers. /// Inventory items are eagerly loaded solely to compute the per-vendor item count badge shown in /// the grid; the count explicitly excludes soft-deleted items so only live references are shown. /// public async Task Index( string? searchTerm, string? sortColumn, string sortDirection = "asc", int pageNumber = 1, int pageSize = 25) { try { // Create and validate grid request var gridRequest = new GridRequest { PageNumber = pageNumber, PageSize = pageSize, SortColumn = sortColumn ?? "CompanyName", SortDirection = sortDirection, SearchTerm = searchTerm }; gridRequest.Validate(); // Build search filter System.Linq.Expressions.Expression>? filter = null; if (!string.IsNullOrWhiteSpace(searchTerm)) { var search = searchTerm.ToLower(); filter = s => s.CompanyName.ToLower().Contains(search) || (s.ContactName != null && s.ContactName.ToLower().Contains(search)) || (s.Email != null && s.Email.ToLower().Contains(search)) || (s.Phone != null && s.Phone.ToLower().Contains(search)) || (s.City != null && s.City.ToLower().Contains(search)); } // Build orderBy function Func, IOrderedQueryable> orderBy = gridRequest.SortColumn switch { "CompanyName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.CompanyName) : q.OrderByDescending(s => s.CompanyName), "ContactName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.ContactName) : q.OrderByDescending(s => s.ContactName), "Phone" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.Phone) : q.OrderByDescending(s => s.Phone), "Email" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.Email) : q.OrderByDescending(s => s.Email), "IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.IsActive) : q.OrderByDescending(s => s.IsActive), "IsPreferred" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.IsPreferred) : q.OrderByDescending(s => s.IsPreferred), _ => q => q.OrderBy(s => s.CompanyName) }; // Get paged data with inventory items eager loading for count var (items, totalCount) = await _unitOfWork.Vendors.GetPagedAsync( gridRequest.PageNumber, gridRequest.PageSize, filter, orderBy, s => s.InventoryItems); // Map to DTOs var vendorDtos = items.Select(s => new VendorListDto { Id = s.Id, CompanyName = s.CompanyName, ContactName = s.ContactName, Phone = s.Phone, Email = s.Email, IsActive = s.IsActive, IsPreferred = s.IsPreferred, InventoryItemCount = s.InventoryItems.Count(i => !i.IsDeleted) }).ToList(); var pagedResult = PagedResult.From(gridRequest, vendorDtos, 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 vendors"); TempData["Error"] = "An error occurred while loading vendors."; return View(new PagedResult()); } } /// /// Shows full vendor detail including linked inventory items and the human-readable default expense /// account name. The account name is resolved via a direct _context.Accounts.FindAsync /// call rather than an eager-load because the Account entity lives outside the standard /// IUnitOfWork pattern and is only needed for display here. /// public async Task Details(int? id) { if (id == null) { return NotFound(); } try { var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, s => s.InventoryItems); if (vendor == null) { return NotFound(); } var vendorDto = _mapper.Map(vendor); if (vendor.DefaultExpenseAccountId.HasValue) { var acct = await _unitOfWork.Accounts.GetByIdAsync(vendor.DefaultExpenseAccountId.Value); vendorDto.DefaultExpenseAccountName = acct != null ? $"{acct.AccountNumber} – {acct.Name}" : null; } return View(vendorDto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving vendor {VendorId}", id); TempData["Error"] = "An error occurred while loading the vendor."; return RedirectToAction(nameof(Index)); } } /// /// Renders the Create form with the expense account dropdown pre-populated. /// When is true the layout is stripped so the HTML can be injected /// into a modal; the form will POST back with the same flag and return JSON instead of a redirect. /// public async Task Create(bool inline = false) { await PopulateExpenseAccountsAsync(); if (inline) return PartialView(new CreateVendorDto()); return View(new CreateVendorDto()); } /// /// Persists a new vendor, stamping it with the current user's CompanyId so multi-tenant /// isolation is enforced at creation rather than relying solely on query filters. Redirects to /// Details on success so the user can immediately verify the saved record. /// When is true (quick-add modal path) returns JSON {success, id, name} /// instead of a redirect so the caller can add the new option to the originating select element. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Create(CreateVendorDto dto, bool inline = false) { if (!ModelState.IsValid) { if (inline) { var errors = ModelState.Values .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage); return Json(new { success = false, errors }); } await PopulateExpenseAccountsAsync(); return View(dto); } try { var currentUser = await _userManager.GetUserAsync(User); var vendor = _mapper.Map(dto); vendor.CompanyId = currentUser!.CompanyId; await _unitOfWork.Vendors.AddAsync(vendor); await _unitOfWork.CompleteAsync(); if (inline) return Json(new { success = true, id = vendor.Id, name = vendor.CompanyName }); TempData["Success"] = $"Vendor '{vendor.CompanyName}' created successfully."; return RedirectToAction(nameof(Details), new { id = vendor.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error creating vendor"); if (inline) return Json(new { success = false, errors = new[] { "An error occurred while saving." } }); TempData["Error"] = "An error occurred while creating the vendor."; return View(dto); } } /// /// Renders the Edit form for an existing vendor. /// public async Task Edit(int? id) { if (id == null) { return NotFound(); } try { var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value); if (vendor == null) { return NotFound(); } var dto = _mapper.Map(vendor); await PopulateExpenseAccountsAsync(); return View(dto); } catch (Exception ex) { _logger.LogError(ex, "Error loading vendor {VendorId} for edit", id); TempData["Error"] = "An error occurred while loading the vendor."; return RedirectToAction(nameof(Index)); } } /// /// Applies edits to an existing vendor. Uses AutoMapper's map-onto-existing-entity overload so /// EF Core's change tracker only marks modified columns dirty, preserving audit timestamps and /// CompanyId that are not present in the DTO. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(int id, UpdateVendorDto dto) { if (id != dto.Id) { return NotFound(); } if (!ModelState.IsValid) { await PopulateExpenseAccountsAsync(); return View(dto); } try { var vendor = await _unitOfWork.Vendors.GetByIdAsync(id); if (vendor == null) { return NotFound(); } _mapper.Map(dto, vendor); await _unitOfWork.Vendors.UpdateAsync(vendor); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Vendor '{vendor.CompanyName}' updated successfully."; return RedirectToAction(nameof(Details), new { id = vendor.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error updating vendor {VendorId}", id); TempData["Error"] = "An error occurred while updating the vendor."; return View(dto); } } /// /// Shows the Delete confirmation page with the vendor's active inventory item count, so the user /// understands the impact before confirming. The count is surfaced here to avoid a surprise /// block error on the POST — surfacing the constraint on the GET gives the user an opportunity to /// reassign items first. /// public async Task Delete(int? id) { if (id == null) { return NotFound(); } try { var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, s => s.InventoryItems); if (vendor == null) { return NotFound(); } var vendorDto = _mapper.Map(vendor); ViewBag.InventoryItemCount = vendor.InventoryItems.Count(i => !i.IsDeleted); return View(vendorDto); } catch (Exception ex) { _logger.LogError(ex, "Error loading vendor {VendorId} for deletion", id); TempData["Error"] = "An error occurred while loading the vendor."; return RedirectToAction(nameof(Index)); } } /// /// Soft-deletes a vendor after a business-rule guard: deletion is blocked when the vendor is /// still referenced by one or more active (non-soft-deleted) inventory items. The block is /// enforced in application code rather than via a DB foreign key constraint because soft deletes /// make constraint-based blocking unreliable — a DB constraint would fire even on logically /// deleted items. Soft delete is used so purchase order and AP bill history that references this /// vendor remains intact. /// [HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] public async Task DeleteConfirmed(int id) { try { var vendor = await _unitOfWork.Vendors.GetByIdAsync(id, false, s => s.InventoryItems); if (vendor == null) { return NotFound(); } var inventoryItemCount = vendor.InventoryItems.Count(i => !i.IsDeleted); if (inventoryItemCount > 0) { TempData["Error"] = $"Cannot delete vendor '{vendor.CompanyName}' because it is currently used by {inventoryItemCount} inventory item(s). Please reassign or remove those items first."; return RedirectToAction(nameof(Delete), new { id }); } var vendorName = vendor.CompanyName; await _unitOfWork.Vendors.SoftDeleteAsync(id); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Vendor '{vendorName}' has been deleted."; return RedirectToAction(nameof(Index)); } catch (Exception ex) { _logger.LogError(ex, "Error deleting vendor {VendorId}", id); TempData["Error"] = "An error occurred while deleting the vendor."; return RedirectToAction(nameof(Index)); } } /// /// Populates ViewBag.ExpenseAccounts with active Expense, Cost of Goods, and Asset accounts /// /// Displays or downloads a dated activity statement for a vendor. /// [HttpGet] public async Task Statement(int id, DateTime? from, DateTime? to, bool pdf = false) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); var toDate = to ?? DateTime.Today; var dto = await _financialReports.GetVendorStatementAsync(companyId, id, fromDate, toDate); if (pdf) { var bytes = StatementPdfHelper.Generate( dto.VendorName, dto.CompanyName, null, dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: true); return File(bytes, "application/pdf", $"Statement-{dto.VendorName}-{toDate:yyyyMMdd}.pdf"); } return View(dto); } /// for the vendor's default expense account dropdown. All three account types are included because /// vendor bills can legitimately be coded to COGS (powder, materials) or asset accounts (equipment /// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so /// the field is optional — not every vendor needs a default account pre-set. /// private async Task PopulateExpenseAccountsAsync() { var accounts = (await _unitOfWork.Accounts.FindAsync( a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods || a.AccountType == AccountType.Asset))) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); accounts.Insert(0, new SelectListItem("— None —", "")); ViewBag.ExpenseAccounts = accounts; } }