dolibarr.handybarcodescanner/js/scanner.js
data c97540e6f6 v9.1: OCR-Feature Bug-Fix - zusammenhängende Zahlenblöcke
extractDigitSequences() Fix:
- VORHER: Alle Ziffern zusammengefasst → Datensalat bei Subsequenzen
- JETZT: Nur isolierte N-stellige Zahlenblöcke (filter auf length === N)
- Beispiel: "Artikel 1234567 Datum 20250311" → nur "1234567" (nicht "2025031" etc.)

Artikelnummer-Suche:
- findproduct.php sucht in: barcode, ref_fourn (Lieferanten-Artikelnummer!), ref
- OCR-erkannte Nummern funktionieren wenn ref_fourn in Dolibarr gepflegt ist

Dokumentation:
- README.md aktualisiert: ZXing-JS, OCR-Feature, Versionierung
- Barcode-Unterstützung erweitert: Code 128, QR, DataMatrix, OCR
- Bibliotheken: ZXing-JS v0.21.3, Tesseract.js v5.1.0

Versionen synchron erhöht:
- pwa.php: ?v=91 (CSS + JS)
- sw.js: scanner-v9.1
- modHandyBarcodeScanner.class.php: 9.1

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-11 20:20:16 +01:00

2438 lines
74 KiB
JavaScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* HandyBarcodeScanner - Mobile Barcode Scanner for Dolibarr
* Integrated version using SCANNER_CONFIG
*/
(function() {
'use strict';
// State
let CONFIG = null;
let currentMode = 'order';
let initialized = false;
// Global init function - can be called after login
window.initScanner = function() {
if (typeof window.SCANNER_CONFIG === 'undefined') {
console.log('HandyBarcodeScanner: Waiting for SCANNER_CONFIG...');
return false;
}
CONFIG = window.SCANNER_CONFIG;
// Validate required config
if (!CONFIG.ajaxUrl || !CONFIG.token) {
console.error('HandyBarcodeScanner: Invalid configuration');
return false;
}
currentMode = CONFIG.mode || 'order';
// Only init once
if (initialized) {
console.log('HandyBarcodeScanner: Already initialized');
return true;
}
init();
return true;
};
let isScanning = false;
let 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 = '<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, {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 = '<div class="ocr-loading-spinner"></div><div class="ocr-loading-text">Erkenne Text...</div>';
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 = '<div class="ocr-select-content">' +
'<div class="ocr-select-header">' +
'<h3 style="margin:0;font-size:16px;color:var(--colortext);">Mehrere Nummern gefunden</h3>' +
'<button type="button" class="ocr-select-close">&times;</button>' +
'</div>' +
'<div class="ocr-select-list">' +
matches.map(function(code) {
return '<div class="ocr-select-item" data-code="' + code + '">' + code + '</div>';
}).join('') +
'</div>' +
'</div>';
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 = '<span class="scan-format-badge">' + label + '</span>' +
'<span class="scan-code-value">' + escapeHtml(code) + '</span>';
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 = `
<span class="scan-format-badge">${escapeHtml(label)}</span>
<span class="scan-code-value">${escapeHtml(code)}</span>
`;
// 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 = `
<div class="search-modal-content">
<div class="search-modal-header">
<h3 style="margin:0;font-size:16px;color:var(--colortext);">Scan-Historie (${history.length})</h3>
<button type="button" class="search-close-btn" id="history-close">&times;</button>
</div>
<div class="search-results" style="max-height:60vh;">
${history.map((item, i) => {
const relativeTime = getRelativeTime(item.timestamp);
const label = formatLabels[item.type] || item.type;
return `
<div class="search-result-item" style="cursor:default;">
<div class="search-result-label">
<span class="scan-format-badge" style="display:inline-block;margin-right:8px;padding:2px 6px;font-size:10px;background:var(--butactionbg);color:#fff;border-radius:3px;">${escapeHtml(label)}</span>
${escapeHtml(item.code)}
</div>
<div class="search-result-info">
<span>${relativeTime}</span>
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
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 = `
<div class="supplier-list">
<span class="supplier-label">${CONFIG.lang.selectSupplier}:</span>
${suppliers.map((s, i) => `
<div class="supplier-option ${i === 0 ? 'selected' : ''}" data-supplier-id="${s.id}" data-price="${s.price}" data-ref-fourn="${escapeHtml(s.ref_fourn || '')}">
<div class="supplier-radio"></div>
<div class="supplier-info">
<div class="supplier-name">${escapeHtml(s.name)}${i === 0 ? '<span class="cheapest-badge">' + CONFIG.lang.cheapest + '</span>' : ''}</div>
${s.ref_fourn ? '<div class="supplier-ref">' + CONFIG.lang.supplierOrderRef + ': ' + escapeHtml(s.ref_fourn) + '</div>' : ''}
</div>
<div class="supplier-price">${formatPrice(s.price)} &euro;</div>
</div>
`).join('')}
</div>
`;
selectedSupplier = cheapest;
} else {
// No supplier assigned - show all available suppliers
if (allSuppliers.length > 0) {
suppliersHtml = `
<div class="supplier-list">
<div class="no-supplier">${CONFIG.lang.noSupplier}</div>
<span class="supplier-label" style="margin-top: 15px; display: block;">${CONFIG.lang.selectSupplier}:</span>
${allSuppliers.map((s, i) => `
<div class="supplier-option ${i === 0 ? 'selected' : ''}" data-supplier-id="${s.id}" data-price="0">
<div class="supplier-radio"></div>
<div class="supplier-info">
<div class="supplier-name">${escapeHtml(s.name)}</div>
</div>
</div>
`).join('')}
</div>
`;
selectedSupplier = allSuppliers[0];
} else {
suppliersHtml = `<div class="no-supplier">${CONFIG.lang.noSupplier}</div>`;
}
}
elements.resultArea.innerHTML = `
<div class="order-tools">
<button type="button" class="tool-btn" id="btn-product-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
Suche
</button>
<button type="button" class="tool-btn" id="btn-freetext">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
Freitext
</button>
</div>
<div class="order-view-container" id="order-view-container">
<div class="swipe-indicator">&larr;</div>
<div class="main-content">
<div class="product-card">
<div class="product-card-header">
<div class="product-info">
<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>
${product.barcode ? `<button type="button" class="print-barcode-btn" id="btn-print-barcode" title="Barcode drucken">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9V2h12v7M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
</button>` : ''}
</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>
</div>
<div class="orders-panel">
<div class="orders-panel-header">
<span class="orders-panel-title">Bestellungen</span>
<button type="button" class="orders-panel-back" id="orders-back">&rarr; Zurück</button>
</div>
<div class="orders-scroller" id="orders-scroller">
<div class="loading-spinner"></div>
</div>
</div>
<div class="order-detail-panel">
<div class="order-detail-header">
<span class="order-detail-title" id="order-detail-title">Bestellung</span>
<button type="button" class="order-detail-back" id="order-detail-back">&rarr; Zurück</button>
</div>
<div class="order-detail-content" id="order-detail-content">
</div>
</div>
</div>
`;
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 = `
<div class="search-modal-content">
<div class="search-modal-header">
<input type="text" id="product-search-input" placeholder="Produkt suchen..." autofocus>
<button type="button" class="search-close-btn" id="search-close">&times;</button>
</div>
<div class="search-results" id="search-results">
<div class="search-loading" style="display:none;">Suche...</div>
</div>
</div>
`;
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 = '<div class="search-loading">Suche...</div>';
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 => `
<div class="search-result-item" data-product='${JSON.stringify(p).replace(/'/g, "&#39;")}'>
<div class="search-result-label">${escapeHtml(p.label)}</div>
<div class="search-result-info">
<span>Ref: ${escapeHtml(p.ref)}</span>
<span>Lager: ${p.stock} ${escapeHtml(p.stock_unit || 'Stk')}</span>
</div>
</div>
`).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 = '<div class="search-loading">Keine Produkte gefunden</div>';
}
})
.catch(err => {
console.error('Search error:', err);
const results = document.getElementById('search-results');
if (results) {
results.innerHTML = '<div class="search-loading">Fehler bei der Suche</div>';
}
});
}
// 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 = `
<div class="freetext-modal-content">
<div class="freetext-modal-title">Freitext-Position</div>
<div class="freetext-form">
<div>
<label>Lieferant</label>
<select id="freetext-supplier">
${allSuppliers.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('')}
</select>
</div>
<div>
<label>Beschreibung</label>
<input type="text" id="freetext-desc" placeholder="z.B. Sonderartikel XYZ">
</div>
<div class="freetext-row">
<div>
<label>Anzahl</label>
<input type="number" id="freetext-qty" value="1" min="1">
</div>
<div>
<label>Preis (netto)</label>
<input type="number" id="freetext-price" value="0" min="0" step="0.01">
</div>
</div>
<div class="freetext-buttons">
<button type="button" class="action-btn btn-secondary" id="freetext-cancel">Abbrechen</button>
<button type="button" class="action-btn btn-primary" id="freetext-add">Hinzufügen</button>
</div>
</div>
</div>
`;
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 = `
<div class="orders-modal-content">
<div class="orders-panel-header">
<span class="orders-panel-title">Bestellungen</span>
<button type="button" class="search-close-btn" id="orders-modal-close">&times;</button>
</div>
<div class="orders-scroller" id="orders-modal-scroller">
<div class="loading-spinner"></div>
</div>
<div class="order-detail-section hidden" id="orders-modal-detail">
<div class="order-detail-header">
<span class="order-detail-title" id="orders-modal-detail-title">Bestellung</span>
<button type="button" class="orders-panel-back" id="orders-modal-detail-back">&larr;</button>
</div>
<div class="order-detail-content" id="orders-modal-detail-content"></div>
</div>
</div>
`;
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 = '<div class="loading-spinner"></div>';
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 = '<div class="no-orders">Fehler beim Laden</div>';
}
})
.catch(err => {
console.error('Load orders error:', err);
scroller.innerHTML = '<div class="no-orders">Fehler beim Laden</div>';
});
}
function renderOrdersInModal(orders) {
const scroller = document.getElementById('orders-modal-scroller');
if (!scroller) return;
if (orders.length === 0) {
scroller.innerHTML = '<div class="no-orders">Keine Bestellungen</div>';
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 `
<div class="order-card ${direktClass} ${isActive ? 'active' : ''}" data-order-id="${order.id}">
${canDelete ? `<button type="button" class="order-delete-btn" data-order-id="${order.id}" title="Bestellung löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>` : ''}
<div class="order-card-supplier">${escapeHtml(order.supplier_name)}</div>
<div class="order-card-ref">${escapeHtml(order.ref)}</div>
<div class="order-card-status ${statusClass}">${escapeHtml(order.status_label)}</div>
<div class="order-card-info">${order.line_count} Pos. / ${order.total_qty} Stk</div>
</div>
`;
}).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 = `
<div class="line-edit-content">
<div class="line-edit-title">Bestellung löschen?</div>
<div class="line-edit-info" style="text-align:center; margin: 15px 0;">
<strong>${escapeHtml(orderRef)}</strong><br>
<span style="color: var(--danger, #993013);">Diese Aktion kann nicht rückgängig gemacht werden.</span>
</div>
<div class="line-edit-buttons">
<button type="button" class="action-btn btn-secondary" id="delete-order-cancel">Abbrechen</button>
<button type="button" class="action-btn btn-danger" id="delete-order-confirm">Löschen</button>
</div>
</div>
`;
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 = '<div class="loading-spinner"></div>';
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 = '<div class="no-orders">Fehler beim Laden</div>';
}
})
.catch(err => {
console.error('Load order lines error:', err);
content.innerHTML = '<div class="no-orders">Fehler beim Laden</div>';
});
}
// 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 = '<div class="no-orders">Keine Positionen</div>';
return;
}
const canEdit = order.status == 0;
content.innerHTML = lines.map(line => `
<div class="order-line" data-line-id="${line.id}" data-order-id="${orderId}" data-is-freetext="${line.is_freetext ? '1' : '0'}">
<div class="order-line-info">
<div class="order-line-label">${escapeHtml(line.product_label)}</div>
<div class="order-line-ref">${line.product_ref ? 'Ref: ' + escapeHtml(line.product_ref) : ''} ${line.is_freetext ? '(Freitext)' : (line.stock > 0 ? '| Lager: ' + line.stock : '')}</div>
</div>
<div class="order-line-qty">${line.qty}x</div>
${canEdit ? `<button type="button" class="order-line-edit-btn" data-line-id="${line.id}" title="Bearbeiten">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>` : ''}
</div>
`).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 = `
<div class="line-edit-content">
<div class="line-edit-title">${isFreetext ? 'Freitext-Position' : escapeHtml(line.product_label)}</div>
${isFreetext ? `
<div class="line-edit-desc">
<label>Beschreibung:</label>
<input type="text" id="line-desc-input" value="${escapeHtml(line.description || line.product_label)}">
</div>
` : `
<div class="line-edit-info">
<div class="line-edit-stock">Lagerbestand: <strong>${line.stock}</strong></div>
</div>
`}
<div class="line-edit-qty">
<label>Anzahl:</label>
<input type="number" id="line-qty-input" value="${line.qty}" min="1">
</div>
<div class="line-edit-buttons">
<button type="button" class="action-btn btn-danger" id="line-delete">Löschen</button>
<button type="button" class="action-btn btn-primary" id="line-save">Speichern</button>
</div>
</div>
`;
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 = '<div class="loading-spinner"></div>';
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 = '<div class="no-orders">Fehler beim Laden</div>';
}
})
.catch(err => {
console.error('Load orders error:', err);
scroller.innerHTML = '<div class="no-orders">Fehler beim Laden</div>';
});
}
function renderOrders(orders) {
const scroller = document.getElementById('orders-scroller');
if (!scroller) return;
if (orders.length === 0) {
scroller.innerHTML = '<div class="no-orders">Keine Bestellungen</div>';
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 `
<div class="order-card ${direktClass} ${isActive ? 'active' : ''}" data-order-id="${order.id}">
<div class="order-card-supplier">${escapeHtml(order.supplier_name)}</div>
<div class="order-card-ref">${escapeHtml(order.ref)}</div>
<div class="order-card-status ${statusClass}">${escapeHtml(order.status_label)}</div>
<div class="order-card-info">${order.line_count} Pos. / ${order.total_qty} Stk</div>
</div>
`;
}).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 = '<div class="loading-spinner"></div>';
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 = '<div class="no-orders">Fehler beim Laden</div>';
}
})
.catch(err => {
console.error('Load order lines error:', err);
content.innerHTML = '<div class="no-orders">Fehler beim Laden</div>';
});
}
function renderOrderLines(lines, order) {
const content = document.getElementById('order-detail-content');
if (!content) return;
if (lines.length === 0) {
content.innerHTML = '<div class="no-orders">Keine Positionen</div>';
return;
}
const canEdit = order.status == 0; // Only draft orders can be edited
content.innerHTML = lines.map(line => `
<div class="order-line ${canEdit ? 'editable' : ''}" data-line-id="${line.id}" data-order-id="${order.id}">
<div class="order-line-info">
<div class="order-line-label">${escapeHtml(line.product_label)}</div>
<div class="order-line-ref">${line.product_ref ? 'Ref: ' + escapeHtml(line.product_ref) : ''} ${line.stock > 0 ? '| Lager: ' + line.stock : ''}</div>
</div>
<div class="order-line-qty">${line.qty}x</div>
</div>
`).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 = `
<div class="line-edit-content">
<div class="line-edit-title">${isFreetext ? 'Freitext-Position' : escapeHtml(line.product_label)}</div>
${isFreetext ? `
<div class="line-edit-desc">
<label>Beschreibung:</label>
<input type="text" id="line-desc-input" value="${escapeHtml(line.description || line.product_label)}">
</div>
` : `
<div class="line-edit-info">
<div class="line-edit-stock">Lagerbestand: <strong>${line.stock}</strong></div>
</div>
`}
<div class="line-edit-qty">
<label>Anzahl:</label>
<input type="number" id="line-qty-input" value="${line.qty}" min="1">
</div>
<div class="line-edit-buttons">
<button type="button" class="action-btn btn-danger" id="line-delete">Löschen</button>
<button type="button" class="action-btn btn-primary" id="line-save">Speichern</button>
</div>
</div>
`;
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 `
<a href="${escapeHtml(shopUrl)}" target="_blank" rel="noopener noreferrer" class="shop-link-btn action-btn">
<span class="shop-link-name">${escapeHtml(s.name)}</span>
${s.ref_fourn ? '<span class="shop-link-ref">' + escapeHtml(s.ref_fourn) + '</span>' : ''}
<span class="shop-link-arrow">&rarr;</span>
</a>
`;
}
return `
<div class="shop-link-btn action-btn" style="opacity: 0.5; cursor: not-allowed;">
<span class="shop-link-name">${escapeHtml(s.name)}</span>
<span class="shop-link-arrow">&mdash;</span>
</div>
`;
}).join('');
} else {
linksHtml = `<div class="no-supplier">${CONFIG.lang.noSupplier}</div>`;
}
elements.resultArea.innerHTML = `
<div class="product-card">
<div class="product-name">${escapeHtml(product.label)}</div>
<div class="product-ref">${CONFIG.lang.ref}: ${escapeHtml(product.ref)}</div>
<div class="product-stock">${CONFIG.lang.stock}: ${product.stock} ${escapeHtml(product.stock_unit || 'Stk')}</div>
</div>
<div class="shop-links">
<span class="supplier-label">${CONFIG.lang.openShop}:</span>
${linksHtml}
</div>
`;
}
// INVENTORY MODE
function showInventoryMode(product) {
elements.resultArea.innerHTML = `
<div class="product-card">
<div class="product-name">${escapeHtml(product.label)}</div>
<div class="product-ref">${CONFIG.lang.ref}: ${escapeHtml(product.ref)}</div>
</div>
<div class="stock-display">
<div class="stock-item">
<div class="stock-value" id="current-stock">${product.stock}</div>
<div class="stock-label">${CONFIG.lang.currentStock}</div>
</div>
<div class="stock-arrow">&rarr;</div>
<div class="stock-item">
<div class="stock-value" id="new-stock-preview">${product.stock}</div>
<div class="stock-label">${CONFIG.lang.newStock}</div>
</div>
</div>
<div class="quantity-section">
<span class="quantity-label">${CONFIG.lang.newStock}:</span>
<div class="quantity-controls">
<button type="button" class="qty-btn" id="stock-minus">&minus;</button>
<input type="number" class="qty-input" id="stock-input" value="${product.stock}" min="0">
<button type="button" class="qty-btn" id="stock-plus">+</button>
</div>
</div>
<button type="button" class="action-btn btn-primary" id="save-stock">${CONFIG.lang.save}</button>
`;
bindInventoryEvents(product.stock);
}
function bindInventoryEvents(originalStock) {
const stockInput = document.getElementById('stock-input');
const preview = document.getElementById('new-stock-preview');
function updatePreview() {
preview.textContent = stockInput.value;
}
stockInput.addEventListener('input', updatePreview);
document.getElementById('stock-minus').addEventListener('click', () => {
const val = parseInt(stockInput.value) || 0;
if (val > 0) {
stockInput.value = val - 1;
updatePreview();
}
});
document.getElementById('stock-plus').addEventListener('click', () => {
const val = parseInt(stockInput.value) || 0;
stockInput.value = val + 1;
updatePreview();
});
document.getElementById('save-stock').addEventListener('click', () => {
const newStock = parseInt(stockInput.value) || 0;
if (newStock !== originalStock) {
showConfirmDialog(originalStock, newStock);
} else {
showToast(CONFIG.lang.saved, 'success');
hideResult();
}
});
}
function showConfirmDialog(oldStock, newStock) {
pauseScanner();
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);
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 = `
<div class="error-message">
<div class="error-icon">&#10060;</div>
<div class="error-text">${CONFIG.lang.productNotFound}</div>
<div class="product-ref" style="margin-top: 10px;">${escapeHtml(barcode)}</div>
</div>
`;
elements.resultArea.classList.remove('hidden');
}
// UI Helpers
function hideResult() {
elements.resultArea.classList.add('hidden');
elements.resultArea.innerHTML = '';
currentProduct = null;
window._scannerCurrentProduct = null;
selectedSupplier = null;
}
function showLoading() {
// Use jQuery blockUI if available (Dolibarr standard)
if (typeof jQuery !== 'undefined' && jQuery.blockUI) {
jQuery.blockUI({ message: '<div class="spinner"></div>' });
}
}
function hideLoading() {
if (typeof jQuery !== 'undefined' && jQuery.unblockUI) {
jQuery.unblockUI();
}
}
function showToast(message, type = 'success') {
// Remove existing toasts
document.querySelectorAll('.scanner-toast').forEach(t => t.remove());
const toast = document.createElement('div');
toast.className = 'scanner-toast ' + type;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatPrice(price) {
return parseFloat(price).toFixed(2).replace('.', ',');
}
// Start when DOM is ready (only if CONFIG already exists)
function tryInit() {
if (typeof window.SCANNER_CONFIG !== 'undefined') {
window.initScanner();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryInit);
} else {
tryInit();
}
})();