Compare commits

..

4 Commits

Author SHA1 Message Date
spouliot 04d16109ae Simplify location display on inventory QR label
Plain text 'Location: <value>' in larger bold font instead of
pill badge with map pin icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:10 -04:00
spouliot f0f3717681 Fix three bugs: vendor duplicate check, page size dropdown, label location
- Vendor Create: reject duplicate company names (case-insensitive) before
  saving; works for both the standalone form and the inline quick-add modal
- _Pagination: define changePageSize() JS function (was called but never
  existed, breaking page size dropdown on every paginated list)
- Inventory Label: show bin/location on printed QR code labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:07 -04:00
spouliot e23b006139 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>
2026-05-24 18:12:05 -04:00
spouliot 0f35946973 Fix dark mode: main settings nav tab buttons showing white UA background
The #settingsTabs <button> elements had no explicit background-color,
letting browser UA button styling (white) bleed through in dark mode.
Added transparent overrides so the dark body background shows instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:02 -04:00
6 changed files with 106 additions and 62 deletions
@@ -56,15 +56,16 @@ public class InventoryController : Controller
/// <summary>
/// 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
/// to QuantityOnHand ascending so the most depleted items surface immediately. Stats
/// (total value, active count, low-stock count) are computed directly on the DbSet
/// using aggregate SQL to avoid loading all rows into memory.
/// color family filter, and a low-stock quick-filter. When lowStockOnly is active the
/// default sort switches to QuantityOnHand ascending so the most depleted items surface
/// immediately. Stats (total value, active count, low-stock count) are computed directly
/// on the DbSet using aggregate SQL to avoid loading all rows into memory.
/// </summary>
public async Task<IActionResult> Index(
string? searchTerm,
string? category,
string? location,
string? colorFamily,
string? sortColumn,
string sortDirection = "asc",
bool lowStockOnly = false,
@@ -88,64 +89,35 @@ public class InventoryController : Controller
};
gridRequest.Validate();
// Build filter — compose search, category, location, and low-stock predicates
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
var hasCategory = !string.IsNullOrWhiteSpace(category);
var hasLocation = !string.IsNullOrWhiteSpace(location);
var hasColorFamily = !string.IsNullOrWhiteSpace(colorFamily);
var search = searchTerm?.ToLower() ?? "";
var cat = category ?? "";
var loc = location ?? "";
var colorFam = colorFamily ?? "";
// Single composable predicate — EF Core evaluates the captured booleans as constants
// so inactive conditions fold to true and are omitted from the generated SQL WHERE clause.
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
var hasCategory = !string.IsNullOrWhiteSpace(category);
var hasLocation = !string.IsNullOrWhiteSpace(location);
var search = searchTerm?.ToLower() ?? "";
var cat = category ?? "";
var loc = location ?? "";
if (lowStockOnly && hasSearch && hasLocation)
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
&& (i.Location != null && i.Location.ToLower() == loc.ToLower())
&& (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 && hasSearch)
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
&& (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)
if (hasSearch || hasCategory || hasLocation || hasColorFamily || lowStockOnly)
{
filter = i =>
(!lowStockOnly || (i.IsActive && i.QuantityOnHand <= i.ReorderPoint)) &&
(!hasSearch || (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.Category.ToLower() == cat.ToLower()
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
else if (hasSearch && hasCategory)
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.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();
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))) &&
(!hasCategory || i.Category.ToLower() == cat.ToLower()) &&
(!hasLocation || (i.Location != null && i.Location.ToLower() == loc.ToLower())) &&
(!hasColorFamily || (i.ColorFamilies != null && (
i.ColorFamilies == colorFam ||
i.ColorFamilies.StartsWith(colorFam + ",") ||
i.ColorFamilies.EndsWith("," + colorFam) ||
i.ColorFamilies.Contains("," + colorFam + ","))));
}
// Build orderBy function
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();
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.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.StatsActiveCount = allItems.Count(i => i.IsActive);
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
@@ -187,6 +167,7 @@ public class InventoryController : Controller
ViewBag.SearchTerm = searchTerm;
ViewBag.Category = category;
ViewBag.Location = location;
ViewBag.ColorFamily = colorFamily;
ViewBag.LowStockOnly = lowStockOnly;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
@@ -215,8 +215,23 @@ public class VendorsController : Controller
try
{
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser!.CompanyId;
var duplicate = await _unitOfWork.Vendors.FirstOrDefaultAsync(
v => v.CompanyId == companyId && v.CompanyName.ToLower() == dto.CompanyName.ToLower());
if (duplicate != null)
{
var msg = $"A vendor named '{dto.CompanyName}' already exists.";
if (inline)
return Json(new { success = false, errors = new[] { msg } });
ModelState.AddModelError(nameof(dto.CompanyName), msg);
await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto);
}
var vendor = _mapper.Map<Vendor>(dto);
vendor.CompanyId = currentUser!.CompanyId;
vendor.CompanyId = companyId;
if (dto.CategoryIds.Any())
{
@@ -124,8 +124,9 @@
@{
var lowStockOnly = (bool)(ViewBag.LowStockOnly ?? false);
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>
@@ -149,6 +150,10 @@
{
<span> in bin "<strong>@activeLocation</strong>"</span>
}
@if (!string.IsNullOrEmpty(activeColorFamily))
{
<span> in color family "<strong>@activeColorFamily</strong>"</span>
}
}
</div>
<div class="d-flex gap-2 flex-wrap">
@@ -182,6 +187,16 @@
<option value="@cat" selected="@(cat == ViewBag.Category)">@cat</option>
}
</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())
{
<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">
@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">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No inventory items found</h5>
@@ -95,6 +95,12 @@
color: #333;
}
.label-location {
font-size: 14px;
font-weight: 700;
color: #111;
}
.label-scan-hint {
font-size: 9px;
color: #888;
@@ -158,6 +164,11 @@
<div class="label-sku" style="color:#777">@Model.Manufacturer</div>
}
@if (!string.IsNullOrEmpty(Model.Location))
{
<div class="label-location">Location: @Model.Location</div>
}
<div class="label-scan-hint">
Scan to log usage &bull; Powder Coating Logix
</div>
@@ -83,4 +83,12 @@
</nav>
</div>
</div>
<script>
function changePageSize(size) {
var url = new URL(window.location.href);
url.searchParams.set('pageSize', size);
url.searchParams.set('pageNumber', '1');
window.location.href = url.toString();
}
</script>
}
@@ -88,6 +88,20 @@
border-bottom: 3px solid var(--bs-primary);
}
/* Dark mode fix for main settings tabs: UA button styling bleeds through
without an explicit background-color, producing white buttons with faint text. */
[data-bs-theme="dark"] #settingsTabs .nav-link {
background-color: transparent;
}
[data-bs-theme="dark"] #settingsTabs .nav-link:hover:not(.active) {
background-color: var(--bs-tertiary-bg);
}
[data-bs-theme="dark"] #settingsTabs .nav-link.active {
background-color: var(--bs-body-bg);
}
/* ── PDF Templates inner tabs (card-header-tabs) ────────────────────────── */
#pdfTemplateTabs .nav-link {
color: var(--bs-secondary-color);