dolibarr.handybarcodescanner/js/scanner.js
data 001f1b304c Add manual barcode input for testing without camera/HTTPS
- Added text input field for manual barcode entry
- Works with Enter key or Search button
- Allows testing functionality without camera access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 11:26:12 +01:00

676 lines
20 KiB
JavaScript

/**
* HandyBarcodeScanner - Mobile Barcode Scanner for Dolibarr
* Integrated version using SCANNER_CONFIG
*/
(function() {
'use strict';
// Use SCANNER_CONFIG from page (Dolibarr integrated)
// Exit early if not on scanner page
if (typeof window.SCANNER_CONFIG === 'undefined') {
return;
}
const CONFIG = window.SCANNER_CONFIG;
// Validate required config
if (!CONFIG.ajaxUrl || !CONFIG.token) {
console.error('HandyBarcodeScanner: Invalid configuration');
return;
}
// State
let currentMode = CONFIG.mode || 'order';
let isScanning = false;
let lastScannedCode = null;
let currentProduct = null;
let selectedSupplier = null;
let allSuppliers = [];
let quaggaInitialized = false;
// 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;
}
// Check for camera support
checkCameraSupport();
bindEvents();
loadAllSuppliers();
}
// Check camera support and show appropriate messages
function checkCameraSupport() {
// Check HTTPS (required for camera access except on localhost)
// Note: In test environments without HTTPS, camera may not work but we still allow trying
const isSecure = location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1';
if (!isSecure) {
// Show warning but don't disable - some browsers/configs may still work
console.warn('HandyBarcodeScanner: HTTPS empfohlen für Kamerazugriff');
}
// Check if getUserMedia is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
showCameraError('Kamera wird von diesem Browser nicht unterstützt');
elements.startBtn.disabled = true;
elements.startBtn.classList.add('butActionRefused');
elements.startBtn.classList.remove('butAction');
return;
}
// Check if Quagga is loaded
if (typeof Quagga === 'undefined') {
showCameraError('Scanner-Bibliothek nicht geladen');
elements.startBtn.disabled = true;
elements.startBtn.classList.add('butActionRefused');
elements.startBtn.classList.remove('butAction');
return;
}
// All checks passed - enable button
elements.startBtn.disabled = false;
}
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(CONFIG.lang.error + ': Scanner library not loaded', 'error');
return;
}
// Disable button while initializing
elements.startBtn.disabled = true;
elements.startBtn.textContent = 'Initialisiere...';
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 }
}
},
decoder: {
readers: [
"ean_reader",
"ean_8_reader",
"code_128_reader",
"code_39_reader"
]
},
locate: true,
frequency: 10
}, function(err) {
if (err) {
console.error('Quagga init error:', err);
elements.startBtn.disabled = false;
elements.startBtn.textContent = CONFIG.lang.startScan || 'Scan starten';
// Show specific error message
let errorMsg = CONFIG.lang.cameraError || 'Kamerafehler';
if (err.name === 'NotAllowedError') {
errorMsg = 'Kamerazugriff verweigert. Bitte Berechtigung erteilen.';
} else if (err.name === 'NotFoundError') {
errorMsg = 'Keine Kamera gefunden.';
} else if (err.name === 'NotReadableError') {
errorMsg = 'Kamera wird bereits verwendet.';
}
showToast(errorMsg, '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);
});
}
function stopScanner() {
if (!isScanning) return;
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) {
const code = result.codeResult.code;
// Prevent duplicate scans
if (code === lastScannedCode) return;
lastScannedCode = code;
// Vibration feedback
if (CONFIG.enableVibration && navigator.vibrate) {
navigator.vibrate(100);
}
// Sound feedback
if (CONFIG.enableSound) {
playBeep();
}
elements.lastScanCode.textContent = code;
// Search product
searchProduct(code);
// Reset duplicate prevention after 2 seconds
setTimeout(() => {
lastScannedCode = null;
}, 2000);
}
// Play beep sound
function playBeep() {
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.frequency.value = 1000;
oscillator.type = 'sine';
gainNode.gain.value = 0.3;
oscillator.start();
setTimeout(() => oscillator.stop(), 100);
} catch (e) {
console.log('Audio not available');
}
}
// 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;
showProductResult(data.product);
} else {
showNotFound(barcode);
}
})
.catch(err => {
hideLoading();
console.error('Search error:', err);
showToast(CONFIG.lang.error, 'error');
});
}
// Display Results based on Mode
function showProductResult(product) {
switch (currentMode) {
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}">
<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>
</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) {
showToast(`${CONFIG.lang.added}: ${currentProduct.label} (${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 => {
if (s.url) {
return `
<a href="${escapeHtml(s.url)}" target="_blank" rel="noopener" class="shop-link-btn">
<span class="shop-link-name">${escapeHtml(s.name)}</span>
<span class="shop-link-arrow">&rarr;</span>
</a>
`;
}
return `
<div class="shop-link-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;
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
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();