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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user