/** * Picker Client JavaScript logic. * Handles UI state, Audio Context beeps, camera scanning, verification, and API calls. */ // --- Global App State -------------------------------------------------------- let activeInvoice = null; let selectedPart = null; let html5QrScanner = null; let activeCameraId = null; let isScanning = false; // --- Sound Synthesizer (Web Audio API) --------------------------------------- const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); function playBeep(type) { // Resume context if suspended (browser security block) if (audioCtx.state === 'suspended') { audioCtx.resume(); } const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); const now = audioCtx.currentTime; if (type === 'success') { // High pitch pleasant dual-chime (ding) osc.type = 'sine'; osc.frequency.setValueAtTime(880, now); // A5 osc.frequency.setValueAtTime(1320, now + 0.08); // E6 gain.gain.setValueAtTime(0.15, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3); osc.start(now); osc.stop(now + 0.3); } else if (type === 'error') { // Low pitch buzzing alarm sound osc.type = 'sawtooth'; osc.frequency.setValueAtTime(130, now); // Low C gain.gain.setValueAtTime(0.2, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4); osc.start(now); osc.stop(now + 0.4); } else if (type === 'warning') { // Warning chime osc.type = 'triangle'; osc.frequency.setValueAtTime(440, now); gain.gain.setValueAtTime(0.1, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2); osc.start(now); osc.stop(now + 0.2); } } // --- Notification Banner ----------------------------------------------------- function showBanner(text, type = 'success') { const banner = document.getElementById('notification-banner'); const bText = document.getElementById('banner-text'); banner.className = `show ${type}`; bText.textContent = text; setTimeout(() => { banner.classList.remove('show'); }, 3000); } // --- API Helpers ------------------------------------------------------------- async function fetchInvoice(invoiceNum) { try { const resp = await fetch(`/api/invoice/${invoiceNum}`); if (resp.status === 202) { const data = await resp.json(); if (data.status === 'sync_pending') { return { sync_pending: true, message: data.message }; } } if (!resp.ok) { const errData = await resp.json(); throw new Error(errData.error || `Server error: ${resp.status}`); } return await resp.json(); } catch (e) { showBanner(e.message, 'error'); playBeep('error'); return null; } } async function triggerReprint(sku, description) { if (!sku) return; try { const payload = { "label_data": { "order_id": activeInvoice ? activeInvoice.invoice_number : "REPRINT", "source": "picker_web", "qb_invoice_number": activeInvoice ? activeInvoice.invoice_number : "", "skus": [{ "sku": sku, "description": description, "line_qty": 1, "unit_index": 1 }] } }; const resp = await fetch('/api/print-label', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (resp.ok) { showBanner(`Label job enqueued for ${sku}!`, 'success'); playBeep('success'); } else { const txt = await resp.text(); throw new Error(txt || `Server error ${resp.status}`); } } catch (e) { showBanner(`Reprint failed: ${e.message}`, 'error'); playBeep('error'); } } // --- UI Operations ----------------------------------------------------------- function displayInvoice(invoice) { activeInvoice = invoice; selectedPart = null; // Clear lists const container = document.getElementById('parts-list'); container.innerHTML = ''; // Unhide info document.getElementById('invoice-info-card').classList.remove('hidden'); document.getElementById('lbl-invoice-num').textContent = invoice.invoice_number; document.getElementById('lbl-customer-name').textContent = invoice.customer; // Populate items invoice.lines.forEach((line, index) => { const card = document.createElement('div'); const isUnavailable = (line.available === 'N' || line.quantity === '0'); card.className = 'part-card' + (isUnavailable ? ' unavailable' : ''); card.id = `part-card-${index}`; card.dataset.index = index; card.dataset.sku = line.sku; card.dataset.picked = isUnavailable ? 'unavailable' : 'false'; const statusIcon = isUnavailable ? `` : ``; card.innerHTML = `
${line.sku}
${line.description}
Bin: ${line.bin_location || 'CSG'} Qty: ${line.qty || 1}
${statusIcon}
`; card.addEventListener('click', () => selectPartCard(index)); container.appendChild(card); }); updateHeaderStats(); // Automatically select the first card if (invoice.lines.length > 0) { selectPartCard(0); } } function selectPartCard(index) { if (!activeInvoice) return; const lines = activeInvoice.lines; const part = lines[index]; selectedPart = { ...part, index: index }; // Update card selection border document.querySelectorAll('.part-card').forEach(c => c.classList.remove('active')); const activeCard = document.getElementById(`part-card-${index}`); if (activeCard) { activeCard.classList.add('active'); } // Show Details Panel content document.getElementById('details-empty-state').classList.add('hidden'); document.getElementById('part-details-content').classList.remove('hidden'); document.getElementById('det-sku').textContent = part.sku; document.getElementById('det-desc').textContent = part.description; document.getElementById('det-bin').textContent = part.bin_location || 'Unknown'; document.getElementById('det-yard').textContent = part.yard_location || 'CSG'; document.getElementById('det-qty').textContent = part.qty || 1; document.getElementById('det-price').textContent = part.price ? `$${part.price}` : '—'; document.getElementById('det-stock').textContent = part.quantity || '0'; } function markActivePartAsPicked() { if (!selectedPart) return; const card = document.getElementById(`part-card-${selectedPart.index}`); if (card) { card.classList.add('picked'); card.dataset.picked = 'true'; } playBeep('success'); showBanner(`Verified SKU: ${selectedPart.sku}`, 'success'); updateHeaderStats(); selectNextUnpickedPart(); } function selectNextUnpickedPart() { if (!activeInvoice) return; const cards = document.querySelectorAll('.part-card'); for (let c of cards) { if (c.dataset.picked === 'false') { selectPartCard(parseInt(c.dataset.index)); return; } } } function updateHeaderStats() { if (!activeInvoice) return; const total = activeInvoice.lines.length; const picked = document.querySelectorAll('.part-card.picked').length; const unavailable = document.querySelectorAll('.part-card.unavailable').length; document.getElementById('items-picked-count').textContent = `${picked} Picked / ${unavailable} Out of Stock (${total} Total)`; } // --- QR Decoding Comparison -------------------------------------------------- function handleScannedCode(decodedText) { const text = decodedText.trim(); if (!text) return; logDebug(`Scanned: "${text}"`); // Check if we are scanning an invoice (idle or search input is focused) const isSearchFocused = document.activeElement === document.getElementById('invoice-search-input'); // If we don't have an invoice loaded yet, or if the search input is focused, // load it as an invoice lookup. Otherwise, always treat as a part SKU scan. if (!activeInvoice || isSearchFocused) { loadInvoiceNum(text); return; } // Otherwise, compare as a part SKU scan if (!selectedPart) { showBanner("Select a part in the list first!", "warning"); playBeep("warning"); return; } if (text.toUpperCase() === selectedPart.sku.toUpperCase()) { markActivePartAsPicked(); } else { // Discrepancy warning modal playBeep('error'); document.getElementById('scanned-sku-display').textContent = text; document.getElementById('target-sku-display').textContent = selectedPart.sku; document.getElementById('discrepancy-modal').style.display = 'flex'; } } async function loadInvoiceNum(num) { document.getElementById('invoice-search-input').value = num; showBanner(`Loading invoice ${num}...`, 'warning'); // Clear any existing polling if (window.invoicePollInterval) { clearInterval(window.invoicePollInterval); window.invoicePollInterval = null; } const invoice = await fetchInvoice(num); if (invoice) { if (invoice.sync_pending) { showBanner(invoice.message, 'warning'); playBeep('warning'); // Show polling status in the items view const partsList = document.getElementById('parts-list'); partsList.innerHTML = `
Querying QuickBooks...
${invoice.message}
Please check/trigger the QuickBooks Web Connector client to complete the sync.
`; // Start polling for invoice creation window.invoicePollInterval = setInterval(async () => { try { const checkResp = await fetch(`/api/invoice/${num}`); if (checkResp.ok && checkResp.status !== 202) { clearInterval(window.invoicePollInterval); window.invoicePollInterval = null; const realInvoice = await checkResp.json(); displayInvoice(realInvoice); showBanner(`Invoice ${num} synced and loaded!`, 'success'); playBeep('success'); } } catch (e) { console.error("Polling check failed", e); } }, 3000); return; } displayInvoice(invoice); showBanner(`Invoice ${num} loaded successfully!`, 'success'); playBeep('success'); } } function logDebug(msg) { console.log(msg); document.getElementById('scanner-status').textContent = msg.substring(0, 15).toUpperCase(); } // --- Camera Scanner Management ----------------------------------------------- function startScanner() { if (!activeCameraId) return; isScanning = true; document.getElementById('toggle-scan-btn').textContent = "Stop Scan"; document.getElementById('toggle-scan-btn').classList.remove('btn-secondary'); document.getElementById('toggle-scan-btn').style.backgroundColor = 'var(--error-color)'; document.querySelector('.scanner-laser').classList.remove('hidden'); html5QrScanner.start( activeCameraId, { fps: 10, qrbox: (width, height) => { const min = Math.min(width, height); return { width: Math.floor(min * 0.7), height: Math.floor(min * 0.7) }; } }, (decodedText, decodedResult) => { // Avoid duplicate scan events triggers within 1.5s if (html5QrScanner.isPaused) return; html5QrScanner.pause(true); setTimeout(() => { if (html5QrScanner && isScanning) { html5QrScanner.resume(); } }, 1500); handleScannedCode(decodedText); }, (errorMessage) => { // Fail silently on frame scan misses (normal behavior) } ).catch(err => { console.error("Camera start failed: ", err); showBanner("Failed to start camera feed.", "error"); isScanning = false; document.getElementById('toggle-scan-btn').textContent = "Start Scan"; }); } function stopScanner() { if (!html5QrScanner || !isScanning) return; isScanning = false; document.getElementById('toggle-scan-btn').textContent = "Start Scan"; document.getElementById('toggle-scan-btn').className = "btn-secondary"; document.getElementById('toggle-scan-btn').removeAttribute('style'); document.querySelector('.scanner-laser').classList.add('hidden'); document.getElementById('scanner-status').textContent = "PAUSED"; html5QrScanner.stop().catch(err => console.error("Camera stop error: ", err)); } // --- Event Listeners --------------------------------------------------------- document.addEventListener('DOMContentLoaded', () => { // 1. Search buttons document.getElementById('invoice-search-btn').addEventListener('click', () => { const val = document.getElementById('invoice-search-input').value.trim(); if (val) loadInvoiceNum(val); }); document.getElementById('invoice-search-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') { const val = e.target.value.trim(); if (val) loadInvoiceNum(val); } }); // 2. Modals (Map) document.getElementById('btn-show-map').addEventListener('click', () => { document.getElementById('map-modal').style.display = 'flex'; playBeep('warning'); }); const closeMap = () => { document.getElementById('map-modal').style.display = 'none'; }; document.getElementById('btn-close-map').addEventListener('click', closeMap); document.getElementById('btn-close-map-ok').addEventListener('click', closeMap); // 3. Modals (Discrepancy warnings) document.getElementById('btn-override-yes').addEventListener('click', () => { document.getElementById('discrepancy-modal').style.display = 'none'; markActivePartAsPicked(); }); document.getElementById('btn-override-no').addEventListener('click', () => { document.getElementById('discrepancy-modal').style.display = 'none'; playBeep('warning'); }); // 4. Reprint Button document.getElementById('btn-reprint-label').addEventListener('click', () => { if (selectedPart) { triggerReprint(selectedPart.sku, selectedPart.description); } }); // 5. Initialize camera selection html5QrScanner = new Html5Qrcode("qr-reader"); Html5Qrcode.getCameras().then(devices => { const select = document.getElementById('camera-select'); select.innerHTML = ''; if (devices && devices.length > 0) { devices.forEach((device, index) => { const opt = document.createElement('option'); opt.value = device.id; // Prefer back camera for scan picker const label = device.label || `Camera ${index + 1}`; opt.textContent = label; if (label.toLowerCase().includes('back') || index === devices.length - 1) { activeCameraId = device.id; opt.selected = true; } select.appendChild(opt); }); if (!activeCameraId) activeCameraId = devices[0].id; // Start scanning immediately on camera load startScanner(); } else { select.innerHTML = ''; showBanner("No camera devices detected.", "error"); } }).catch(err => { console.error("Get cameras failed: ", err); document.getElementById('camera-select').innerHTML = ''; }); // Camera select change handler document.getElementById('camera-select').addEventListener('change', (e) => { activeCameraId = e.target.value; if (isScanning) { stopScanner(); setTimeout(startScanner, 500); } }); // Toggle scanning button click document.getElementById('toggle-scan-btn').addEventListener('click', () => { if (isScanning) { stopScanner(); } else { startScanner(); } }); // Mark part as unavailable click document.getElementById('btn-mark-unavailable').addEventListener('click', async () => { if (!selectedPart || !activeInvoice) return; const sku = selectedPart.sku; if (!confirm(`Are you sure you want to mark SKU ${sku} as Out of Stock?\nThis will update total stock to 0 in the database.`)) { return; } try { const resp = await fetch(`/api/part/${encodeURIComponent(sku)}/unavailable`, { method: 'POST' }); if (resp.ok) { // Update local model selectedPart.quantity = '0'; selectedPart.available = 'N'; activeInvoice.lines[selectedPart.index].quantity = '0'; activeInvoice.lines[selectedPart.index].available = 'N'; // Update UI details document.getElementById('det-stock').textContent = '0'; // Style list card const card = document.getElementById(`part-card-${selectedPart.index}`); if (card) { card.classList.add('unavailable'); card.classList.remove('picked'); card.dataset.picked = 'unavailable'; // Replace status icon with cross const statusDiv = card.querySelector('.part-status'); if (statusDiv) { statusDiv.innerHTML = ``; } } showBanner(`SKU ${sku} marked Out of Stock.`, 'warning'); playBeep('success'); updateHeaderStats(); selectNextUnpickedPart(); } else { const txt = await resp.text(); throw new Error(txt || `Server error ${resp.status}`); } } catch (e) { showBanner(`Failed to update stock: ${e.message}`, 'error'); playBeep('error'); } }); }); // ============================================================================ // QR Generator Tab // ============================================================================ /** In-memory batch queue of QR entries */ let qrBatchQueue = []; /** Current part context for the QR form */ let qrCurrentPart = null; // --- Tab switching ----------------------------------------------------------- function switchTab(tab) { const pickMain = document.querySelector('main'); const qrTab = document.getElementById('tab-qr'); const masterTab = document.getElementById('tab-master'); const pickBtn = document.getElementById('tab-pick-btn'); const qrBtn = document.getElementById('tab-qr-btn'); const masterBtn = document.getElementById('tab-master-btn'); // Reset visibility of all tabs pickMain.style.display = 'none'; qrTab.style.display = 'none'; masterTab.style.display = 'none'; pickBtn.classList.remove('active'); qrBtn.classList.remove('active'); masterBtn.classList.remove('active'); if (tab === 'pick') { pickMain.style.display = ''; pickBtn.classList.add('active'); } else if (tab === 'qr') { qrTab.style.display = 'flex'; qrTab.style.flexDirection = 'column'; qrBtn.classList.add('active'); document.getElementById('qr-sku-input').focus(); } else if (tab === 'master') { masterTab.style.display = 'flex'; masterTab.style.flexDirection = 'column'; masterBtn.classList.add('active'); document.getElementById('master-search-input').focus(); // Lazy load master list data if not already loaded if (masterParts.length === 0) { loadMasterList(); } } } // --- Part lookup ------------------------------------------------------------- async function lookupQrSku() { const sku = document.getElementById('qr-sku-input').value.trim(); if (!sku) return; const btn = document.querySelector('#tab-qr .qr-gen-card button'); btn.textContent = '⏳ Looking up…'; btn.disabled = true; try { const resp = await fetch(`/api/part/${encodeURIComponent(sku)}`); if (!resp.ok) { const err = await resp.json().catch(() => ({})); showBanner(err.error || `SKU "${sku}" not found.`, 'error'); qrCurrentPart = null; clearQrFields(); } else { const data = await resp.json(); qrCurrentPart = data; document.getElementById('qr-bin-input').value = data.bin_location || '—'; document.getElementById('qr-desc-input').value = data.description || '—'; showBanner(`Found: ${sku} → Bin ${data.bin_location || '?'}`, 'success'); } } catch (e) { showBanner('Could not reach picker server.', 'error'); } finally { btn.textContent = 'Look Up'; btn.disabled = false; } } function clearQrFields() { document.getElementById('qr-bin-input').value = ''; document.getElementById('qr-desc-input').value = ''; } // --- Generate / Batch -------------------------------------------------------- function buildQrContent(sku, part) { const mode = document.getElementById('qr-content-select').value; if (mode === 'sku_bin' && part && part.bin_location) { return `${sku}\n${part.bin_location}`; } return sku; } function generateQrCode() { const sku = document.getElementById('qr-sku-input').value.trim(); if (!sku) { showBanner('Enter a SKU first.', 'error'); return; } const content = buildQrContent(sku, qrCurrentPart); const entry = { sku, bin: qrCurrentPart ? (qrCurrentPart.bin_location || '') : '', description: qrCurrentPart ? (qrCurrentPart.description || '') : '', content, id: `qr-${Date.now()}`, }; // Replace existing card for this SKU if already in grid, else prepend const existing = qrBatchQueue.findIndex(e => e.sku === sku); if (existing >= 0) qrBatchQueue.splice(existing, 1); qrBatchQueue.unshift(entry); renderQrGrid(); } function addToQueue() { const sku = document.getElementById('qr-sku-input').value.trim(); if (!sku) { showBanner('Enter a SKU first.', 'error'); return; } const content = buildQrContent(sku, qrCurrentPart); const entry = { sku, bin: qrCurrentPart ? (qrCurrentPart.bin_location || '') : '', description: qrCurrentPart ? (qrCurrentPart.description || '') : '', content, id: `qr-${Date.now()}`, }; if (!qrBatchQueue.find(e => e.sku === sku)) { qrBatchQueue.push(entry); } renderQrGrid(); showBanner(`Added ${sku} to batch (${qrBatchQueue.length} total)`, 'success'); // Clear SKU field for next entry document.getElementById('qr-sku-input').value = ''; clearQrFields(); qrCurrentPart = null; document.getElementById('qr-sku-input').focus(); } function clearQrQueue() { qrBatchQueue = []; renderQrGrid(); } // --- Render ------------------------------------------------------------------ function renderQrGrid() { const grid = document.getElementById('qr-output-grid'); const badge = document.getElementById('qr-count-badge'); grid.innerHTML = ''; if (!qrBatchQueue.length) { badge.textContent = '— none yet'; grid.innerHTML = '
Look up a SKU above and click Generate QR or Add to Batch.
'; return; } badge.textContent = `${qrBatchQueue.length} code${qrBatchQueue.length !== 1 ? 's' : ''}`; qrBatchQueue.forEach(entry => { const card = document.createElement('div'); card.className = 'qr-output-card'; card.id = `card-${entry.id}`; const wrap = document.createElement('div'); wrap.className = 'qr-canvas-wrap'; wrap.style.width = '140px'; wrap.style.height = '140px'; const skuLabel = document.createElement('div'); skuLabel.className = 'qr-sku-label'; skuLabel.textContent = entry.sku; const descLabel = document.createElement('div'); descLabel.className = 'qr-bin-label'; descLabel.textContent = entry.description ? `🚗 ${entry.description}` : ''; const actions = document.createElement('div'); actions.className = 'qr-card-actions'; const dlBtn = document.createElement('button'); dlBtn.textContent = '⬇️ Save'; dlBtn.onclick = () => downloadSingleQr(entry.id, entry.sku); const rmBtn = document.createElement('button'); rmBtn.className = 'btn-secondary'; rmBtn.textContent = '✕'; rmBtn.style.color = 'var(--error-color)'; rmBtn.style.flex = '0 0 36px'; rmBtn.onclick = () => { qrBatchQueue = qrBatchQueue.filter(e => e.id !== entry.id); renderQrGrid(); }; actions.appendChild(dlBtn); actions.appendChild(rmBtn); card.appendChild(wrap); card.appendChild(skuLabel); if (entry.description) card.appendChild(descLabel); card.appendChild(actions); grid.appendChild(card); // Render QR into wrap div using qrcodejs new QRCode(wrap, { text: entry.content, width: 124, height: 124, colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.M, }); // Store reference to canvas for download setTimeout(() => { const canvas = wrap.querySelector('canvas'); if (canvas) canvas.dataset.qrId = entry.id; }, 100); }); } // --- Download / Print -------------------------------------------------------- function getCanvas(cardId) { const card = document.getElementById(`card-${cardId}`); return card ? card.querySelector('canvas') : null; } function downloadSingleQr(cardId, sku) { // Give qrcodejs 150ms to finish rendering setTimeout(() => { const canvas = getCanvas(cardId); if (!canvas) { showBanner('Canvas not ready — try again.', 'error'); return; } // Save the raw QR canvas — no text, no borders, nothing extra const link = document.createElement('a'); link.download = `QR_${sku.replace(/[^a-zA-Z0-9_-]/g, '_')}.png`; link.href = canvas.toDataURL('image/png'); link.click(); }, 150); } async function downloadAllQr() { if (!qrBatchQueue.length) { showBanner('No QR codes to download.', 'error'); return; } // Download each individually (ZIP library not bundled) for (const entry of qrBatchQueue) { downloadSingleQr(entry.id, entry.sku); await new Promise(r => setTimeout(r, 300)); // stagger to avoid browser blocking } showBanner(`Downloaded ${qrBatchQueue.length} QR code(s).`, 'success'); } function printAllQr() { if (!qrBatchQueue.length) { showBanner('No QR codes to print.', 'error'); return; } // Build a printable page — QR image only, no text const win = window.open('', '_blank'); win.document.write(`CSG QR Labels
`); let pending = qrBatchQueue.length; qrBatchQueue.forEach(entry => { setTimeout(() => { const canvas = getCanvas(entry.id); const src = canvas ? canvas.toDataURL('image/png') : ''; win.document.write(`
`); pending--; if (pending === 0) { win.document.write('
'); win.document.close(); win.focus(); setTimeout(() => win.print(), 400); } }, 200); }); } // ============================================================================ // Master Parts List Tab // ============================================================================ let masterParts = []; let masterFiltered = []; let masterIndex = 0; const MASTER_BATCH_SIZE = 40; async function loadMasterList() { const grid = document.getElementById('master-grid'); grid.innerHTML = '
Loading master parts inventory...
'; try { const resp = await fetch('/api/parts'); if (!resp.ok) throw new Error('API request failed'); const data = await resp.json(); masterParts = data.parts || []; masterFiltered = [...masterParts]; masterIndex = 0; renderMasterGrid(); } catch (e) { grid.innerHTML = `
Failed to load inventory: ${e.message}
`; } } function filterMasterList() { const query = document.getElementById('master-search-input').value.trim().toUpperCase(); if (!query) { masterFiltered = [...masterParts]; } else { masterFiltered = masterParts.filter(part => part.sku.includes(query) || (part.description && part.description.toUpperCase().includes(query)) || (part.bin_location && part.bin_location.toUpperCase().includes(query)) ); } masterIndex = 0; renderMasterGrid(); } function renderMasterGrid() { const grid = document.getElementById('master-grid'); grid.innerHTML = ''; if (masterFiltered.length === 0) { grid.innerHTML = '
No matching parts found.
'; document.getElementById('master-load-more').style.display = 'none'; return; } loadMoreMasterParts(); } function loadMoreMasterParts() { const grid = document.getElementById('master-grid'); const start = masterIndex; const end = Math.min(start + MASTER_BATCH_SIZE, masterFiltered.length); for (let i = start; i < end; i++) { const part = masterFiltered[i]; const card = document.createElement('div'); card.className = 'qr-output-card'; card.style.background = 'rgba(255, 255, 255, 0.03)'; card.style.border = '1px solid var(--border-color)'; card.style.borderRadius = '12px'; card.style.padding = '1rem'; card.style.display = 'flex'; card.style.flexDirection = 'column'; card.style.alignItems = 'center'; card.style.gap = '0.5rem'; const wrap = document.createElement('div'); wrap.className = 'qr-canvas-wrap'; wrap.style.width = '100px'; wrap.style.height = '100px'; const skuDiv = document.createElement('div'); skuDiv.className = 'qr-sku-label'; skuDiv.style.fontWeight = '700'; skuDiv.style.fontSize = '0.80rem'; skuDiv.style.color = 'var(--text-primary)'; skuDiv.textContent = part.sku; const descDiv = document.createElement('div'); descDiv.style.fontSize = '0.7rem'; descDiv.style.color = 'var(--text-secondary)'; descDiv.style.textAlign = 'center'; descDiv.style.lineHeight = '1.3'; descDiv.style.maxHeight = '2.6em'; descDiv.style.overflow = 'hidden'; descDiv.style.textOverflow = 'ellipsis'; descDiv.style.display = '-webkit-box'; descDiv.style.webkitLineClamp = '2'; descDiv.style.webkitBoxOrient = 'vertical'; descDiv.textContent = part.description || 'No description'; const metaDiv = document.createElement('div'); metaDiv.style.fontSize = '0.7rem'; metaDiv.style.display = 'flex'; metaDiv.style.gap = '0.5rem'; metaDiv.innerHTML = `Bin: ${part.bin_location || '—'} Qty: ${part.quantity || '0'}`; card.appendChild(wrap); card.appendChild(skuDiv); card.appendChild(descDiv); card.appendChild(metaDiv); grid.appendChild(card); // Generate QR code new QRCode(wrap, { text: part.sku, width: 84, height: 84, colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.M }); } masterIndex = end; // Show/hide load more button const loadMoreBtn = document.getElementById('master-load-more'); if (masterIndex < masterFiltered.length) { loadMoreBtn.style.display = 'block'; } else { loadMoreBtn.style.display = 'none'; } }