Initial commit

This commit is contained in:
Jonah
2026-07-06 00:24:30 -05:00
commit 085f8e0c1e
1112 changed files with 131611 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+965
View File
@@ -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';
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+677
View File
@@ -0,0 +1,677 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSG First-Time Setup: Batch Label Generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #0d0f12;
--panel-bg: rgba(22, 26, 33, 0.85);
--border-color: rgba(255, 255, 255, 0.08);
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--success-color: #10b981;
--error-color: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: rgba(13, 15, 18, 0.7);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-color);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-dot {
width: 12px;
height: 12px;
background-color: var(--accent-color);
border-radius: 50%;
box-shadow: 0 0 12px var(--accent-color);
}
h1 {
font-size: 1.15rem;
font-weight: 600;
letter-spacing: -0.025em;
}
header p {
font-size: 0.75rem;
color: var(--text-secondary);
}
.back-btn {
font-size: 0.8rem;
background-color: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
border: 1px solid var(--border-color);
padding: 0.4rem 0.8rem;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
transition: all 0.15s;
}
.back-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: var(--text-secondary);
}
main {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 320px;
background: var(--panel-bg);
border-right: 1px solid var(--border-color);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
overflow-y: auto;
}
.sidebar h2 {
font-size: 0.95rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-group label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input, .form-group select {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.form-group input:focus, .form-group select:focus {
border-color: var(--accent-color);
}
button.primary-btn {
background: var(--accent-color);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.6rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
button.primary-btn:hover {
background: var(--accent-hover);
}
button.secondary-btn {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.6rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
button.secondary-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.content-area {
flex: 1;
padding: 2rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.status-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.status-title {
font-size: 0.9rem;
font-weight: 600;
}
.status-desc {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Labels View grid default screen preview */
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* Label style in preview */
.label-card {
background: #fff;
color: #000;
border-radius: 8px;
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid #ddd;
page-break-inside: avoid;
}
.label-qr-wrap {
width: 90px;
height: 90px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.label-text-wrap {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0; /* allows text truncation/wrap */
}
.label-sku {
font-family: monospace;
font-size: 13px;
font-weight: 700;
word-break: break-all;
line-height: 1.2;
}
.label-meta {
font-size: 11px;
color: #555;
line-height: 1.3;
}
.progress-bar-wrap {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.05);
border-radius: 9999px;
overflow: hidden;
display: none;
}
.progress-bar-fill {
height: 100%;
width: 0%;
background: var(--accent-color);
transition: width 0.1s linear;
}
/* Printing Specific Styles */
@media print {
.no-print {
display: none !important;
}
body, html {
background: #fff !important;
color: #000 !important;
height: auto !important;
min-height: auto !important;
overflow: visible !important;
}
main {
display: block !important;
overflow: visible !important;
}
.content-area {
padding: 0 !important;
margin: 0 !important;
display: block !important;
overflow: visible !important;
}
/* Printing layout based on selection */
.preview-grid.print-cols-1 {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
}
.preview-grid.print-cols-2 {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 12px !important;
}
.preview-grid.print-cols-3 {
display: grid !important;
grid-template-columns: 1fr 1fr 1fr !important;
gap: 8px !important;
}
.label-card {
box-shadow: none !important;
border: 1px solid #aaa !important;
border-radius: 4px !important;
background: #fff !important;
color: #000 !important;
margin: 0 !important;
page-break-inside: avoid !important;
}
}
</style>
</head>
<body>
<!-- Header (Hidden during print) -->
<header class="no-print">
<div class="header-brand">
<div class="logo-dot"></div>
<div>
<h1>CSG Warehouse Setup</h1>
<p>Batch Label Generation & Printing Utility</p>
</div>
</div>
<a href="/picker" class="back-btn">⬅ Back to Picker Home</a>
</header>
<main>
<!-- Sidebar Controls (Hidden during print) -->
<div class="sidebar no-print">
<h2>🔍 Filter Parts</h2>
<div class="form-group">
<label for="filter-sku">SKU Search / Prefix</label>
<input type="text" id="filter-sku" placeholder="e.g. 84126" oninput="applyFilters()">
</div>
<h2>📏 Label Layout</h2>
<div class="form-group">
<label for="print-cols">Layout Columns (for print)</label>
<select id="print-cols" onchange="updatePrintCols()">
<option value="print-cols-1">Roll Printer (1 Column)</option>
<option value="print-cols-2" selected>Sheet Paper (2 Columns)</option>
<option value="print-cols-3">Sheet Paper (3 Columns)</option>
</select>
</div>
<div class="form-group">
<label for="qr-mode">QR Code Mode</label>
<select id="qr-mode" onchange="applyFilters()">
<option value="sku">SKU only (highly recommended)</option>
<option value="sku_bin">SKU + Bin Location</option>
</select>
</div>
<div class="form-group">
<label for="batch-limit">Batch Render Limit</label>
<select id="batch-limit" onchange="applyFilters()">
<option value="50">First 50 matches</option>
<option value="100" selected>First 100 matches</option>
<option value="250">First 250 matches</option>
<option value="500">First 500 matches</option>
<option value="all">⚠️ Render All (2,000+ parts)</option>
</select>
</div>
<h2>💾 Import / Export</h2>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem;">
<button class="secondary-btn" style="flex: 1; padding: 0.5rem;" onclick="triggerUpload()">📤 Upload</button>
<button class="secondary-btn" style="flex: 1; padding: 0.5rem;" onclick="exportLabels()">⬇️ Export</button>
</div>
<input type="file" id="upload-file-input" accept=".json,.csv" style="display:none;" onchange="handleUpload(event)">
<button class="primary-btn" onclick="triggerPrint()">🖨️ Print Labels</button>
<button class="secondary-btn" onclick="resetFilters()">🔄 Reset Filters</button>
</div>
<!-- Printable content / preview area -->
<div class="content-area">
<!-- Setup Progress / Status Banner (Hidden during print) -->
<div class="status-card no-print">
<div class="status-info">
<span class="status-title" id="status-title">Loading parts inventory...</span>
<span class="status-desc" id="status-desc">Fetching latest warehouse spreadsheet database.</span>
<div class="progress-bar-wrap" id="progress-wrap">
<div class="progress-bar-fill" id="progress-fill"></div>
</div>
</div>
</div>
<!-- Preview Label Grid -->
<div class="preview-grid print-cols-2" id="labels-grid">
<!-- Dynamically loaded label cards -->
</div>
</div>
</main>
<!-- Dependencies -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
let allParts = [];
let filteredParts = [];
let renderQueue = [];
let isRendering = false;
// Fetch all parts on load
async function fetchParts() {
try {
const response = await fetch('/api/parts');
if (!response.ok) throw new Error('Failed to load parts api');
const data = await response.json();
allParts = data.parts || [];
document.getElementById('status-title').textContent = `Loaded ${allParts.length} parts database.`;
document.getElementById('status-desc').textContent = 'Filter below to print barcodes in batches.';
applyFilters();
} catch (err) {
document.getElementById('status-title').textContent = '⚠️ Inventory Database Error';
document.getElementById('status-desc').textContent = 'Failed to fetch parts. Ensure picker server is running.';
console.error(err);
}
}
function triggerUpload() {
document.getElementById('upload-file-input').click();
}
function handleUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
const text = e.target.result;
let parsedList = [];
try {
if (file.name.endsWith('.json')) {
// Parse JSON
const data = JSON.parse(text);
const items = Array.isArray(data) ? data : (data.parts || data.items || []);
parsedList = items.map(item => ({
sku: (item.sku || item.SKU || item.stock_number || item['STOCK NUMBER'] || '').trim().toUpperCase(),
description: (item.description || item.DESCRIPTION || item.desc || '')
})).filter(x => x.sku);
} else {
// Parse CSV
const lines = text.split(/\r?\n/);
if (lines.length > 0) {
const headers = lines[0].split(',').map(h => h.trim().toUpperCase());
// Find column indices
let skuIdx = headers.findIndex(h => h === 'SKU' || h === 'STOCK NUMBER' || h === 'STOCK_NUMBER');
let descIdx = headers.findIndex(h => h === 'DESCRIPTION' || h === 'DESC');
// Fallback if no header match
if (skuIdx === -1) skuIdx = 0;
if (descIdx === -1) descIdx = 1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Split comma-separated, respecting quoted fields
const cols = line.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/).map(c => c.replace(/^"|"$/g, '').trim());
const sku = (cols[skuIdx] || '').toUpperCase();
const description = cols[descIdx] || '';
if (sku) {
parsedList.push({ sku, description });
}
}
}
}
if (parsedList.length === 0) {
alert("No valid part labels found in the uploaded file. Ensure columns contain SKU/STOCK NUMBER and DESCRIPTION.");
return;
}
// Update parts list
allParts = parsedList;
document.getElementById('status-title').textContent = `Uploaded Custom List: ${allParts.length} parts loaded.`;
document.getElementById('status-desc').textContent = 'Successfully imported custom label batch.';
// Reset search input
document.getElementById('filter-sku').value = '';
applyFilters();
alert(`Successfully imported ${allParts.length} custom labels!`);
} catch (err) {
alert("Error parsing file: " + err.message);
}
};
reader.readAsText(file);
event.target.value = '';
}
function exportLabels() {
if (filteredParts.length === 0) {
alert("No labels to export.");
return;
}
const exportData = filteredParts.map(part => ({
sku: part.sku,
description: part.description
}));
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData, null, 2));
const dlAnchor = document.createElement('a');
dlAnchor.setAttribute("href", dataStr);
dlAnchor.setAttribute("download", `CSG_Labels_Export_${Date.now()}.json`);
document.body.appendChild(dlAnchor);
dlAnchor.click();
dlAnchor.remove();
}
function applyFilters() {
const skuVal = document.getElementById('filter-sku').value.toUpperCase().trim();
const limitVal = document.getElementById('batch-limit').value;
// Apply filters
filteredParts = allParts.filter(part => {
if (skuVal && !part.sku.includes(skuVal)) return false;
return true;
});
// Handle limit
const limit = limitVal === 'all' ? filteredParts.length : parseInt(limitVal, 10);
renderQueue = filteredParts.slice(0, limit);
// Re-render
startBatchRender();
}
function resetFilters() {
document.getElementById('filter-sku').value = '';
document.getElementById('batch-limit').value = '100';
applyFilters();
}
function updatePrintCols() {
const grid = document.getElementById('labels-grid');
grid.className = 'preview-grid ' + document.getElementById('print-cols').value;
}
// Start progressive asynchronous rendering of QRs to keep page responsive
function startBatchRender() {
isRendering = false; // stops active loops
const grid = document.getElementById('labels-grid');
grid.innerHTML = '';
if (renderQueue.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('status-title').textContent = 'Loaded 0 labels';
document.getElementById('progress-wrap').style.display = 'none';
return;
}
document.getElementById('status-title').textContent = `Generating ${renderQueue.length} labels...`;
document.getElementById('progress-wrap').style.display = 'block';
document.getElementById('progress-fill').style.width = '0%';
isRendering = true;
let index = 0;
const qrMode = document.getElementById('qr-mode').value;
function renderNextChunk() {
if (!isRendering) return;
const chunk = 10; // Render 10 at a time
const end = Math.min(index + chunk, renderQueue.length);
for (let i = index; i < end; i++) {
const part = renderQueue[i];
createLabelCard(grid, part, i, qrMode);
}
index = end;
const percentage = (index / renderQueue.length) * 100;
document.getElementById('progress-fill').style.width = percentage + '%';
if (index < renderQueue.length) {
requestAnimationFrame(renderNextChunk);
} else {
document.getElementById('status-title').textContent = `Ready: ${renderQueue.length} labels generated.`;
document.getElementById('status-desc').textContent = 'Press "Print Labels" to open printer queue dialog.';
document.getElementById('progress-wrap').style.display = 'none';
isRendering = false;
}
}
requestAnimationFrame(renderNextChunk);
}
function createLabelCard(container, part, index, qrMode) {
const card = document.createElement('div');
card.className = 'label-card';
const qrWrap = document.createElement('div');
qrWrap.className = 'label-qr-wrap';
qrWrap.id = `setup-qr-wrap-${index}`;
const textWrap = document.createElement('div');
textWrap.className = 'label-text-wrap';
const skuDiv = document.createElement('div');
skuDiv.className = 'label-sku';
skuDiv.textContent = part.sku;
const metaDiv = document.createElement('div');
metaDiv.className = 'label-meta';
metaDiv.innerHTML = part.description || 'No description available';
textWrap.appendChild(skuDiv);
textWrap.appendChild(metaDiv);
card.appendChild(qrWrap);
card.appendChild(textWrap);
container.appendChild(card);
// Determine QR code text
let qrText = part.sku;
if (qrMode === 'sku_bin' && part.bin_location) {
qrText = `${part.sku}\n${part.bin_location}`;
}
// Create QR code
new QRCode(qrWrap, {
text: qrText,
width: 80,
height: 80,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M
});
}
function triggerPrint() {
if (isRendering) {
alert("Please wait for all labels to finish rendering before printing!");
return;
}
window.print();
}
// Initialize on load
window.addEventListener('DOMContentLoaded', fetchParts);
</script>
</body>
</html>