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:
@@ -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)} · ${esc(item.sku)} · $${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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
})();
|
||||
@@ -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';
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user