Initial commit
This commit is contained in:
@@ -0,0 +1,965 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user