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:
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user