966 lines
35 KiB
JavaScript
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';
|
|
}
|
|
}
|
|
|