Add vendor supply categories with inventory auto-filter

Vendors can now be tagged with one or more inventory categories (Powder,
Chemical, etc.) via checkboxes on the Create/Edit form. The inventory
Create/Edit vendor dropdown automatically filters to matching vendors when
a category is selected; falls back to all vendors if none are tagged.
Includes migration AddVendorCategories (VendorInventoryCategories join table).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 09:52:34 -04:00
parent a7bf97a2df
commit d77b3778ac
13 changed files with 10983 additions and 7 deletions
@@ -1495,8 +1495,20 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories))
.OrderBy(v => v.CompanyName).ToList();
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
// Build {categoryId: [vendorId, ...]} so the inventory form can filter vendors by category
var categoryVendorMap = new Dictionary<string, List<int>>();
foreach (var v in vendors)
foreach (var cat in v.Categories)
{
var key = cat.Id.ToString();
if (!categoryVendorMap.ContainsKey(key)) categoryVendorMap[key] = new List<int>();
categoryVendorMap[key].Add(v.Id);
}
ViewBag.CategoryVendorMapJson = System.Text.Json.JsonSerializer.Serialize(categoryVendorMap);
// Load categories from lookup table
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
@@ -181,6 +181,7 @@ public class VendorsController : Controller
public async Task<IActionResult> Create(bool inline = false)
{
await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync();
if (inline)
return PartialView(new CreateVendorDto());
return View(new CreateVendorDto());
@@ -207,6 +208,7 @@ public class VendorsController : Controller
return Json(new { success = false, errors });
}
await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto);
}
@@ -216,6 +218,12 @@ public class VendorsController : Controller
var vendor = _mapper.Map<Vendor>(dto);
vendor.CompanyId = currentUser!.CompanyId;
if (dto.CategoryIds.Any())
{
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
vendor.Categories = cats.ToList();
}
await _unitOfWork.Vendors.AddAsync(vendor);
await _unitOfWork.CompleteAsync();
@@ -247,14 +255,16 @@ public class VendorsController : Controller
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value);
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, v => v.Categories);
if (vendor == null)
{
return NotFound();
}
var dto = _mapper.Map<UpdateVendorDto>(vendor);
dto.CategoryIds = vendor.Categories.Select(c => c.Id).ToList();
await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto);
}
catch (Exception ex)
@@ -282,18 +292,27 @@ public class VendorsController : Controller
if (!ModelState.IsValid)
{
await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto);
}
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id);
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id, false, v => v.Categories);
if (vendor == null)
{
return NotFound();
}
_mapper.Map(dto, vendor);
vendor.Categories.Clear();
if (dto.CategoryIds.Any())
{
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
foreach (var cat in cats) vendor.Categories.Add(cat);
}
await _unitOfWork.Vendors.UpdateAsync(vendor);
await _unitOfWork.CompleteAsync();
@@ -413,6 +432,20 @@ public class VendorsController : Controller
/// 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>
/// <summary>
/// Populates ViewBag.VendorCategories with active inventory categories for the checkbox list,
/// and ViewBag.SelectedCategoryIds with the IDs already assigned to the vendor being edited.
/// </summary>
private async Task PopulateVendorCategoriesAsync(IEnumerable<int>? selectedIds = null)
{
var companyId = (await _userManager.GetUserAsync(User))!.CompanyId;
var cats = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId && c.IsActive))
.OrderBy(c => c.DisplayOrder)
.ToList();
ViewBag.VendorCategories = cats;
ViewBag.SelectedCategoryIds = (selectedIds ?? Enumerable.Empty<int>()).ToHashSet();
}
private async Task PopulateExpenseAccountsAsync()
{
var accounts = (await _unitOfWork.Accounts.FindAsync(