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:
@@ -171,7 +171,8 @@
|
||||
"PowerShell(Select-String *)",
|
||||
"Bash(Select-Object -First 20)",
|
||||
"PowerShell(node -e \"require\\('fs'\\).existsSync\\(require\\('path'\\).join\\(process.cwd\\(\\), 'node_modules', 'sharp'\\)\\) ? console.log\\('sharp ok'\\) : console.log\\('no sharp'\\)\")",
|
||||
"WebFetch(domain:www.powdercoatinglogix.com)"
|
||||
"WebFetch(domain:www.powdercoatinglogix.com)",
|
||||
"PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
Shop Management App TO DO List
|
||||
==============================
|
||||
-Inventory Lookup not always finding price for Columbia Coatings
|
||||
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
|
||||
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
|
||||
-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓
|
||||
|
||||
|
||||
-Google review request email after a job
|
||||
-Check my ChatGPT chat about surface area for a few solid ideas for the system
|
||||
-Fix up approve/decline messages between customer and user on quote approval feature
|
||||
|
||||
+8
-4
@@ -1,9 +1,9 @@
|
||||
Shop Management App TO DO List
|
||||
==============================
|
||||
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
|
||||
-Lookup Modal not showing ALL matches. Maybe make scrollable
|
||||
-Pickup cure information from TDS Sheet if not found by AI Search
|
||||
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
|
||||
-Inventory Lookup not always finding price for Columbia Coatings
|
||||
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
|
||||
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
|
||||
|
||||
-Google review request email after a job
|
||||
-Check my ChatGPT chat about surface area for a few solid ideas for the system
|
||||
-Fix up approve/decline messages between customer and user on quote approval feature
|
||||
@@ -181,6 +181,10 @@ AI Agent item where we upload a picture and it will calculate the approximate sq
|
||||
-When scanning inventory QR Code, there is no cancel button
|
||||
-Bug: When scanning Inventory QR Code, if not logged in...it takes you to the dashboard after login, not our inventory scanning screen
|
||||
-Add SMS capabilities
|
||||
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
|
||||
-Lookup Modal not showing ALL matches. Maybe make scrollable
|
||||
-Pickup cure information from TDS Sheet if not found by AI Search
|
||||
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
|
||||
|
||||
Ideas Removed
|
||||
=======================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ public class InventoryAiLookupResult
|
||||
public bool? RequiresClearCoat { get; set; }
|
||||
|
||||
// Application properties
|
||||
public decimal? SpecificGravity { get; set; } // used to derive theoretical coverage when docs omit coverage
|
||||
public decimal? CoverageSqFtPerLb { get; set; } // typical ~80-120 sq ft/lb
|
||||
public decimal? TransferEfficiency { get; set; } // typical 50-75%
|
||||
public decimal? UnitCostPerLb { get; set; } // price per lb/unit if found in search results
|
||||
|
||||
@@ -27,6 +27,9 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
/// </summary>
|
||||
public class InventoryAiLookupService : IInventoryAiLookupService
|
||||
{
|
||||
private const decimal DefaultTransferEfficiency = 65m;
|
||||
private const decimal TheoreticalCoverageAtOneMilFactor = 192.3m;
|
||||
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<InventoryAiLookupService> _logger;
|
||||
@@ -47,6 +50,7 @@ Respond ONLY with a valid JSON object — no markdown, no explanation:
|
||||
""cureTimeMinutes"": number or null,
|
||||
""colorFamilies"": ""comma-separated list from: Red,Orange,Yellow,Green,Blue,Purple,Pink,Brown,Black,White,Gray,Silver,Gold,Bronze,Copper,Clear — or null if unknown"",
|
||||
""requiresClearCoat"": true or false or null,
|
||||
""specificGravity"": number or null,
|
||||
""coverageSqFtPerLb"": number or null,
|
||||
""transferEfficiency"": number or null,
|
||||
""unitCostPerLb"": number or null,
|
||||
@@ -250,6 +254,7 @@ Rules:
|
||||
result.CureTimeMinutes = GetInt(parsed, "cureTimeMinutes");
|
||||
result.ColorFamilies = GetString(parsed, "colorFamilies");
|
||||
result.RequiresClearCoat = GetBool(parsed, "requiresClearCoat");
|
||||
result.SpecificGravity = GetDecimal(parsed, "specificGravity");
|
||||
result.CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb");
|
||||
result.TransferEfficiency = GetDecimal(parsed, "transferEfficiency");
|
||||
result.UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb");
|
||||
@@ -260,6 +265,7 @@ Rules:
|
||||
result.ImageUrl = pageImageUrl;
|
||||
result.Reasoning = GetString(parsed, "reasoning");
|
||||
|
||||
ApplyPowderFallbacks(result);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -366,7 +372,7 @@ Rules:
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
|
||||
return new InventoryAiLookupResult
|
||||
var result = new InventoryAiLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Manufacturer = GetString(parsed, "manufacturer"),
|
||||
@@ -378,11 +384,15 @@ Rules:
|
||||
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
||||
ColorFamilies = GetString(parsed, "colorFamilies"),
|
||||
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
|
||||
SpecificGravity = GetDecimal(parsed, "specificGravity"),
|
||||
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
|
||||
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
|
||||
VendorName = GetString(parsed, "vendorName"),
|
||||
Reasoning = GetString(parsed, "reasoning"),
|
||||
};
|
||||
|
||||
ApplyPowderFallbacks(result);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -447,7 +457,7 @@ Rules:
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
|
||||
return new InventoryAiLookupResult
|
||||
var result = new InventoryAiLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Manufacturer = GetString(parsed, "manufacturer"),
|
||||
@@ -460,6 +470,7 @@ Rules:
|
||||
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
||||
ColorFamilies = GetString(parsed, "colorFamilies"),
|
||||
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
|
||||
SpecificGravity = GetDecimal(parsed, "specificGravity"),
|
||||
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
|
||||
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
|
||||
UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb"),
|
||||
@@ -470,6 +481,9 @@ Rules:
|
||||
ImageUrl = pageImageUrl,
|
||||
Reasoning = GetString(parsed, "reasoning"),
|
||||
};
|
||||
|
||||
ApplyPowderFallbacks(result);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1226,4 +1240,15 @@ Rules:
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ApplyPowderFallbacks(InventoryAiLookupResult result)
|
||||
{
|
||||
result.TransferEfficiency ??= DefaultTransferEfficiency;
|
||||
|
||||
if (!result.CoverageSqFtPerLb.HasValue && result.SpecificGravity is > 0)
|
||||
{
|
||||
var calculatedCoverage = TheoreticalCoverageAtOneMilFactor / result.SpecificGravity.Value;
|
||||
result.CoverageSqFtPerLb = Math.Round(calculatedCoverage, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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