/** * HandyBarcodeScanner - Mobile Barcode Scanner for Dolibarr * Integrated version using SCANNER_CONFIG */ (function() { 'use strict'; // State let CONFIG = null; let currentMode = 'order'; let initialized = false; // Global init function - can be called after login window.initScanner = function() { if (typeof window.SCANNER_CONFIG === 'undefined') { console.log('HandyBarcodeScanner: Waiting for SCANNER_CONFIG...'); return false; } CONFIG = window.SCANNER_CONFIG; // Validate required config if (!CONFIG.ajaxUrl || !CONFIG.token) { console.error('HandyBarcodeScanner: Invalid configuration'); return false; } currentMode = CONFIG.mode || 'order'; // Only init once if (initialized) { console.log('HandyBarcodeScanner: Already initialized'); return true; } init(); return true; }; let isScanning = false; let lastScannedCode = null; let currentProduct = null; let selectedSupplier = null; let allSuppliers = []; let quaggaInitialized = false; // Multi-read confirmation - code must be read multiple times to be accepted let pendingCode = null; let pendingCodeCount = 0; const REQUIRED_CONFIRMATIONS = 2; // Code must be read 2 times to be accepted // Timeout: Hinweis wenn nichts erkannt wird let scanTimeoutTimer = null; const SCAN_TIMEOUT_MS = 8000; // 8 Sekunden ohne Erkennung // DOM Elements (will be set on init) let elements = {}; // Initialize function init() { // Get DOM elements elements = { startBtn: document.getElementById('start-scan-btn'), stopBtn: document.getElementById('stop-scan-btn'), videoContainer: document.getElementById('scanner-video-container'), video: document.getElementById('scanner-video'), lastScanCode: document.getElementById('last-scan-code'), resultArea: document.getElementById('result-area'), manualInput: document.getElementById('manual-barcode-input'), manualSearchBtn: document.getElementById('manual-search-btn') }; if (!elements.startBtn || !elements.videoContainer) { // Not on scanner page - exit silently return; } // Mark as initialized initialized = true; console.log('HandyBarcodeScanner: Initialized'); // Check for camera support checkCameraSupport(); bindEvents(); loadAllSuppliers(); // Globale Hooks fuer Tab-Switch (inline onclick) window._scannerHideResult = hideResult; window._scannerShowResult = showProductResult; window._scannerCurrentProduct = null; } // Check camera support - just log warnings, never disable button function checkCameraSupport() { // Check HTTPS const isSecure = location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1'; if (!isSecure) { console.warn('HandyBarcodeScanner: HTTPS empfohlen fuer Kamerazugriff'); } // Check if Quagga is loaded if (typeof Quagga === 'undefined') { console.error('HandyBarcodeScanner: Quagga nicht geladen'); } // Check mediaDevices if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { console.warn('HandyBarcodeScanner: mediaDevices nicht gefunden'); } // Never disable button - always allow click } function showCameraError(message) { const errorDiv = document.createElement('div'); errorDiv.className = 'error-message'; errorDiv.style.cssText = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; text-align: center; padding: 20px;'; errorDiv.innerHTML = '
⚠️
' + message + '
'; elements.videoContainer.appendChild(errorDiv); } // Event Bindings function bindEvents() { elements.startBtn.addEventListener('click', startScanner); elements.stopBtn.addEventListener('click', stopScanner); // Manual barcode input if (elements.manualSearchBtn && elements.manualInput) { elements.manualSearchBtn.addEventListener('click', handleManualSearch); elements.manualInput.addEventListener('keypress', function(e) { if (e.key === 'Enter') { handleManualSearch(); } }); } } // Handle manual barcode input function handleManualSearch() { const barcode = elements.manualInput.value.trim(); if (!barcode) { showToast('Bitte Barcode eingeben', 'error'); return; } // Update last scan display elements.lastScanCode.textContent = barcode; // Vibration feedback if (CONFIG.enableVibration && navigator.vibrate) { navigator.vibrate(100); } // Search product searchProduct(barcode); // Clear input elements.manualInput.value = ''; } // Load all suppliers for manual selection function loadAllSuppliers() { fetch(CONFIG.ajaxUrl + 'getsuppliers.php?token=' + CONFIG.token) .then(res => res.json()) .then(data => { if (data.success) { allSuppliers = data.suppliers; } }) .catch(err => console.error('Error loading suppliers:', err)); } // Scanner Functions function startScanner() { if (isScanning) { return; } if (elements.startBtn.disabled) { return; } // Check if Quagga is available if (typeof Quagga === 'undefined') { showToast('Quagga Bibliothek nicht geladen', 'error'); return; } // Disable button while initializing elements.startBtn.disabled = true; elements.startBtn.textContent = 'Initialisiere...'; // First request camera permission explicitly navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }) .then(function(stream) { // Permission granted - stop the test stream stream.getTracks().forEach(track => track.stop()); // Now start Quagga initQuagga(); }) .catch(function(err) { console.error('Camera permission error:', err); elements.startBtn.disabled = false; elements.startBtn.textContent = CONFIG.lang.startScan || 'Scan starten'; let errorMsg = CONFIG.lang.cameraError || 'Kamerafehler'; if (err.name === 'NotAllowedError') { errorMsg = 'Kamerazugriff verweigert. Bitte Berechtigung in den Browser-Einstellungen erteilen.'; } else if (err.name === 'NotFoundError') { errorMsg = 'Keine Kamera gefunden.'; } else if (err.name === 'NotReadableError') { errorMsg = 'Kamera wird bereits verwendet.'; } showToast(errorMsg, 'error'); }); } function initQuagga() { Quagga.init({ inputStream: { name: "Live", type: "LiveStream", target: elements.videoContainer, constraints: { facingMode: "environment", width: { min: 640, ideal: 1280, max: 1920 }, height: { min: 480, ideal: 720, max: 1080 } }, area: { // Scan-Bereich: groesserer Bereich fuer kleine Barcodes top: "10%", right: "5%", left: "5%", bottom: "10%" } }, decoder: { readers: [ "code_128_reader", // Best for internal codes - prioritize "ean_reader", "ean_8_reader", "code_39_reader" ], multiple: false }, locate: true, locator: { patchSize: "medium", // medium = besser fuer kleine Barcodes halfSample: false // volle Aufloesung = bessere Genauigkeit }, numOfWorkers: navigator.hardwareConcurrency || 4, frequency: 20 // haeufiger scannen fuer bessere Erkennung }, function(err) { if (err) { console.error('Quagga init error:', err); elements.startBtn.disabled = false; elements.startBtn.textContent = CONFIG.lang.startScan || 'Scan starten'; showToast(CONFIG.lang.cameraError || 'Kamerafehler', 'error'); return; } // Success - start scanning Quagga.start(); quaggaInitialized = true; isScanning = true; elements.startBtn.classList.add('hidden'); elements.stopBtn.classList.remove('hidden'); elements.videoContainer.classList.add('scanning'); // Register detection handler Quagga.onDetected(onBarcodeDetected); // Timeout-Timer starten startScanTimeout(); }); } // Timeout-Hinweis wenn nichts erkannt wird function startScanTimeout() { clearTimeout(scanTimeoutTimer); scanTimeoutTimer = setTimeout(function() { if (isScanning) { showToast('Kein Barcode erkannt – naeher ran oder Barcode manuell eingeben', 'error'); // Timer erneut starten fuer wiederholten Hinweis startScanTimeout(); } }, SCAN_TIMEOUT_MS); } function stopScanner() { if (!isScanning) return; clearTimeout(scanTimeoutTimer); if (quaggaInitialized) { Quagga.offDetected(onBarcodeDetected); Quagga.stop(); } isScanning = false; quaggaInitialized = false; elements.startBtn.classList.remove('hidden'); elements.startBtn.disabled = false; elements.startBtn.textContent = CONFIG.lang.startScan || 'Scan starten'; elements.stopBtn.classList.add('hidden'); elements.videoContainer.classList.remove('scanning'); } function onBarcodeDetected(result) { let code = result.codeResult.code; const format = result.codeResult.format; // EAN-13 detected but might be internal code with added check digit // If it's 13 digits and doesn't validate, try removing the last digit if (format === 'ean_13' || (code.length === 13 && /^\d{13}$/.test(code))) { if (!validateEAN13(code)) { // Try without last digit (might be internal 12-digit code) const shortened = code.substring(0, 12); console.log('HandyBarcodeScanner: Invalid EAN13, trying shortened:', shortened); code = shortened; } } // Validate EAN8 checksum if (format === 'ean_8' && code.length === 8 && /^\d{8}$/.test(code)) { if (!validateEAN8(code)) { // Try without last digit code = code.substring(0, 7); } } // Already processed this code recently if (code === lastScannedCode) return; // Multi-read confirmation: same code must be detected multiple times if (code === pendingCode) { pendingCodeCount++; } else { // Different code - reset counter pendingCode = code; pendingCodeCount = 1; } // Not enough confirmations yet if (pendingCodeCount < REQUIRED_CONFIRMATIONS) { return; } // Code confirmed! Accept it lastScannedCode = code; pendingCode = null; pendingCodeCount = 0; // Timeout-Timer zuruecksetzen startScanTimeout(); // Feedback if (navigator.vibrate) { navigator.vibrate([100, 50, 100]); } playBeep(); showToast('Barcode: ' + code, 'success'); elements.lastScanCode.textContent = code; // Search product searchProduct(code); // Reset duplicate prevention after 3 seconds setTimeout(() => { lastScannedCode = null; }, 3000); } // Validate EAN13 checksum function validateEAN13(code) { if (!/^\d{13}$/.test(code)) return false; let sum = 0; for (let i = 0; i < 12; i++) { sum += parseInt(code[i]) * (i % 2 === 0 ? 1 : 3); } const checkDigit = (10 - (sum % 10)) % 10; return checkDigit === parseInt(code[12]); } // Validate EAN8 checksum function validateEAN8(code) { if (!/^\d{8}$/.test(code)) return false; let sum = 0; for (let i = 0; i < 7; i++) { sum += parseInt(code[i]) * (i % 2 === 0 ? 3 : 1); } const checkDigit = (10 - (sum % 10)) % 10; return checkDigit === parseInt(code[7]); } // Shared AudioContext (must be created after user interaction) let audioCtx = null; function getAudioContext() { if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } // Resume if suspended (mobile browsers) if (audioCtx.state === 'suspended') { audioCtx.resume(); } return audioCtx; } // Play success beep sound (high pitch) function playBeep() { try { const ctx = getAudioContext(); const oscillator = ctx.createOscillator(); const gainNode = ctx.createGain(); oscillator.connect(gainNode); gainNode.connect(ctx.destination); oscillator.frequency.value = 1200; // Higher pitch for success oscillator.type = 'sine'; gainNode.gain.value = 0.5; oscillator.start(); oscillator.stop(ctx.currentTime + 0.15); } catch (e) { console.log('Audio not available:', e); } } // Play error beep sound (low pitch, longer) function playErrorBeep() { try { const ctx = getAudioContext(); const oscillator = ctx.createOscillator(); const gainNode = ctx.createGain(); oscillator.connect(gainNode); gainNode.connect(ctx.destination); oscillator.frequency.value = 300; // Low pitch for error oscillator.type = 'square'; gainNode.gain.value = 0.4; oscillator.start(); oscillator.stop(ctx.currentTime + 0.4); } catch (e) { console.log('Audio not available:', e); } } // Product Search function searchProduct(barcode) { showLoading(); fetch(CONFIG.ajaxUrl + 'findproduct.php?token=' + CONFIG.token + '&barcode=' + encodeURIComponent(barcode)) .then(res => res.json()) .then(data => { hideLoading(); if (data.success && data.product) { currentProduct = data.product; window._scannerCurrentProduct = data.product; // Success feedback - double beep playBeep(); setTimeout(playBeep, 150); if (navigator.vibrate) navigator.vibrate([100, 100, 100]); showToast('Produkt gefunden: ' + data.product.label, 'success'); showProductResult(data.product); } else { // Not found feedback - long vibration, error sound if (navigator.vibrate) navigator.vibrate(500); playErrorBeep(); showNotFound(barcode); } }) .catch(err => { hideLoading(); console.error('Search error:', err); if (navigator.vibrate) navigator.vibrate(500); showToast(CONFIG.lang.error, 'error'); }); } // Display Results based on Mode function showProductResult(product) { // Always read current mode from CONFIG (may have changed via tab click) const mode = CONFIG.mode || currentMode; switch (mode) { case 'order': showOrderMode(product); break; case 'shop': showShopMode(product); break; case 'inventory': showInventoryMode(product); break; } elements.resultArea.classList.remove('hidden'); } // ORDER MODE function showOrderMode(product) { const suppliers = product.suppliers || []; const cheapest = suppliers.length > 0 ? suppliers[0] : null; let suppliersHtml = ''; if (suppliers.length > 0) { suppliersHtml = `
${CONFIG.lang.selectSupplier}: ${suppliers.map((s, i) => `
${escapeHtml(s.name)}${i === 0 ? '' + CONFIG.lang.cheapest + '' : ''}
${s.ref_fourn ? '
' + CONFIG.lang.supplierOrderRef + ': ' + escapeHtml(s.ref_fourn) + '
' : ''}
${formatPrice(s.price)} €
`).join('')}
`; selectedSupplier = cheapest; } else { // No supplier assigned - show all available suppliers if (allSuppliers.length > 0) { suppliersHtml = `
${CONFIG.lang.noSupplier}
${CONFIG.lang.selectSupplier}: ${allSuppliers.map((s, i) => `
${escapeHtml(s.name)}
`).join('')}
`; selectedSupplier = allSuppliers[0]; } else { suppliersHtml = `
${CONFIG.lang.noSupplier}
`; } } elements.resultArea.innerHTML = `
${escapeHtml(product.label)}
${CONFIG.lang.ref}: ${escapeHtml(product.ref)}
${CONFIG.lang.stock}: ${product.stock} ${escapeHtml(product.stock_unit || 'Stk')}
${suppliersHtml}
${CONFIG.lang.quantity}:
`; bindOrderEvents(); } function bindOrderEvents() { // Supplier selection document.querySelectorAll('.supplier-option').forEach(opt => { opt.addEventListener('click', function() { document.querySelectorAll('.supplier-option').forEach(o => o.classList.remove('selected')); this.classList.add('selected'); selectedSupplier = { id: this.dataset.supplierId, price: parseFloat(this.dataset.price) || 0 }; }); }); // Quantity controls const qtyInput = document.getElementById('qty-input'); document.getElementById('qty-minus').addEventListener('click', () => { const val = parseInt(qtyInput.value) || 1; if (val > 1) qtyInput.value = val - 1; }); document.getElementById('qty-plus').addEventListener('click', () => { const val = parseInt(qtyInput.value) || 1; qtyInput.value = val + 1; }); // Add to order document.getElementById('add-to-order').addEventListener('click', addToOrder); } function addToOrder() { if (!currentProduct || !selectedSupplier) { showToast(CONFIG.lang.error, 'error'); return; } const qty = parseInt(document.getElementById('qty-input').value) || 1; showLoading(); fetch(CONFIG.ajaxUrl + 'addtoorder.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `token=${CONFIG.token}&product_id=${currentProduct.id}&supplier_id=${selectedSupplier.id}&qty=${qty}&price=${selectedSupplier.price || 0}` }) .then(res => res.json()) .then(data => { hideLoading(); if (data.success) { const productName = currentProduct.label || currentProduct.ref || 'Produkt'; showToast(`${CONFIG.lang.added}: ${productName} (${qty}x)`, 'success'); hideResult(); } else { showToast(data.error || CONFIG.lang.error, 'error'); } }) .catch(err => { hideLoading(); console.error('Add to order error:', err); showToast(CONFIG.lang.error, 'error'); }); } // SHOP MODE function showShopMode(product) { const suppliers = product.suppliers || []; let linksHtml = ''; if (suppliers.length > 0) { linksHtml = suppliers.map(s => { // shop_url + artikelnummer let shopUrl = s.url || ''; if (shopUrl && s.ref_fourn) { shopUrl = shopUrl + s.ref_fourn; } if (shopUrl) { return ` ${escapeHtml(s.name)} ${s.ref_fourn ? '' + escapeHtml(s.ref_fourn) + '' : ''} `; } return ` `; }).join(''); } else { linksHtml = `
${CONFIG.lang.noSupplier}
`; } elements.resultArea.innerHTML = `
${escapeHtml(product.label)}
${CONFIG.lang.ref}: ${escapeHtml(product.ref)}
${CONFIG.lang.stock}: ${product.stock} ${escapeHtml(product.stock_unit || 'Stk')}
`; } // INVENTORY MODE function showInventoryMode(product) { elements.resultArea.innerHTML = `
${escapeHtml(product.label)}
${CONFIG.lang.ref}: ${escapeHtml(product.ref)}
${product.stock}
${CONFIG.lang.currentStock}
${product.stock}
${CONFIG.lang.newStock}
${CONFIG.lang.newStock}:
`; bindInventoryEvents(product.stock); } function bindInventoryEvents(originalStock) { const stockInput = document.getElementById('stock-input'); const preview = document.getElementById('new-stock-preview'); function updatePreview() { preview.textContent = stockInput.value; } stockInput.addEventListener('input', updatePreview); document.getElementById('stock-minus').addEventListener('click', () => { const val = parseInt(stockInput.value) || 0; if (val > 0) { stockInput.value = val - 1; updatePreview(); } }); document.getElementById('stock-plus').addEventListener('click', () => { const val = parseInt(stockInput.value) || 0; stockInput.value = val + 1; updatePreview(); }); document.getElementById('save-stock').addEventListener('click', () => { const newStock = parseInt(stockInput.value) || 0; if (newStock !== originalStock) { showConfirmDialog(originalStock, newStock); } else { showToast(CONFIG.lang.saved, 'success'); hideResult(); } }); } function showConfirmDialog(oldStock, newStock) { const dialog = document.createElement('div'); dialog.className = 'confirm-dialog'; dialog.innerHTML = `
${CONFIG.lang.confirmStockChange}
${oldStock} → ${newStock}
`; document.body.appendChild(dialog); document.getElementById('confirm-cancel').addEventListener('click', () => { dialog.remove(); }); document.getElementById('confirm-ok').addEventListener('click', () => { dialog.remove(); saveStock(newStock); }); } function saveStock(newStock) { if (!currentProduct) return; showLoading(); fetch(CONFIG.ajaxUrl + 'updatestock.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `token=${CONFIG.token}&product_id=${currentProduct.id}&stock=${newStock}` }) .then(res => res.json()) .then(data => { hideLoading(); if (data.success) { showToast(CONFIG.lang.saved, 'success'); hideResult(); } else { showToast(data.error || CONFIG.lang.error, 'error'); } }) .catch(err => { hideLoading(); console.error('Save stock error:', err); showToast(CONFIG.lang.error, 'error'); }); } // Not Found function showNotFound(barcode) { elements.resultArea.innerHTML = `
${CONFIG.lang.productNotFound}
${escapeHtml(barcode)}
`; elements.resultArea.classList.remove('hidden'); } // UI Helpers function hideResult() { elements.resultArea.classList.add('hidden'); elements.resultArea.innerHTML = ''; currentProduct = null; window._scannerCurrentProduct = null; selectedSupplier = null; } function showLoading() { // Use jQuery blockUI if available (Dolibarr standard) if (typeof jQuery !== 'undefined' && jQuery.blockUI) { jQuery.blockUI({ message: '
' }); } } function hideLoading() { if (typeof jQuery !== 'undefined' && jQuery.unblockUI) { jQuery.unblockUI(); } } function showToast(message, type = 'success') { // Remove existing toasts document.querySelectorAll('.scanner-toast').forEach(t => t.remove()); const toast = document.createElement('div'); toast.className = 'scanner-toast ' + type; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.remove(); }, 3000); } function escapeHtml(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function formatPrice(price) { return parseFloat(price).toFixed(2).replace('.', ','); } // Start when DOM is ready (only if CONFIG already exists) function tryInit() { if (typeof window.SCANNER_CONFIG !== 'undefined') { window.initScanner(); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', tryInit); } else { tryInit(); } })();