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,359 @@
/**
* In-browser powder label scanner for the Inventory Create/Edit forms.
*
* Flow:
* 1. User clicks "Scan Label" → modal opens, rear camera starts.
* 2. jsQR runs in a requestAnimationFrame loop scanning the live video.
* - QR detected automatically → POST qrUrl to /Inventory/ScanLabel → fill form.
* 3. If no QR within ~5 s the "Scan Text" button appears as a fallback.
* - User taps it → grab current frame → base64 → POST to /Inventory/ScanLabel → fill form.
* 4. Either path: server searches catalog, auto-contributes if new, returns unified data.
* 5. Modal closes, form fills using the same field IDs as inventory-catalog-lookup.js.
*/
(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 scanBtn = document.getElementById('scan-label-btn');
const statusEl = document.getElementById('ai-lookup-status');
if (!scanBtn) return;
let stream = null; // MediaStream
let rafId = null; // requestAnimationFrame handle
let qrFound = false;
let shutterTimer = null; // timer to reveal the fallback shutter button
// ── Modal bootstrap ───────────────────────────────────────────────────
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');
if (!modalEl || !videoEl || !canvasEl) return;
scanBtn.addEventListener('click', openScanner);
modalEl.addEventListener('hide.bs.modal', stopCamera);
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
// ── Open / close ──────────────────────────────────────────────────────
async function openScanner() {
if (!bsModal) return;
qrFound = false;
if (shutterWrap) shutterWrap.classList.add('d-none');
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Starting camera…');
bsModal.show();
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
videoEl.srcObject = stream;
await videoEl.play();
setScanStatus('info', 'Point the camera at the powder label — QR code will scan automatically.');
await loadJsQR();
startQrLoop();
// Reveal the shutter fallback after 5 s in case there is no QR
shutterTimer = setTimeout(() => {
if (!qrFound && shutterWrap) shutterWrap.classList.remove('d-none');
}, 5000);
} catch (err) {
setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message);
if (shutterWrap) shutterWrap.classList.remove('d-none'); // let them try vision path
}
}
function stopCamera() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (shutterTimer) { clearTimeout(shutterTimer); shutterTimer = null; }
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
videoEl.srcObject = null;
}
// ── jsQR lazy load ────────────────────────────────────────────────────
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 scan loop ──────────────────────────────────────────────────────
function startQrLoop() {
const ctx = canvasEl.getContext('2d');
function tick() {
if (!stream || qrFound) return;
if (videoEl.readyState === videoEl.HAVE_ENOUGH_DATA) {
canvasEl.width = videoEl.videoWidth;
canvasEl.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0);
const imageData = ctx.getImageData(0, 0, canvasEl.width, canvasEl.height);
const code = window.jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert'
});
if (code && code.data) {
qrFound = true;
handleQrResult(code.data);
return;
}
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
}
// ── QR result handler ─────────────────────────────────────────────────
async function handleQrResult(url) {
stopCamera();
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>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() {
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Reading label…');
setScanBtnLoading(true);
// If camera is running, grab the current frame; otherwise show file picker
if (stream && videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
const ctx = canvasEl.getContext('2d');
// Scale down to ≤1024px wide to keep payload small while preserving label text
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);
stopCamera();
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 (e.g. desktop) — fall back to file picker
stopCamera();
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async () => {
const file = input.files[0];
if (!file) { setScanBtnLoading(false); return; }
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) {
setScanStatus('danger', data.errorMessage || 'Scan failed.');
return;
}
bsModal.hide();
fillFromScan(data);
} catch (err) {
setScanStatus('danger', 'Scan failed: ' + err.message);
}
}
// ── Fill the inventory form from scan result ───────────────────────────
function fillFromScan(data) {
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');
// Name field: use color name if blank
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 (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 blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// Strip the data URI prefix — server only wants the raw base64
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';
}
})();