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
@@ -73,6 +73,49 @@
</div>
</section>
<section id="bin-filter" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-geo-alt text-primary me-2"></i>Filtering by Location &amp; Printing a Bin List
</h2>
<p>
Every inventory item has an optional <strong>Location</strong> 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:
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Filtering by location</h3>
<p>
When at least one item has a location set, a <strong>Location</strong> 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.
</p>
<p>
Each location badge shown in the Location column of the table is also a direct link &mdash;
clicking it immediately filters to that bin without using the dropdown.
</p>
<h3 class="h6 fw-semibold mt-4 mb-2">Printing a bin list</h3>
<p>
With a location filter active, a <strong>Print Bin</strong> 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 <strong>Print</strong>
on that page or use your browser&rsquo;s print shortcut to send it to a printer or save
as PDF. The page has no site chrome &mdash; just the table &mdash; so it prints cleanly on a
standard sheet.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
The Location dropdown only appears once at least one inventory item has a location set.
If you don&rsquo;t see it, open any item, fill in the <strong>Location</strong> field on the
edit form, and save &mdash; the dropdown will appear on your next visit to the Inventory list.
</div>
</div>
</section>
<section id="catalog-lookup" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-search text-primary me-2"></i>Catalog Lookup &amp; Label Scanner
@@ -533,6 +576,7 @@
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#adding-items">Adding Inventory Items</a>
<a class="nav-link py-1 px-3 small text-body" href="#bin-filter">Location Filtering &amp; Bin Print</a>
<a class="nav-link py-1 px-3 small text-body" href="#catalog-lookup">Catalog Lookup &amp; Label Scanner</a>
<a class="nav-link py-1 px-3 small text-body" href="#stock-levels">Stock Levels and Reorder Points</a>
<a class="nav-link py-1 px-3 small text-body" href="#stock-adjustment">Stock Adjustment</a>
@@ -123,10 +123,11 @@
@{
var lowStockOnly = (bool)(ViewBag.LowStockOnly ?? false);
var activeLocation = ViewBag.Location as string;
}
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm) || !string.IsNullOrEmpty(ViewBag.Category) || lowStockOnly)
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm) || !string.IsNullOrEmpty(ViewBag.Category) || !string.IsNullOrEmpty(activeLocation) || lowStockOnly)
{
<div class="alert @(lowStockOnly ? "alert-warning" : "alert-info") alert-permanent d-flex justify-content-between align-items-center">
<div class="alert @(lowStockOnly ? "alert-warning" : "alert-info") alert-permanent d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<i class="bi bi-funnel-fill me-2"></i>
@if (lowStockOnly)
@@ -144,11 +145,24 @@
{
<span> in category "<strong>@ViewBag.Category</strong>"</span>
}
@if (!string.IsNullOrEmpty(activeLocation))
{
<span> in bin "<strong>@activeLocation</strong>"</span>
}
}
</div>
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x me-1"></i>Clear Filters
</a>
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrEmpty(activeLocation))
{
<a href="@Url.Action("PrintBin", new { location = activeLocation })" target="_blank"
class="btn btn-sm btn-outline-primary" title="Print bin list">
<i class="bi bi-printer me-1"></i>Print Bin
</a>
}
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x me-1"></i>Clear Filters
</a>
</div>
</div>
}
@@ -161,14 +175,24 @@
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<select name="category" class="form-select" style="max-width: 250px; min-width: 150px;" onchange="this.form.submit()">
<select name="category" class="form-select" style="max-width: 180px; min-width: 130px;" onchange="this.form.submit()">
<option value="">All Categories</option>
@foreach (var cat in ViewBag.Categories)
{
<option value="@cat" selected="@(cat == ViewBag.Category)">@cat</option>
}
</select>
<div class="input-group" style="max-width: 480px; min-width: 300px;">
@if (((IEnumerable<string?>)ViewBag.Locations).Any())
{
<select name="location" class="form-select" style="max-width: 180px; min-width: 130px;" onchange="this.form.submit()">
<option value="">All Locations</option>
@foreach (var loc in ViewBag.Locations)
{
<option value="@loc" selected="@(loc == activeLocation)">@loc</option>
}
</select>
}
<div class="input-group" style="max-width: 380px; min-width: 260px;">
<span class="input-group-text bg-white border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
@@ -210,6 +234,7 @@
<th sortable="Name" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4">Item Name</th>
<th sortable="Category" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Category</th>
<th sortable="ColorName" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Color</th>
<th>Location</th>
<th>Vendor</th>
<th sortable="QuantityOnHand" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Quantity</th>
<th>Reorder Point</th>
@@ -250,6 +275,21 @@
<span class="text-muted">&mdash;</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(item.Location))
{
<a href="@Url.Action("Index", new { location = item.Location })"
class="badge bg-info bg-opacity-10 text-info text-decoration-none"
onclick="event.stopPropagation();"
title="Filter by this location">
<i class="bi bi-geo-alt me-1"></i>@item.Location
</a>
}
else
{
<span class="text-muted">&mdash;</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(item.PrimaryVendorName))
{
@@ -352,6 +392,15 @@
<span class="mobile-card-value">@item.ColorName</span>
</div>
}
@if (!string.IsNullOrEmpty(item.Location))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Location</span>
<span class="mobile-card-value">
<i class="bi bi-geo-alt me-1 text-info"></i>@item.Location
</span>
</div>
}
@if (!string.IsNullOrEmpty(item.PrimaryVendorName))
{
<div class="mobile-card-row">
@@ -0,0 +1,98 @@
@model IEnumerable<PowderCoating.Application.DTOs.Inventory.InventoryListDto>
@{
Layout = null;
var location = ViewBag.Location as string ?? "";
var printedAt = (DateTime)(ViewBag.PrintedAt ?? DateTime.Now);
var items = Model.ToList();
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bin @location &mdash; Inventory</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; font-size: 11pt; color: #111; background: #fff; padding: 20px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 14px; }
.header h1 { font-size: 18pt; }
.header .meta { font-size: 9pt; color: #555; text-align: right; line-height: 1.6; }
.summary { font-size: 9.5pt; color: #444; margin-bottom: 14px; }
table { width: 100%; border-collapse: collapse; }
thead th { background: #f0f0f0; border-top: 1px solid #bbb; border-bottom: 1px solid #bbb; padding: 6px 8px; text-align: left; font-size: 9pt; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; }
tbody td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; vertical-align: middle; font-size: 10pt; }
tbody tr:last-child td { border-bottom: 2px solid #bbb; }
.qty { text-align: right; font-weight: 600; }
.qty.low { color: #c00; }
.qty.out { color: #888; }
.reorder { text-align: right; color: #555; }
.cost { text-align: right; }
.status-low { font-size: 8pt; color: #c00; font-weight: 600; }
.status-out { font-size: 8pt; color: #888; font-weight: 600; }
.footer { margin-top: 18px; font-size: 8.5pt; color: #888; border-top: 1px solid #ddd; padding-top: 8px; display: flex; justify-content: space-between; }
@@media print {
body { padding: 10px; }
.no-print { display: none !important; }
a { text-decoration: none; color: inherit; }
}
</style>
</head>
<body>
<div class="no-print" style="margin-bottom:16px;">
<button onclick="window.print()" style="padding:6px 16px;font-size:11pt;cursor:pointer;margin-right:8px;">
&#128438; Print
</button>
<a href="javascript:history.back()" style="font-size:10pt;color:#0d6efd;">&larr; Back</a>
</div>
<div class="header">
<div>
<h1>Bin: @location</h1>
<div style="font-size:10pt;color:#555;margin-top:4px;">Inventory count sheet</div>
</div>
<div class="meta">
Powder Coating Logix<br />
Printed: @printedAt.ToString("MMM d, yyyy h:mm tt")<br />
@items.Count item@(items.Count == 1 ? "" : "s")
</div>
</div>
@if (!items.Any())
{
<p style="color:#888;margin-top:20px;">No active inventory items found in this location.</p>
}
else
{
<table>
<thead>
<tr>
<th style="width:5%">#</th>
<th style="width:45%">Item Name</th>
<th style="width:25%">Color</th>
<th style="width:25%">SKU</th>
</tr>
</thead>
<tbody>
@{ var row = 0; }
@foreach (var item in items)
{
row++;
<tr>
<td style="color:#aaa;font-size:9pt;">@row</td>
<td><strong>@item.Name</strong></td>
<td>@(item.ColorName ?? "&mdash;")</td>
<td style="font-family:monospace;font-size:9.5pt;">@item.SKU</td>
</tr>
}
</tbody>
</table>
}
<div class="footer">
<span>Bin: @location</span>
<span>Powder Coating Logix &mdash; @printedAt.ToString("yyyy")</span>
</div>
</body>
</html>