Add platform powder catalog management UI with full CRUD and AI lookup

- PowderCatalogController: Create, Edit, ToggleDiscontinued actions; searchable/filterable/sortable Index with pagination; AiLookup and AiAugmentFromUrl endpoints backed by IInventoryAiLookupService
- New views: Create, Edit, _Form partial (with AI-assisted field population), overhauled Index grid with completeness quality badges and responsive mobile cards
- New ViewModels: PowderCatalogIndexViewModel, PowderCatalogFormViewModel, PowderCatalogListItemViewModel
- AI lookup improvements: SpecificGravity field added to InventoryAiLookupResult; ApplyPowderFallbacks derives CoverageSqFtPerLb from specific gravity when docs omit it; DefaultTransferEfficiency (65%) applied everywhere transfer efficiency is null
- powder-catalog-ai-lookup.js: client-side AI lookup and URL augment wiring for the catalog form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 00:27:44 -04:00
parent 713efbc2b6
commit 11a1b91be1
15 changed files with 8642 additions and 94 deletions
@@ -0,0 +1,42 @@
@model PowderCoating.Web.ViewModels.PowderCatalog.PowderCatalogFormViewModel
@{
ViewData["Title"] = "Add Powder Catalog Item";
ViewData["PageIcon"] = "bi-plus-circle";
}
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm me-3">
<i class="bi bi-arrow-left"></i>
</a>
<div>
<h4 class="mb-0"><i class="bi bi-plus-circle me-2 text-primary"></i>Add Powder Catalog Item</h4>
<small class="text-muted">Create a platform-level powder record for inventory autofill and documentation links.</small>
</div>
</div>
<div class="row justify-content-center">
<div class="col-xl-9">
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<partial name="_Form" model="Model" />
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
@@ -0,0 +1,50 @@
@model PowderCoating.Web.ViewModels.PowderCatalog.PowderCatalogFormViewModel
@{
ViewData["Title"] = "Edit Powder Catalog Item";
ViewData["PageIcon"] = "bi-pencil-square";
}
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm me-3">
<i class="bi bi-arrow-left"></i>
</a>
<div>
<h4 class="mb-0"><i class="bi bi-pencil-square me-2 text-primary"></i>Edit Powder Catalog Item</h4>
<small class="text-muted">@Model.VendorName - @Model.Sku</small>
</div>
</div>
<div class="row justify-content-center">
<div class="col-xl-9">
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="Edit" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken()
<input asp-for="Id" type="hidden" />
<input asp-for="CreatedAt" type="hidden" />
<input asp-for="UpdatedAt" type="hidden" />
<input asp-for="LastSyncedAt" type="hidden" />
@{
ViewData["EnableAiLookup"] = true;
}
<partial name="_Form" model="Model" />
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Changes
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script src="~/js/powder-catalog-ai-lookup.js" asp-append-version="true"></script>
}
@@ -1,12 +1,94 @@
@model PowderCoating.Application.DTOs.Inventory.PowderCatalogStatsDto
@model PowderCoating.Web.ViewModels.PowderCatalog.PowderCatalogIndexViewModel
@{
ViewData["Title"] = "Powder Catalog";
ViewData["PageIcon"] = "bi-palette2";
Layout = "_Layout";
ViewData["PageHelpTitle"] = "Powder Catalog";
ViewData["PageHelpContent"] = "Manage the platform-level powder master list used to auto-fill inventory. Filter for contributed records, missing specs, or discontinued powders, then edit entries directly from here.";
}
<div class="container-fluid">
@functions {
string SortLink(string column)
{
var route = new Dictionary<string, object?>
{
["searchTerm"] = Model.SearchTerm,
["vendorName"] = Model.VendorName,
["status"] = Model.Status,
["source"] = Model.Source,
["completeness"] = Model.Completeness,
["pageNumber"] = 1,
["pageSize"] = Model.Catalog.PageSize,
["sortColumn"] = column,
["sortDirection"] = Model.SortColumn == column && Model.SortDirection == "asc" ? "desc" : "asc"
};
return Url.Action("Index", route) ?? "#";
}
string SortIcon(string column)
{
if (!string.Equals(Model.SortColumn, column, StringComparison.OrdinalIgnoreCase))
return "bi-arrow-down-up";
return Model.SortDirection == "asc" ? "bi-arrow-up" : "bi-arrow-down";
}
}
@section Styles {
<style>
.powder-catalog-summary .card {
border: 0;
box-shadow: 0 .125rem .5rem rgba(15, 23, 42, .08);
}
.powder-catalog-summary .metric-icon {
width: 3rem;
height: 3rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 1rem;
}
.powder-catalog-grid .table th a {
color: inherit;
}
.powder-catalog-grid .powder-name {
min-width: 15rem;
}
.powder-catalog-grid .status-stack {
display: flex;
flex-wrap: wrap;
gap: .35rem;
}
.powder-catalog-grid .quality-stack {
display: flex;
flex-wrap: wrap;
gap: .35rem;
}
.powder-catalog-grid .quality-stack .badge,
.powder-catalog-grid .status-stack .badge {
font-weight: 600;
}
.powder-catalog-grid .table td,
.powder-catalog-grid .table th {
vertical-align: middle;
}
[data-bs-theme="dark"] .powder-catalog-grid .table-light th,
[data-bs-theme="dark"] .powder-catalog-grid .table-light td {
background-color: var(--bs-secondary-bg);
color: var(--bs-body-color);
}
</style>
}
<div class="container-fluid py-3">
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show mb-3" role="alert">
@@ -22,75 +104,93 @@
</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="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4">
<div>
<h4 class="mb-1"><i class="bi bi-palette2 me-2 text-primary"></i>Powder Catalog</h4>
<div class="text-muted small">Platform-level lookup library for inventory autofill, SDS/TDS links, and curing specs.</div>
</div>
<div class="d-flex gap-2">
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Add Powder
</a>
</div>
</div>
<div class="row g-3 mb-4 powder-catalog-summary">
<div class="col-sm-6 col-xl-2">
<div class="card 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 class="metric-icon text-primary" style="background:rgba(13,110,253,.12);">
<i class="bi bi-collection fs-4"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.TotalProducts.ToString("N0")</div>
<div class="text-muted small">Total Products</div>
<div class="fs-4 fw-bold">@Model.Stats.TotalProducts.ToString("N0")</div>
<div class="small text-muted">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="col-sm-6 col-xl-2">
<div class="card 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 class="metric-icon text-success" style="background:rgba(25,135,84,.12);">
<i class="bi bi-check-circle fs-4"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.ActiveProducts.ToString("N0")</div>
<div class="text-muted small">Active</div>
<div class="fs-4 fw-bold">@Model.Stats.ActiveProducts.ToString("N0")</div>
<div class="small text-muted">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="col-sm-6 col-xl-2">
<div class="card 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 class="metric-icon text-warning" style="background:rgba(255,193,7,.18);">
<i class="bi bi-slash-circle fs-4"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.DiscontinuedProducts.ToString("N0")</div>
<div class="text-muted small">Discontinued</div>
<div class="fs-4 fw-bold">@Model.Stats.DiscontinuedProducts.ToString("N0")</div>
<div class="small text-muted">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="col-sm-6 col-xl-2">
<div class="card 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 class="metric-icon text-info" style="background:rgba(13,202,240,.14);">
<i class="bi bi-building fs-4"></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 class="fs-4 fw-bold">@Model.Stats.VendorCount</div>
<div class="small text-muted">Vendors</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="col-sm-6 col-xl-2">
<div class="card 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 class="metric-icon" style="background:rgba(111,66,193,.14); color:#6f42c1;">
<i class="bi bi-qr-code-scan fs-4"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.UserContributedProducts.ToString("N0")</div>
<div class="text-muted small">Tenant Contributed</div>
<div class="fs-4 fw-bold">@Model.Stats.UserContributedProducts.ToString("N0")</div>
<div class="small text-muted">Contributed</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="metric-icon text-secondary" style="background:rgba(108,117,125,.14);">
<i class="bi bi-arrow-repeat fs-4"></i>
</div>
<div>
<div class="fw-bold">@(Model.Stats.LastImportedAt?.ToString("MMM d, yyyy") ?? "Never")</div>
<div class="small text-muted">Last Sync</div>
</div>
</div>
</div>
@@ -98,50 +198,271 @@
</div>
<div class="row g-4">
<!-- Import card -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="col-xl-8">
<div class="card border-0 shadow-sm powder-catalog-grid">
<div class="card-header bg-transparent border-bottom">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<h5 class="mb-1"><i class="bi bi-list-ul me-2 text-primary"></i>Manage Catalog Records</h5>
<div class="small text-muted">Search, filter, and edit the powders your inventory lookup depends on.</div>
</div>
<div class="small text-muted">@Model.Catalog.TotalCount.ToString("N0") filtered result@(Model.Catalog.TotalCount == 1 ? "" : "s")</div>
</div>
</div>
<div class="card-body">
<form method="get" class="row g-2 align-items-end mb-3">
<div class="col-12 col-md-4">
<label class="form-label small mb-1">Search</label>
<input type="text" name="searchTerm" class="form-control form-control-sm" value="@Model.SearchTerm" placeholder="Vendor, SKU, color, finish..." />
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Vendor</label>
<select name="vendorName" class="form-select form-select-sm">
<option value="">All Vendors</option>
@foreach (var vendor in Model.Vendors)
{
<option value="@vendor" selected="@(string.Equals(Model.VendorName, vendor, StringComparison.OrdinalIgnoreCase))">@vendor</option>
}
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="all" selected="@(Model.Status == "all")">All</option>
<option value="active" selected="@(Model.Status == "active")">Active</option>
<option value="discontinued" selected="@(Model.Status == "discontinued")">Discontinued</option>
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Source</label>
<select name="source" class="form-select form-select-sm">
<option value="all" selected="@(Model.Source == "all")">All</option>
<option value="curated" selected="@(Model.Source == "curated")">Curated</option>
<option value="contributed" selected="@(Model.Source == "contributed")">Contributed</option>
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Completeness</label>
<select name="completeness" class="form-select form-select-sm">
<option value="all" selected="@(Model.Completeness == "all")">All</option>
<option value="ready" selected="@(Model.Completeness == "ready")">Ready</option>
<option value="missing-specs" selected="@(Model.Completeness == "missing-specs")">Missing Specs</option>
<option value="missing-docs" selected="@(Model.Completeness == "missing-docs")">Missing Docs</option>
<option value="missing-image" selected="@(Model.Completeness == "missing-image")">Missing Image</option>
</select>
</div>
<input type="hidden" name="sortColumn" value="@Model.SortColumn" />
<input type="hidden" name="sortDirection" value="@Model.SortDirection" />
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-funnel me-1"></i>Apply Filters
</button>
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">Clear</a>
</div>
</form>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th><a href="@SortLink("VendorName")" class="text-decoration-none">Vendor <i class="bi @SortIcon("VendorName")"></i></a></th>
<th><a href="@SortLink("Sku")" class="text-decoration-none">SKU <i class="bi @SortIcon("Sku")"></i></a></th>
<th class="powder-name"><a href="@SortLink("ColorName")" class="text-decoration-none">Powder <i class="bi @SortIcon("ColorName")"></i></a></th>
<th><a href="@SortLink("Finish")" class="text-decoration-none">Finish <i class="bi @SortIcon("Finish")"></i></a></th>
<th><a href="@SortLink("UnitPrice")" class="text-decoration-none">Price <i class="bi @SortIcon("UnitPrice")"></i></a></th>
<th>Status</th>
<th>Quality</th>
<th><a href="@SortLink("LastSyncedAt")" class="text-decoration-none">Synced <i class="bi @SortIcon("LastSyncedAt")"></i></a></th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@if (!Model.Catalog.Items.Any())
{
<tr>
<td colspan="9" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-2 d-block mb-2 opacity-50"></i>
No powder catalog records matched your filters.
</td>
</tr>
}
else
{
@foreach (var item in Model.Catalog.Items)
{
<tr>
<td class="fw-medium">@item.VendorName</td>
<td><code>@item.Sku</code></td>
<td>
<div class="fw-semibold">@item.ColorName</div>
<div class="small text-muted">Updated @(item.UpdatedAt?.ToString("MMM d, yyyy") ?? item.CreatedAt.ToString("MMM d, yyyy"))</div>
</td>
<td>@(string.IsNullOrWhiteSpace(item.Finish) ? "-" : item.Finish)</td>
<td>@(item.UnitPrice > 0 ? item.UnitPrice.ToString("C") : "-")</td>
<td>
<div class="status-stack">
@if (item.IsDiscontinued)
{
<span class="badge bg-warning text-dark">Discontinued</span>
}
else
{
<span class="badge bg-success">Active</span>
}
@if (item.IsUserContributed)
{
<span class="badge bg-info text-dark">Contributed</span>
}
else
{
<span class="badge bg-secondary">Curated</span>
}
</div>
</td>
<td>
<div class="quality-stack">
<span class="badge @(item.HasCoreSpecs ? "bg-success-subtle text-success border" : "bg-danger-subtle text-danger border")">Specs</span>
<span class="badge @(item.HasDocuments ? "bg-success-subtle text-success border" : "bg-danger-subtle text-danger border")">Docs</span>
<span class="badge @(item.HasImage ? "bg-success-subtle text-success border" : "bg-warning-subtle text-warning border")">Image</span>
</div>
</td>
<td class="small text-muted">@(item.LastSyncedAt?.ToString("MMM d, yyyy") ?? "-")</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form asp-action="ToggleDiscontinued" asp-route-id="@item.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-@(item.IsDiscontinued ? "success" : "warning")" title="@(item.IsDiscontinued ? "Reactivate" : "Mark discontinued")">
<i class="bi @(item.IsDiscontinued ? "bi-arrow-counterclockwise" : "bi-slash-circle")"></i>
</button>
</form>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="mobile-card-view mt-3">
@if (!Model.Catalog.Items.Any())
{
<div class="text-center text-muted py-4">No powder catalog records matched your filters.</div>
}
else
{
<div class="mobile-card-list">
@foreach (var item in Model.Catalog.Items)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon @(item.IsDiscontinued ? "bg-warning" : "bg-primary")">
<i class="bi bi-palette2"></i>
</div>
<div class="mobile-card-title">
<h6>@item.ColorName</h6>
<small>@item.VendorName - @item.Sku</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Finish</span>
<span class="mobile-card-value">@(string.IsNullOrWhiteSpace(item.Finish) ? "-" : item.Finish)</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Price</span>
<span class="mobile-card-value">@(item.UnitPrice > 0 ? item.UnitPrice.ToString("C") : "-")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (item.IsDiscontinued)
{
<span class="badge bg-warning text-dark">Discontinued</span>
}
else
{
<span class="badge bg-success">Active</span>
}
@if (item.IsUserContributed)
{
<span class="badge bg-info text-dark ms-1">Contributed</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Quality</span>
<span class="mobile-card-value">@(item.HasCoreSpecs ? "Specs" : "Missing Specs"), @(item.HasDocuments ? "Docs" : "Missing Docs"), @(item.HasImage ? "Image" : "Missing Image")</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Edit
</a>
<form asp-action="ToggleDiscontinued" asp-route-id="@item.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-@(item.IsDiscontinued ? "success" : "warning")">
<i class="bi @(item.IsDiscontinued ? "bi-arrow-counterclockwise" : "bi-slash-circle") me-1"></i>@(item.IsDiscontinued ? "Reactivate" : "Discontinue")
</button>
</form>
</div>
</div>
}
</div>
}
</div>
</div>
@if (Model.Catalog.TotalPages > 1)
{
<div class="card-footer bg-white">
<partial name="_Pagination" model="Model.Catalog" />
</div>
}
</div>
</div>
<div class="col-xl-4">
<div class="card border-0 shadow-sm mb-4">
<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>.
Upload a Prismatic-style scrape JSON file with a top-level <code>results</code> array.
Existing rows are updated by vendor + SKU and new powders are inserted automatically.
</p>
<form asp-action="Import" method="post" enctype="multipart/form-data">
<form asp-action="Import" method="post" enctype="multipart/form-data" id="powder-catalog-import-form">
<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 class="form-text">Used as part of 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 class="form-text">Max 50 MB. Must contain the scraped <code>results[]</code> payload.</div>
</div>
<button type="submit" class="btn btn-primary" id="btn-import">
<i class="bi bi-upload me-2"></i>Import
<button type="submit" class="btn btn-primary w-100" id="btn-import">
<i class="bi bi-upload me-2"></i>Import Catalog
</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 border-0 shadow-sm">
<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>
<h5 class="mb-0"><i class="bi bi-info-circle me-2 text-primary"></i>Management Notes</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 class="list-unstyled mb-0" style="line-height:1.9;">
<li><i class="bi bi-check2 text-success me-2"></i><strong>Contributed powders</strong> are auto-added from tenant lookups when a catalog match does not exist.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Specs matter</strong> because inventory autofill uses finish, cure data, coverage, and transfer efficiency when available.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Documents matter</strong> because the inventory form surfaces product, SDS, and TDS links directly from this catalog.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Discontinued powders stay searchable</strong> so historical inventory and customer references still resolve.</li>
</ul>
</div>
</div>
@@ -149,10 +470,13 @@
</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>
@section Scripts {
<script>
document.getElementById('powder-catalog-import-form')?.addEventListener('submit', function () {
var btn = document.getElementById('btn-import');
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Importing...';
});
</script>
}
@@ -0,0 +1,184 @@
@model PowderCoating.Web.ViewModels.PowderCatalog.PowderCatalogFormViewModel
@{
var enableAiLookup = ViewData["EnableAiLookup"] as bool? == true;
}
@if (enableAiLookup)
{
<div class="card border-0 bg-light-subtle mb-4">
<div class="card-body p-3">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<h6 class="mb-0">
<i class="bi bi-stars me-2 text-primary"></i>AI Lookup
</h6>
<button type="button" class="btn btn-sm btn-primary" id="powder-ai-lookup-btn">
<i class="bi bi-search me-1"></i>Search Missing Info
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="powder-ai-url-btn">
<i class="bi bi-box-arrow-up-right me-1"></i>Use Product URL
</button>
</div>
<div class="small text-muted mb-2">
Search the web for missing specs, cure data, and SDS/TDS links. Existing values are left alone unless the field is blank.
</div>
<div id="ai-lookup-status" class="alert alert-info d-none py-2 small mb-0"></div>
</div>
</div>
}
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3 small"></div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="VendorName" class="form-label fw-medium"></label>
<input asp-for="VendorName" class="form-control" id="field-vendorname" />
<span asp-validation-for="VendorName" class="text-danger small"></span>
</div>
<div class="col-md-3">
<label asp-for="Sku" class="form-label fw-medium"></label>
<input asp-for="Sku" class="form-control" id="field-sku" />
<span asp-validation-for="Sku" class="text-danger small"></span>
</div>
<div class="col-md-3">
<label asp-for="UnitPrice" class="form-label fw-medium"></label>
<input asp-for="UnitPrice" class="form-control" id="field-unitprice" />
<span asp-validation-for="UnitPrice" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="ColorName" class="form-label fw-medium"></label>
<input asp-for="ColorName" class="form-control" id="field-colorname" />
<span asp-validation-for="ColorName" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="Description" class="form-label fw-medium"></label>
<textarea asp-for="Description" class="form-control" id="field-description" rows="3"></textarea>
<span asp-validation-for="Description" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Finish" class="form-label fw-medium"></label>
<input asp-for="Finish" class="form-control" id="field-finish" placeholder="Gloss, Matte, Satin, Metallic..." />
<span asp-validation-for="Finish" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="ColorFamilies" class="form-label fw-medium"></label>
<input asp-for="ColorFamilies" class="form-control" id="field-colorfamilies" placeholder="Blue, Purple, Metallic" />
<span asp-validation-for="ColorFamilies" class="text-danger small"></span>
</div>
<div class="col-md-3">
<label asp-for="CureTemperatureF" class="form-label fw-medium"></label>
<input asp-for="CureTemperatureF" class="form-control" id="field-curetemp" />
<span asp-validation-for="CureTemperatureF" class="text-danger small"></span>
</div>
<div class="col-md-3">
<label asp-for="CureTimeMinutes" class="form-label fw-medium"></label>
<input asp-for="CureTimeMinutes" class="form-control" id="field-curetime" />
<span asp-validation-for="CureTimeMinutes" class="text-danger small"></span>
</div>
<div class="col-md-3">
<label asp-for="CoverageSqFtPerLb" class="form-label fw-medium"></label>
<input asp-for="CoverageSqFtPerLb" class="form-control" id="field-coverage" />
<span asp-validation-for="CoverageSqFtPerLb" class="text-danger small"></span>
</div>
<div class="col-md-3">
<label asp-for="TransferEfficiency" class="form-label fw-medium"></label>
<input asp-for="TransferEfficiency" class="form-control" id="field-transfer" />
<span asp-validation-for="TransferEfficiency" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="ProductUrl" class="form-label fw-medium"></label>
<div class="input-group">
<input asp-for="ProductUrl" class="form-control" id="field-producturl" />
<a id="field-producturl-link" href="@Model.ProductUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.ProductUrl) ? "d-none" : "")" title="Open Product URL">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<span asp-validation-for="ProductUrl" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="ImageUrl" class="form-label fw-medium"></label>
<input asp-for="ImageUrl" class="form-control" id="field-imageurl" />
<span asp-validation-for="ImageUrl" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label asp-for="SdsUrl" class="form-label fw-medium"></label>
<div class="input-group">
<input asp-for="SdsUrl" class="form-control" id="field-sdsurl" />
<a id="field-sdsurl-link" href="@Model.SdsUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.SdsUrl) ? "d-none" : "")" title="Open SDS URL">
<i class="bi bi-file-earmark-pdf"></i>
</a>
</div>
<span asp-validation-for="SdsUrl" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label asp-for="TdsUrl" class="form-label fw-medium"></label>
<div class="input-group">
<input asp-for="TdsUrl" class="form-control" id="field-tdsurl" />
<a id="field-tdsurl-link" href="@Model.TdsUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.TdsUrl) ? "d-none" : "")" title="Open TDS URL">
<i class="bi bi-file-earmark-text"></i>
</a>
</div>
<span asp-validation-for="TdsUrl" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label asp-for="ApplicationGuideUrl" class="form-label fw-medium"></label>
<div class="input-group">
<input asp-for="ApplicationGuideUrl" class="form-control" id="field-applicationguideurl" />
<a id="field-applicationguideurl-link" href="@Model.ApplicationGuideUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.ApplicationGuideUrl) ? "d-none" : "")" title="Open Application Guide URL">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<span asp-validation-for="ApplicationGuideUrl" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label asp-for="RequiresClearCoat" class="form-label fw-medium"></label>
<select asp-for="RequiresClearCoat" class="form-select" id="field-clearcoat">
<option value="">Unknown</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
<span asp-validation-for="RequiresClearCoat" class="text-danger small"></span>
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-4">
<input asp-for="IsDiscontinued" class="form-check-input" type="checkbox" />
<label asp-for="IsDiscontinued" class="form-check-label"></label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-4">
<input asp-for="IsUserContributed" class="form-check-input" type="checkbox" />
<label asp-for="IsUserContributed" class="form-check-label"></label>
</div>
</div>
</div>
@if (Model.Id > 0)
{
<hr class="my-4" />
<div class="row g-3 small text-muted">
<div class="col-md-4">
<div class="fw-semibold text-body">Created</div>
<div>@Model.CreatedAt.ToString("MMM d, yyyy h:mm tt") UTC</div>
</div>
<div class="col-md-4">
<div class="fw-semibold text-body">Updated</div>
<div>@(Model.UpdatedAt?.ToString("MMM d, yyyy h:mm tt") ?? "Never")@(Model.UpdatedAt.HasValue ? " UTC" : string.Empty)</div>
</div>
<div class="col-md-4">
<div class="fw-semibold text-body">Last Synced</div>
<div>@(Model.LastSyncedAt?.ToString("MMM d, yyyy h:mm tt") ?? "Never")@(Model.LastSyncedAt.HasValue ? " UTC" : string.Empty)</div>
</div>
</div>
}