Initial commit
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
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.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageVendors)]
|
||||
public class VendorsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<VendorsController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public VendorsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<VendorsController> logger,
|
||||
ApplicationDbContext context)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> 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<Func<Vendor, bool>>? 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<IQueryable<Vendor>, IOrderedQueryable<Vendor>> 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();
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<VendorListDto>
|
||||
{
|
||||
Items = vendorDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = 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<VendorListDto>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows full vendor detail including linked inventory items and the human-readable default expense
|
||||
/// account name. The account name is resolved via a direct <c>_context.Accounts.FindAsync</c>
|
||||
/// call rather than an eager-load because the Account entity lives outside the standard
|
||||
/// <c>IUnitOfWork</c> pattern and is only needed for display here.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> 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<VendorDto>(vendor);
|
||||
if (vendor.DefaultExpenseAccountId.HasValue)
|
||||
{
|
||||
var acct = await _context.Accounts.FindAsync(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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the Create form with the expense account dropdown pre-populated.
|
||||
/// When <paramref name="inline"/> 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.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(bool inline = false)
|
||||
{
|
||||
await PopulateExpenseAccountsAsync();
|
||||
if (inline)
|
||||
return PartialView(new CreateVendorDto());
|
||||
return View(new CreateVendorDto());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a new vendor, stamping it with the current user's <c>CompanyId</c> 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 <paramref name="inline"/> 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.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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<Vendor>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the Edit form for an existing vendor.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> 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<UpdateVendorDto>(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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>CompanyId</c> that are not present in the DTO.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> 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<VendorDto>(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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost, ActionName("Delete")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates <c>ViewBag.ExpenseAccounts</c> with active Expense, Cost of Goods, and Asset accounts
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task PopulateExpenseAccountsAsync()
|
||||
{
|
||||
var accounts = await _context.Accounts
|
||||
.Where(a => !a.IsDeleted && 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()))
|
||||
.ToListAsync();
|
||||
|
||||
accounts.Insert(0, new SelectListItem("— None —", ""));
|
||||
ViewBag.ExpenseAccounts = accounts;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user