From dfb1d34af39cf6a9d77bb61005e3c317f41a3f79 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Fri, 22 May 2026 15:19:11 -0400 Subject: [PATCH] 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 --- .../DTOs/Inventory/InventoryDtos.cs | 1 + .../Services/InventoryAiLookupService.cs | 9 + .../Controllers/InventoryController.cs | 201 ++++++++++++++---- .../Helpers/HelpKnowledgeBase.cs | 20 +- src/PowderCoating.Web/Program.cs | 55 +++++ .../Views/Help/Inventory.cshtml | 44 ++++ .../Views/Inventory/Index.cshtml | 63 +++++- .../Views/Inventory/PrintBin.cshtml | 98 +++++++++ 8 files changed, 443 insertions(+), 48 deletions(-) create mode 100644 src/PowderCoating.Web/Views/Inventory/PrintBin.cshtml diff --git a/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs b/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs index 8ee4760..61edd7d 100644 --- a/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs +++ b/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs @@ -68,6 +68,7 @@ public class InventoryListDto public string? CategoryName { get; set; } public string Category { get; set; } = string.Empty; // Legacy field public string? ColorName { get; set; } + public string? Location { get; set; } public decimal QuantityOnHand { get; set; } public string UnitOfMeasure { get; set; } = "lbs"; public decimal ReorderPoint { get; set; } diff --git a/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs b/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs index ed21de1..39aaaed 100644 --- a/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs +++ b/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs @@ -1209,6 +1209,15 @@ Rules: sb.AppendLine("Page content:"); sb.AppendLine(pageContent); } + else if (!string.IsNullOrWhiteSpace(fetchUrl)) + { + // Page content unavailable (fetch failed or blocked) — still surface the URL so Claude + // can use its training knowledge of the manufacturer URL structure (e.g. Prismatic SKU + // in the path) to infer product identity rather than returning all-null fields. + sb.AppendLine(); + sb.AppendLine($"Product URL (page content could not be fetched): {fetchUrl}"); + sb.AppendLine("Use your training knowledge of this manufacturer and the URL to fill in as many fields as possible."); + } return sb.ToString(); } diff --git a/src/PowderCoating.Web/Controllers/InventoryController.cs b/src/PowderCoating.Web/Controllers/InventoryController.cs index 694a917..3ae5d49 100644 --- a/src/PowderCoating.Web/Controllers/InventoryController.cs +++ b/src/PowderCoating.Web/Controllers/InventoryController.cs @@ -64,6 +64,7 @@ public class InventoryController : Controller public async Task 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>? 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, IOrderedQueryable> orderBy = gridRequest.SortColumn switch @@ -159,19 +174,21 @@ public class InventoryController : Controller var pagedResult = PagedResult.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 } } + /// + /// 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. + /// + public async Task 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>(items.OrderBy(i => i.Name).ToList()); + ViewBag.Location = loc; + ViewBag.PrintedAt = DateTime.Now; + return View(dtos); + } + /// /// 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; } + /// + /// 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. + /// + private static (string? manufacturer, string? colorName, string? partNumber) TryParseManufacturerUrl( + string url, IEnumerable 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(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); + } + /// /// 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 diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index ca0ef10..143a4ab 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -301,7 +301,18 @@ public static class HelpKnowledgeBase **Time Entries:** Track labor time on a job from the Details page. - **Rework:** If a job needs to be redone, a rework record can be created from the Details page. Tracks rework type, reason, and resolution. + **Rework / Redo:** Rework and redo mean the same thing in this system. If a finished part fails QA or a customer brings it back damaged, open the original job's Details page and use the **Rework Log** section to record it. + + Each rework entry captures: the type (internal defect, customer damage, warranty), the reason (adhesion failure, color mismatch, runs/sags, insufficient coverage, etc.), a defect description, who discovered it, and pricing responsibility. + + **Pricing responsibility options:** + - **Shop Fault — no charge:** All rework job item prices are set to $0. Use this when the defect is the shop's fault. + - **Customer responsible — reduced rate:** Item prices are copied from the original job. Edit them down after the rework job is created. + - **Customer responsible — full price:** Item prices are copied from the original job as-is. + + **Creating a rework job:** Toggle "Parts are back — create a Rework Job" in the log form. Select which items need to be redone and choose the pricing responsibility. The system creates a new job with a sub-number (e.g., JOB-2605-0001-R1), copies the selected items with their coats and prep services, and auto-records intake (parts are already on hand). The rework job description shows the defect type, reason, and pricing at the top of the job where it is easy to see. + + **Rework job completion:** When the rework job reaches a terminal status (Completed, Delivered, etc.), the linked rework record on the original job is automatically marked as **Resolved**. If the rework job is Cancelled instead, the record is marked **Written Off**. No manual follow-up on the original job is needed. **Job Templates:** [/JobTemplates](/JobTemplates) — Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items. @@ -462,6 +473,13 @@ public static class HelpKnowledgeBase - Every scan log is recorded as a JobUsage or Adjustment InventoryTransaction and immediately reduces QuantityOnHand; visible in Inventory Activity ledger - First-time scan on a new device requires login; browser caches the session after that + **Location / Bin filtering and printing:** + - Every inventory item has an optional **Location** field (e.g. "Shelf A", "Bin 3") set on the Create/Edit form. + - Once any item has a location, a **Location dropdown** appears in the Inventory list filter bar (next to Category). Selecting a location filters the list to only items stored there. Can be combined with keyword search and category filter simultaneously. + - Each location badge in the table is a clickable link that instantly filters to that bin. + - With a location filter active, a **Print Bin** button appears in the filter banner. Clicking it opens a printer-ready page (new tab) at /Inventory/PrintBin?location=... listing all items in that bin with line number, name, color, and SKU. No site chrome — prints cleanly on a standard sheet. + - The Location dropdown only appears when at least one item has a location value set. If a user doesn't see it, they need to fill in the Location field on at least one inventory item. + **Catalog Lookup & Label Scanner (when adding/editing inventory items):** - When creating or editing an inventory item, click the **Lookup** button next to the SKU/Part Number field to search a built-in platform catalog of thousands of Prismatic Powders and other manufacturer SKUs. Select a match to auto-fill name, manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specs. - The catalog only shows products not already in the company's inventory (prevents duplicates). When editing, the item's own catalog entry is always shown. diff --git a/src/PowderCoating.Web/Program.cs b/src/PowderCoating.Web/Program.cs index 51f2d8a..8ff44c1 100644 --- a/src/PowderCoating.Web/Program.cs +++ b/src/PowderCoating.Web/Program.cs @@ -180,6 +180,18 @@ builder.Services.AddIdentity(options => .AddDefaultUI() .AddClaimsPrincipalFactory(); +// Configure the auth cookie to survive mobile browser suspensions (iOS Safari clears session +// cookies when it suspends a tab). Max-Age on the cookie itself makes it persistent regardless +// of whether the user checked "Remember me". SlidingExpiration renews the window on each request. +builder.Services.ConfigureApplicationCookie(options => +{ + options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.SlidingExpiration = true; + options.Cookie.MaxAge = TimeSpan.FromDays(30); + options.Cookie.IsEssential = true; + options.Cookie.SameSite = SameSiteMode.Lax; +}); + // Register HttpContextAccessor for multi-tenancy builder.Services.AddHttpContextAccessor(); @@ -530,6 +542,49 @@ builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + // Return a proper HTML body on 429 so mobile browsers (especially iOS Safari) don't try to + // download the empty response as a file. Without this, Safari shows "Login / data Zero KB". + options.OnRejected = async (context, ct) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + + var isAjax = context.HttpContext.Request.Headers.XRequestedWith == "XMLHttpRequest" + || (context.HttpContext.Request.Headers.Accept.ToString().Contains("application/json")); + + if (isAjax) + { + context.HttpContext.Response.ContentType = "application/json; charset=utf-8"; + await context.HttpContext.Response.WriteAsync( + """{"success":false,"error":"Too many requests. Please wait a moment and try again."}""", ct); + } + else + { + context.HttpContext.Response.ContentType = "text/html; charset=utf-8"; + await context.HttpContext.Response.WriteAsync(""" + + + + + + Too Many Requests — Powder Coating Logix + + + +
+

Too Many Requests

+

You've made too many login attempts in a short period. Please wait a minute and try again.

+ Back to Login +
+ + + """, ct); + } + }; + // login / password-reset — 10 per minute per IP options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx => RateLimitPartition.GetSlidingWindowLimiter( diff --git a/src/PowderCoating.Web/Views/Help/Inventory.cshtml b/src/PowderCoating.Web/Views/Help/Inventory.cshtml index aba9e46..25d32a6 100644 --- a/src/PowderCoating.Web/Views/Help/Inventory.cshtml +++ b/src/PowderCoating.Web/Views/Help/Inventory.cshtml @@ -73,6 +73,49 @@ +
+

+ Filtering by Location & Printing a Bin List +

+

+ Every inventory item has an optional Location field (for example "Shelf A", + "Bin 3", or "Back Wall") that you can set when creating or editing an item. Once locations + are filled in, the Inventory list gives you two shortcuts for physical counts and + stocktaking: +

+ +

Filtering by location

+

+ When at least one item has a location set, a Location dropdown appears in + the filter bar at the top of the Inventory list, next to the Category filter. Select a + location from the dropdown to instantly narrow the list to only the items stored there. + You can combine the location filter with a keyword search or category filter at the same time. +

+

+ Each location badge shown in the Location column of the table is also a direct link — + clicking it immediately filters to that bin without using the dropdown. +

+ +

Printing a bin list

+

+ With a location filter active, a Print Bin button appears next to the + Clear Filters button. Click it to open a printer-ready page (in a new tab) listing every + item in that bin with its item number, name, color, and SKU. Click Print + on that page or use your browser’s print shortcut to send it to a printer or save + as PDF. The page has no site chrome — just the table — so it prints cleanly on a + standard sheet. +

+ + +
+

Catalog Lookup & Label Scanner @@ -533,6 +576,7 @@