- 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>
868 lines
25 KiB
JavaScript
Executable file
868 lines
25 KiB
JavaScript
Executable file
/**
|
||
* 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)} €</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">−</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">→</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">—</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">→</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">−</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} → ${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">❌</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();
|
||
}
|
||
})();
|