Compare commits

..

6 Commits

Author SHA1 Message Date
spouliot b7ab85ff92 Merge dev into master: QR scan URL fixes and http scheme failsafe 2026-05-22 17:41:01 -04:00
spouliot 15b070398b Change URL scheme fallback from https to http
Manufacturer product pages are often not on secure connections; http:// is the
safer default to avoid connection failures on non-SSL sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:40:14 -04:00
spouliot 14f220347b Add scheme failsafe to all inventory URL link buttons
If a stored URL is missing http:// or https:// the browser treats it as relative
and appends it to the app URL. Guard in three places:

- inventory-catalog-lookup.js syncLinkButton: ensureAbsoluteUrl() prepends https://
- inventory-label-scan.js syncLink: same guard for scan-filled URL fields
- Details.cshtml SafeUrl() Razor helper on SpecPageUrl, SdsUrl, TdsUrl links

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:38:09 -04:00
spouliot baec0b33f7 Fix QR scan stripping scheme from product URL
LookupAsync builds SpecPageUrl from the ProductUrlTemplate via TryBuildDirectUrl.
If the template is stored without a scheme the link is scheme-less and browsers
treat it as relative, appending it to the app URL.

The scanned QR URL is always fully-qualified and always the correct product page
(it came from the manufacturer's bag), so use it unconditionally as SpecPageUrl
on the pattern-matched QR path instead of only when SpecPageUrl was null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:26:56 -04:00
spouliot ce7b00b68c Merge dev into master: inventory bin filter, print bin, mobile login fixes, QR scan fix 2026-05-22 15:22:38 -04:00
spouliot dfb1d34af3 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>
2026-05-22 15:19:11 -04:00
11 changed files with 466 additions and 53 deletions
@@ -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; }
@@ -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();
}
@@ -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,31 @@ 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);
// The scanned QR URL is always the authoritative product page link — it came
// directly from the manufacturer's bag and is always fully-qualified. Overwrite
// whatever LookupAsync returned (which may be a scheme-less path from the template).
if (aiResult.Success)
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 +1330,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
@@ -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.
+55
View File
@@ -180,6 +180,18 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
.AddDefaultUI()
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>();
// 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("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Too Many Requests &mdash; Powder Coating Logix</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}
.card{background:#fff;border-radius:8px;padding:2rem;max-width:420px;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.1)}
h2{margin-top:0;color:#333}p{color:#666}a{color:#0d6efd}
</style>
</head>
<body>
<div class="card">
<h2>Too Many Requests</h2>
<p>You've made too many login attempts in a short period. Please wait a minute and try again.</p>
<a href="/Identity/Account/Login">Back to Login</a>
</div>
</body>
</html>
""", ct);
}
};
// login / password-reset — 10 per minute per IP
options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx =>
RateLimitPartition.GetSlidingWindowLimiter(
@@ -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>
@@ -5,6 +5,11 @@
ViewData["PageIcon"] = "bi-box-seam";
ViewData["PageHelpTitle"] = "Inventory Item";
ViewData["PageHelpContent"] = "Full detail for this inventory item. Stock Information shows current quantity and reorder thresholds &mdash; a Low Stock banner appears when quantity is at or below the Reorder Point. Pricing shows Unit Cost (what you paid), Average Cost (weighted average across purchases), and Total Stock Value. Use the Actions panel to edit, view jobs using this powder, or delete the item.";
string SafeUrl(string? url) =>
string.IsNullOrEmpty(url) ? "#"
: (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
? url : "http://" + url;
}
@section Styles {
@@ -184,7 +189,7 @@
<div class="col-12">
<label class="text-muted small mb-1">Product URL</label>
<p class="mb-0">
<a href="@Model.SpecPageUrl" target="_blank" class="text-decoration-none">
<a href="@SafeUrl(Model.SpecPageUrl)" target="_blank" class="text-decoration-none">
<i class="bi bi-box-arrow-up-right me-1"></i>View on Manufacturer's Web Site
</a>
</p>
@@ -197,13 +202,13 @@
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrEmpty(Model.SdsUrl))
{
<a href="@Model.SdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
<a href="@SafeUrl(Model.SdsUrl)" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-pdf me-1"></i>Safety Data Sheet
</a>
}
@if (!string.IsNullOrEmpty(Model.TdsUrl))
{
<a href="@Model.TdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
<a href="@SafeUrl(Model.TdsUrl)" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-text me-1"></i>Technical Data Sheet
</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>
@@ -413,10 +413,15 @@
// ── Helpers ───────────────────────────────────────────────────────────────
function ensureAbsoluteUrl(url) {
if (!url) return url;
return /^https?:\/\//i.test(url) ? url : 'http://' + url;
}
function syncLinkButton(inputId, linkId, url) {
const link = document.getElementById(linkId);
if (!link) return;
if (url) { link.href = url; link.classList.remove('d-none'); }
if (url) { link.href = ensureAbsoluteUrl(url); link.classList.remove('d-none'); }
else { link.classList.add('d-none'); }
}
@@ -550,10 +550,15 @@
});
}
function ensureAbsoluteUrl(url) {
if (!url) return url;
return /^https?:\/\//i.test(url) ? url : 'http://' + url;
}
function syncLink(inputId, linkId, url) {
const link = document.getElementById(linkId);
if (!link) return;
if (url) { link.href = url; link.classList.remove('d-none'); }
if (url) { link.href = ensureAbsoluteUrl(url); link.classList.remove('d-none'); }
else { link.classList.add('d-none'); }
}