Merge dev: inventory label scanner improvements and AI lookup parity

This commit is contained in:
2026-05-03 20:30:44 -04:00
25 changed files with 21971 additions and 27 deletions
@@ -679,9 +679,407 @@ public class InventoryController : Controller
return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account. Contact your administrator." });
var result = await _aiLookupService.LookupAsync(manufacturer, colorName, colorCode, partNumber);
if (result.Success)
{
await EnrichFromCatalogAsync(result, autoContribute: true);
await ApplyTdsCureFallbackAsync(result, colorName);
}
return Json(result);
}
/// <summary>
/// Augments a catalog fill with cure specs, color families, and finish by fetching the
/// product's known URL and running it through Claude. Skips Serper — the URL is already
/// known from the catalog record so no search step is needed. Gated behind the same
/// AI Inventory Assist subscription flag as AiLookup.
/// </summary>
[HttpPost]
public async Task<IActionResult> AiAugmentFromUrl(
[FromForm] string? productUrl,
[FromForm] string? colorName)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId))
return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled." });
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>
/// Looks up <paramref name="result"/> in the platform powder catalog by SKU + manufacturer.
/// If a match is found, catalog values overwrite Claude-inferred ones for spec fields
/// (catalog is the authoritative source) and fill gaps for URL/price fields.
/// If no match and <paramref name="autoContribute"/> is true, inserts a new catalog entry
/// so future lookups resolve instantly without an API call.
/// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges.
/// Mutates <paramref name="result"/> in place.
/// </summary>
private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync(
InventoryAiLookupResult result, bool autoContribute)
{
var sku = result.ManufacturerPartNumber?.Trim();
var manufacturer = (result.Manufacturer ?? result.VendorName)?.Trim();
var colorName = result.ColorName?.Trim();
PowderCatalogItem? match = null;
if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer))
{
var skuLower = sku.ToLower();
var mfrLower = manufacturer.ToLower();
var hits = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
match = hits.FirstOrDefault();
}
if (match != null)
{
// Catalog is authoritative for spec fields — overwrite AI inference
if (match.Finish != null) result.Finish = match.Finish;
if (match.CureTemperatureF != null) result.CureTemperatureF = match.CureTemperatureF;
if (match.CureTimeMinutes != null) result.CureTimeMinutes = match.CureTimeMinutes;
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;
// URL / price fields: fill gaps only — AI may have found something better
result.ImageUrl ??= match.ImageUrl;
result.SpecPageUrl ??= match.ProductUrl;
result.SdsUrl ??= match.SdsUrl;
result.TdsUrl ??= match.TdsUrl;
if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice;
return (true, false);
}
if (!autoContribute
|| string.IsNullOrEmpty(sku)
|| string.IsNullOrEmpty(manufacturer)
|| string.IsNullOrEmpty(colorName))
return (false, false);
// Auto-contribute: insert into platform catalog so future lookups/scans resolve instantly
try
{
var newItem = new PowderCatalogItem
{
VendorName = manufacturer,
Sku = sku,
ColorName = colorName,
CureTemperatureF = result.CureTemperatureF,
CureTimeMinutes = result.CureTimeMinutes,
Finish = result.Finish,
ColorFamilies = result.ColorFamilies,
RequiresClearCoat = result.RequiresClearCoat,
CoverageSqFtPerLb = result.CoverageSqFtPerLb,
TransferEfficiency = result.TransferEfficiency,
ImageUrl = result.ImageUrl,
ProductUrl = result.SpecPageUrl,
SdsUrl = result.SdsUrl,
TdsUrl = result.TdsUrl,
IsUserContributed = true,
CreatedAt = DateTime.UtcNow,
};
await _unitOfWork.PowderCatalog.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Auto-contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku);
return (false, true);
}
catch (Exception ex)
{
// Unique constraint violation means another request beat us — not an error
_logger.LogInformation("Catalog auto-insert skipped (likely duplicate): {Message}", ex.Message);
return (false, false);
}
}
/// <summary>
/// If cure temperature or cure time is still missing after the primary lookup but a TDS URL
/// was returned, fetches that page and asks Claude to extract only the cure schedule.
/// Mutates <paramref name="result"/> in place; silently no-ops on failure so callers
/// can always return the result even if the TDS fetch does not help.
/// </summary>
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
{
if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
&& !string.IsNullOrEmpty(result.TdsUrl))
{
_logger.LogInformation("Cure specs missing after lookup; trying TDS at {Url}", result.TdsUrl);
var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName);
if (tds.Success)
{
if (result.CureTemperatureF == null) result.CureTemperatureF = tds.CureTemperatureF;
if (result.CureTimeMinutes == null) result.CureTimeMinutes = tds.CureTimeMinutes;
}
}
}
/// <summary>
/// Accepts a base64 label photo or a decoded QR URL from the in-browser label scanner,
/// runs it through Claude (vision for photos, URL-fetch for QR), searches the platform
/// catalog, and — when the product is not yet in the catalog and enough data was extracted
/// — inserts it automatically as a user-contributed entry so future scans resolve instantly.
/// </summary>
[HttpPost]
public async Task<IActionResult> ScanLabel(
[FromForm] string? imageBase64,
[FromForm] string? mediaType,
[FromForm] string? qrUrl)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId))
return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account." });
InventoryAiLookupResult aiResult;
if (!string.IsNullOrWhiteSpace(qrUrl))
{
// QR path: fetch the product page; LookupByUrlAsync now maps all identity + spec fields
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
}
else if (!string.IsNullOrWhiteSpace(imageBase64))
{
// Vision path: Claude reads what's printed on the label (limited to visible text)
aiResult = await _aiLookupService.ScanLabelAsync(imageBase64, mediaType ?? "image/jpeg");
// Follow-up web lookup so we get SDS/TDS URLs, product page, image, description,
// and any specs not printed on the label. Label values are kept as-is (authoritative);
// the full lookup only fills fields that are still null.
if (aiResult.Success)
{
var mfr = aiResult.Manufacturer ?? aiResult.VendorName;
if (!string.IsNullOrWhiteSpace(mfr) &&
(!string.IsNullOrWhiteSpace(aiResult.ColorName) || !string.IsNullOrWhiteSpace(aiResult.ManufacturerPartNumber)))
{
var full = await _aiLookupService.LookupAsync(
mfr, aiResult.ColorName, aiResult.ColorCode, aiResult.ManufacturerPartNumber);
if (full.Success)
{
aiResult.Description ??= full.Description;
aiResult.SdsUrl ??= full.SdsUrl;
aiResult.TdsUrl ??= full.TdsUrl;
aiResult.ImageUrl ??= full.ImageUrl;
aiResult.SpecPageUrl ??= full.SpecPageUrl;
aiResult.UnitCostPerLb ??= full.UnitCostPerLb;
aiResult.VendorName ??= full.VendorName;
aiResult.ColorFamilies ??= full.ColorFamilies;
aiResult.Finish ??= full.Finish;
aiResult.CureTemperatureF ??= full.CureTemperatureF;
aiResult.CureTimeMinutes ??= full.CureTimeMinutes;
aiResult.RequiresClearCoat ??= full.RequiresClearCoat;
aiResult.CoverageSqFtPerLb ??= full.CoverageSqFtPerLb;
aiResult.TransferEfficiency ??= full.TransferEfficiency;
aiResult.ManufacturerPartNumber ??= full.ManufacturerPartNumber;
aiResult.ColorName ??= full.ColorName;
aiResult.ColorCode ??= full.ColorCode;
}
}
}
}
else
{
return Json(new { success = false, errorMessage = "Provide either a label image or a QR code URL." });
}
if (!aiResult.Success)
return Json(new { success = false, errorMessage = aiResult.ErrorMessage });
var sku = aiResult.ManufacturerPartNumber?.Trim();
var manufacturer = (aiResult.Manufacturer ?? aiResult.VendorName)?.Trim();
var colorName = aiResult.ColorName?.Trim();
// Catalog lookup, merge, and auto-contribute — same logic as AiLookup button
var (wasInCatalog, addedToCatalog) = await EnrichFromCatalogAsync(aiResult, autoContribute: true);
// TDS cure fallback — same logic as AiLookup button
await ApplyTdsCureFallbackAsync(aiResult, colorName);
// Check if this product already exists in the tenant's inventory.
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
// Returns the first active match so the UI can prompt to add stock inline.
int? existingInventoryId = null;
string? existingInventoryName = null;
decimal? existingQuantityOnHand = null;
string? existingUnitOfMeasure = null;
InventoryItem? existingHit = null;
if (!string.IsNullOrEmpty(sku))
{
var skuLower = sku.ToLower();
var byPart = await _unitOfWork.InventoryItems.FindAsync(i =>
i.ManufacturerPartNumber != null &&
i.ManufacturerPartNumber.ToLower() == skuLower);
existingHit = byPart.FirstOrDefault();
}
if (existingHit == null && !string.IsNullOrEmpty(colorName))
{
var nameLower = colorName.ToLower();
var mfrLower = manufacturer?.ToLower() ?? "";
var byName = await _unitOfWork.InventoryItems.FindAsync(i =>
(i.ColorName != null && i.ColorName.ToLower() == nameLower) ||
i.Name.ToLower() == nameLower);
existingHit = byName.FirstOrDefault(i =>
string.IsNullOrEmpty(mfrLower) ||
(i.Manufacturer ?? "").ToLower().Contains(mfrLower) ||
mfrLower.Contains((i.Manufacturer ?? "").ToLower().Trim()));
}
if (existingHit != null)
{
existingInventoryId = existingHit.Id;
existingInventoryName = existingHit.Name;
existingQuantityOnHand = existingHit.QuantityOnHand;
existingUnitOfMeasure = existingHit.UnitOfMeasure;
}
return Json(new
{
success = true,
manufacturer = manufacturer,
manufacturerPartNumber = sku,
colorName = colorName,
description = aiResult.Description,
finish = aiResult.Finish,
cureTemperatureF = aiResult.CureTemperatureF,
cureTimeMinutes = aiResult.CureTimeMinutes,
colorFamilies = aiResult.ColorFamilies,
requiresClearCoat = aiResult.RequiresClearCoat,
coverageSqFtPerLb = aiResult.CoverageSqFtPerLb,
transferEfficiency = aiResult.TransferEfficiency,
unitPrice = aiResult.UnitCostPerLb ?? 0m,
imageUrl = aiResult.ImageUrl,
productUrl = aiResult.SpecPageUrl,
sdsUrl = aiResult.SdsUrl,
tdsUrl = aiResult.TdsUrl,
vendorName = manufacturer,
wasInCatalog = wasInCatalog,
addedToCatalog = addedToCatalog,
existingInventoryId = existingInventoryId,
existingInventoryName = existingInventoryName,
existingQuantityOnHand = existingQuantityOnHand,
existingUnitOfMeasure = existingUnitOfMeasure,
reasoning = aiResult.Reasoning,
});
}
/// <summary>
/// Adds stock to an existing inventory item from the label scanner inline prompt.
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
/// </summary>
[HttpPost]
public async Task<IActionResult> AddStock(int inventoryItemId, decimal quantity, decimal? unitCost, string? notes)
{
try
{
if (quantity <= 0)
return Json(new { success = false, errorMessage = "Quantity must be greater than zero." });
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
if (item == null) return Json(new { success = false, errorMessage = "Item not found." });
var cost = (unitCost.HasValue && unitCost.Value > 0) ? unitCost.Value : item.UnitCost;
item.QuantityOnHand += quantity;
item.LastPurchaseDate = DateTime.UtcNow;
if (unitCost.HasValue && unitCost.Value > 0)
{
item.LastPurchasePrice = unitCost.Value;
item.UnitCost = unitCost.Value;
}
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = InventoryTransactionType.Purchase,
Quantity = quantity,
UnitCost = cost,
TotalCost = quantity * cost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
Notes = !string.IsNullOrWhiteSpace(notes) ? notes.Trim() : "Added via label scan",
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Label scan added {Qty} {UOM} to inventory item {Id} ({Name})",
quantity, item.UnitOfMeasure, item.Id, item.Name);
return Json(new
{
success = true,
newQuantityOnHand = item.QuantityOnHand,
unitOfMeasure = item.UnitOfMeasure,
itemName = item.Name,
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding stock via label scan to inventory item {ItemId}", inventoryItemId);
return Json(new { success = false, errorMessage = "An error occurred. Please try again." });
}
}
/// <summary>
/// Searches the platform-level PowderCatalogItems table by SKU or color name and returns
/// up to 10 matches as JSON. Called by the inventory Create/Edit form before falling back
/// to the AI Lookup, avoiding unnecessary API calls for known products.
/// </summary>
[HttpGet]
public async Task<IActionResult> CatalogLookup(string? q, string? vendor)
{
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Json(Array.Empty<object>());
var term = q.Trim().ToLower();
var vendorTerm = vendor?.Trim().ToLower();
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term));
// When a vendor hint is provided, prefer records where VendorName matches,
// then fall back to all results so the user still sees cross-vendor options.
var results = matches
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
.ThenBy(p => !string.IsNullOrEmpty(vendorTerm) && p.VendorName.ToLower().Contains(vendorTerm) ? 0 : 1)
.ThenBy(p => p.ColorName)
.Take(10)
.Select(p => new
{
id = p.Id,
vendorName = p.VendorName,
sku = p.Sku,
colorName = p.ColorName,
description = p.Description,
unitPrice = p.UnitPrice,
imageUrl = p.ImageUrl,
sdsUrl = p.SdsUrl,
tdsUrl = p.TdsUrl,
applicationGuideUrl = p.ApplicationGuideUrl,
productUrl = p.ProductUrl,
isDiscontinued = p.IsDiscontinued,
cureTemperatureF = p.CureTemperatureF,
cureTimeMinutes = p.CureTimeMinutes,
finish = p.Finish,
colorFamilies = p.ColorFamilies,
requiresClearCoat = p.RequiresClearCoat,
coverageSqFtPerLb = p.CoverageSqFtPerLb,
transferEfficiency = p.TransferEfficiency
});
return Json(results);
}
/// <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
@@ -0,0 +1,289 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Inventory;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class PowderCatalogController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<PowderCatalogController> _logger;
public PowderCatalogController(IUnitOfWork unitOfWork, ILogger<PowderCatalogController> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Shows platform-level catalog stats and the import form.
/// </summary>
public async Task<IActionResult> Index()
{
var all = await _unitOfWork.PowderCatalog.GetAllAsync();
var list = all.ToList();
var stats = 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
};
return View(stats);
}
/// <summary>
/// Accepts a JSON file upload (Prismatic Powders scrape format) and upserts all records
/// into PowderCatalogItems. Strips page-scrape boilerplate from descriptions.
/// Existing records matched by (VendorName, Sku) are updated in-place; new ones are inserted.
/// </summary>
[HttpPost]
[RequestSizeLimit(50 * 1024 * 1024)] // 50 MB
public async Task<IActionResult> Import(IFormFile file, string vendorName = "Prismatic Powders")
{
if (file == null || file.Length == 0)
{
TempData["Error"] = "Please select a JSON file to import.";
return RedirectToAction(nameof(Index));
}
PowderCatalogImportResult result;
try
{
result = await ImportJsonAsync(file, vendorName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Powder catalog import failed");
TempData["Error"] = $"Import failed: {ex.Message}";
return RedirectToAction(nameof(Index));
}
if (!result.Success)
{
TempData["Error"] = result.ErrorMessage ?? "Import failed.";
}
else
{
TempData["Success"] = $"Import complete — {result.Inserted:N0} inserted, {result.Updated:N0} updated, {result.Skipped:N0} skipped.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// AJAX endpoint used by the inventory form to search the catalog by SKU or color name.
/// SKU exact matches are ranked first; color name substring matches follow.
/// Returns up to 10 results. Accessible to all authenticated users so the inventory
/// Create/Edit form can call it without a separate policy.
/// </summary>
[AllowAnonymous]
[Authorize]
public async Task<IActionResult> Lookup(string? q)
{
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Json(Array.Empty<PowderCatalogLookupResult>());
var term = q.Trim();
// Exact SKU match first, then color name contains
var all = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term.ToLower() ||
p.ColorName.ToLower().Contains(term.ToLower()) ||
p.Sku.ToLower().Contains(term.ToLower()));
var results = all
.OrderBy(p => p.Sku.ToLower() == term.ToLower() ? 0 : 1)
.ThenBy(p => p.ColorName)
.Take(10)
.Select(p => new PowderCatalogLookupResult
{
Id = p.Id,
VendorName = p.VendorName,
Sku = p.Sku,
ColorName = p.ColorName,
Description = p.Description,
UnitPrice = p.UnitPrice,
ImageUrl = p.ImageUrl,
SdsUrl = p.SdsUrl,
TdsUrl = p.TdsUrl,
ApplicationGuideUrl = p.ApplicationGuideUrl,
ProductUrl = p.ProductUrl,
IsDiscontinued = p.IsDiscontinued
})
.ToList();
return Json(results);
}
// ── Private helpers ────────────────────────────────────────────────────────
private async Task<PowderCatalogImportResult> ImportJsonAsync(IFormFile file, string vendorName)
{
using var stream = file.OpenReadStream();
using var doc = await JsonDocument.ParseAsync(stream);
if (!doc.RootElement.TryGetProperty("results", out var resultsEl) ||
resultsEl.ValueKind != JsonValueKind.Array)
{
return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' array." };
}
// Load existing records for this vendor into a lookup dictionary
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => p.VendorName == vendorName))
.ToDictionary(p => p.Sku, StringComparer.OrdinalIgnoreCase);
var now = DateTime.UtcNow;
int inserted = 0, updated = 0, skipped = 0, errors = 0;
var toAdd = new List<PowderCatalogItem>();
foreach (var item in resultsEl.EnumerateArray())
{
try
{
var sku = item.GetStringOrNull("sku");
var colorName = item.GetStringOrNull("color_name");
if (string.IsNullOrWhiteSpace(sku) || string.IsNullOrWhiteSpace(colorName))
{
skipped++;
continue;
}
var rawDesc = item.GetStringOrNull("description");
var cleanDesc = StripBoilerplate(rawDesc);
var unitPrice = ExtractBasePrice(item);
var priceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl)
? tiersEl.GetRawText()
: null;
if (existing.TryGetValue(sku, out var record))
{
record.ColorName = colorName;
record.Description = cleanDesc;
record.UnitPrice = unitPrice;
record.PriceTiersJson = priceTiersJson;
record.ImageUrl = item.GetStringOrNull("sample_image_url");
record.SdsUrl = item.GetStringOrNull("safety_data_sheet_url");
record.TdsUrl = item.GetStringOrNull("technical_data_sheet_url");
record.ApplicationGuideUrl = item.GetStringOrNull("application_guide_url");
record.ProductUrl = item.GetStringOrNull("product_url");
record.UpdatedAt = now;
record.LastSyncedAt = now;
await _unitOfWork.PowderCatalog.UpdateAsync(record);
updated++;
}
else
{
toAdd.Add(new PowderCatalogItem
{
VendorName = vendorName,
Sku = sku,
ColorName = colorName,
Description = cleanDesc,
UnitPrice = unitPrice,
PriceTiersJson = priceTiersJson,
ImageUrl = item.GetStringOrNull("sample_image_url"),
SdsUrl = item.GetStringOrNull("safety_data_sheet_url"),
TdsUrl = item.GetStringOrNull("technical_data_sheet_url"),
ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"),
ProductUrl = item.GetStringOrNull("product_url"),
CreatedAt = now,
LastSyncedAt = now
});
inserted++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Skipping catalog record due to parse error");
errors++;
}
}
if (toAdd.Any())
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
await _unitOfWork.CompleteAsync();
return new PowderCatalogImportResult
{
Success = true,
Inserted = inserted,
Updated = updated,
Skipped = skipped,
Errors = errors
};
}
/// <summary>
/// Strips page-scrape boilerplate that starts at "PRODUCT SUPPORT" or "CUSTOMER SERVICE".
/// Returns a trimmed first-paragraph description suitable for display.
/// </summary>
private static string? StripBoilerplate(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var cutpoints = new[] { "PRODUCT SUPPORT", "CUSTOMER SERVICE", "Q&As", "FAQs" };
var cut = raw.Length;
foreach (var cp in cutpoints)
{
var idx = raw.IndexOf(cp, StringComparison.OrdinalIgnoreCase);
if (idx > 0 && idx < cut)
cut = idx;
}
var cleaned = raw[..cut].Trim();
// Collapse multiple whitespace runs
cleaned = Regex.Replace(cleaned, @"\s{3,}", " ").Trim();
return cleaned.Length > 0 ? cleaned : null;
}
/// <summary>
/// Extracts the base (lowest-quantity) unit price from the price_tiers array.
/// Falls back to 0 if the array is missing or malformed.
/// </summary>
private static decimal ExtractBasePrice(JsonElement item)
{
if (!item.TryGetProperty("price_tiers", out var tiers) || tiers.ValueKind != JsonValueKind.Array)
return 0m;
// Find the tier with the lowest min (base price = smallest quantity break)
decimal? price = null;
int lowestMin = int.MaxValue;
foreach (var tier in tiers.EnumerateArray())
{
if (tier.TryGetProperty("min", out var minEl) && minEl.TryGetInt32(out var min) &&
tier.TryGetProperty("price", out var priceEl) && priceEl.TryGetDecimal(out var p))
{
if (min < lowestMin && min >= 1 && p > 0)
{
lowestMin = min;
price = p;
}
}
}
return price ?? 0m;
}
}
/// <summary>Extension helpers for reading nullable strings from JsonElement.</summary>
internal static class JsonElementExtensions
{
internal static string? GetStringOrNull(this JsonElement el, string property)
{
if (el.TryGetProperty(property, out var val) && val.ValueKind == JsonValueKind.String)
return val.GetString();
return null;
}
}
@@ -72,17 +72,20 @@
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0 d-flex align-items-center">
<i class="bi bi-palette me-2 text-primary"></i>Product Details
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? true))
<button type="button" class="btn btn-sm btn-primary ms-2" id="smart-lookup-btn">
<i class="bi bi-search me-1"></i>Lookup
</button>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<button type="button" class="btn btn-sm btn-outline-primary ms-2" id="ai-lookup-btn">
<i class="bi bi-stars me-1"></i>AI Lookup
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="scan-label-btn" title="Scan a powder bag label with your camera">
<i class="bi bi-qr-code-scan me-1"></i>Scan Label
</button>
}
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Product Details"
data-bs-content="Manufacturer, part number, color name, color code, and finish describe the physical product. AI Lookup can auto-fill these fields from a manufacturer name or part number. Coverage is how many sq ft one pound coats at 1 mil thickness (typical: 30). Transfer Efficiency is what percentage of the powder actually sticks (typical: 6070%). Both values are used to calculate Powder Needed on quotes and jobs.">
data-bs-content="Manufacturer, part number, color name, color code, and finish describe the physical product. Use Lookup to auto-fill these fields — it checks the product catalog first, then falls back to AI. Coverage is how many sq ft one pound coats at 1 mil thickness (typical: 30). Transfer Efficiency is what percentage of the powder actually sticks (typical: 6070%). Both values are used to calculate Powder Needed on quotes and jobs.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -123,6 +126,28 @@
</div>
<span asp-validation-for="SpecPageUrl" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="SdsUrl" class="form-label">Safety Data Sheet (SDS)</label>
<div class="input-group">
<input asp-for="SdsUrl" class="form-control" id="field-sdsurl" placeholder="https://…" />
<a id="field-sdsurl-link" href="@Model.SdsUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.SdsUrl) ? "d-none" : "")" title="Open SDS">
<i class="bi bi-file-earmark-pdf"></i>
</a>
</div>
<span asp-validation-for="SdsUrl" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="TdsUrl" class="form-label">Technical Data Sheet (TDS)</label>
<div class="input-group">
<input asp-for="TdsUrl" class="form-control" id="field-tdsurl" placeholder="https://…" />
<a id="field-tdsurl-link" href="@Model.TdsUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.TdsUrl) ? "d-none" : "")" title="Open TDS">
<i class="bi bi-file-earmark-text"></i>
</a>
</div>
<span asp-validation-for="TdsUrl" class="text-danger"></span>
</div>
<input asp-for="ImageUrl" type="hidden" id="field-imageurl" />
<div class="col-12" id="wrap-imagepreview" style="display:@(Model.ImageUrl != null ? "" : "none");">
<label class="form-label text-muted small">Product Image (from AI Lookup)</label>
@@ -374,8 +399,18 @@
</div>
</div>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<partial name="_LabelScanModal" />
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>const inventoryFormIsCreate = true;</script>
<partial name="_InventoryColorFamilyScripts" />
<script src="~/js/inventory-catalog-lookup.js"></script>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<script src="~/js/inventory-label-scan.js"></script>
}
}
@@ -190,6 +190,26 @@
</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.SdsUrl) || !string.IsNullOrEmpty(Model.TdsUrl))
{
<div class="col-12">
<label class="text-muted small mb-1">Data Sheets</label>
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrEmpty(Model.SdsUrl))
{
<a href="@Model.SdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-pdf me-1"></i>Safety Data Sheet
</a>
}
@if (!string.IsNullOrEmpty(Model.TdsUrl))
{
<a href="@Model.TdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-text me-1"></i>Technical Data Sheet
</a>
}
</div>
</div>
}
}
@if (!string.IsNullOrEmpty(Model.Notes))
{
@@ -74,10 +74,13 @@
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0 d-flex align-items-center">
<i class="bi bi-palette me-2 text-primary"></i>Product Details
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? true))
<button type="button" class="btn btn-sm btn-primary ms-2" id="smart-lookup-btn">
<i class="bi bi-search me-1"></i>Lookup
</button>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<button type="button" class="btn btn-sm btn-outline-primary ms-2" id="ai-lookup-btn">
<i class="bi bi-stars me-1"></i>AI Lookup
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="scan-label-btn" title="Scan a powder bag label with your camera">
<i class="bi bi-qr-code-scan me-1"></i>Scan Label
</button>
}
</h5>
@@ -125,6 +128,28 @@
</div>
<span asp-validation-for="SpecPageUrl" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="SdsUrl" class="form-label">Safety Data Sheet (SDS)</label>
<div class="input-group">
<input asp-for="SdsUrl" class="form-control" id="field-sdsurl" placeholder="https://…" />
<a id="field-sdsurl-link" href="@Model.SdsUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.SdsUrl) ? "d-none" : "")" title="Open SDS">
<i class="bi bi-file-earmark-pdf"></i>
</a>
</div>
<span asp-validation-for="SdsUrl" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="TdsUrl" class="form-label">Technical Data Sheet (TDS)</label>
<div class="input-group">
<input asp-for="TdsUrl" class="form-control" id="field-tdsurl" placeholder="https://…" />
<a id="field-tdsurl-link" href="@Model.TdsUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.TdsUrl) ? "d-none" : "")" title="Open TDS">
<i class="bi bi-file-earmark-text"></i>
</a>
</div>
<span asp-validation-for="TdsUrl" class="text-danger"></span>
</div>
<input asp-for="ImageUrl" type="hidden" id="field-imageurl" />
<div class="col-12" id="wrap-imagepreview" style="display:@(Model.ImageUrl != null ? "" : "none");">
<label class="form-label text-muted small">Product Image (from AI Lookup)</label>
@@ -394,7 +419,17 @@
</div>
</div>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<partial name="_LabelScanModal" />
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<partial name="_InventoryColorFamilyScripts" />
<script src="~/js/inventory-catalog-lookup.js"></script>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<script src="~/js/inventory-label-scan.js"></script>
}
}
@@ -36,7 +36,7 @@
// ── Category → IsCoating map + show/hide coating section ─────────────
const categorySelect = document.getElementById('field-category');
const coatingSection = document.getElementById('coating-specs-section');
const aiBtn = document.getElementById('ai-lookup-btn');
const smartLookupBtn = document.getElementById('smart-lookup-btn');
let coatingMap = {};
if (categorySelect && categorySelect.dataset.coatingMap) {
@@ -53,7 +53,7 @@
function updateCoatingVisibility(catId) {
const show = isCoatingCategory(catId);
if (coatingSection) coatingSection.style.display = show ? '' : 'none';
if (aiBtn) aiBtn.style.display = show ? '' : 'none';
if (smartLookupBtn) smartLookupBtn.style.display = show ? '' : 'none';
const samplePanelSection = document.getElementById('sample-panel-section');
if (samplePanelSection) samplePanelSection.style.display = show ? '' : 'none';
coatingOnlyFields.forEach(id => {
@@ -253,11 +253,8 @@
});
// ── AI Lookup ─────────────────────────────────────────────────────────
const btn = document.getElementById('ai-lookup-btn');
const statusEl = document.getElementById('ai-lookup-status');
if (!btn) return;
function showBadMatchBtn() {
if (document.getElementById('ai-bad-match-btn')) return; // already shown
const b = document.createElement('button');
@@ -297,14 +294,15 @@
showStatus('info', '<i class="bi bi-check-circle me-1"></i>Fields cleared. Update any details above and click <em>AI Lookup</em> again.');
}
});
btn.insertAdjacentElement('afterend', b);
const lookupBtn = document.getElementById('smart-lookup-btn');
if (lookupBtn) lookupBtn.insertAdjacentElement('afterend', b);
}
function hideBadMatchBtn() {
document.getElementById('ai-bad-match-btn')?.remove();
}
btn.addEventListener('click', async () => {
async function performAiLookup() {
const manufacturer = document.getElementById('field-manufacturer')?.value?.trim() || '';
const colorName = document.getElementById('field-colorname')?.value?.trim() || '';
const colorCode = document.getElementById('field-colorcode')?.value?.trim() || '';
@@ -325,8 +323,6 @@
const effectiveColorName = colorName || itemName;
hideBadMatchBtn();
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Looking up...';
showInfo('Searching for product specifications…', 'AI Lookup');
try {
@@ -454,6 +450,21 @@
aiFilledImage = true;
}
// SDS / TDS document URLs — fill inputs and show open-link buttons
const fillDocUrl = (fieldId, linkId, url, label) => {
if (!url) return;
const el = document.getElementById(fieldId);
const link = document.getElementById(linkId);
if (el && (forceRefill || !el.value.trim())) {
el.value = url;
filled.push(label);
if (!aiFilledFields.includes(fieldId)) aiFilledFields.push(fieldId);
}
if (link) { link.href = url; link.classList.remove('d-none'); }
};
fillDocUrl('field-sdsurl', 'field-sdsurl-link', data.sdsUrl, 'SDS');
fillDocUrl('field-tdsurl', 'field-tdsurl-link', data.tdsUrl, 'TDS');
// Build a persistent "needs more info" tip if key identity fields are still unknown
const missingHints = [];
if (!data.manufacturer && !document.getElementById('field-manufacturer')?.value?.trim())
@@ -487,11 +498,12 @@
showError('Request failed: ' + err.message, 'AI Lookup Error');
showStatus('danger', 'Request failed: ' + err.message);
} finally {
forceRefill = false; // reset after each run
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-stars me-1"></i>AI Lookup';
forceRefill = false; // reset after each run
}
});
}
// Expose so inventory-catalog-lookup.js can fall back to AI when catalog misses
window._runInventoryAiLookup = performAiLookup;
function debugPanel(data) {
const json = JSON.stringify(data, null, 2);
@@ -0,0 +1,114 @@
<!-- Add-stock modal: shown when label scan matches an existing inventory item -->
<div class="modal fade" id="addStockModal" tabindex="-1" aria-labelledby="addStockModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width:420px;">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="addStockModalLabel">
<i class="bi bi-box-seam me-2 text-success"></i>Already in Inventory
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pb-2">
<p class="mb-1">
<strong id="add-stock-item-name" class="text-body"></strong> is already in your inventory.
</p>
<p class="text-muted small mb-3">
Current stock: <strong id="add-stock-current-qty"></strong>
</p>
<div class="mb-3">
<label class="form-label fw-semibold small">Quantity to Add <span class="text-danger">*</span></label>
<div class="input-group input-group-sm">
<input type="number" id="add-stock-qty" class="form-control" min="0.01" step="0.01" placeholder="e.g. 5">
<span class="input-group-text" id="add-stock-uom-label">lbs</span>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small">Unit Cost <span class="text-muted fw-normal">(optional — updates item cost)</span></label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" id="add-stock-cost" class="form-control" min="0" step="0.01" placeholder="Leave blank to keep current">
</div>
</div>
<div class="mb-2">
<label class="form-label fw-semibold small">Notes <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" id="add-stock-notes" class="form-control form-control-sm" placeholder="e.g. New bag received">
</div>
<div id="add-stock-status" class="d-none small mt-2"></div>
</div>
<div class="modal-footer flex-column align-items-stretch gap-2 py-2">
<button id="add-stock-confirm-btn" type="button" class="btn btn-success">
<i class="bi bi-plus-circle me-1"></i>Add Stock
</button>
<button id="add-stock-new-btn" type="button" class="btn btn-link btn-sm text-muted">
Add as a new entry instead (e.g. different lot)
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="labelScanModal" tabindex="-1" aria-labelledby="labelScanModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width:480px;">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="labelScanModalLabel">
<i class="bi bi-qr-code-scan me-2 text-primary"></i>Scan Powder Label
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0 position-relative bg-black" style="min-height:300px;">
<!-- Live camera feed -->
<video id="scan-video" autoplay playsinline muted
style="width:100%;display:block;max-height:400px;object-fit:cover;"></video>
<!-- Hidden canvas used for QR analysis and frame capture -->
<canvas id="scan-canvas" style="display:none;"></canvas>
<!-- Targeting overlay: darkened edges with a bright center window -->
<div style="position:absolute;inset:0;pointer-events:none;">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<mask id="scan-mask">
<rect width="100%" height="100%" fill="white"/>
<rect x="15%" y="20%" width="70%" height="60%" rx="8" fill="black"/>
</mask>
</defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.45)" mask="url(#scan-mask)"/>
<!-- Corner brackets -->
<g stroke="#fff" stroke-width="3" fill="none" opacity="0.9">
<path d="M 15% 28% L 15% 20% L 23% 20%"/>
<path d="M 77% 20% L 85% 20% L 85% 28%"/>
<path d="M 85% 72% L 85% 80% L 77% 80%"/>
<path d="M 23% 80% L 15% 80% L 15% 72%"/>
</g>
</svg>
</div>
<!-- Processing overlay: shown while the server lookup is running -->
<div id="scan-processing" style="display:none;position:absolute;inset:0;z-index:10;background:rgba(0,0,0,0.88);align-items:center;justify-content:center;flex-direction:column;color:#fff;text-align:center;padding:1.5rem;">
<div class="spinner-border text-light mb-3" style="width:2.5rem;height:2.5rem;"></div>
<div id="scan-processing-msg" class="fw-medium fs-6">Looking up product…</div>
<div class="text-white-50 small mt-1">This may take a few seconds</div>
</div>
<!-- Status inside the modal -->
<div id="scan-modal-status" class="alert alert-info py-2 small mb-0 mt-2 d-none mx-2 mb-2"
style="position:absolute;bottom:0;left:0;right:0;margin:8px !important;"></div>
</div>
<div class="modal-footer flex-column align-items-stretch py-2 gap-2">
<div class="text-muted small text-center">
<i class="bi bi-magic me-1"></i>QR codes are detected automatically.
</div>
<div id="scan-shutter-wrap" class="d-none">
<div class="text-muted small text-center mb-2">No QR code? Tap to read the label text with AI.</div>
<button id="scan-shutter-btn" type="button" class="btn btn-secondary w-100">
<i class="bi bi-camera me-1"></i>Scan Label Text
</button>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,158 @@
@model PowderCoating.Application.DTOs.Inventory.PowderCatalogStatsDto
@{
ViewData["Title"] = "Powder Catalog";
ViewData["PageIcon"] = "bi-palette2";
Layout = "_Layout";
}
<div class="container-fluid">
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show mb-3" role="alert">
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent alert-dismissible fade show mb-3" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<!-- Stats cards -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-primary bg-opacity-10">
<i class="bi bi-collection fs-4 text-primary"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.TotalProducts.ToString("N0")</div>
<div class="text-muted small">Total Products</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-success bg-opacity-10">
<i class="bi bi-check-circle fs-4 text-success"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.ActiveProducts.ToString("N0")</div>
<div class="text-muted small">Active</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-warning bg-opacity-10">
<i class="bi bi-slash-circle fs-4 text-warning"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.DiscontinuedProducts.ToString("N0")</div>
<div class="text-muted small">Discontinued</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-info bg-opacity-10">
<i class="bi bi-building fs-4 text-info"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.VendorCount</div>
<div class="text-muted small">
@(Model.VendorCount == 1 ? "Vendor" : "Vendors")
@if (Model.LastImportedAt.HasValue)
{
<br /><span class="text-muted" style="font-size:.75rem;">Last sync @Model.LastImportedAt.Value.ToString("MMM d, yyyy")</span>
}
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-purple bg-opacity-10" style="background:rgba(111,66,193,.1)">
<i class="bi bi-qr-code-scan fs-4" style="color:#6f42c1;"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.UserContributedProducts.ToString("N0")</div>
<div class="text-muted small">Tenant Contributed</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<!-- Import card -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent border-bottom">
<h5 class="mb-0"><i class="bi bi-cloud-upload me-2 text-primary"></i>Import Catalog Data</h5>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Upload a Prismatic Powders scrape JSON file (the <code>prismatic_powders.json</code> format with
a top-level <code>results</code> array). Existing SKUs are updated in-place; new ones are inserted.
Discontinued products remain in the catalog flagged as <code>IsDiscontinued</code>.
</p>
<form asp-action="Import" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label fw-medium">Vendor Name</label>
<input type="text" name="vendorName" value="Prismatic Powders" class="form-control" required />
<div class="form-text">Must match exactly — used as the upsert key alongside SKU.</div>
</div>
<div class="mb-3">
<label class="form-label fw-medium">JSON File <span class="text-danger">*</span></label>
<input type="file" name="file" accept=".json" class="form-control" required />
<div class="form-text">Max 50 MB. Must be the scraped format with <code>results[]</code> array.</div>
</div>
<button type="submit" class="btn btn-primary" id="btn-import">
<i class="bi bi-upload me-2"></i>Import
</button>
</form>
</div>
</div>
</div>
<!-- Info / how it works card -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent border-bottom">
<h5 class="mb-0"><i class="bi bi-info-circle me-2 text-primary"></i>How It Works</h5>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0" style="line-height:2;">
<li><i class="bi bi-check2 text-success me-2"></i><strong>Platform-level:</strong> One shared catalog, no per-tenant copies.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Catalog-first lookup:</strong> When a tenant adds inventory, the form searches here before calling the AI API.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Auto-fill:</strong> Selecting a result fills color name, manufacturer, part number, unit cost, SDS/TDS links, and product image.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Discontinued:</strong> Flagged <code>IsDiscontinued = true</code> — never hidden, always available for historical lookups.</li>
<li><i class="bi bi-clock text-muted me-2"></i><strong>Phase 2:</strong> Monthly price sync + push to tenant inventory items.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
document.querySelector('form').addEventListener('submit', function () {
var btn = document.getElementById('btn-import');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Importing…';
});
</script>
@@ -1308,6 +1308,10 @@
<i class="bi bi-database-fill-gear"></i>
<span>Seed Data</span>
</a>
<a asp-controller="PowderCatalog" asp-action="Index" class="nav-link">
<i class="bi bi-palette2"></i>
<span>Powder Catalog</span>
</a>
<a asp-controller="ManufacturerLookupPatterns" asp-action="Index" class="nav-link">
<i class="bi bi-link-45deg"></i>
<span>Manufacturer Lookup Patterns</span>
@@ -0,0 +1,416 @@
/**
* Unified Lookup button for the Inventory Create/Edit forms.
*
* Flow:
* 1. User fills in Manufacturer + Color Name (and/or Part Number) in the existing fields.
* 2. Clicks "Lookup".
* 3. This script searches the platform PowderCatalogItems table first (no API cost).
* - 1 exact/best match → auto-fills fields immediately (same UX as AI Lookup).
* - Multiple matches → Bootstrap modal lets user pick the right one.
* - No match → falls through to window._runInventoryAiLookup() if AI is enabled.
* 4. After a catalog hit, if AI is enabled, augments with cure data from the product URL.
*
* The AI-only button (#ai-lookup-btn) is still wired by _InventoryColorFamilyScripts.cshtml
* and can be used to skip the catalog and go straight to AI.
*/
(function () {
'use strict';
const LOOKUP_URL = '/Inventory/CatalogLookup';
const AUGMENT_URL = '/Inventory/AiAugmentFromUrl';
const smartBtn = document.getElementById('smart-lookup-btn');
const statusEl = document.getElementById('ai-lookup-status'); // shared with AI lookup
if (!smartBtn) return;
// Snapshot of field values set by the catalog fill so we can clear them all
// when the user starts typing a new color name. null when no catalog fill is active.
let catalogSnapshot = null;
// ── Button click ──────────────────────────────────────────────────────────
smartBtn.addEventListener('click', async function () {
const manufacturer = document.getElementById('field-manufacturer')?.value?.trim() || '';
const colorName = document.getElementById('field-colorname')?.value?.trim() || '';
const itemName = document.getElementById('field-name')?.value?.trim() || '';
// Don't use part number as the search term if the catalog previously filled it —
// the snapshot tracks catalog-owned field values.
const partNumberEl = document.getElementById('field-partnumber');
const partNumber = (catalogSnapshot?.['field-partnumber'] == null && partNumberEl?.value?.trim()) || '';
// Color name takes priority — it's what the user types when they want a specific powder.
const searchTerm = colorName || itemName || partNumber;
if (!searchTerm && !manufacturer) {
showStatus('warning', 'Fill in at least a Color Name or Part Number, then click Lookup.');
return;
}
setLoading(true);
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Searching catalog…');
try {
const params = new URLSearchParams();
if (searchTerm) params.set('q', searchTerm);
if (manufacturer) params.set('vendor', manufacturer);
const resp = await fetch(`${LOOKUP_URL}?${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const items = await resp.json();
if (items.length === 0) {
// No catalog match — fall back to AI if available
hideStatus();
if (typeof window._runInventoryAiLookup === 'function') {
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Not in catalog — searching with AI…');
await window._runInventoryAiLookup();
} else {
showStatus('warning', 'No match found in the catalog. Enter details manually or enable AI Lookup.');
}
return;
}
if (items.length === 1) {
await fillFields(items[0]);
return;
}
// Multiple matches — let the user pick via modal
hideStatus();
showPickerModal(items);
} catch (err) {
showStatus('danger', 'Lookup failed: ' + err.message);
} finally {
setLoading(false);
}
});
// ── Fill fields from a catalog result ────────────────────────────────────
async function fillFields(item) {
catalogSnapshot = {};
const filled = [];
function setIf(id, value, label) {
const el = document.getElementById(id);
if (el && value != null && String(value).trim()) {
el.value = String(value).trim();
catalogSnapshot[id] = String(value).trim();
filled.push(label);
}
}
setIf('field-manufacturer', item.vendorName, 'Manufacturer');
setIf('field-partnumber', item.sku, 'Part Number');
setIf('field-colorname', item.colorName, 'Color Name');
// Name field (coating items use color name as name)
const nameEl = document.getElementById('field-name');
if (nameEl && !nameEl.value.trim() && item.colorName) {
nameEl.value = item.colorName;
catalogSnapshot['field-name'] = item.colorName;
filled.push('Name');
}
// Description — only fill if currently empty
const descEl = document.getElementById('field-description');
if (descEl && !descEl.value.trim() && item.description) {
descEl.value = item.description;
catalogSnapshot['field-description'] = item.description;
filled.push('Description');
}
// Unit cost — only fill if currently zero/empty
const costEl = document.getElementById('field-unitcost');
if (costEl && item.unitPrice > 0 && (parseFloat(costEl.value) || 0) === 0) {
costEl.value = item.unitPrice;
catalogSnapshot['field-unitcost'] = String(item.unitPrice);
filled.push('Unit Cost');
}
// Coating specs — populated for scan-contributed entries; skip if already filled
function setIfEmpty(id, value, label) {
const el = document.getElementById(id);
if (el && value != null && String(value).trim() && !el.value.trim()) {
el.value = String(value).trim();
filled.push(label);
}
}
setIfEmpty('field-finish', item.finish, 'Finish');
setIfEmpty('field-curetemp', item.cureTemperatureF, 'Cure Temp');
setIfEmpty('field-curetime', item.cureTimeMinutes, 'Cure Time');
setIfEmpty('field-coverage', item.coverageSqFtPerLb, 'Coverage');
setIfEmpty('field-transfer', item.transferEfficiency,'Transfer Efficiency');
if (item.requiresClearCoat != null) {
const cc = document.getElementById('field-clearcoat');
if (cc) { cc.checked = item.requiresClearCoat; filled.push('Clear Coat'); }
}
if (item.colorFamilies) {
const hiddenInput = document.getElementById('field-colorfamilies');
if (hiddenInput && !hiddenInput.value.trim()) {
const families = item.colorFamilies.split(',').map(s => s.trim()).filter(Boolean);
hiddenInput.value = families.join(',');
document.querySelectorAll('.color-family-chip').forEach(chip => {
chip.classList.toggle('active', families.includes(chip.dataset.family));
});
filled.push('Color Families');
}
}
// Product URL + open-link button
setIf('field-specpageurl', item.productUrl, 'Product URL');
syncLinkButton('field-specpageurl', 'field-specpageurl-link', item.productUrl);
// SDS / TDS
setIf('field-sdsurl', item.sdsUrl, 'SDS');
syncLinkButton('field-sdsurl', 'field-sdsurl-link', item.sdsUrl);
setIf('field-tdsurl', item.tdsUrl, 'TDS');
syncLinkButton('field-tdsurl', 'field-tdsurl-link', item.tdsUrl);
// Image
if (item.imageUrl) {
const imgInput = document.getElementById('field-imageurl');
const imgEl = document.getElementById('field-imagepreview-img');
const imgWrap = document.getElementById('wrap-imagepreview');
if (imgInput) { imgInput.value = item.imageUrl; catalogSnapshot['field-imageurl'] = item.imageUrl; }
if (imgEl) imgEl.src = item.imageUrl;
if (imgWrap) imgWrap.style.display = '';
filled.push('Image');
}
// Vendor dropdown — match by name
const vendorSel = document.getElementById('field-vendor');
if (vendorSel && !vendorSel.value && item.vendorName) {
const needle = item.vendorName.toLowerCase();
const match = Array.from(vendorSel.options).find(o =>
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
);
if (match) { vendorSel.value = match.value; filled.push('Vendor'); }
}
const discontinuedNote = item.isDiscontinued
? ' <span class="badge bg-warning text-dark ms-1">Discontinued</span>' : '';
if (filled.length > 0) {
showStatus('success', `Filled from catalog: ${filled.join(', ')}.${discontinuedNote}`);
} else {
showStatus('info', `Found in catalog but no empty fields to fill.${discontinuedNote}`);
}
// Augment with AI if enabled and we have a product URL with cure data to fetch
if (item.productUrl && typeof window._runInventoryAiLookup === 'function') {
await augmentFromUrl(item.productUrl, item.colorName, filled, discontinuedNote);
}
}
// ── AI augmentation from product URL ────────────────────────────────────
async function augmentFromUrl(productUrl, colorName, alreadyFilled, discontinuedNote) {
smartBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Augmenting with AI…';
showStatus('info',
'<span class="spinner-border spinner-border-sm me-1"></span>' +
'Filled from catalog — fetching cure specs with AI…');
try {
const fd = new FormData();
fd.append('productUrl', productUrl);
if (colorName) fd.append('colorName', colorName);
const resp = await fetch(AUGMENT_URL, { method: 'POST', body: fd });
if (!resp.ok) {
// Restore the plain catalog success message and bail
showStatus('success', `Filled from catalog: ${alreadyFilled.join(', ')}.${discontinuedNote}`);
return;
}
const data = await resp.json();
if (!data.success) {
showStatus('success', `Filled from catalog: ${alreadyFilled.join(', ')}.${discontinuedNote}`);
return;
}
const augFilled = [];
function setIfEmpty(id, value, label) {
const el = document.getElementById(id);
if (el && value != null && String(value).trim() && !el.value.trim()) {
el.value = String(value).trim();
augFilled.push(label);
}
}
setIfEmpty('field-finish', data.finish, 'Finish');
setIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage');
setIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency');
setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp');
setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time');
if (data.requiresClearCoat !== null && data.requiresClearCoat !== undefined) {
const cc = document.getElementById('field-clearcoat');
if (cc) { cc.checked = data.requiresClearCoat; augFilled.push('Clear Coat'); }
}
// Color families — only set if not already chosen
if (data.colorFamilies) {
const hiddenInput = document.getElementById('field-colorfamilies');
if (hiddenInput && !hiddenInput.value.trim()) {
const families = data.colorFamilies.split(',').map(s => s.trim()).filter(Boolean);
hiddenInput.value = families.join(',');
document.querySelectorAll('.color-family-chip').forEach(chip => {
chip.classList.toggle('active', families.includes(chip.dataset.family));
});
augFilled.push('Color Families');
}
}
// Image — only if catalog didn't provide one
if (data.imageUrl && !document.getElementById('field-imageurl')?.value?.trim()) {
const imgInput = document.getElementById('field-imageurl');
const imgEl = document.getElementById('field-imagepreview-img');
const imgWrap = document.getElementById('wrap-imagepreview');
if (imgInput) imgInput.value = data.imageUrl;
if (imgEl) imgEl.src = data.imageUrl;
if (imgWrap) imgWrap.style.display = '';
augFilled.push('Image');
}
const allFilled = [...alreadyFilled, ...augFilled];
if (augFilled.length > 0) {
showStatus('success', `Filled from catalog + AI: ${allFilled.join(', ')}.${discontinuedNote}`);
} else {
showStatus('success', `Filled from catalog: ${alreadyFilled.join(', ')}.${discontinuedNote}`);
}
} catch (err) {
// AI augment is optional — restore the catalog success message
showStatus('success', `Filled from catalog: ${alreadyFilled.join(', ')}.${discontinuedNote}`);
} finally {
// Always restore button label — the outer click handler manages disabled state
// for single-match path, but the modal picker path needs this finally to reset it.
smartBtn.innerHTML = '<i class="bi bi-search me-1"></i>Lookup';
}
}
// ── Clear all catalog-filled fields ─────────────────────────────────────
function clearCatalogFill() {
if (!catalogSnapshot) return;
Object.keys(catalogSnapshot).forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
// Clear image preview if catalog filled the image
if (catalogSnapshot['field-imageurl']) {
const imgEl = document.getElementById('field-imagepreview-img');
const imgWrap= document.getElementById('wrap-imagepreview');
if (imgEl) imgEl.src = '';
if (imgWrap) imgWrap.style.display = 'none';
}
// Clear color families if they were set by augment
const hiddenInput = document.getElementById('field-colorfamilies');
if (hiddenInput) {
hiddenInput.value = '';
document.querySelectorAll('.color-family-chip').forEach(c => c.classList.remove('active'));
}
catalogSnapshot = null;
hideStatus();
}
// When user starts typing a new color name, clear all catalog-filled fields so the
// next search uses the fresh value rather than catalog-owned data.
const colorNameEl = document.getElementById('field-colorname');
if (colorNameEl) {
colorNameEl.addEventListener('input', function () {
if (catalogSnapshot && colorNameEl.value !== (catalogSnapshot['field-colorname'] || '')) {
clearCatalogFill();
}
});
}
// ── Modal picker for multiple results ────────────────────────────────────
function showPickerModal(items) {
// Remove any stale instance
document.getElementById('catalogPickerModal')?.remove();
const rows = items.map((item, i) => {
const img = item.imageUrl
? `<img src="${esc(item.imageUrl)}" style="width:36px;height:36px;object-fit:contain;border-radius:4px;" alt="">`
: `<div style="width:36px;height:36px;background:var(--bs-secondary-bg);border-radius:4px;"></div>`;
const disc = item.isDiscontinued
? `<span class="badge bg-warning text-dark ms-1" style="font-size:.65rem;">Discontinued</span>` : '';
return `
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-2 catalog-pick-row" data-idx="${i}">
${img}
<div class="flex-grow-1 text-start">
<div class="fw-medium">${esc(item.colorName)} ${disc}</div>
<div class="text-muted small">${esc(item.vendorName)} &middot; ${esc(item.sku)} &middot; $${item.unitPrice.toFixed(2)}/lb</div>
</div>
</button>`;
}).join('');
const modal = document.createElement('div');
modal.innerHTML = `
<div class="modal fade" id="catalogPickerModal" tabindex="-1">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-list-ul me-2"></i>Multiple matches — pick one</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div class="list-group list-group-flush">${rows}</div>
</div>
</div>
</div>
</div>`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(document.getElementById('catalogPickerModal'));
document.querySelectorAll('.catalog-pick-row').forEach(function (btn) {
btn.addEventListener('click', function () {
const idx = parseInt(this.dataset.idx, 10);
bsModal.hide();
fillFields(items[idx]);
});
});
bsModal.show();
}
// ── Helpers ───────────────────────────────────────────────────────────────
function syncLinkButton(inputId, linkId, url) {
const link = document.getElementById(linkId);
if (!link) return;
if (url) { link.href = url; link.classList.remove('d-none'); }
else { link.classList.add('d-none'); }
}
function setLoading(on) {
smartBtn.disabled = on;
smartBtn.innerHTML = on
? '<span class="spinner-border spinner-border-sm me-1"></span>Looking up…'
: '<i class="bi bi-search me-1"></i>Lookup';
}
function showStatus(type, msg) {
if (!statusEl) return;
statusEl.className = `alert alert-${type} py-2 small mb-3 alert-permanent`;
statusEl.innerHTML = msg;
statusEl.classList.remove('d-none');
}
function hideStatus() {
if (statusEl) statusEl.classList.add('d-none');
}
function esc(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
})();
@@ -0,0 +1,580 @@
/**
* In-browser powder label scanner for the Inventory Create/Edit forms.
*
* QR scanning strategy (parallel for maximum compatibility):
* 1. BarcodeDetector (Chrome/Edge/Android) starts immediately — canvas snapshot approach.
* 2. jsQR starts in parallel after JSQR_DELAY_MS so both libraries run simultaneously.
* First one to decode anything wins. Running both covers cases where BarcodeDetector
* silently returns empty arrays for certain QR variants (e.g. Prismatic Powders).
*
* Camera permission:
* Pre-warm only fires when navigator.permissions.query returns 'granted' so we never
* show a browser prompt on page load — if Chrome has the site at "Ask", the prompt
* appears only when the user explicitly clicks Scan Label (once per page session after
* that, because the stream stays alive between modal opens).
*/
(function () {
'use strict';
const SCAN_URL = '/Inventory/ScanLabel';
const JSQR_CDN = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js';
const JSQR_DELAY_MS = 1500; // start jsQR fallback this long after BarcodeDetector
const IDLE_RELEASE_MS = 2 * 60 * 1000;
const scanBtn = document.getElementById('scan-label-btn');
const statusEl = document.getElementById('ai-lookup-status');
if (!scanBtn) return;
let stream = null;
let rafId = null; // BarcodeDetector rAF
let rafId2 = null; // jsQR rAF (parallel fallback)
let jsqrTimer = null; // timer that starts jsQR loop
let qrFound = false;
let shutterTimer = null;
let idleTimer = null;
// ── Modal elements ────────────────────────────────────────────────────
const modalEl = document.getElementById('labelScanModal');
const bsModal = modalEl ? new bootstrap.Modal(modalEl) : null;
const videoEl = document.getElementById('scan-video');
const canvasEl = document.getElementById('scan-canvas');
const scanStatusEl = document.getElementById('scan-modal-status');
const shutterBtn = document.getElementById('scan-shutter-btn');
const shutterWrap = document.getElementById('scan-shutter-wrap');
const processingEl = document.getElementById('scan-processing');
const processingMsgEl= document.getElementById('scan-processing-msg');
// Add-stock modal elements
const addStockModalEl = document.getElementById('addStockModal');
const bsAddStockModal = addStockModalEl ? new bootstrap.Modal(addStockModalEl) : null;
const addStockItemName = document.getElementById('add-stock-item-name');
const addStockCurrentQty= document.getElementById('add-stock-current-qty');
const addStockUomLabel = document.getElementById('add-stock-uom-label');
const addStockQtyInput = document.getElementById('add-stock-qty');
const addStockCostInput = document.getElementById('add-stock-cost');
const addStockNotesInput= document.getElementById('add-stock-notes');
const addStockStatusEl = document.getElementById('add-stock-status');
const addStockConfirmBtn= document.getElementById('add-stock-confirm-btn');
let _addStockItemId = null;
let _lastScanData = null;
if (!modalEl || !videoEl || !canvasEl) return;
scanBtn.addEventListener('click', openScanner);
modalEl.addEventListener('hide.bs.modal', onModalClose);
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
if (addStockConfirmBtn) addStockConfirmBtn.addEventListener('click', submitAddStock);
// "Create new entry instead" hides the add-stock modal and pre-fills the create form
const addStockNewBtn = document.getElementById('add-stock-new-btn');
if (addStockNewBtn) addStockNewBtn.addEventListener('click', () => {
bsAddStockModal?.hide();
if (_lastScanData) fillFromScan(_lastScanData, /* skipDuplicatePrompt */ true);
});
window.addEventListener('beforeunload', releaseCamera);
// Pre-warm camera if browser has already granted permission (no prompt risk)
preWarmCamera();
// ── Open / close ──────────────────────────────────────────────────────
async function openScanner() {
if (!bsModal) return;
qrFound = false;
stopQrLoop();
hideProcessing();
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
if (shutterWrap) shutterWrap.classList.add('d-none');
bsModal.show();
if (!stream) {
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Starting camera…');
try {
stream = await startStream();
} catch (err) {
setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message);
if (shutterWrap) shutterWrap.classList.remove('d-none');
return;
}
}
if (videoEl.srcObject !== stream) videoEl.srcObject = stream;
if (videoEl.paused) { try { await videoEl.play(); } catch { /* ignore */ } }
setScanStatus('info', 'Point the camera at the powder label — QR code will scan automatically.');
startQrLoop();
shutterTimer = setTimeout(() => {
if (!qrFound && shutterWrap) shutterWrap.classList.remove('d-none');
}, 5000);
}
function onModalClose() {
stopQrLoop();
hideProcessing();
idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS);
}
function stopQrLoop() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (rafId2) { cancelAnimationFrame(rafId2); rafId2 = null; }
if (jsqrTimer) { clearTimeout(jsqrTimer); jsqrTimer = null; }
if (shutterTimer){ clearTimeout(shutterTimer); shutterTimer = null; }
}
function releaseCamera() {
stopQrLoop();
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
videoEl.srcObject = null;
}
async function startStream() {
return navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
}
// ── Camera pre-warm (no-prompt-on-page-load guarantee) ────────────────
async function preWarmCamera() {
// Only attempt if the Permissions API confirms the user has already granted access.
// Skipping when state is 'prompt' ensures we never show a browser dialog on page load.
try {
if (!navigator.permissions) return;
const perm = await navigator.permissions.query({ name: 'camera' });
if (perm.state !== 'granted') return;
stream = await startStream();
idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS);
} catch { /* permission denied or getUserMedia unavailable — ignore */ }
}
// ── QR scanning: BarcodeDetector + jsQR in parallel ──────────────────
function startQrLoop() {
const hasBD = typeof BarcodeDetector !== 'undefined';
if (hasBD) {
startBarcodeDetectorLoop();
}
// Always start jsQR after a short delay — runs in parallel with BarcodeDetector
// (or immediately if BarcodeDetector isn't available). This ensures Prismatic-style
// QR codes that BarcodeDetector silently misses still get decoded by jsQR.
jsqrTimer = setTimeout(() => {
if (!qrFound) {
loadJsQR().then(() => { if (!qrFound) startJsQrLoop(); }).catch(() => {
if (!hasBD && shutterWrap) shutterWrap.classList.remove('d-none');
});
}
}, hasBD ? JSQR_DELAY_MS : 0);
}
// BarcodeDetector loop — canvas snapshot for reliability
async function startBarcodeDetectorLoop() {
let detector;
try {
const supported = await BarcodeDetector.getSupportedFormats();
detector = new BarcodeDetector({ formats: supported.length ? supported : ['qr_code'] });
} catch {
return; // BarcodeDetector unavailable — jsQR timer will handle it
}
const ctx = canvasEl.getContext('2d');
async function tick() {
if (!stream || qrFound) return;
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA && videoEl.videoWidth > 0) {
try {
canvasEl.width = videoEl.videoWidth;
canvasEl.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0);
const codes = await detector.detect(canvasEl);
if (codes.length > 0 && !qrFound) {
qrFound = true;
handleQrResult(codes[0].rawValue);
return;
}
} catch { /* frame not ready — try next */ }
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
}
// jsQR loop — separate canvas context to avoid interfering with BarcodeDetector
function startJsQrLoop() {
const canvas2 = document.createElement('canvas');
const ctx2 = canvas2.getContext('2d');
function tick() {
if (!stream || qrFound) return;
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
canvas2.width = videoEl.videoWidth;
canvas2.height = videoEl.videoHeight;
ctx2.drawImage(videoEl, 0, 0);
const imageData = ctx2.getImageData(0, 0, canvas2.width, canvas2.height);
const code = window.jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'attemptBoth'
});
if (code && code.data && !qrFound) {
qrFound = true;
handleQrResult(code.data);
return;
}
}
rafId2 = requestAnimationFrame(tick);
}
rafId2 = requestAnimationFrame(tick);
}
function loadJsQR() {
return new Promise((resolve, reject) => {
if (window.jsQR) { resolve(); return; }
const s = document.createElement('script');
s.src = JSQR_CDN;
s.onload = resolve;
s.onerror = () => reject(new Error('Failed to load jsQR'));
document.head.appendChild(s);
});
}
// ── QR result handler ─────────────────────────────────────────────────
async function handleQrResult(url) {
stopQrLoop();
showProcessing('QR code found — looking up product…');
setScanBtnLoading(true);
try {
const fd = new FormData();
fd.append('qrUrl', url);
await submitScan(fd);
} finally {
setScanBtnLoading(false);
}
}
// ── Vision fallback: grab frame and POST ──────────────────────────────
async function captureFrame() {
stopQrLoop();
setScanBtnLoading(true);
if (stream && videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
const ctx = canvasEl.getContext('2d');
const maxW = 1024;
const scale = videoEl.videoWidth > maxW ? maxW / videoEl.videoWidth : 1;
canvasEl.width = Math.round(videoEl.videoWidth * scale);
canvasEl.height = Math.round(videoEl.videoHeight * scale);
ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height);
showProcessing('Reading label with AI…');
canvasEl.toBlob(async (blob) => {
try {
const base64 = await blobToBase64(blob);
const fd = new FormData();
fd.append('imageBase64', base64);
fd.append('mediaType', 'image/jpeg');
await submitScan(fd);
} finally {
setScanBtnLoading(false);
}
}, 'image/jpeg', 0.88);
} else {
// No live camera — fall back to file picker
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async () => {
const file = input.files[0];
if (!file) { setScanBtnLoading(false); return; }
showProcessing('Reading label with AI…');
try {
const base64 = await blobToBase64(file);
const fd = new FormData();
fd.append('imageBase64', base64);
fd.append('mediaType', file.type || 'image/jpeg');
await submitScan(fd);
} finally {
setScanBtnLoading(false);
}
};
input.click();
}
}
// ── Submit to server and fill form ────────────────────────────────────
async function submitScan(fd) {
try {
const resp = await fetch(SCAN_URL, { method: 'POST', body: fd });
if (!resp.ok) throw new Error(`Server error ${resp.status}`);
const data = await resp.json();
if (!data.success) {
hideProcessing();
setScanStatus('danger', data.errorMessage || 'Scan failed.');
return;
}
bsModal.hide();
if (data.existingInventoryId) {
// Product already in inventory — show inline add-stock prompt
_lastScanData = data;
_addStockItemId = data.existingInventoryId;
openAddStockModal(data);
} else {
fillFromScan(data);
}
} catch (err) {
hideProcessing();
setScanStatus('danger', 'Scan failed: ' + err.message);
}
}
// ── Add-stock modal ───────────────────────────────────────────────────
function openAddStockModal(data) {
if (!bsAddStockModal) { fillFromScan(data); return; }
const uom = data.existingUnitOfMeasure || 'lbs';
if (addStockItemName) addStockItemName.textContent = data.existingInventoryName || data.colorName || 'This product';
if (addStockCurrentQty) addStockCurrentQty.textContent = `${(data.existingQuantityOnHand ?? 0).toFixed(2)} ${uom}`;
if (addStockUomLabel) addStockUomLabel.textContent = uom;
if (addStockQtyInput) addStockQtyInput.value = '';
if (addStockCostInput) addStockCostInput.value = data.unitPrice > 0 ? data.unitPrice : '';
if (addStockNotesInput) addStockNotesInput.value = '';
if (addStockStatusEl) { addStockStatusEl.className = 'd-none'; addStockStatusEl.textContent = ''; }
if (addStockConfirmBtn) addStockConfirmBtn.disabled = false;
bsAddStockModal.show();
}
async function submitAddStock() {
const qty = parseFloat(addStockQtyInput?.value);
if (!qty || qty <= 0) {
showAddStockStatus('danger', 'Please enter a quantity greater than zero.');
return;
}
addStockConfirmBtn.disabled = true;
addStockConfirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
try {
const params = new URLSearchParams({
inventoryItemId: _addStockItemId,
quantity: qty,
});
const cost = parseFloat(addStockCostInput?.value);
if (cost > 0) params.append('unitCost', cost);
const notes = addStockNotesInput?.value?.trim();
if (notes) params.append('notes', notes);
const resp = await fetch('/Inventory/AddStock?' + params.toString(), { method: 'POST' });
if (!resp.ok) throw new Error(`Server error ${resp.status}`);
const data = await resp.json();
if (!data.success) {
showAddStockStatus('danger', data.errorMessage || 'Failed to add stock.');
addStockConfirmBtn.disabled = false;
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
return;
}
// Success — close modal and show confirmation on the form
bsAddStockModal.hide();
showFormStatus('success',
`<i class="bi bi-check-circle-fill me-1"></i>` +
`Added <strong>${qty.toFixed(2)} ${data.unitOfMeasure}</strong> to <strong>${data.itemName}</strong>. ` +
`New stock: ${(data.newQuantityOnHand ?? 0).toFixed(2)} ${data.unitOfMeasure}. ` +
`<a href="/Inventory/Details/${_addStockItemId}" class="alert-link">View item</a>`
);
} catch (err) {
showAddStockStatus('danger', 'Error: ' + err.message);
addStockConfirmBtn.disabled = false;
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
}
}
function showAddStockStatus(type, msg) {
if (!addStockStatusEl) return;
addStockStatusEl.className = `alert alert-${type} py-2 small`;
addStockStatusEl.textContent = msg;
}
// ── Fill the inventory form from scan result ───────────────────────────
function fillFromScan(data, skipDuplicatePrompt = false) {
const filled = [];
function setIf(id, value, label) {
const el = document.getElementById(id);
if (el && value != null && String(value).trim()) {
el.value = String(value).trim();
filled.push(label);
}
}
function setIfEmpty(id, value, label) {
const el = document.getElementById(id);
if (el && value != null && String(value).trim() && !el.value.trim()) {
el.value = String(value).trim();
filled.push(label);
}
}
setIf('field-manufacturer', data.manufacturer, 'Manufacturer');
setIf('field-partnumber', data.manufacturerPartNumber, 'Part Number');
setIf('field-colorname', data.colorName, 'Color Name');
const nameEl = document.getElementById('field-name');
if (nameEl && !nameEl.value.trim() && data.colorName) {
nameEl.value = data.colorName;
filled.push('Name');
}
setIfEmpty('field-description', data.description, 'Description');
setIfEmpty('field-finish', data.finish, 'Finish');
setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp');
setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time');
setIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage');
setIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency');
if (data.unitPrice > 0) {
const costEl = document.getElementById('field-unitcost');
if (costEl && (parseFloat(costEl.value) || 0) === 0) {
costEl.value = data.unitPrice;
filled.push('Unit Cost');
}
}
if (data.requiresClearCoat != null) {
const cc = document.getElementById('field-clearcoat');
if (cc) { cc.checked = data.requiresClearCoat; filled.push('Clear Coat'); }
}
if (data.colorFamilies) {
const hiddenInput = document.getElementById('field-colorfamilies');
if (hiddenInput && !hiddenInput.value.trim()) {
const families = data.colorFamilies.split(',').map(s => s.trim()).filter(Boolean);
hiddenInput.value = families.join(',');
document.querySelectorAll('.color-family-chip').forEach(chip => {
chip.classList.toggle('active', families.includes(chip.dataset.family));
});
filled.push('Color Families');
}
}
setIf('field-specpageurl', data.productUrl, 'Product URL');
syncLink('field-specpageurl', 'field-specpageurl-link', data.productUrl);
setIf('field-sdsurl', data.sdsUrl, 'SDS');
syncLink('field-sdsurl', 'field-sdsurl-link', data.sdsUrl);
setIf('field-tdsurl', data.tdsUrl, 'TDS');
syncLink('field-tdsurl', 'field-tdsurl-link', data.tdsUrl);
if (data.imageUrl) {
const imgInput = document.getElementById('field-imageurl');
const imgEl = document.getElementById('field-imagepreview-img');
const imgWrap = document.getElementById('wrap-imagepreview');
if (imgInput) imgInput.value = data.imageUrl;
if (imgEl) imgEl.src = data.imageUrl;
if (imgWrap) imgWrap.style.display = '';
filled.push('Image');
}
const vendorSel = document.getElementById('field-vendor');
if (vendorSel && !vendorSel.value && data.vendorName) {
const needle = data.vendorName.toLowerCase();
const match = Array.from(vendorSel.options).find(o =>
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
);
if (match) { vendorSel.value = match.value; filled.push('Vendor'); }
}
const catalogNote = data.wasInCatalog
? ' <span class="badge bg-secondary ms-1">From catalog</span>'
: data.addedToCatalog
? ' <span class="badge bg-success ms-1">Added to platform catalog</span>'
: '';
if (data.existingInventoryId && !skipDuplicatePrompt) {
// Duplicate handled by add-stock modal — don't show a banner here
} else if (data.existingInventoryId && skipDuplicatePrompt) {
const itemName = data.existingInventoryName || data.colorName || 'This product';
const filledNote = filled.length > 0 ? ` Fields pre-filled from scan.` : '';
showFormStatus('warning',
`<i class="bi bi-exclamation-triangle-fill me-1"></i>` +
`Creating a new entry — <strong>${itemName}</strong> already exists. ` +
`<a href="/Inventory/Details/${data.existingInventoryId}" class="alert-link">View existing item</a>` +
`${filledNote}${catalogNote}`
);
} else if (filled.length > 0) {
showFormStatus('success', `Filled from label scan: ${filled.join(', ')}.${catalogNote}`);
} else {
showFormStatus('warning', `Label scanned but no empty fields to fill.${catalogNote}`);
}
}
// ── Helpers ───────────────────────────────────────────────────────────
function showProcessing(msg) {
if (processingEl) {
if (processingMsgEl) processingMsgEl.textContent = msg;
processingEl.style.display = 'flex';
}
}
function hideProcessing() {
if (processingEl) processingEl.style.display = 'none';
}
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
const comma = result.indexOf(',');
resolve(comma >= 0 ? result.slice(comma + 1) : result);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
function syncLink(inputId, linkId, url) {
const link = document.getElementById(linkId);
if (!link) return;
if (url) { link.href = url; link.classList.remove('d-none'); }
else { link.classList.add('d-none'); }
}
function setScanStatus(type, msg) {
if (!scanStatusEl) return;
scanStatusEl.className = `alert alert-${type} py-2 small mb-0 mt-2`;
scanStatusEl.innerHTML = msg;
scanStatusEl.classList.remove('d-none');
}
function showFormStatus(type, msg) {
if (!statusEl) return;
statusEl.className = `alert alert-${type} py-2 small mb-3 alert-permanent`;
statusEl.innerHTML = msg;
statusEl.classList.remove('d-none');
}
function setScanBtnLoading(on) {
if (!shutterBtn) return;
shutterBtn.disabled = on;
shutterBtn.innerHTML = on
? '<span class="spinner-border spinner-border-sm me-1"></span>Reading…'
: '<i class="bi bi-camera me-1"></i>Scan Label Text';
}
})();