Add platform powder catalog, catalog-first lookup, and label scanner

- Platform PowderCatalogItem table (IPlainRepository, no tenant filter) with
  full spec fields: cure temp/time, finish, color families, clear coat flag,
  coverage sq ft/lb, transfer efficiency, IsUserContributed
- Two EF migrations: AddPowderCatalogItem + AddPowderCatalogSpecFields
- PowderCatalogController (SuperAdminOnly): import from Prismatic JSON scrape,
  Lookup AJAX endpoint (catalog-first, ranked by SKU exact match), stats view
  with Tenant Contributed card
- Unified smart Lookup button on inventory Create/Edit: catalog hit fills all
  fields via catalogSnapshot pattern; AI augments cure/finish data from product
  URL if subscription enabled; catalog miss falls through to AI lookup
- In-browser label scanner (_LabelScanModal): getUserMedia live camera feed,
  jsQR auto-detects QR codes in rAF loop; "Scan Label Text" fallback sends
  captured frame to Claude vision via /Inventory/ScanLabel
- ScanLabel endpoint handles both QR URL path (LookupByUrlAsync) and vision
  path (ScanLabelAsync); auto-inserts unrecognized products as
  IsUserContributed=true; returns wasInCatalog/addedToCatalog flags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 16:36:25 -04:00
parent 90f333c8f3
commit 1fc79b77fe
25 changed files with 21279 additions and 23 deletions
@@ -0,0 +1,158 @@
@model PowderCoating.Application.DTOs.Inventory.PowderCatalogStatsDto
@{
ViewData["Title"] = "Powder Catalog";
ViewData["PageIcon"] = "bi-palette2";
Layout = "_Layout";
}
<div class="container-fluid">
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show mb-3" role="alert">
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent alert-dismissible fade show mb-3" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<!-- Stats cards -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-primary bg-opacity-10">
<i class="bi bi-collection fs-4 text-primary"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.TotalProducts.ToString("N0")</div>
<div class="text-muted small">Total Products</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-success bg-opacity-10">
<i class="bi bi-check-circle fs-4 text-success"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.ActiveProducts.ToString("N0")</div>
<div class="text-muted small">Active</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-warning bg-opacity-10">
<i class="bi bi-slash-circle fs-4 text-warning"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.DiscontinuedProducts.ToString("N0")</div>
<div class="text-muted small">Discontinued</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-info bg-opacity-10">
<i class="bi bi-building fs-4 text-info"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.VendorCount</div>
<div class="text-muted small">
@(Model.VendorCount == 1 ? "Vendor" : "Vendors")
@if (Model.LastImportedAt.HasValue)
{
<br /><span class="text-muted" style="font-size:.75rem;">Last sync @Model.LastImportedAt.Value.ToString("MMM d, yyyy")</span>
}
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-purple bg-opacity-10" style="background:rgba(111,66,193,.1)">
<i class="bi bi-qr-code-scan fs-4" style="color:#6f42c1;"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.UserContributedProducts.ToString("N0")</div>
<div class="text-muted small">Tenant Contributed</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<!-- Import card -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent border-bottom">
<h5 class="mb-0"><i class="bi bi-cloud-upload me-2 text-primary"></i>Import Catalog Data</h5>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Upload a Prismatic Powders scrape JSON file (the <code>prismatic_powders.json</code> format with
a top-level <code>results</code> array). Existing SKUs are updated in-place; new ones are inserted.
Discontinued products remain in the catalog flagged as <code>IsDiscontinued</code>.
</p>
<form asp-action="Import" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label fw-medium">Vendor Name</label>
<input type="text" name="vendorName" value="Prismatic Powders" class="form-control" required />
<div class="form-text">Must match exactly — used as the upsert key alongside SKU.</div>
</div>
<div class="mb-3">
<label class="form-label fw-medium">JSON File <span class="text-danger">*</span></label>
<input type="file" name="file" accept=".json" class="form-control" required />
<div class="form-text">Max 50 MB. Must be the scraped format with <code>results[]</code> array.</div>
</div>
<button type="submit" class="btn btn-primary" id="btn-import">
<i class="bi bi-upload me-2"></i>Import
</button>
</form>
</div>
</div>
</div>
<!-- Info / how it works card -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent border-bottom">
<h5 class="mb-0"><i class="bi bi-info-circle me-2 text-primary"></i>How It Works</h5>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0" style="line-height:2;">
<li><i class="bi bi-check2 text-success me-2"></i><strong>Platform-level:</strong> One shared catalog, no per-tenant copies.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Catalog-first lookup:</strong> When a tenant adds inventory, the form searches here before calling the AI API.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Auto-fill:</strong> Selecting a result fills color name, manufacturer, part number, unit cost, SDS/TDS links, and product image.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Discontinued:</strong> Flagged <code>IsDiscontinued = true</code> — never hidden, always available for historical lookups.</li>
<li><i class="bi bi-clock text-muted me-2"></i><strong>Phase 2:</strong> Monthly price sync + push to tenant inventory items.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
document.querySelector('form').addEventListener('submit', function () {
var btn = document.getElementById('btn-import');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Importing…';
});
</script>