dolibarr.handybarcodescanner/js/scanner.js
data ad180db510 v4.6: Menü unter Produkte, bessere Barcode-Erkennung, Tab-Wechsel ohne Reload
- Menü aus Header entfernt, neuer Eintrag unter Produkte > Scanner
- Barcode-Erkennung: patchSize medium, grösserer Scan-Bereich, höhere Frequenz
- Timeout-Hinweis nach 8s wenn kein Barcode erkannt wird
- Tab-Wechsel (Order/Shop/Inventur) ohne Seitenreload, Kamera bleibt aktiv
- PWA: gleiche Tab-Logik, Buttons statt Links
- Changelog und README aktualisiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:54:13 +01:00

868 lines
25 KiB
JavaScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = '<div style="font-size: 40px; margin-bottom: 10px;">⚠️</div><div>' + message + '</div>';
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 = `
<div class="supplier-list">
<span class="supplier-label">${CONFIG.lang.selectSupplier}:</span>
${suppliers.map((s, i) => `
<div class="supplier-option ${i === 0 ? 'selected' : ''}" data-supplier-id="${s.id}" data-price="${s.price}" data-ref-fourn="${escapeHtml(s.ref_fourn || '')}">
<div class="supplier-radio"></div>
<div class="supplier-info">
<div class="supplier-name">${escapeHtml(s.name)}${i === 0 ? '<span class="cheapest-badge">' + CONFIG.lang.cheapest + '</span>' : ''}</div>
${s.ref_fourn ? '<div class="supplier-ref">' + CONFIG.lang.supplierOrderRef + ': ' + escapeHtml(s.ref_fourn) + '</div>' : ''}
</div>
<div class="supplier-price">${formatPrice(s.price)} &euro;</div>
</div>
`).join('')}
</div>
`;
selectedSupplier = cheapest;
} else {
// No supplier assigned - show all available suppliers
if (allSuppliers.length > 0) {
suppliersHtml = `
<div class="supplier-list">
<div class="no-supplier">${CONFIG.lang.noSupplier}</div>
<span class="supplier-label" style="margin-top: 15px; display: block;">${CONFIG.lang.selectSupplier}:</span>
${allSuppliers.map((s, i) => `
<div class="supplier-option ${i === 0 ? 'selected' : ''}" data-supplier-id="${s.id}" data-price="0">
<div class="supplier-radio"></div>
<div class="supplier-info">
<div class="supplier-name">${escapeHtml(s.name)}</div>
</div>
</div>
`).join('')}
</div>
`;
selectedSupplier = allSuppliers[0];
} else {
suppliersHtml = `<div class="no-supplier">${CONFIG.lang.noSupplier}</div>`;
}
}
elements.resultArea.innerHTML = `
<div class="product-card">
<div class="product-name">${escapeHtml(product.label)}</div>
<div class="product-ref">${CONFIG.lang.ref}: ${escapeHtml(product.ref)}</div>
<div class="product-stock">${CONFIG.lang.stock}: ${product.stock} ${escapeHtml(product.stock_unit || 'Stk')}</div>
</div>
${suppliersHtml}
<div class="quantity-section">
<span class="quantity-label">${CONFIG.lang.quantity}:</span>
<div class="quantity-controls">
<button type="button" class="qty-btn" id="qty-minus">&minus;</button>
<input type="number" class="qty-input" id="qty-input" value="1" min="1">
<button type="button" class="qty-btn" id="qty-plus">+</button>
</div>
</div>
<button type="button" class="action-btn btn-primary" id="add-to-order">${CONFIG.lang.add}</button>
`;
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 `
<a href="${escapeHtml(shopUrl)}" target="_blank" rel="noopener noreferrer" class="shop-link-btn action-btn">
<span class="shop-link-name">${escapeHtml(s.name)}</span>
${s.ref_fourn ? '<span class="shop-link-ref">' + escapeHtml(s.ref_fourn) + '</span>' : ''}
<span class="shop-link-arrow">&rarr;</span>
</a>
`;
}
return `
<div class="shop-link-btn action-btn" style="opacity: 0.5; cursor: not-allowed;">
<span class="shop-link-name">${escapeHtml(s.name)}</span>
<span class="shop-link-arrow">&mdash;</span>
</div>
`;
}).join('');
} else {
linksHtml = `<div class="no-supplier">${CONFIG.lang.noSupplier}</div>`;
}
elements.resultArea.innerHTML = `
<div class="product-card">
<div class="product-name">${escapeHtml(product.label)}</div>
<div class="product-ref">${CONFIG.lang.ref}: ${escapeHtml(product.ref)}</div>
<div class="product-stock">${CONFIG.lang.stock}: ${product.stock} ${escapeHtml(product.stock_unit || 'Stk')}</div>
</div>
<div class="shop-links">
<span class="supplier-label">${CONFIG.lang.openShop}:</span>
${linksHtml}
</div>
`;
}
// INVENTORY MODE
function showInventoryMode(product) {
elements.resultArea.innerHTML = `
<div class="product-card">
<div class="product-name">${escapeHtml(product.label)}</div>
<div class="product-ref">${CONFIG.lang.ref}: ${escapeHtml(product.ref)}</div>
</div>
<div class="stock-display">
<div class="stock-item">
<div class="stock-value" id="current-stock">${product.stock}</div>
<div class="stock-label">${CONFIG.lang.currentStock}</div>
</div>
<div class="stock-arrow">&rarr;</div>
<div class="stock-item">
<div class="stock-value" id="new-stock-preview">${product.stock}</div>
<div class="stock-label">${CONFIG.lang.newStock}</div>
</div>
</div>
<div class="quantity-section">
<span class="quantity-label">${CONFIG.lang.newStock}:</span>
<div class="quantity-controls">
<button type="button" class="qty-btn" id="stock-minus">&minus;</button>
<input type="number" class="qty-input" id="stock-input" value="${product.stock}" min="0">
<button type="button" class="qty-btn" id="stock-plus">+</button>
</div>
</div>
<button type="button" class="action-btn btn-primary" id="save-stock">${CONFIG.lang.save}</button>
`;
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 = `
<div class="confirm-content">
<div class="confirm-title">${CONFIG.lang.confirmStockChange}</div>
<div class="confirm-message">${oldStock} &rarr; ${newStock}</div>
<div class="confirm-buttons">
<button type="button" class="action-btn btn-secondary" id="confirm-cancel">${CONFIG.lang.cancel}</button>
<button type="button" class="action-btn btn-primary" id="confirm-ok">${CONFIG.lang.confirm}</button>
</div>
</div>
`;
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 = `
<div class="error-message">
<div class="error-icon">&#10060;</div>
<div class="error-text">${CONFIG.lang.productNotFound}</div>
<div class="product-ref" style="margin-top: 10px;">${escapeHtml(barcode)}</div>
</div>
`;
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: '<div class="spinner"></div>' });
}
}
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();
}
})();