Add color family filter to inventory index
Adds an 'All Colors' dropdown to the inventory filter bar populated from the ColorFamilies values already stored on inventory items. Selecting a family (e.g. 'Red') returns only items tagged with that family. Also refactors the 16-branch if/else filter builder into a single composable predicate, making future filter additions trivial. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -56,15 +56,16 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Displays the paginated inventory list with optional keyword search, category filter,
|
/// Displays the paginated inventory list with optional keyword search, category filter,
|
||||||
/// and a low-stock quick-filter. When lowStockOnly is active the default sort switches
|
/// color family filter, and a low-stock quick-filter. When lowStockOnly is active the
|
||||||
/// to QuantityOnHand ascending so the most depleted items surface immediately. Stats
|
/// default sort switches to QuantityOnHand ascending so the most depleted items surface
|
||||||
/// (total value, active count, low-stock count) are computed directly on the DbSet
|
/// immediately. Stats (total value, active count, low-stock count) are computed directly
|
||||||
/// using aggregate SQL to avoid loading all rows into memory.
|
/// on the DbSet using aggregate SQL to avoid loading all rows into memory.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index(
|
public async Task<IActionResult> Index(
|
||||||
string? searchTerm,
|
string? searchTerm,
|
||||||
string? category,
|
string? category,
|
||||||
string? location,
|
string? location,
|
||||||
|
string? colorFamily,
|
||||||
string? sortColumn,
|
string? sortColumn,
|
||||||
string sortDirection = "asc",
|
string sortDirection = "asc",
|
||||||
bool lowStockOnly = false,
|
bool lowStockOnly = false,
|
||||||
@@ -88,64 +89,35 @@ public class InventoryController : Controller
|
|||||||
};
|
};
|
||||||
gridRequest.Validate();
|
gridRequest.Validate();
|
||||||
|
|
||||||
// Build filter — compose search, category, location, and low-stock predicates
|
|
||||||
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
|
|
||||||
|
|
||||||
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
|
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
|
||||||
var hasCategory = !string.IsNullOrWhiteSpace(category);
|
var hasCategory = !string.IsNullOrWhiteSpace(category);
|
||||||
var hasLocation = !string.IsNullOrWhiteSpace(location);
|
var hasLocation = !string.IsNullOrWhiteSpace(location);
|
||||||
|
var hasColorFamily = !string.IsNullOrWhiteSpace(colorFamily);
|
||||||
|
|
||||||
var search = searchTerm?.ToLower() ?? "";
|
var search = searchTerm?.ToLower() ?? "";
|
||||||
var cat = category ?? "";
|
var cat = category ?? "";
|
||||||
var loc = location ?? "";
|
var loc = location ?? "";
|
||||||
|
var colorFam = colorFamily ?? "";
|
||||||
|
|
||||||
if (lowStockOnly && hasSearch && hasLocation)
|
// Single composable predicate — EF Core evaluates the captured booleans as constants
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
// so inactive conditions fold to true and are omitted from the generated SQL WHERE clause.
|
||||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower())
|
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
|
||||||
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
if (hasSearch || hasCategory || hasLocation || hasColorFamily || lowStockOnly)
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
{
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
filter = i =>
|
||||||
else if (lowStockOnly && hasSearch)
|
(!lowStockOnly || (i.IsActive && i.QuantityOnHand <= i.ReorderPoint)) &&
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
(!hasSearch || (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
|
||||||
else if (lowStockOnly && hasLocation)
|
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
|
||||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
|
||||||
else if (lowStockOnly)
|
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint;
|
|
||||||
else if (hasSearch && hasCategory && hasLocation)
|
|
||||||
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))) &&
|
||||||
&& i.Category.ToLower() == cat.ToLower()
|
(!hasCategory || i.Category.ToLower() == cat.ToLower()) &&
|
||||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
(!hasLocation || (i.Location != null && i.Location.ToLower() == loc.ToLower())) &&
|
||||||
else if (hasSearch && hasCategory)
|
(!hasColorFamily || (i.ColorFamilies != null && (
|
||||||
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
i.ColorFamilies == colorFam ||
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
i.ColorFamilies.StartsWith(colorFam + ",") ||
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
i.ColorFamilies.EndsWith("," + colorFam) ||
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
i.ColorFamilies.Contains("," + colorFam + ","))));
|
||||||
&& i.Category.ToLower() == cat.ToLower();
|
}
|
||||||
else if (hasSearch && hasLocation)
|
|
||||||
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
|
||||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
|
||||||
else if (hasSearch)
|
|
||||||
filter = i => i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search));
|
|
||||||
else if (hasCategory && hasLocation)
|
|
||||||
filter = i => i.Category.ToLower() == cat.ToLower()
|
|
||||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
|
||||||
else if (hasCategory)
|
|
||||||
filter = i => i.Category.ToLower() == cat.ToLower();
|
|
||||||
else if (hasLocation)
|
|
||||||
filter = i => i.Location != null && i.Location.ToLower() == loc.ToLower();
|
|
||||||
|
|
||||||
// Build orderBy function
|
// Build orderBy function
|
||||||
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
|
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
|
||||||
@@ -179,6 +151,14 @@ public class InventoryController : Controller
|
|||||||
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
||||||
ViewBag.Locations = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
|
ViewBag.Locations = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
|
||||||
|
ViewBag.ColorFamilies = allItems
|
||||||
|
.Where(i => !string.IsNullOrEmpty(i.ColorFamilies))
|
||||||
|
.SelectMany(i => i.ColorFamilies!.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
.Select(f => f.Trim())
|
||||||
|
.Where(f => f.Length > 0)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(f => f)
|
||||||
|
.ToList();
|
||||||
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||||
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
||||||
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||||||
@@ -187,6 +167,7 @@ public class InventoryController : Controller
|
|||||||
ViewBag.SearchTerm = searchTerm;
|
ViewBag.SearchTerm = searchTerm;
|
||||||
ViewBag.Category = category;
|
ViewBag.Category = category;
|
||||||
ViewBag.Location = location;
|
ViewBag.Location = location;
|
||||||
|
ViewBag.ColorFamily = colorFamily;
|
||||||
ViewBag.LowStockOnly = lowStockOnly;
|
ViewBag.LowStockOnly = lowStockOnly;
|
||||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||||
|
|||||||
@@ -124,8 +124,9 @@
|
|||||||
@{
|
@{
|
||||||
var lowStockOnly = (bool)(ViewBag.LowStockOnly ?? false);
|
var lowStockOnly = (bool)(ViewBag.LowStockOnly ?? false);
|
||||||
var activeLocation = ViewBag.Location as string;
|
var activeLocation = ViewBag.Location as string;
|
||||||
|
var activeColorFamily = ViewBag.ColorFamily as string;
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm) || !string.IsNullOrEmpty(ViewBag.Category) || !string.IsNullOrEmpty(activeLocation) || lowStockOnly)
|
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm) || !string.IsNullOrEmpty(ViewBag.Category) || !string.IsNullOrEmpty(activeLocation) || !string.IsNullOrEmpty(activeColorFamily) || lowStockOnly)
|
||||||
{
|
{
|
||||||
<div class="alert @(lowStockOnly ? "alert-warning" : "alert-info") alert-permanent d-flex justify-content-between align-items-center flex-wrap gap-2">
|
<div class="alert @(lowStockOnly ? "alert-warning" : "alert-info") alert-permanent d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -149,6 +150,10 @@
|
|||||||
{
|
{
|
||||||
<span> in bin "<strong>@activeLocation</strong>"</span>
|
<span> in bin "<strong>@activeLocation</strong>"</span>
|
||||||
}
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(activeColorFamily))
|
||||||
|
{
|
||||||
|
<span> in color family "<strong>@activeColorFamily</strong>"</span>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
@@ -182,6 +187,16 @@
|
|||||||
<option value="@cat" selected="@(cat == ViewBag.Category)">@cat</option>
|
<option value="@cat" selected="@(cat == ViewBag.Category)">@cat</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
@if (((IEnumerable<string>)ViewBag.ColorFamilies).Any())
|
||||||
|
{
|
||||||
|
<select name="colorFamily" class="form-select" style="max-width: 160px; min-width: 120px;" onchange="this.form.submit()">
|
||||||
|
<option value="">All Colors</option>
|
||||||
|
@foreach (var family in ViewBag.ColorFamilies)
|
||||||
|
{
|
||||||
|
<option value="@family" selected="@(family == activeColorFamily)">@family</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
@if (((IEnumerable<string?>)ViewBag.Locations).Any())
|
@if (((IEnumerable<string?>)ViewBag.Locations).Any())
|
||||||
{
|
{
|
||||||
<select name="location" class="form-select" style="max-width: 180px; min-width: 130px;" onchange="this.form.submit()">
|
<select name="location" class="form-select" style="max-width: 180px; min-width: 130px;" onchange="this.form.submit()">
|
||||||
@@ -215,7 +230,7 @@
|
|||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (!Model.Items.Any())
|
@if (!Model.Items.Any())
|
||||||
{
|
{
|
||||||
var isInventoryFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string) || !string.IsNullOrEmpty(ViewBag.Category as string) || lowStockOnly;
|
var isInventoryFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string) || !string.IsNullOrEmpty(ViewBag.Category as string) || !string.IsNullOrEmpty(activeColorFamily) || lowStockOnly;
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||||
<h5 class="mt-3 text-muted">No inventory items found</h5>
|
<h5 class="mt-3 text-muted">No inventory items found</h5>
|
||||||
|
|||||||
Reference in New Issue
Block a user