/**
* 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 isPaused = false; // Scanner pausiert (Dialog offen)
let lastScannedCode = null;
let currentProduct = null;
let selectedSupplier = null;
let allSuppliers = [];
let scannerInitialized = false;
let openDialogCount = 0; // Zaehlt offene Dialoge
// ZXing Scanner State
let currentBarcodeType = 'CODE_128'; // Aktuell gewahlter Barcode-Typ
let currentScanner = null; // ZXing BrowserMultiFormatReader instance
// OCR State (Tesseract.js)
let ocrWorker = null; // Singleton Worker
let ocrInitialized = false;
let currentDigitCount = 8; // Anzahl Ziffern für OCR-Erkennung
// Persistente Einstellungen - eine zentrale Config statt einzelner Keys
const STORAGE_KEY = 'hbs_config';
function loadConfig() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
} catch (e) {
return {};
}
}
function saveConfig(key, value) {
const cfg = loadConfig();
cfg[key] = value;
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
}
// Migration: alte einzelne Keys -> neue zentrale Config
(function migrateOldKeys() {
const oldSupplier = localStorage.getItem('hbs_lastFreetextSupplierId');
const oldOrder = localStorage.getItem('hbs_lastOrderId');
if (oldSupplier) {
saveConfig('lastFreetextSupplierId', oldSupplier);
localStorage.removeItem('hbs_lastFreetextSupplierId');
}
if (oldOrder) {
saveConfig('lastOrderId', parseInt(oldOrder));
localStorage.removeItem('hbs_lastOrderId');
}
})();
let lastFreetextSupplierId = loadConfig().lastFreetextSupplierId || null;
// Order overview state (persistent über App-Neustarts)
let lastOrderId = loadConfig().lastOrderId || null;
let cachedOrders = [];
let currentOrderDetail = null;
// 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;
// Globale Hooks fuer Toolbar-Buttons (PWA)
window._showProductSearchModal = showProductSearchModal;
window._showFreetextModal = showFreetextModal;
window._showOrdersPanel = showOrdersPanel;
// History-Button Event-Handler
const historyBtn = document.getElementById('ctrl-history');
if (historyBtn) {
historyBtn.addEventListener('click', showBarcodeHistory);
}
// Barcode-Typ-Dropdown Event-Handler
const typeSelect = document.getElementById('barcode-type-select');
if (typeSelect) {
// Gespeicherten Typ laden
const savedType = loadConfig().barcodeType || 'CODE_128';
typeSelect.value = savedType;
currentBarcodeType = savedType;
// Initiale Scan-Region-Farbe setzen
updateScanRegionColor(savedType);
// OCR-Digit-Selector initial ein/ausblenden
const digitSelector = document.getElementById('ocr-digit-selector');
if (digitSelector) {
digitSelector.classList.toggle('hidden', savedType !== 'OCR');
}
// Event-Listener fuer Typ-Wechsel
typeSelect.addEventListener('change', function() {
switchBarcodeType(this.value);
});
}
// OCR Digit-Count Dropdown Handler
const digitSelect = document.getElementById('ocr-digit-count');
if (digitSelect) {
const savedCount = loadConfig().ocrDigitCount || 8;
digitSelect.value = savedCount;
currentDigitCount = savedCount;
digitSelect.addEventListener('change', function() {
currentDigitCount = parseInt(this.value);
saveConfig('ocrDigitCount', currentDigitCount);
showToast('OCR: ' + currentDigitCount + ' Ziffern', 'success');
});
}
}
// 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 ZXing is loaded
if (typeof ZXing === 'undefined') {
console.error('HandyBarcodeScanner: ZXing 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, {credentials: 'same-origin'})
.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 libraries are available
if (currentBarcodeType === 'OCR') {
if (typeof Tesseract === 'undefined') {
showToast('Tesseract.js Bibliothek nicht geladen', 'error');
return;
}
} else {
if (typeof ZXing === 'undefined') {
showToast('ZXing 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());
// OCR-Modus oder Barcode-Modus?
if (currentBarcodeType === 'OCR') {
// OCR-Modus: Kamera-Preview + "Foto aufnehmen" Button
startCameraPreview();
} else {
// Barcode-Modus: ZXing Video-Stream
currentScanner = initZXing(currentBarcodeType);
}
})
.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 getBarcodeFormat(type) {
const formats = {
'CODE_128': ZXing.BarcodeFormat.CODE_128,
'QR_CODE': ZXing.BarcodeFormat.QR_CODE,
'DATA_MATRIX': ZXing.BarcodeFormat.DATA_MATRIX,
'CODE_39': ZXing.BarcodeFormat.CODE_39,
'EAN_13': ZXing.BarcodeFormat.EAN_13,
'EAN_8': ZXing.BarcodeFormat.EAN_8
};
return formats[type] || ZXing.BarcodeFormat.CODE_128;
}
function initZXing(format) {
format = format || currentBarcodeType;
try {
const hints = new Map();
const possibleFormats = [getBarcodeFormat(format)];
hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, possibleFormats);
const codeReader = new ZXing.BrowserMultiFormatReader(hints);
codeReader.decodeFromVideoDevice(
undefined, // Auto-select Kamera
elements.video,
(result, err) => {
if (result) {
onBarcodeDetected(result);
}
// Errors werden ignoriert (passiert bei jedem Frame ohne Code)
}
).then(() => {
// Scanner erfolgreich gestartet
scannerInitialized = true;
isScanning = true;
elements.startBtn.disabled = false;
elements.startBtn.classList.add('hidden');
elements.stopBtn.classList.remove('hidden');
elements.videoContainer.classList.add('scanning');
// Timeout-Timer starten
startScanTimeout();
}).catch((err) => {
console.error('ZXing init error:', err);
elements.startBtn.disabled = false;
elements.startBtn.textContent = CONFIG.lang.startScan || 'Scan starten';
showToast(CONFIG.lang.cameraError || 'Kamerafehler', 'error');
});
return codeReader;
} catch (e) {
console.error('ZXing initialization error:', e);
elements.startBtn.disabled = false;
elements.startBtn.textContent = CONFIG.lang.startScan || 'Scan starten';
showToast('Scanner konnte nicht initialisiert werden', 'error');
return null;
}
}
// 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() {
clearTimeout(scanTimeoutTimer);
// OCR-Modus: Video-Stream manuell stoppen
if (currentBarcodeType === 'OCR') {
const video = elements.video;
if (video && video.srcObject) {
video.srcObject.getTracks().forEach(track => track.stop());
video.srcObject = null;
}
// Button-Click-Handler zurücksetzen
elements.startBtn.onclick = null;
}
if (currentScanner && scannerInitialized) {
try {
currentScanner.reset();
} catch (e) {
console.log('Scanner stop error:', e);
}
}
isScanning = false;
isPaused = false;
scannerInitialized = false;
currentScanner = null;
elements.startBtn.classList.remove('hidden');
elements.startBtn.disabled = false;
elements.startBtn.textContent = CONFIG.lang.startScan || 'Scannen';
elements.stopBtn.classList.add('hidden');
elements.videoContainer.classList.remove('scanning');
}
// Scanner pausieren (bei Dialog-Oeffnung)
// ZXing hat kein natives pause() - wir nutzen ein Flag und ignorieren Detections
function pauseScanner() {
if (!isScanning || isPaused) return;
openDialogCount++;
isPaused = true;
console.log('Scanner paused, dialogs:', openDialogCount);
}
// Scanner fortsetzen (wenn alle Dialoge geschlossen)
function resumeScanner() {
openDialogCount = Math.max(0, openDialogCount - 1);
if (openDialogCount > 0) {
console.log('Still dialogs open:', openDialogCount);
return;
}
if (!isScanning || !isPaused) return;
isPaused = false;
console.log('Scanner resumed');
startScanTimeout();
}
// Globale Funktionen fuer PWA
window._pauseScanner = pauseScanner;
window._resumeScanner = resumeScanner;
// ===== OCR-Funktionen (Tesseract.js) =====
// Kamera-Preview starten (ohne Barcode-Detection)
function startCameraPreview() {
const video = elements.video;
navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
})
.then(function(stream) {
video.srcObject = stream;
video.play();
isScanning = true;
scannerInitialized = true;
// OCR: Start-Button NICHT verstecken, sondern als "Foto aufnehmen" nutzen
elements.startBtn.classList.remove('hidden');
elements.startBtn.textContent = 'Foto aufnehmen';
elements.stopBtn.classList.remove('hidden');
elements.videoContainer.classList.add('scanning');
elements.startBtn.disabled = false;
// Button-Click auf Snapshot-Capture umstellen
elements.startBtn.onclick = captureOCRSnapshot;
console.log('OCR Kamera-Preview gestartet');
})
.catch(function(err) {
console.error('OCR Camera error:', err);
showToast('Kamerafehler: ' + err.message, 'error');
elements.startBtn.disabled = false;
elements.startBtn.textContent = 'Scannen';
});
}
// Foto vom Video-Stream aufnehmen und OCR durchführen
function captureOCRSnapshot() {
const video = elements.video;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// Canvas-Größe = Video-Größe
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Video-Frame auf Canvas zeichnen
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// Canvas zu Blob konvertieren
canvas.toBlob(function(blob) {
if (blob) {
performOCR(blob);
} else {
showToast('Fehler beim Erstellen des Fotos', 'error');
}
}, 'image/jpeg', 0.9);
}
// Tesseract Worker initialisieren (Singleton, Lazy)
async function initOCRWorker() {
if (ocrInitialized && ocrWorker) {
return ocrWorker;
}
try {
console.log('Initialisiere Tesseract Worker...');
ocrWorker = await Tesseract.createWorker('eng');
// Nur Ziffern erkennen, einfache Einstellungen
await ocrWorker.setParameters({
tessedit_char_whitelist: '0123456789'
// Keine PSM/OEM - Tesseract-Defaults verwenden
});
ocrInitialized = true;
console.log('Tesseract Worker bereit');
return ocrWorker;
} catch (e) {
console.error('OCR Worker Init Error:', e);
showToast('OCR-Fehler: ' + e.message, 'error');
throw e;
}
}
// OCR durchführen mit Timeout
async function performOCR(imageBlob) {
// Loading-Overlay anzeigen
const overlay = document.createElement('div');
overlay.className = 'ocr-loading-overlay';
overlay.innerHTML = 'Erkenne Text...
';
document.body.appendChild(overlay);
try {
// Worker initialisieren (falls noch nicht geschehen)
const worker = await initOCRWorker();
// Bild vorverarbeiten für bessere Erkennung
const processedBlob = await preprocessImage(imageBlob);
// OCR mit 10s Timeout
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('OCR Timeout')), 10000)
);
const ocrPromise = worker.recognize(processedBlob);
const result = await Promise.race([ocrPromise, timeoutPromise]);
overlay.remove();
const recognizedText = result.data.text;
console.log('OCR Result:', recognizedText);
console.log('OCR Confidence:', result.data.confidence);
// DEBUG: Erkannten Text anzeigen
showToast('OCR erkannte: "' + recognizedText.trim().substring(0, 50) + '"', 'info', 5000);
// Ziffern extrahieren
const matches = extractDigitSequences(recognizedText, currentDigitCount);
console.log('Gefundene ' + currentDigitCount + '-stellige Zahlen:', matches);
if (matches.length === 0) {
// Keine Treffer
showToast('Keine ' + currentDigitCount + '-stellige Zahl gefunden', 'error');
playErrorBeep();
} else if (matches.length === 1) {
// Ein Treffer - direkt verwenden
onOCRDetected(matches[0]);
} else {
// Mehrere Treffer - Auswahl anzeigen
showOCRSelectionModal(matches);
}
} catch (e) {
overlay.remove();
console.error('OCR Error:', e);
if (e.message === 'OCR Timeout') {
showToast('OCR-Timeout - bitte erneut versuchen', 'error');
} else {
showToast('OCR-Fehler: ' + e.message, 'error');
}
}
}
// Bild vorverarbeiten: MINIMAL - nur Grayscale
async function preprocessImage(blob) {
return new Promise((resolve) => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
img.onload = function() {
// Original-Größe beibehalten - keine Skalierung
canvas.width = img.width;
canvas.height = img.height;
// Bild zeichnen
ctx.drawImage(img, 0, 0);
// NUR Grayscale - kein Kontrast, kein Binary!
// Tesseract macht das selbst besser
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
// Grayscale
const gray = 0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2];
data[i] = data[i+1] = data[i+2] = gray;
}
ctx.putImageData(imageData, 0, 0);
// Zurück zu Blob
canvas.toBlob(function(processedBlob) {
resolve(processedBlob);
}, 'image/jpeg', 0.95);
};
img.src = URL.createObjectURL(blob);
});
}
// N-stellige Zahlensequenzen extrahieren - NUR zusammenhängende Zahlenblöcke mit EXAKT N Ziffern!
function extractDigitSequences(text, digitCount) {
// Alle zusammenhängenden Zahlenblöcke finden
const numberBlocks = text.match(/\d+/g) || [];
// Nur Blöcke mit EXAKT digitCount Ziffern (keine Subsequenzen, keine Zusammensetzungen!)
const matches = numberBlocks.filter(function(block) {
return block.length === digitCount;
});
// Duplikate entfernen
return [...new Set(matches)];
}
// Auswahl-Modal bei mehreren OCR-Treffern
function showOCRSelectionModal(matches) {
pauseScanner();
const modal = document.createElement('div');
modal.className = 'ocr-select-modal';
modal.innerHTML = '' +
'' +
'
' +
matches.map(function(code) {
return '
' + code + '
';
}).join('') +
'
' +
'
';
document.body.appendChild(modal);
function closeModal() {
modal.remove();
resumeScanner();
}
// Close button
modal.querySelector('.ocr-select-close').addEventListener('click', closeModal);
// Item selection
modal.querySelectorAll('.ocr-select-item').forEach(function(item) {
item.addEventListener('click', function() {
const code = this.getAttribute('data-code');
modal.remove();
onOCRDetected(code);
resumeScanner();
});
});
// Backdrop click
modal.addEventListener('click', function(e) {
if (e.target === modal) closeModal();
});
}
// OCR-Ergebnis verarbeiten (analog zu onBarcodeDetected)
function onOCRDetected(code) {
// Ignore detections wenn Scanner pausiert ist
if (isPaused) return;
const format = 'OCR';
// Duplicate Check
if (lastScannedCode === code) {
showToast('Code bereits gescannt: ' + code, 'info');
return;
}
lastScannedCode = code;
console.log('OCR detected:', code);
// Success Vibration
if ('vibrate' in navigator) {
navigator.vibrate([100, 50, 100]);
}
// In Historie speichern
addToHistory(format, code);
// Last Scan anzeigen
const formatLabels = {
'OCR': 'OCR'
};
const label = formatLabels[format] || format;
elements.lastScanCode.innerHTML = '' + label + '' +
'' + escapeHtml(code) + '';
elements.lastScanArea.classList.remove('hidden');
// Toast
showToast('OCR: ' + code, 'success');
// Produktsuche starten
searchProduct(code);
}
// Barcode-Typ wechseln (ohne Kamera-Neustart)
function switchBarcodeType(newType) {
if (newType === currentBarcodeType) return;
console.log('Switching barcode type from', currentBarcodeType, 'to', newType);
// OCR-Digit-Selector ein/ausblenden
const digitSelector = document.getElementById('ocr-digit-selector');
if (digitSelector) {
digitSelector.classList.toggle('hidden', newType !== 'OCR');
}
// 1. Aktuellen Scanner stoppen (NICHT Kamera-Stream!)
if (currentScanner && isScanning) {
try {
currentScanner.reset();
console.log('Scanner reset for type switch');
// Stream bleibt aktiv!
} catch (e) {
console.log('Scanner reset error:', e);
}
}
// 2. Format wechseln
currentBarcodeType = newType;
saveConfig('barcodeType', newType);
// 3. Scanner mit neuem Format neu starten (falls er lief)
if (isScanning && newType !== 'OCR') {
// Barcode-Modus: ZXing neu starten, onclick zurücksetzen
elements.startBtn.onclick = null;
currentScanner = initZXing(newType);
} else if (isScanning && newType === 'OCR') {
// OCR-Modus: Stream läuft bereits, nur Button umstellen
elements.startBtn.classList.remove('hidden');
elements.startBtn.textContent = 'Foto aufnehmen';
elements.startBtn.disabled = false;
elements.startBtn.onclick = captureOCRSnapshot;
console.log('OCR-Modus aktiviert (Stream läuft bereits)');
}
// 4. UI-Feedback
const formatNames = {
'CODE_128': 'Code 128',
'QR_CODE': 'QR-Code',
'DATA_MATRIX': 'DataMatrix',
'OCR': 'OCR'
};
showToast('Barcode-Typ: ' + (formatNames[newType] || newType), 'success');
// Scan-Region-Farbe andern
updateScanRegionColor(newType);
}
// Scan-Region-Farbe je nach Barcode-Typ andern
function updateScanRegionColor(type) {
const colors = {
'CODE_128': 'var(--primary)', // Blau (Standard)
'QR_CODE': '#25a580', // Grun
'DATA_MATRIX': '#bc9526', // Orange/Gelb
'OCR': '#e74c3c' // Rot
};
const region = document.querySelector('.scan-region-highlight');
if (region) {
region.style.borderColor = colors[type] || colors['CODE_128'];
}
}
function onBarcodeDetected(result) {
// Ignore detections wenn Scanner pausiert ist
if (isPaused) return;
// ZXing Result-Format
let code = result.getText();
let formatObj = result.getBarcodeFormat();
// Format normalisieren: CODE_128, QR_CODE, DATA_MATRIX, EAN_13, EAN_8
const format = formatObj ? formatObj.toString().toUpperCase() : 'UNKNOWN';
// 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.includes('EAN') && format.includes('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.includes('EAN') && format.includes('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();
// Format-Label fuer Anzeige
const formatLabels = {
'CODE_128': 'Code 128',
'CODE_39': 'Code 39',
'QR_CODE': 'QR',
'DATA_MATRIX': 'DataMatrix',
'EAN_13': 'EAN-13',
'EAN_8': 'EAN-8'
};
const label = formatLabels[format] || format;
showToast('Barcode: ' + code, 'success');
// Last Scan mit Format-Badge anzeigen
elements.lastScanCode.innerHTML = `
${escapeHtml(label)}
${escapeHtml(code)}
`;
// In Historie speichern
addToHistory(format, code);
// Search product
searchProduct(code);
// Reset duplicate prevention after 3 seconds
setTimeout(() => {
lastScannedCode = null;
}, 3000);
}
// Barcode-Scan in Historie speichern (max. 10 Einträge)
function addToHistory(format, code) {
const cfg = loadConfig();
let history = cfg.barcodeHistory || [];
// Neuen Eintrag hinzufügen
history.unshift({
type: format,
code: code,
timestamp: Math.floor(Date.now() / 1000)
});
// Maximal 10 Einträge behalten
if (history.length > 10) {
history = history.slice(0, 10);
}
saveConfig('barcodeHistory', history);
}
// Scan-Historie-Modal anzeigen
function showBarcodeHistory() {
pauseScanner();
const cfg = loadConfig();
const history = cfg.barcodeHistory || [];
if (history.length === 0) {
showToast('Noch keine Scans vorhanden', 'error');
resumeScanner();
return;
}
const formatLabels = {
'CODE_128': 'Code 128',
'CODE_39': 'Code 39',
'QR_CODE': 'QR',
'DATA_MATRIX': 'DataMatrix',
'EAN_13': 'EAN-13',
'EAN_8': 'EAN-8'
};
const modal = document.createElement('div');
modal.className = 'search-modal'; // Bestehende Modal-Styles wiederverwenden
modal.id = 'history-modal';
modal.innerHTML = `
${history.map((item, i) => {
const relativeTime = getRelativeTime(item.timestamp);
const label = formatLabels[item.type] || item.type;
return `
${escapeHtml(label)}
${escapeHtml(item.code)}
${relativeTime}
`;
}).join('')}
`;
document.body.appendChild(modal);
function closeModal() {
modal.remove();
resumeScanner();
}
document.getElementById('history-close').addEventListener('click', closeModal);
modal.addEventListener('click', function(e) {
if (e.target === modal) closeModal();
});
}
// Relative Zeit-Anzeige (vor X Sekunden/Minuten/Stunden/Tagen)
function getRelativeTime(timestamp) {
const now = Math.floor(Date.now() / 1000);
const diff = now - timestamp;
if (diff < 60) return 'vor ' + diff + ' Sek';
if (diff < 3600) return 'vor ' + Math.floor(diff / 60) + ' Min';
if (diff < 86400) return 'vor ' + Math.floor(diff / 3600) + ' Std';
return 'vor ' + Math.floor(diff / 86400) + ' Tagen';
}
// 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), {credentials: 'same-origin'})
.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) => `
`).join('')}
`;
selectedSupplier = allSuppliers[0];
} else {
suppliersHtml = `${CONFIG.lang.noSupplier}
`;
}
}
elements.resultArea.innerHTML = `
←
${suppliersHtml}
`;
bindOrderEvents();
bindOrderToolsEvents();
bindSwipeEvents();
}
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);
// Print barcode button
const printBtn = document.getElementById('btn-print-barcode');
if (printBtn) {
printBtn.addEventListener('click', function() {
if (typeof window._showPrintBarcodeModal === 'function') {
window._showPrintBarcodeModal(currentProduct);
}
});
}
}
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',
credentials: 'same-origin',
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');
// Save last order ID for auto-open in order view
lastOrderId = data.order_id;
saveConfig('lastOrderId', data.order_id);
hideResult();
} else {
showToast(data.error || CONFIG.lang.error, 'error');
}
})
.catch(err => {
hideLoading();
console.error('Add to order error:', err);
showToast(CONFIG.lang.error, 'error');
});
}
// Bind order tools (search + freetext buttons)
function bindOrderToolsEvents() {
const searchBtn = document.getElementById('btn-product-search');
const freetextBtn = document.getElementById('btn-freetext');
if (searchBtn) {
searchBtn.addEventListener('click', showProductSearchModal);
}
if (freetextBtn) {
freetextBtn.addEventListener('click', showFreetextModal);
}
}
// PRODUCT SEARCH MODAL
function showProductSearchModal() {
pauseScanner();
const modal = document.createElement('div');
modal.className = 'search-modal';
modal.id = 'search-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
const input = document.getElementById('product-search-input');
const results = document.getElementById('search-results');
let searchTimeout = null;
function closeModal() {
modal.remove();
resumeScanner();
}
input.addEventListener('input', function() {
clearTimeout(searchTimeout);
const query = this.value.trim();
if (query.length < 2) {
results.innerHTML = '';
return;
}
results.innerHTML = 'Suche...
';
searchTimeout = setTimeout(() => {
searchProducts(query, closeModal);
}, 300);
});
document.getElementById('search-close').addEventListener('click', closeModal);
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeModal();
}
});
input.focus();
}
function searchProducts(query, closeModalCallback) {
fetch(CONFIG.ajaxUrl + 'searchproduct.php?token=' + CONFIG.token + '&q=' + encodeURIComponent(query), {credentials: 'same-origin'})
.then(res => res.json())
.then(data => {
const results = document.getElementById('search-results');
if (!results) return;
if (data.success && data.products.length > 0) {
results.innerHTML = data.products.map(p => `
${escapeHtml(p.label)}
Ref: ${escapeHtml(p.ref)}
Lager: ${p.stock} ${escapeHtml(p.stock_unit || 'Stk')}
`).join('');
results.querySelectorAll('.search-result-item').forEach(item => {
item.addEventListener('click', function() {
const product = JSON.parse(this.dataset.product);
if (closeModalCallback) closeModalCallback();
currentProduct = product;
window._scannerCurrentProduct = product;
showProductResult(product);
});
});
} else {
results.innerHTML = 'Keine Produkte gefunden
';
}
})
.catch(err => {
console.error('Search error:', err);
const results = document.getElementById('search-results');
if (results) {
results.innerHTML = 'Fehler bei der Suche
';
}
});
}
// FREETEXT MODAL
function showFreetextModal() {
// Need a supplier for freetext
if (allSuppliers.length === 0) {
showToast('Keine Lieferanten verfügbar', 'error');
return;
}
pauseScanner();
const savedSupplierId = loadConfig().lastFreetextSupplierId;
const modal = document.createElement('div');
modal.className = 'freetext-modal';
modal.id = 'freetext-modal';
modal.innerHTML = `
Freitext-Position
`;
document.body.appendChild(modal);
// Gespeicherten Lieferant per JS setzen
const supplierSelect = document.getElementById('freetext-supplier');
if (savedSupplierId) {
supplierSelect.value = savedSupplierId;
}
// Bei jeder Aenderung sofort speichern
supplierSelect.addEventListener('change', function() {
saveConfig('lastFreetextSupplierId', this.value);
});
function closeModal() {
modal.remove();
resumeScanner();
}
document.getElementById('freetext-cancel').addEventListener('click', closeModal);
document.getElementById('freetext-add').addEventListener('click', function() {
addFreetextLine(closeModal);
});
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeModal();
}
});
document.getElementById('freetext-desc').focus();
}
function addFreetextLine(closeModalCallback) {
const supplierId = document.getElementById('freetext-supplier').value;
const description = document.getElementById('freetext-desc').value.trim();
const qty = parseFloat(document.getElementById('freetext-qty').value) || 1;
const price = parseFloat(document.getElementById('freetext-price').value) || 0;
if (!description) {
showToast('Bitte Beschreibung eingeben', 'error');
return;
}
// Lieferant merken für nächstes Mal (auch über Neustarts hinweg)
lastFreetextSupplierId = supplierId;
saveConfig('lastFreetextSupplierId', supplierId);
showLoading();
fetch(CONFIG.ajaxUrl + 'addfreetextline.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `token=${CONFIG.token}&supplier_id=${supplierId}&description=${encodeURIComponent(description)}&qty=${qty}&price=${price}`
})
.then(res => res.json())
.then(data => {
hideLoading();
if (data.success) {
showToast(`Hinzugefügt: ${description} (${qty}x)`, 'success');
lastOrderId = data.order_id;
saveConfig('lastOrderId', data.order_id);
if (closeModalCallback) closeModalCallback();
} else {
showToast(data.error || CONFIG.lang.error, 'error');
}
})
.catch(err => {
hideLoading();
console.error('Add freetext error:', err);
showToast(CONFIG.lang.error, 'error');
});
}
// ORDERS PANEL AS MODAL (for toolbar button)
function showOrdersPanel() {
pauseScanner();
const modal = document.createElement('div');
modal.className = 'orders-modal';
modal.id = 'orders-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
function closeModal() {
modal.remove();
resumeScanner();
}
// Close button
document.getElementById('orders-modal-close').addEventListener('click', closeModal);
// Click outside to close
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeModal();
}
});
// Back button in detail view
document.getElementById('orders-modal-detail-back').addEventListener('click', function() {
document.getElementById('orders-modal-detail').classList.add('hidden');
document.getElementById('orders-modal-scroller').classList.remove('hidden');
});
// Load orders
loadOrdersIntoModal();
}
function loadOrdersIntoModal() {
const scroller = document.getElementById('orders-modal-scroller');
if (!scroller) return;
scroller.innerHTML = '';
fetch(CONFIG.ajaxUrl + 'getorders.php?token=' + CONFIG.token, {credentials: 'same-origin'})
.then(res => res.json())
.then(data => {
if (data.success) {
cachedOrders = data.orders;
renderOrdersInModal(data.orders);
} else {
scroller.innerHTML = 'Fehler beim Laden
';
}
})
.catch(err => {
console.error('Load orders error:', err);
scroller.innerHTML = 'Fehler beim Laden
';
});
}
function renderOrdersInModal(orders) {
const scroller = document.getElementById('orders-modal-scroller');
if (!scroller) return;
if (orders.length === 0) {
scroller.innerHTML = 'Keine Bestellungen
';
return;
}
scroller.innerHTML = orders.map(order => {
const statusClass = order.status === 0 ? 'draft' : 'validated';
const isActive = order.id === lastOrderId;
const direktClass = order.is_direkt ? 'direkt' : '';
const canDelete = order.status === 0; // Nur Entwürfe können gelöscht werden
return `
${canDelete ? `
` : ''}
${escapeHtml(order.supplier_name)}
${escapeHtml(order.ref)}
${escapeHtml(order.status_label)}
${order.line_count} Pos. / ${order.total_qty} Stk
`;
}).join('');
// Bind click events for order cards
scroller.querySelectorAll('.order-card').forEach(card => {
card.addEventListener('click', function(e) {
// Ignoriere Klicks auf den Lösch-Button
if (e.target.closest('.order-delete-btn')) return;
const orderId = parseInt(this.dataset.orderId);
openOrderDetailInModal(orderId);
});
});
// Bind delete button events
scroller.querySelectorAll('.order-delete-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const orderId = parseInt(this.dataset.orderId);
const order = cachedOrders.find(o => o.id === orderId);
showDeleteOrderDialog(orderId, order ? order.ref : '');
});
});
}
function showDeleteOrderDialog(orderId, orderRef) {
const dialog = document.createElement('div');
dialog.className = 'line-edit-dialog';
dialog.id = 'delete-order-dialog';
dialog.innerHTML = `
Bestellung löschen?
${escapeHtml(orderRef)}
Diese Aktion kann nicht rückgängig gemacht werden.
`;
document.body.appendChild(dialog);
function closeDialog() {
dialog.remove();
}
document.getElementById('delete-order-cancel').addEventListener('click', closeDialog);
document.getElementById('delete-order-confirm').addEventListener('click', function() {
closeDialog();
deleteOrder(orderId);
});
dialog.addEventListener('click', function(e) {
if (e.target === dialog) closeDialog();
});
}
function deleteOrder(orderId) {
showLoading();
fetch(CONFIG.ajaxUrl + 'deleteorder.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `token=${CONFIG.token}&order_id=${orderId}`
})
.then(res => res.json())
.then(data => {
hideLoading();
if (data.success) {
showToast('Bestellung gelöscht', 'success');
loadOrdersIntoModal();
} else {
showToast(data.error || CONFIG.lang.error, 'error');
}
})
.catch(err => {
hideLoading();
console.error('Delete order error:', err);
showToast(CONFIG.lang.error, 'error');
});
}
function openOrderDetailInModal(orderId) {
const scroller = document.getElementById('orders-modal-scroller');
const detailSection = document.getElementById('orders-modal-detail');
const content = document.getElementById('orders-modal-detail-content');
const title = document.getElementById('orders-modal-detail-title');
if (!scroller || !detailSection || !content) return;
scroller.classList.add('hidden');
detailSection.classList.remove('hidden');
content.innerHTML = '';
fetch(CONFIG.ajaxUrl + 'getorderlines.php?token=' + CONFIG.token + '&order_id=' + orderId, {credentials: 'same-origin'})
.then(res => res.json())
.then(data => {
if (data.success) {
currentOrderDetail = data.order;
title.textContent = data.order.ref + ' - ' + data.order.supplier_name;
renderOrderLinesInModal(data.lines, data.order, orderId);
} else {
content.innerHTML = 'Fehler beim Laden
';
}
})
.catch(err => {
console.error('Load order lines error:', err);
content.innerHTML = 'Fehler beim Laden
';
});
}
// Cache für aktuelle Bestellzeilen (Modal)
let currentModalLines = [];
function renderOrderLinesInModal(lines, order, orderId) {
const content = document.getElementById('orders-modal-detail-content');
if (!content) return;
// Lines cachen für Click-Handler
currentModalLines = lines;
if (lines.length === 0) {
content.innerHTML = 'Keine Positionen
';
return;
}
const canEdit = order.status == 0;
content.innerHTML = lines.map(line => `
${escapeHtml(line.product_label)}
${line.product_ref ? 'Ref: ' + escapeHtml(line.product_ref) : ''} ${line.is_freetext ? '(Freitext)' : (line.stock > 0 ? '| Lager: ' + line.stock : '')}
${line.qty}x
${canEdit ? `
` : ''}
`).join('');
// Edit-Button Events
content.querySelectorAll('.order-line-edit-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const lineId = parseInt(this.dataset.lineId);
const line = currentModalLines.find(l => l.id === lineId);
if (line) {
showLineEditDialogInModal(line, orderId);
}
});
});
}
function showLineEditDialogInModal(line, orderId) {
pauseScanner();
const isFreetext = line.is_freetext || !line.product_id;
const dialog = document.createElement('div');
dialog.className = 'line-edit-dialog';
dialog.id = 'line-edit-dialog';
dialog.innerHTML = `
${isFreetext ? 'Freitext-Position' : escapeHtml(line.product_label)}
${isFreetext ? `
` : `
Lagerbestand: ${line.stock}
`}
`;
document.body.appendChild(dialog);
function closeDialog() {
dialog.remove();
resumeScanner();
}
document.getElementById('line-save').addEventListener('click', () => {
const newQty = parseFloat(document.getElementById('line-qty-input').value) || 1;
const descInput = document.getElementById('line-desc-input');
const newDesc = descInput ? descInput.value.trim() : null;
updateOrderLineInModal(orderId, line.id, 'update', newQty, newDesc);
closeDialog();
});
document.getElementById('line-delete').addEventListener('click', () => {
if (confirm('Position wirklich löschen?')) {
updateOrderLineInModal(orderId, line.id, 'delete');
closeDialog();
}
});
dialog.addEventListener('click', function(e) {
if (e.target === dialog) {
closeDialog();
}
});
}
function updateOrderLineInModal(orderId, lineId, action, qty = 0, description = null) {
showLoading();
let body = `token=${CONFIG.token}&order_id=${orderId}&line_id=${lineId}&action=${action}`;
if (action === 'update') {
body += `&qty=${qty}`;
if (description !== null) {
body += `&description=${encodeURIComponent(description)}`;
}
}
fetch(CONFIG.ajaxUrl + 'updateorderline.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body
})
.then(res => res.json())
.then(data => {
hideLoading();
if (data.success) {
showToast(action === 'delete' ? 'Position gelöscht' : 'Anzahl aktualisiert', 'success');
if (data.order_deleted) {
// Go back to orders list
document.getElementById('orders-modal-detail').classList.add('hidden');
document.getElementById('orders-modal-scroller').classList.remove('hidden');
loadOrdersIntoModal();
} else {
// Reload order detail
openOrderDetailInModal(orderId);
}
} else {
showToast(data.error || CONFIG.lang.error, 'error');
}
})
.catch(err => {
hideLoading();
console.error('Update line error:', err);
showToast(CONFIG.lang.error, 'error');
});
}
// SWIPE GESTURE FOR ORDER OVERVIEW
function bindSwipeEvents() {
const container = document.getElementById('order-view-container');
if (!container) return;
let startX = 0;
let startY = 0;
let isDragging = false;
container.addEventListener('touchstart', function(e) {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
isDragging = true;
}, { passive: true });
container.addEventListener('touchmove', function(e) {
if (!isDragging) return;
const deltaX = e.touches[0].clientX - startX;
const deltaY = e.touches[0].clientY - startY;
// Only horizontal swipe
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 30) {
e.preventDefault();
}
}, { passive: false });
container.addEventListener('touchend', function(e) {
if (!isDragging) return;
isDragging = false;
const endX = e.changedTouches[0].clientX;
const deltaX = endX - startX;
// Swipe left to show orders
if (deltaX < -50 && !container.classList.contains('swiped')) {
container.classList.add('swiped');
loadOrders();
}
// Swipe right to go back
else if (deltaX > 50) {
if (container.classList.contains('detail-open')) {
container.classList.remove('detail-open');
} else if (container.classList.contains('swiped')) {
container.classList.remove('swiped');
}
}
}, { passive: true });
// Back buttons
const ordersBack = document.getElementById('orders-back');
const detailBack = document.getElementById('order-detail-back');
if (ordersBack) {
ordersBack.addEventListener('click', function() {
container.classList.remove('swiped');
});
}
if (detailBack) {
detailBack.addEventListener('click', function() {
container.classList.remove('detail-open');
});
}
}
// LOAD ORDERS
function loadOrders() {
const scroller = document.getElementById('orders-scroller');
if (!scroller) return;
scroller.innerHTML = '';
fetch(CONFIG.ajaxUrl + 'getorders.php?token=' + CONFIG.token, {credentials: 'same-origin'})
.then(res => res.json())
.then(data => {
if (data.success) {
cachedOrders = data.orders;
renderOrders(data.orders);
} else {
scroller.innerHTML = 'Fehler beim Laden
';
}
})
.catch(err => {
console.error('Load orders error:', err);
scroller.innerHTML = 'Fehler beim Laden
';
});
}
function renderOrders(orders) {
const scroller = document.getElementById('orders-scroller');
if (!scroller) return;
if (orders.length === 0) {
scroller.innerHTML = 'Keine Bestellungen
';
return;
}
scroller.innerHTML = orders.map(order => {
const statusClass = order.status === 0 ? 'draft' : 'validated';
const isActive = order.id === lastOrderId;
const direktClass = order.is_direkt ? 'direkt' : '';
return `
${escapeHtml(order.supplier_name)}
${escapeHtml(order.ref)}
${escapeHtml(order.status_label)}
${order.line_count} Pos. / ${order.total_qty} Stk
`;
}).join('');
// Bind click events
scroller.querySelectorAll('.order-card').forEach(card => {
card.addEventListener('click', function() {
const orderId = parseInt(this.dataset.orderId);
openOrderDetail(orderId);
});
});
// Auto-scroll to active order
if (lastOrderId) {
const activeCard = scroller.querySelector('.order-card.active');
if (activeCard) {
activeCard.scrollIntoView({ behavior: 'smooth', inline: 'center' });
}
}
}
// OPEN ORDER DETAIL
function openOrderDetail(orderId) {
const container = document.getElementById('order-view-container');
const content = document.getElementById('order-detail-content');
const title = document.getElementById('order-detail-title');
if (!container || !content) return;
content.innerHTML = '';
container.classList.add('detail-open');
fetch(CONFIG.ajaxUrl + 'getorderlines.php?token=' + CONFIG.token + '&order_id=' + orderId, {credentials: 'same-origin'})
.then(res => res.json())
.then(data => {
if (data.success) {
currentOrderDetail = data.order;
title.textContent = data.order.ref + ' - ' + data.order.supplier_name;
renderOrderLines(data.lines, data.order);
} else {
content.innerHTML = 'Fehler beim Laden
';
}
})
.catch(err => {
console.error('Load order lines error:', err);
content.innerHTML = 'Fehler beim Laden
';
});
}
function renderOrderLines(lines, order) {
const content = document.getElementById('order-detail-content');
if (!content) return;
if (lines.length === 0) {
content.innerHTML = 'Keine Positionen
';
return;
}
const canEdit = order.status == 0; // Only draft orders can be edited
content.innerHTML = lines.map(line => `
${escapeHtml(line.product_label)}
${line.product_ref ? 'Ref: ' + escapeHtml(line.product_ref) : ''} ${line.stock > 0 ? '| Lager: ' + line.stock : ''}
${line.qty}x
`).join('');
if (canEdit) {
content.querySelectorAll('.order-line').forEach(lineEl => {
lineEl.addEventListener('click', function() {
const lineId = parseInt(this.dataset.lineId);
const orderId = parseInt(this.dataset.orderId);
const line = lines.find(l => l.id === lineId);
if (line) {
showLineEditDialog(line, orderId);
}
});
});
}
}
// LINE EDIT DIALOG
function showLineEditDialog(line, orderId) {
pauseScanner();
const isFreetext = line.is_freetext || !line.product_id;
const dialog = document.createElement('div');
dialog.className = 'line-edit-dialog';
dialog.id = 'line-edit-dialog';
dialog.innerHTML = `
${isFreetext ? 'Freitext-Position' : escapeHtml(line.product_label)}
${isFreetext ? `
` : `
Lagerbestand: ${line.stock}
`}
`;
document.body.appendChild(dialog);
function closeDialog() {
dialog.remove();
resumeScanner();
}
document.getElementById('line-save').addEventListener('click', () => {
const newQty = parseFloat(document.getElementById('line-qty-input').value) || 1;
const descInput = document.getElementById('line-desc-input');
const newDesc = descInput ? descInput.value.trim() : null;
updateOrderLine(orderId, line.id, 'update', newQty, newDesc);
closeDialog();
});
document.getElementById('line-delete').addEventListener('click', () => {
if (confirm('Position wirklich löschen?')) {
updateOrderLine(orderId, line.id, 'delete');
closeDialog();
}
});
dialog.addEventListener('click', function(e) {
if (e.target === dialog) {
closeDialog();
}
});
}
function updateOrderLine(orderId, lineId, action, qty = 0, description = null) {
showLoading();
let body = `token=${CONFIG.token}&order_id=${orderId}&line_id=${lineId}&action=${action}`;
if (action === 'update') {
body += `&qty=${qty}`;
if (description !== null) {
body += `&description=${encodeURIComponent(description)}`;
}
}
fetch(CONFIG.ajaxUrl + 'updateorderline.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body
})
.then(res => res.json())
.then(data => {
hideLoading();
if (data.success) {
showToast(action === 'delete' ? 'Position gelöscht' : 'Anzahl aktualisiert', 'success');
if (data.order_deleted) {
// Go back to orders list
const container = document.getElementById('order-view-container');
if (container) {
container.classList.remove('detail-open');
}
loadOrders();
} else {
// Reload order detail
openOrderDetail(orderId);
}
} else {
showToast(data.error || CONFIG.lang.error, 'error');
}
})
.catch(err => {
hideLoading();
console.error('Update line 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 `
${escapeHtml(s.name)}
—
`;
}).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')}
${CONFIG.lang.openShop}:
${linksHtml}
`;
}
// 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}
`;
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) {
pauseScanner();
const dialog = document.createElement('div');
dialog.className = 'confirm-dialog';
dialog.innerHTML = `
${CONFIG.lang.confirmStockChange}
${oldStock} → ${newStock}
`;
document.body.appendChild(dialog);
function closeDialog() {
dialog.remove();
resumeScanner();
}
document.getElementById('confirm-cancel').addEventListener('click', closeDialog);
document.getElementById('confirm-ok').addEventListener('click', () => {
closeDialog();
saveStock(newStock);
});
}
function saveStock(newStock) {
if (!currentProduct) return;
showLoading();
fetch(CONFIG.ajaxUrl + 'updatestock.php', {
method: 'POST',
credentials: 'same-origin',
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();
}
})();