Add platform powder catalog, catalog-first lookup, and label scanner

- Platform PowderCatalogItem table (IPlainRepository, no tenant filter) with
  full spec fields: cure temp/time, finish, color families, clear coat flag,
  coverage sq ft/lb, transfer efficiency, IsUserContributed
- Two EF migrations: AddPowderCatalogItem + AddPowderCatalogSpecFields
- PowderCatalogController (SuperAdminOnly): import from Prismatic JSON scrape,
  Lookup AJAX endpoint (catalog-first, ranked by SKU exact match), stats view
  with Tenant Contributed card
- Unified smart Lookup button on inventory Create/Edit: catalog hit fills all
  fields via catalogSnapshot pattern; AI augments cure/finish data from product
  URL if subscription enabled; catalog miss falls through to AI lookup
- In-browser label scanner (_LabelScanModal): getUserMedia live camera feed,
  jsQR auto-detects QR codes in rAF loop; "Scan Label Text" fallback sends
  captured frame to Claude vision via /Inventory/ScanLabel
- ScanLabel endpoint handles both QR URL path (LookupByUrlAsync) and vision
  path (ScanLabelAsync); auto-inserts unrecognized products as
  IsUserContributed=true; returns wasInCatalog/addedToCatalog flags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 16:36:25 -04:00
parent 90f333c8f3
commit 1fc79b77fe
25 changed files with 21279 additions and 23 deletions
@@ -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;');
}
})();