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;
}
}