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:
@@ -20,6 +20,8 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
public class InventoryController : Controller
|
||||
{
|
||||
private const decimal DefaultTransferEfficiency = 65m;
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ILogger<InventoryController> _logger;
|
||||
@@ -745,7 +747,7 @@ public class InventoryController : Controller
|
||||
if (match.ColorFamilies != null) result.ColorFamilies = match.ColorFamilies;
|
||||
if (match.RequiresClearCoat != null) result.RequiresClearCoat = match.RequiresClearCoat;
|
||||
if (match.CoverageSqFtPerLb != null) result.CoverageSqFtPerLb = match.CoverageSqFtPerLb;
|
||||
if (match.TransferEfficiency != null) result.TransferEfficiency = match.TransferEfficiency;
|
||||
result.TransferEfficiency ??= GetEffectiveTransferEfficiency(match.TransferEfficiency);
|
||||
// URL / price fields: fill gaps only — AI may have found something better
|
||||
result.ImageUrl ??= match.ImageUrl;
|
||||
result.SpecPageUrl ??= match.ProductUrl;
|
||||
@@ -775,7 +777,7 @@ public class InventoryController : Controller
|
||||
ColorFamilies = result.ColorFamilies,
|
||||
RequiresClearCoat = result.RequiresClearCoat,
|
||||
CoverageSqFtPerLb = result.CoverageSqFtPerLb,
|
||||
TransferEfficiency = result.TransferEfficiency,
|
||||
TransferEfficiency = GetEffectiveTransferEfficiency(result.TransferEfficiency),
|
||||
ImageUrl = result.ImageUrl,
|
||||
ProductUrl = result.SpecPageUrl,
|
||||
SdsUrl = result.SdsUrl,
|
||||
@@ -873,7 +875,7 @@ public class InventoryController : Controller
|
||||
aiResult.CureTimeMinutes ??= full.CureTimeMinutes;
|
||||
aiResult.RequiresClearCoat ??= full.RequiresClearCoat;
|
||||
aiResult.CoverageSqFtPerLb ??= full.CoverageSqFtPerLb;
|
||||
aiResult.TransferEfficiency ??= full.TransferEfficiency;
|
||||
aiResult.TransferEfficiency ??= GetEffectiveTransferEfficiency(full.TransferEfficiency);
|
||||
aiResult.ManufacturerPartNumber ??= full.ManufacturerPartNumber;
|
||||
aiResult.ColorName ??= full.ColorName;
|
||||
aiResult.ColorCode ??= full.ColorCode;
|
||||
@@ -952,7 +954,7 @@ public class InventoryController : Controller
|
||||
colorFamilies = aiResult.ColorFamilies,
|
||||
requiresClearCoat = aiResult.RequiresClearCoat,
|
||||
coverageSqFtPerLb = aiResult.CoverageSqFtPerLb,
|
||||
transferEfficiency = aiResult.TransferEfficiency,
|
||||
transferEfficiency = aiResult.TransferEfficiency ?? DefaultTransferEfficiency,
|
||||
unitPrice = aiResult.UnitCostPerLb ?? 0m,
|
||||
imageUrl = aiResult.ImageUrl,
|
||||
productUrl = aiResult.SpecPageUrl,
|
||||
@@ -1104,13 +1106,18 @@ public class InventoryController : Controller
|
||||
colorFamilies = p.ColorFamilies,
|
||||
requiresClearCoat = p.RequiresClearCoat,
|
||||
coverageSqFtPerLb = p.CoverageSqFtPerLb,
|
||||
transferEfficiency = p.TransferEfficiency
|
||||
transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Json(results);
|
||||
}
|
||||
|
||||
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
|
||||
{
|
||||
return transferEfficiency ?? DefaultTransferEfficiency;
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Inventory;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.ViewModels.PowderCatalog;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -12,34 +15,324 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class PowderCatalogController : Controller
|
||||
{
|
||||
private const decimal DefaultTransferEfficiency = 65m;
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
private readonly ILogger<PowderCatalogController> _logger;
|
||||
|
||||
public PowderCatalogController(IUnitOfWork unitOfWork, ILogger<PowderCatalogController> logger)
|
||||
public PowderCatalogController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IInventoryAiLookupService aiLookupService,
|
||||
ILogger<PowderCatalogController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_aiLookupService = aiLookupService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows platform-level catalog stats and the import form.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
public async Task<IActionResult> Index(
|
||||
string? searchTerm,
|
||||
string? vendorName,
|
||||
string status = "all",
|
||||
string source = "all",
|
||||
string completeness = "all",
|
||||
string? sortColumn = null,
|
||||
string sortDirection = "asc",
|
||||
int pageNumber = 1,
|
||||
int pageSize = 25)
|
||||
{
|
||||
var all = await _unitOfWork.PowderCatalog.GetAllAsync();
|
||||
var list = all.ToList();
|
||||
|
||||
var stats = new PowderCatalogStatsDto
|
||||
var stats = BuildStats(list);
|
||||
var vendors = list
|
||||
.Select(p => p.VendorName)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(v => v)
|
||||
.ToList();
|
||||
|
||||
status = NormalizeFilter(status, "all", "active", "discontinued");
|
||||
source = NormalizeFilter(source, "all", "curated", "contributed");
|
||||
completeness = NormalizeFilter(completeness, "all", "ready", "missing-specs", "missing-docs", "missing-image");
|
||||
sortDirection = sortDirection?.Equals("desc", StringComparison.OrdinalIgnoreCase) == true ? "desc" : "asc";
|
||||
sortColumn = NormalizeSortColumn(sortColumn);
|
||||
pageNumber = Math.Max(pageNumber, 1);
|
||||
pageSize = pageSize is < 1 or > 100 ? 25 : pageSize;
|
||||
|
||||
IEnumerable<PowderCatalogItem> query = list;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
TotalProducts = list.Count,
|
||||
ActiveProducts = list.Count(p => !p.IsDiscontinued),
|
||||
DiscontinuedProducts = list.Count(p => p.IsDiscontinued),
|
||||
VendorCount = list.Select(p => p.VendorName).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
UserContributedProducts = list.Count(p => p.IsUserContributed),
|
||||
LastImportedAt = list.Any() ? list.Max(p => p.LastSyncedAt) : null
|
||||
var term = searchTerm.Trim();
|
||||
query = query.Where(p =>
|
||||
ContainsIgnoreCase(p.VendorName, term) ||
|
||||
ContainsIgnoreCase(p.Sku, term) ||
|
||||
ContainsIgnoreCase(p.ColorName, term) ||
|
||||
ContainsIgnoreCase(p.Description, term) ||
|
||||
ContainsIgnoreCase(p.Finish, term));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vendorName))
|
||||
query = query.Where(p => string.Equals(p.VendorName, vendorName.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
query = status switch
|
||||
{
|
||||
"active" => query.Where(p => !p.IsDiscontinued),
|
||||
"discontinued" => query.Where(p => p.IsDiscontinued),
|
||||
_ => query
|
||||
};
|
||||
|
||||
return View(stats);
|
||||
query = source switch
|
||||
{
|
||||
"curated" => query.Where(p => !p.IsUserContributed),
|
||||
"contributed" => query.Where(p => p.IsUserContributed),
|
||||
_ => query
|
||||
};
|
||||
|
||||
query = completeness switch
|
||||
{
|
||||
"ready" => query.Where(IsCatalogReady),
|
||||
"missing-specs" => query.Where(p => !HasCoreSpecs(p)),
|
||||
"missing-docs" => query.Where(p => !HasDocuments(p)),
|
||||
"missing-image" => query.Where(p => string.IsNullOrWhiteSpace(p.ImageUrl)),
|
||||
_ => query
|
||||
};
|
||||
|
||||
query = ApplySort(query, sortColumn, sortDirection);
|
||||
|
||||
var totalCount = query.Count();
|
||||
var items = query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(MapListItem)
|
||||
.ToList();
|
||||
|
||||
var vm = new PowderCatalogIndexViewModel
|
||||
{
|
||||
Stats = stats,
|
||||
Vendors = vendors,
|
||||
SearchTerm = searchTerm,
|
||||
VendorName = vendorName,
|
||||
Status = status,
|
||||
Source = source,
|
||||
Completeness = completeness,
|
||||
SortColumn = sortColumn,
|
||||
SortDirection = sortDirection,
|
||||
Catalog = new PagedResult<PowderCatalogListItemViewModel>
|
||||
{
|
||||
Items = items,
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
SortColumn = sortColumn,
|
||||
SortDirection = sortDirection,
|
||||
SearchTerm = searchTerm
|
||||
}
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
public IActionResult Create()
|
||||
{
|
||||
return View(new PowderCatalogFormViewModel
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
TransferEfficiency = DefaultTransferEfficiency
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(PowderCatalogFormViewModel model)
|
||||
{
|
||||
NormalizeModel(model);
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
return View(model);
|
||||
|
||||
if (await CatalogRecordExistsAsync(model.VendorName, model.Sku))
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "A powder catalog item with this vendor and SKU already exists.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var entity = new PowderCatalogItem
|
||||
{
|
||||
VendorName = model.VendorName,
|
||||
Sku = model.Sku,
|
||||
ColorName = model.ColorName,
|
||||
Description = NullIfWhiteSpace(model.Description),
|
||||
UnitPrice = model.UnitPrice,
|
||||
ImageUrl = NullIfWhiteSpace(model.ImageUrl),
|
||||
SdsUrl = NullIfWhiteSpace(model.SdsUrl),
|
||||
TdsUrl = NullIfWhiteSpace(model.TdsUrl),
|
||||
ApplicationGuideUrl = NullIfWhiteSpace(model.ApplicationGuideUrl),
|
||||
ProductUrl = NullIfWhiteSpace(model.ProductUrl),
|
||||
CureTemperatureF = model.CureTemperatureF,
|
||||
CureTimeMinutes = model.CureTimeMinutes,
|
||||
Finish = NullIfWhiteSpace(model.Finish),
|
||||
ColorFamilies = NullIfWhiteSpace(model.ColorFamilies),
|
||||
RequiresClearCoat = model.RequiresClearCoat,
|
||||
CoverageSqFtPerLb = model.CoverageSqFtPerLb,
|
||||
TransferEfficiency = model.TransferEfficiency,
|
||||
IsDiscontinued = model.IsDiscontinued,
|
||||
IsUserContributed = model.IsUserContributed,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _unitOfWork.PowderCatalog.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = $"Added powder catalog item \"{entity.ColorName}\" ({entity.Sku}).";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating powder catalog item {VendorName} {Sku}", model.VendorName, model.Sku);
|
||||
TempData["Error"] = "An error occurred while creating the powder catalog item.";
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var entity = await _unitOfWork.PowderCatalog.GetByIdAsync(id);
|
||||
if (entity == null)
|
||||
return NotFound();
|
||||
|
||||
return View(MapForm(entity));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, PowderCatalogFormViewModel model)
|
||||
{
|
||||
if (id != model.Id)
|
||||
return NotFound();
|
||||
|
||||
NormalizeModel(model);
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
return View(model);
|
||||
|
||||
var entity = await _unitOfWork.PowderCatalog.GetByIdAsync(id);
|
||||
if (entity == null)
|
||||
return NotFound();
|
||||
|
||||
if (await CatalogRecordExistsAsync(model.VendorName, model.Sku, id))
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "A powder catalog item with this vendor and SKU already exists.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
entity.VendorName = model.VendorName;
|
||||
entity.Sku = model.Sku;
|
||||
entity.ColorName = model.ColorName;
|
||||
entity.Description = NullIfWhiteSpace(model.Description);
|
||||
entity.UnitPrice = model.UnitPrice;
|
||||
entity.ImageUrl = NullIfWhiteSpace(model.ImageUrl);
|
||||
entity.SdsUrl = NullIfWhiteSpace(model.SdsUrl);
|
||||
entity.TdsUrl = NullIfWhiteSpace(model.TdsUrl);
|
||||
entity.ApplicationGuideUrl = NullIfWhiteSpace(model.ApplicationGuideUrl);
|
||||
entity.ProductUrl = NullIfWhiteSpace(model.ProductUrl);
|
||||
entity.CureTemperatureF = model.CureTemperatureF;
|
||||
entity.CureTimeMinutes = model.CureTimeMinutes;
|
||||
entity.Finish = NullIfWhiteSpace(model.Finish);
|
||||
entity.ColorFamilies = NullIfWhiteSpace(model.ColorFamilies);
|
||||
entity.RequiresClearCoat = model.RequiresClearCoat;
|
||||
entity.CoverageSqFtPerLb = model.CoverageSqFtPerLb;
|
||||
entity.TransferEfficiency = model.TransferEfficiency;
|
||||
entity.IsDiscontinued = model.IsDiscontinued;
|
||||
entity.IsUserContributed = model.IsUserContributed;
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = $"Updated powder catalog item \"{entity.ColorName}\" ({entity.Sku}).";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating powder catalog item {Id}", id);
|
||||
TempData["Error"] = "An error occurred while updating the powder catalog item.";
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleDiscontinued(int id)
|
||||
{
|
||||
var entity = await _unitOfWork.PowderCatalog.GetByIdAsync(id);
|
||||
if (entity == null)
|
||||
return NotFound();
|
||||
|
||||
entity.IsDiscontinued = !entity.IsDiscontinued;
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = entity.IsDiscontinued
|
||||
? $"Marked \"{entity.ColorName}\" as discontinued."
|
||||
: $"Reactivated \"{entity.ColorName}\".";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error toggling discontinued status for powder catalog item {Id}", id);
|
||||
TempData["Error"] = "An error occurred while updating the catalog item status.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AiLookup(
|
||||
[FromForm] string? vendorName,
|
||||
[FromForm] string? colorName,
|
||||
[FromForm] string? sku)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vendorName)
|
||||
&& string.IsNullOrWhiteSpace(colorName)
|
||||
&& string.IsNullOrWhiteSpace(sku))
|
||||
{
|
||||
return Json(new { success = false, errorMessage = "Enter a vendor, color name, or SKU first." });
|
||||
}
|
||||
|
||||
var result = await _aiLookupService.LookupAsync(vendorName, colorName, null, sku);
|
||||
if (result.Success)
|
||||
await ApplyTdsCureFallbackAsync(result, colorName);
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AiAugmentFromUrl(
|
||||
[FromForm] string? productUrl,
|
||||
[FromForm] string? colorName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(productUrl))
|
||||
return Json(new { success = false, errorMessage = "No product URL provided." });
|
||||
|
||||
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
||||
if (result.Success)
|
||||
await ApplyTdsCureFallbackAsync(result, colorName);
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -75,7 +368,7 @@ public class PowderCatalogController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Success"] = $"Import complete — {result.Inserted:N0} inserted, {result.Updated:N0} updated, {result.Skipped:N0} skipped.";
|
||||
TempData["Success"] = $"Import complete - {result.Inserted:N0} inserted, {result.Updated:N0} updated, {result.Skipped:N0} skipped.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
@@ -126,7 +419,21 @@ public class PowderCatalogController : Controller
|
||||
return Json(results);
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
|
||||
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
|
||||
{
|
||||
if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
|
||||
&& !string.IsNullOrWhiteSpace(result.TdsUrl))
|
||||
{
|
||||
var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName);
|
||||
if (tds.Success)
|
||||
{
|
||||
result.CureTemperatureF ??= tds.CureTemperatureF;
|
||||
result.CureTimeMinutes ??= tds.CureTimeMinutes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PowderCatalogImportResult> ImportJsonAsync(IFormFile file, string vendorName)
|
||||
{
|
||||
@@ -275,6 +582,184 @@ public class PowderCatalogController : Controller
|
||||
|
||||
return price ?? 0m;
|
||||
}
|
||||
|
||||
private static PowderCatalogStatsDto BuildStats(List<PowderCatalogItem> list)
|
||||
{
|
||||
return new PowderCatalogStatsDto
|
||||
{
|
||||
TotalProducts = list.Count,
|
||||
ActiveProducts = list.Count(p => !p.IsDiscontinued),
|
||||
DiscontinuedProducts = list.Count(p => p.IsDiscontinued),
|
||||
VendorCount = list.Select(p => p.VendorName).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
UserContributedProducts = list.Count(p => p.IsUserContributed),
|
||||
LastImportedAt = list.Any() ? list.Max(p => p.LastSyncedAt) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<PowderCatalogItem> ApplySort(IEnumerable<PowderCatalogItem> query, string sortColumn, string sortDirection)
|
||||
{
|
||||
var descending = sortDirection.Equals("desc", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return (sortColumn, descending) switch
|
||||
{
|
||||
("Sku", false) => query.OrderBy(p => p.Sku).ThenBy(p => p.ColorName),
|
||||
("Sku", true) => query.OrderByDescending(p => p.Sku).ThenBy(p => p.ColorName),
|
||||
("ColorName", false) => query.OrderBy(p => p.ColorName).ThenBy(p => p.Sku),
|
||||
("ColorName", true) => query.OrderByDescending(p => p.ColorName).ThenBy(p => p.Sku),
|
||||
("UnitPrice", false) => query.OrderBy(p => p.UnitPrice).ThenBy(p => p.ColorName),
|
||||
("UnitPrice", true) => query.OrderByDescending(p => p.UnitPrice).ThenBy(p => p.ColorName),
|
||||
("Finish", false) => query.OrderBy(p => p.Finish).ThenBy(p => p.ColorName),
|
||||
("Finish", true) => query.OrderByDescending(p => p.Finish).ThenBy(p => p.ColorName),
|
||||
("UpdatedAt", false) => query.OrderBy(p => p.UpdatedAt ?? p.CreatedAt).ThenBy(p => p.ColorName),
|
||||
("UpdatedAt", true) => query.OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt).ThenBy(p => p.ColorName),
|
||||
("LastSyncedAt", false) => query.OrderBy(p => p.LastSyncedAt ?? DateTime.MinValue).ThenBy(p => p.ColorName),
|
||||
("LastSyncedAt", true) => query.OrderByDescending(p => p.LastSyncedAt ?? DateTime.MinValue).ThenBy(p => p.ColorName),
|
||||
("VendorName", true) => query.OrderByDescending(p => p.VendorName).ThenBy(p => p.ColorName),
|
||||
_ => query.OrderBy(p => p.VendorName).ThenBy(p => p.ColorName)
|
||||
};
|
||||
}
|
||||
|
||||
private static PowderCatalogListItemViewModel MapListItem(PowderCatalogItem item)
|
||||
{
|
||||
return new PowderCatalogListItemViewModel
|
||||
{
|
||||
Id = item.Id,
|
||||
VendorName = item.VendorName,
|
||||
Sku = item.Sku,
|
||||
ColorName = item.ColorName,
|
||||
Finish = item.Finish,
|
||||
UnitPrice = item.UnitPrice,
|
||||
IsDiscontinued = item.IsDiscontinued,
|
||||
IsUserContributed = item.IsUserContributed,
|
||||
HasImage = !string.IsNullOrWhiteSpace(item.ImageUrl),
|
||||
HasCoreSpecs = HasCoreSpecs(item),
|
||||
HasDocuments = HasDocuments(item),
|
||||
CreatedAt = item.CreatedAt,
|
||||
UpdatedAt = item.UpdatedAt,
|
||||
LastSyncedAt = item.LastSyncedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static PowderCatalogFormViewModel MapForm(PowderCatalogItem item)
|
||||
{
|
||||
return new PowderCatalogFormViewModel
|
||||
{
|
||||
Id = item.Id,
|
||||
VendorName = item.VendorName,
|
||||
Sku = item.Sku,
|
||||
ColorName = item.ColorName,
|
||||
Description = item.Description,
|
||||
UnitPrice = item.UnitPrice,
|
||||
ImageUrl = item.ImageUrl,
|
||||
SdsUrl = item.SdsUrl,
|
||||
TdsUrl = item.TdsUrl,
|
||||
ApplicationGuideUrl = item.ApplicationGuideUrl,
|
||||
ProductUrl = item.ProductUrl,
|
||||
CureTemperatureF = item.CureTemperatureF,
|
||||
CureTimeMinutes = item.CureTimeMinutes,
|
||||
Finish = item.Finish,
|
||||
ColorFamilies = item.ColorFamilies,
|
||||
RequiresClearCoat = item.RequiresClearCoat,
|
||||
CoverageSqFtPerLb = item.CoverageSqFtPerLb,
|
||||
TransferEfficiency = GetEffectiveTransferEfficiency(item.TransferEfficiency),
|
||||
IsDiscontinued = item.IsDiscontinued,
|
||||
IsUserContributed = item.IsUserContributed,
|
||||
CreatedAt = item.CreatedAt,
|
||||
UpdatedAt = item.UpdatedAt,
|
||||
LastSyncedAt = item.LastSyncedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasCoreSpecs(PowderCatalogItem item)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(item.Finish)
|
||||
&& item.CureTemperatureF.HasValue
|
||||
&& item.CureTimeMinutes.HasValue
|
||||
&& item.CoverageSqFtPerLb.HasValue
|
||||
&& GetEffectiveTransferEfficiency(item.TransferEfficiency).HasValue;
|
||||
}
|
||||
|
||||
private static bool HasDocuments(PowderCatalogItem item)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(item.ProductUrl)
|
||||
&& !string.IsNullOrWhiteSpace(item.SdsUrl)
|
||||
&& !string.IsNullOrWhiteSpace(item.TdsUrl);
|
||||
}
|
||||
|
||||
private static bool IsCatalogReady(PowderCatalogItem item)
|
||||
{
|
||||
return HasCoreSpecs(item)
|
||||
&& HasDocuments(item)
|
||||
&& !string.IsNullOrWhiteSpace(item.ImageUrl);
|
||||
}
|
||||
|
||||
private static bool ContainsIgnoreCase(string? value, string searchTerm)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value)
|
||||
&& value.Contains(searchTerm, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizeFilter(string? value, string fallback, params string[] allowedValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return fallback;
|
||||
|
||||
return allowedValues.Contains(value, StringComparer.OrdinalIgnoreCase)
|
||||
? value.ToLowerInvariant()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static string NormalizeSortColumn(string? sortColumn)
|
||||
{
|
||||
var allowed = new[]
|
||||
{
|
||||
"VendorName", "Sku", "ColorName", "Finish", "UnitPrice", "UpdatedAt", "LastSyncedAt"
|
||||
};
|
||||
|
||||
return allowed.Contains(sortColumn, StringComparer.OrdinalIgnoreCase)
|
||||
? allowed.First(s => s.Equals(sortColumn, StringComparison.OrdinalIgnoreCase))
|
||||
: "VendorName";
|
||||
}
|
||||
|
||||
private async Task<bool> CatalogRecordExistsAsync(string vendorName, string sku, int? excludeId = null)
|
||||
{
|
||||
var existing = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.VendorName.ToLower() == vendorName.ToLower() &&
|
||||
p.Sku.ToLower() == sku.ToLower());
|
||||
|
||||
return existing.Any(p => excludeId == null || p.Id != excludeId.Value);
|
||||
}
|
||||
|
||||
private static void NormalizeModel(PowderCatalogFormViewModel model)
|
||||
{
|
||||
model.VendorName = model.VendorName?.Trim() ?? string.Empty;
|
||||
model.Sku = model.Sku?.Trim() ?? string.Empty;
|
||||
model.ColorName = model.ColorName?.Trim() ?? string.Empty;
|
||||
model.Description = TrimToNull(model.Description);
|
||||
model.ImageUrl = TrimToNull(model.ImageUrl);
|
||||
model.SdsUrl = TrimToNull(model.SdsUrl);
|
||||
model.TdsUrl = TrimToNull(model.TdsUrl);
|
||||
model.ApplicationGuideUrl = TrimToNull(model.ApplicationGuideUrl);
|
||||
model.ProductUrl = TrimToNull(model.ProductUrl);
|
||||
model.Finish = TrimToNull(model.Finish);
|
||||
model.ColorFamilies = TrimToNull(model.ColorFamilies);
|
||||
model.TransferEfficiency ??= DefaultTransferEfficiency;
|
||||
}
|
||||
|
||||
private static string? TrimToNull(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string? NullIfWhiteSpace(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static decimal? GetEffectiveTransferEfficiency(decimal? transferEfficiency)
|
||||
{
|
||||
return transferEfficiency ?? DefaultTransferEfficiency;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Extension helpers for reading nullable strings from JsonElement.</summary>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Web.ViewModels.PowderCatalog;
|
||||
|
||||
public class PowderCatalogFormViewModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(120)]
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
[Display(Name = "SKU")]
|
||||
public string Sku { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(160)]
|
||||
[Display(Name = "Color Name")]
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(4000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Range(0, 999999)]
|
||||
[Display(Name = "Unit Price")]
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
[Url]
|
||||
[Display(Name = "Image URL")]
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
[Url]
|
||||
[Display(Name = "SDS URL")]
|
||||
public string? SdsUrl { get; set; }
|
||||
|
||||
[Url]
|
||||
[Display(Name = "TDS URL")]
|
||||
public string? TdsUrl { get; set; }
|
||||
|
||||
[Url]
|
||||
[Display(Name = "Application Guide URL")]
|
||||
public string? ApplicationGuideUrl { get; set; }
|
||||
|
||||
[Url]
|
||||
[Display(Name = "Product URL")]
|
||||
public string? ProductUrl { get; set; }
|
||||
|
||||
[Range(0, 1000)]
|
||||
[Display(Name = "Cure Temperature (F)")]
|
||||
public decimal? CureTemperatureF { get; set; }
|
||||
|
||||
[Range(0, 1000)]
|
||||
[Display(Name = "Cure Time (Minutes)")]
|
||||
public int? CureTimeMinutes { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string? Finish { get; set; }
|
||||
|
||||
[StringLength(300)]
|
||||
[Display(Name = "Color Families")]
|
||||
public string? ColorFamilies { get; set; }
|
||||
|
||||
[Display(Name = "Requires Clear Coat")]
|
||||
public bool? RequiresClearCoat { get; set; }
|
||||
|
||||
[Range(0, 10000)]
|
||||
[Display(Name = "Coverage (Sq Ft / Lb)")]
|
||||
public decimal? CoverageSqFtPerLb { get; set; }
|
||||
|
||||
[Range(0, 100)]
|
||||
[Display(Name = "Transfer Efficiency (%)")]
|
||||
public decimal? TransferEfficiency { get; set; }
|
||||
|
||||
[Display(Name = "Discontinued")]
|
||||
public bool IsDiscontinued { get; set; }
|
||||
|
||||
[Display(Name = "User Contributed")]
|
||||
public bool IsUserContributed { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public DateTime? LastSyncedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Inventory;
|
||||
|
||||
namespace PowderCoating.Web.ViewModels.PowderCatalog;
|
||||
|
||||
public class PowderCatalogIndexViewModel
|
||||
{
|
||||
public PowderCatalogStatsDto Stats { get; set; } = new();
|
||||
public PagedResult<PowderCatalogListItemViewModel> Catalog { get; set; } = new();
|
||||
public IReadOnlyList<string> Vendors { get; set; } = Array.Empty<string>();
|
||||
|
||||
public string? SearchTerm { get; set; }
|
||||
public string? VendorName { get; set; }
|
||||
public string Status { get; set; } = "all";
|
||||
public string Source { get; set; } = "all";
|
||||
public string Completeness { get; set; } = "all";
|
||||
public string SortColumn { get; set; } = "VendorName";
|
||||
public string SortDirection { get; set; } = "asc";
|
||||
}
|
||||
|
||||
public class PowderCatalogListItemViewModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string Sku { get; set; } = string.Empty;
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
public string? Finish { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public bool IsDiscontinued { get; set; }
|
||||
public bool IsUserContributed { get; set; }
|
||||
public bool HasImage { get; set; }
|
||||
public bool HasCoreSpecs { get; set; }
|
||||
public bool HasDocuments { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public DateTime? LastSyncedAt { get; set; }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const lookupBtn = document.getElementById('powder-ai-lookup-btn');
|
||||
const urlBtn = document.getElementById('powder-ai-url-btn');
|
||||
const statusEl = document.getElementById('ai-lookup-status');
|
||||
|
||||
if (!lookupBtn || !statusEl) return;
|
||||
|
||||
const endpoints = {
|
||||
lookup: '/PowderCatalog/AiLookup',
|
||||
byUrl: '/PowderCatalog/AiAugmentFromUrl'
|
||||
};
|
||||
|
||||
lookupBtn.addEventListener('click', async function () {
|
||||
const vendorName = getValue('field-vendorname');
|
||||
const colorName = getValue('field-colorname');
|
||||
const sku = getValue('field-sku');
|
||||
|
||||
if (!vendorName && !colorName && !sku) {
|
||||
showStatus('warning', 'Enter a vendor, color name, or SKU first.');
|
||||
return;
|
||||
}
|
||||
|
||||
await runLookup(endpoints.lookup, {
|
||||
vendorName: vendorName,
|
||||
colorName: colorName,
|
||||
sku: sku
|
||||
}, 'Searching the web for missing powder specs and documents...');
|
||||
});
|
||||
|
||||
urlBtn?.addEventListener('click', async function () {
|
||||
const productUrl = getValue('field-producturl');
|
||||
const colorName = getValue('field-colorname');
|
||||
|
||||
if (!productUrl) {
|
||||
showStatus('warning', 'Add a product URL first, then try AI From URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
await runLookup(endpoints.byUrl, {
|
||||
productUrl: productUrl,
|
||||
colorName: colorName
|
||||
}, 'Reading the product page for missing specs and document links...');
|
||||
});
|
||||
|
||||
async function runLookup(url, payload, loadingMessage) {
|
||||
setButtonsDisabled(true);
|
||||
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>' + loadingMessage);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => formData.append(key, value || ''));
|
||||
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
if (token) formData.append('__RequestVerificationToken', token);
|
||||
|
||||
const response = await fetch(url, { method: 'POST', body: formData });
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
showStatus('danger', 'AI lookup failed: ' + (data.errorMessage || 'Unknown error.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const filled = applyLookupResult(data);
|
||||
const reasoning = data.reasoning ? `<div class="text-muted mt-1">${escapeHtml(data.reasoning)}</div>` : '';
|
||||
|
||||
if (filled.length > 0) {
|
||||
showStatus('success', `Filled missing fields: ${filled.join(', ')}.${reasoning}`);
|
||||
} else {
|
||||
showStatus('warning', 'AI found the product, but there were no empty specs or docs to fill.' + reasoning);
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('danger', 'AI lookup request failed: ' + error.message);
|
||||
} finally {
|
||||
setButtonsDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLookupResult(data) {
|
||||
const filled = [];
|
||||
|
||||
fillIfEmpty('field-vendorname', data.manufacturer || data.vendorName, 'Vendor', filled);
|
||||
fillIfEmpty('field-sku', data.manufacturerPartNumber, 'SKU', filled);
|
||||
fillIfEmpty('field-colorname', data.colorName, 'Color Name', filled);
|
||||
fillIfEmpty('field-description', data.description, 'Description', filled, true);
|
||||
fillIfEmpty('field-finish', data.finish, 'Finish', filled);
|
||||
fillIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp', filled);
|
||||
fillIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time', filled);
|
||||
fillIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage', filled);
|
||||
fillIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency', filled);
|
||||
fillIfEmpty('field-producturl', data.specPageUrl, 'Product URL', filled);
|
||||
fillIfEmpty('field-imageurl', data.imageUrl, 'Image URL', filled);
|
||||
fillIfEmpty('field-sdsurl', data.sdsUrl, 'SDS URL', filled);
|
||||
fillIfEmpty('field-tdsurl', data.tdsUrl, 'TDS URL', filled);
|
||||
fillIfEmpty('field-colorfamilies', data.colorFamilies, 'Color Families', filled);
|
||||
|
||||
if (data.unitCostPerLb !== null && data.unitCostPerLb !== undefined) {
|
||||
const unitPrice = document.getElementById('field-unitprice');
|
||||
const current = unitPrice ? parseFloat(unitPrice.value) || 0 : 0;
|
||||
if (unitPrice && current === 0) {
|
||||
unitPrice.value = String(data.unitCostPerLb).trim();
|
||||
filled.push('Unit Price');
|
||||
}
|
||||
}
|
||||
|
||||
const clearCoat = document.getElementById('field-clearcoat');
|
||||
if (clearCoat && isEmptyValue(clearCoat.value) && data.requiresClearCoat !== null && data.requiresClearCoat !== undefined) {
|
||||
clearCoat.value = data.requiresClearCoat ? 'true' : 'false';
|
||||
filled.push('Requires Clear Coat');
|
||||
}
|
||||
|
||||
syncLinkButton('field-producturl', 'field-producturl-link');
|
||||
syncLinkButton('field-sdsurl', 'field-sdsurl-link');
|
||||
syncLinkButton('field-tdsurl', 'field-tdsurl-link');
|
||||
syncLinkButton('field-applicationguideurl', 'field-applicationguideurl-link');
|
||||
|
||||
return filled;
|
||||
}
|
||||
|
||||
function fillIfEmpty(id, value, label, filled, isTextarea) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
|
||||
const normalized = value !== null && value !== undefined ? String(value).trim() : '';
|
||||
const current = isTextarea ? (el.value || '').trim() : (el.value || '').trim();
|
||||
if (!normalized || current) return;
|
||||
|
||||
el.value = normalized;
|
||||
filled.push(label);
|
||||
}
|
||||
|
||||
function getValue(id) {
|
||||
return document.getElementById(id)?.value?.trim() || '';
|
||||
}
|
||||
|
||||
function syncLinkButton(inputId, linkId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const link = document.getElementById(linkId);
|
||||
if (!input || !link) return;
|
||||
|
||||
if (input.value && input.value.trim()) {
|
||||
link.href = input.value.trim();
|
||||
link.classList.remove('d-none');
|
||||
} else {
|
||||
link.href = '#';
|
||||
link.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function setButtonsDisabled(disabled) {
|
||||
lookupBtn.disabled = disabled;
|
||||
if (urlBtn) urlBtn.disabled = disabled;
|
||||
}
|
||||
|
||||
function isEmptyValue(value) {
|
||||
return value === null || value === undefined || String(value).trim() === '';
|
||||
}
|
||||
|
||||
function showStatus(type, message) {
|
||||
statusEl.className = `alert alert-${type} py-2 small mb-3`;
|
||||
statusEl.innerHTML = message;
|
||||
statusEl.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user