Add inventory bin filter, print bin, mobile login fixes, and QR scan fix

- Inventory: location filter dropdown + Print Bin page (line #, name, color, SKU)
- Fix: Prismatic Powders QR scan now extracts manufacturer/SKU/color from URL path
  and uses full LookupAsync pipeline instead of relying on page fetch alone
- Fix: iOS Safari 'Login / data Zero KB' download -- add OnRejected HTML response to rate limiter
- Fix: mobile session logout -- ConfigureApplicationCookie with 30-day MaxAge persistent cookie
- Help: new 'Location Filtering & Bin Print' section in Inventory help article
- Help: HelpKnowledgeBase updated with bin filter and print bin details

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 15:19:11 -04:00
parent 8c86eba4f2
commit dfb1d34af3
8 changed files with 443 additions and 48 deletions
@@ -64,6 +64,7 @@ public class InventoryController : Controller
public async Task<IActionResult> Index(
string? searchTerm,
string? category,
string? location,
string? sortColumn,
string sortDirection = "asc",
bool lowStockOnly = false,
@@ -87,50 +88,64 @@ public class InventoryController : Controller
};
gridRequest.Validate();
// Build search and category filter
// Build filter — compose search, category, location, and low-stock predicates
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
if (lowStockOnly && !string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
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.SKU.ToLower().Contains(search)
|| i.Name.ToLower().Contains(search)
&& (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 (!string.IsNullOrWhiteSpace(searchTerm) && !string.IsNullOrWhiteSpace(category))
{
// Both search and category filter
var search = searchTerm.ToLower();
var cat = category;
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 (hasSearch && hasCategory && 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.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 (!string.IsNullOrWhiteSpace(searchTerm))
{
// Search only
var search = searchTerm.ToLower();
filter = i => i.SKU.ToLower().Contains(search)
|| i.Name.ToLower().Contains(search)
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 (!string.IsNullOrWhiteSpace(category))
{
// Category filter only
var cat = category;
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
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
@@ -159,19 +174,21 @@ public class InventoryController : Controller
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
// Load all items once to compute sidebar stats and category list in memory
// Load all items once to compute sidebar stats and dropdown option lists in memory
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
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.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;
// Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm;
ViewBag.Category = category;
ViewBag.SearchTerm = searchTerm;
ViewBag.Category = category;
ViewBag.Location = location;
ViewBag.LowStockOnly = lowStockOnly;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
@@ -184,6 +201,26 @@ public class InventoryController : Controller
}
}
/// <summary>
/// Returns a print-optimised list of all active inventory items in a given bin/location.
/// Renders without the site chrome (no layout) so the browser print dialog produces a
/// clean page. Items are sorted by name within the bin.
/// </summary>
public async Task<IActionResult> PrintBin(string location)
{
if (string.IsNullOrWhiteSpace(location))
return RedirectToAction(nameof(Index));
var loc = location.Trim();
var items = await _unitOfWork.InventoryItems.FindAsync(
i => i.Location != null && i.Location.ToLower() == loc.ToLower());
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
ViewBag.Location = loc;
ViewBag.PrintedAt = DateTime.Now;
return View(dtos);
}
/// <summary>
/// Renders the inventory item detail page. The primary vendor name is looked up
/// separately because the repository does not eager-load Vendor by default, avoiding
@@ -897,10 +934,28 @@ public class InventoryController : Controller
if (!string.IsNullOrWhiteSpace(qrUrl))
{
// QR path: fetch the product page; LookupByUrlAsync now maps all identity + spec fields
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
// QR path: try to extract product identity from the URL using manufacturer patterns.
// Prismatic Powders URLs embed both the SKU and color slug in the path
// (e.g. /shop/powder-coating-colors/PMB-6906/fire-red), so we can feed the full
// LookupAsync pipeline (Serper + direct URL + Claude) with real context instead of
// relying solely on a page fetch that may fail in production.
var activePatterns = (await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true))
.Where(p => p.IsActive).ToList();
var (urlMfr, urlColor, urlPart) = TryParseManufacturerUrl(qrUrl, activePatterns);
if (!string.IsNullOrWhiteSpace(urlMfr))
{
aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart);
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
}
else
{
// No pattern match — fall back to URL-based lookup
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
}
}
else if (!string.IsNullOrWhiteSpace(imageBase64))
{
@@ -1272,6 +1327,72 @@ public class InventoryController : Controller
return transferEfficiency ?? DefaultTransferEfficiency;
}
/// <summary>
/// Reverse-parses a scanned QR URL against known manufacturer URL templates to extract
/// product identity (manufacturer name, color name, part number). For example, a Prismatic
/// Powders URL like /shop/powder-coating-colors/PMB-6906/fire-red yields manufacturer
/// "Prismatic Powders", partNumber "PMB-6906", and colorName "Fire Red". Returns nulls
/// when no active pattern matches the URL domain.
/// </summary>
private static (string? manufacturer, string? colorName, string? partNumber) TryParseManufacturerUrl(
string url, IEnumerable<Core.Entities.ManufacturerLookupPattern> patterns)
{
Uri? parsed;
try { parsed = new Uri(url); } catch { return (null, null, null); }
var host = parsed.Host.Replace("www.", "", StringComparison.OrdinalIgnoreCase);
foreach (var pattern in patterns.Where(p => !string.IsNullOrEmpty(p.Domain) && !string.IsNullOrEmpty(p.ProductUrlTemplate)))
{
if (!host.Equals(pattern.Domain, StringComparison.OrdinalIgnoreCase)) continue;
var template = pattern.ProductUrlTemplate!;
var placeholderKeys = new[] { "{partNumber}", "{slug}", "{colorName}", "{colorCode}" };
// Find the first placeholder to split off the static prefix
var firstIdx = placeholderKeys
.Select(ph => template.IndexOf(ph, StringComparison.OrdinalIgnoreCase))
.Where(i => i >= 0)
.DefaultIfEmpty(-1)
.Min();
if (firstIdx < 0) continue;
var staticPrefix = template[..firstIdx].TrimEnd('/');
var fullUrl = url.TrimEnd('/');
if (!fullUrl.StartsWith(staticPrefix, StringComparison.OrdinalIgnoreCase)) continue;
var templateSegments = template[firstIdx..].Split('/', StringSplitOptions.RemoveEmptyEntries);
var urlSegments = fullUrl[staticPrefix.Length..].Split('/', StringSplitOptions.RemoveEmptyEntries);
var extracted = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
bool matched = true;
for (int i = 0; i < templateSegments.Length && i < urlSegments.Length; i++)
{
var seg = templateSegments[i];
if (seg.StartsWith("{") && seg.EndsWith("}"))
extracted[seg[1..^1]] = urlSegments[i];
else if (!seg.Equals(urlSegments[i], StringComparison.OrdinalIgnoreCase))
{ matched = false; break; }
}
if (!matched) continue;
extracted.TryGetValue("partNumber", out var partNumber);
var slug = extracted.TryGetValue("slug", out var s) ? s
: extracted.TryGetValue("colorName", out var cn) ? cn
: extracted.TryGetValue("colorCode", out var cc) ? cc : null;
// Convert URL slug to display name: "fire-red" → "Fire Red"
string? colorName = slug == null ? null
: string.Join(" ", slug.Split(new[] { '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.Select(w => w.Length > 0 ? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant() : w));
return (pattern.ManufacturerName, colorName, partNumber);
}
return (null, null, null);
}
/// <summary>
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
/// inventory item names on create and edit so the list view is consistently formatted