Files
CSG-Picker-Shipper-Helper/app/assets/picker.js
T
2026-07-06 00:24:30 -05:00

966 lines
35 KiB
JavaScript

/**
* 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
? `<span style="font-weight:700; font-size:0.9rem;">✕</span>`
: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
card.innerHTML = `
<div class="part-details-left">
<div class="part-sku">${line.sku}</div>
<div class="part-desc">${line.description}</div>
<div class="tag-row">
<span class="tag bin">Bin: ${line.bin_location || 'CSG'}</span>
<span class="tag qty">Qty: ${line.qty || 1}</span>
</div>
</div>
<div class="part-status">
${statusIcon}
</div>
`;
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 = `
<div style="padding: 3rem 1.5rem; text-align: center; color: var(--text-secondary); background: var(--panel-bg); border-radius: 12px; border: 1px solid var(--border-color);">
<div class="spin" style="font-size: 2.5rem; margin-bottom: 1rem; color: var(--warning-color);">⏳</div>
<div style="font-size: 1.1rem; font-weight: 500; color: var(--text-primary); margin-bottom: 0.5rem;">Querying QuickBooks...</div>
<div style="font-size: 0.9rem; line-height: 1.4;">${invoice.message}</div>
<div style="margin-top: 1.5rem; font-size: 0.8rem; color: var(--text-secondary); font-style: italic;">
Please check/trigger the QuickBooks Web Connector client to complete the sync.
</div>
</div>
`;
// 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 = '<option value="">No cameras detected</option>';
showBanner("No camera devices detected.", "error");
}
}).catch(err => {
console.error("Get cameras failed: ", err);
document.getElementById('camera-select').innerHTML = '<option value="">Permission denied</option>';
});
// 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 = `<span style="font-weight:700; font-size:0.9rem;">✕</span>`;
}
}
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 = '<div style="color:var(--text-secondary); font-size:0.9rem; padding:1rem 0;">Look up a SKU above and click <strong>Generate QR</strong> or <strong>Add to Batch</strong>.</div>';
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(`<!DOCTYPE html><html><head><title>CSG QR Labels</title><style>
body { margin:0; padding:4px; }
.grid { display: flex; flex-wrap: wrap; gap: 4px; }
.label { page-break-inside: avoid; line-height:0; }
.label img { display:block; width:130px; height:130px; }
@media print { @page { margin: 4mm; } }
</style></head><body><div class="grid">`);
let pending = qrBatchQueue.length;
qrBatchQueue.forEach(entry => {
setTimeout(() => {
const canvas = getCanvas(entry.id);
const src = canvas ? canvas.toDataURL('image/png') : '';
win.document.write(`<div class="label"><img src="${src}"></div>`);
pending--;
if (pending === 0) {
win.document.write('</div></body></html>');
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 = '<div style="color:var(--text-secondary); grid-column: 1 / -1; text-align: center; padding: 2rem;">Loading master parts inventory...</div>';
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 = `<div style="color:var(--error-color); grid-column: 1 / -1; text-align: center; padding: 2rem;">Failed to load inventory: ${e.message}</div>`;
}
}
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 = '<div style="color:var(--text-secondary); grid-column: 1 / -1; text-align: center; padding: 2rem;">No matching parts found.</div>';
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 = `<span style="color:var(--warning-color);">Bin: ${part.bin_location || '—'}</span> <span style="color:var(--success-color);">Qty: ${part.quantity || '0'}</span>`;
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';
}
}