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

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

829 lines
22 KiB
PHP
Executable file

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* PWA Standalone Scanner - Mit eigenem Login (15 Tage gespeichert)
*/
// Kein Login erforderlich - wird via JavaScript geprueft
if (!defined('NOLOGIN')) {
define('NOLOGIN', '1');
}
if (!defined('NOREQUIREMENU')) {
define('NOREQUIREMENU', '1');
}
// Load Dolibarr environment
$res = 0;
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res && file_exists("../../../main.inc.php")) {
$res = @include "../../../main.inc.php";
}
if (!$res) {
die("Dolibarr konnte nicht geladen werden");
}
// Load translation files
$langs->loadLangs(array("handybarcodescanner@handybarcodescanner", "products", "orders", "stocks"));
// Get parameters
$mode = GETPOST('mode', 'alpha') ?: 'order';
// Check mode-specific permissions
$enableOrder = getDolGlobalInt('HANDYBARCODESCANNER_ENABLE_ORDER', 1);
$enableShop = getDolGlobalInt('HANDYBARCODESCANNER_ENABLE_SHOP', 1);
$enableInventory = getDolGlobalInt('HANDYBARCODESCANNER_ENABLE_INVENTORY', 1);
// Get Dolibarr theme colors
$colormain = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#0077b3');
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="<?php echo $colormain; ?>">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Scanner">
<title>Barcode Scanner</title>
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="img/icon-192.png">
<link rel="stylesheet" href="css/scanner.css">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--primary: <?php echo $colormain; ?>;
--colorbackbody: #1d1e20;
--colorbackcard: #1d1e20;
--colorbacktitle: #3b3c3e;
--colorbackline: #38393d;
--colorbackinput: rgb(70, 70, 70);
--colortext: rgb(220,220,220);
--colortextmuted: rgb(180,180,180);
--colortextlink: #4390dc;
--colorborder: #2b2c2e;
--butactionbg: rgb(173,140,79);
--textbutaction: rgb(255,255,255);
--success: #25a580;
--danger: #993013;
--warning: #bc9526;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--colorbackbody);
color: var(--colortext);
min-height: 100vh;
min-height: 100dvh;
overflow-x: hidden;
}
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
}
/* Login Screen */
.login-screen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-box {
width: 100%;
max-width: 340px;
background: var(--colorbackline);
border-radius: 12px;
padding: 30px 24px;
border: 1px solid var(--colorborder);
}
.login-logo {
text-align: center;
margin-bottom: 24px;
}
.login-logo svg {
width: 64px;
height: 64px;
fill: var(--primary);
}
.login-title {
text-align: center;
font-size: 22px;
font-weight: 600;
margin-bottom: 8px;
}
.login-subtitle {
text-align: center;
font-size: 14px;
color: var(--colortextmuted);
margin-bottom: 24px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 14px;
color: var(--colortextmuted);
}
.form-input {
padding: 14px 16px;
font-size: 16px;
background: var(--colorbackinput);
border: 1px solid var(--colorborder);
border-radius: 8px;
color: var(--colortext);
outline: none;
}
.form-input:focus {
border-color: var(--primary);
}
.remember-row {
display: flex;
align-items: center;
gap: 10px;
}
.remember-row input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: var(--primary);
}
.remember-row label {
font-size: 14px;
color: var(--colortextmuted);
}
.login-btn {
padding: 16px;
font-size: 17px;
font-weight: 600;
background: var(--butactionbg);
color: var(--textbutaction);
border: none;
border-radius: 8px;
cursor: pointer;
margin-top: 8px;
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-error {
background: var(--danger);
color: white;
padding: 12px;
border-radius: 8px;
font-size: 14px;
text-align: center;
display: none;
}
.login-info {
background: var(--colorbacktitle);
padding: 12px;
border-radius: 8px;
font-size: 13px;
color: var(--colortextmuted);
text-align: center;
margin-top: 16px;
}
/* Header */
.header {
background: var(--primary);
color: #fff;
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.header-title {
font-size: 18px;
font-weight: 600;
}
.header-user {
display: flex;
align-items: center;
gap: 10px;
}
.header-username {
font-size: 13px;
opacity: 0.9;
}
.header-logout {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
/* Mode Tabs */
.mode-tabs {
display: flex;
background: var(--colorbacktitle);
border-bottom: 1px solid var(--colorborder);
}
.mode-tab {
flex: 1;
padding: 14px 8px;
text-align: center;
color: var(--colortextmuted);
text-decoration: none;
font-size: 14px;
font-weight: 500;
border-bottom: 3px solid transparent;
transition: all 0.2s;
cursor: pointer;
background: none;
border-left: none;
border-right: none;
border-top: none;
}
.mode-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: 600;
}
/* Scanner Area */
.scanner-area {
flex: 1;
display: flex;
flex-direction: column;
padding: 12px;
}
.scanner-box {
background: #333;
border-radius: 12px;
overflow: hidden;
margin-bottom: 12px;
}
.scanner-video-box {
position: relative;
width: 100%;
aspect-ratio: 4/3;
max-height: 35vh;
background: #000;
}
#scanner-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.scan-region-highlight {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 70%;
height: 50%;
border: 3px solid var(--primary);
border-radius: 8px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
pointer-events: none;
}
.scanner-video-box.scanning .scan-region-highlight {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { border-color: var(--primary); }
50% { border-color: var(--success); }
}
.scanner-controls {
padding: 10px;
background: #444;
text-align: center;
}
.scan-btn {
min-width: 180px;
padding: 14px 28px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.scan-btn.start {
background: var(--primary);
color: white;
}
.scan-btn.stop {
background: var(--danger);
color: white;
}
/* Last Scan */
.scanner-last-scan {
background: var(--colorbackline);
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--colorborder);
}
.last-scan-label {
color: var(--colortextmuted);
font-size: 14px;
}
#last-scan-code {
font-family: monospace;
font-size: 16px;
font-weight: 600;
color: var(--colortextlink);
}
#result-area {
flex: 1;
}
.hidden {
display: none !important;
}
/* Toast */
.scanner-toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
padding: 14px 28px;
border-radius: 10px;
font-size: 15px;
font-weight: 500;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
z-index: 10000;
}
.scanner-toast.success {
background: var(--success);
color: white;
}
.scanner-toast.error {
background: var(--danger);
color: white;
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--colorbackbody);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid var(--colorborder);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Dolibarr Dark Theme Overrides */
.product-card { background: var(--colorbackline); border: 1px solid var(--colorborder); color: var(--colortext); }
.product-name { color: var(--colortext); }
.product-ref { color: var(--colortextmuted); }
.product-stock { background: var(--colorbacktitle); color: var(--colortext); }
.supplier-option { background: var(--colorbackline); border: 2px solid var(--colorborder); color: var(--colortext); }
.supplier-option.selected { border-color: var(--butactionbg); background: rgba(173, 140, 79, 0.15); }
.supplier-name { color: var(--colortext); }
.supplier-price { color: var(--colortextlink); }
.qty-btn { background: var(--colorbacktitle); border: 1px solid var(--colorborder); color: var(--colortext); }
.qty-input { background: var(--colorbackinput); border: 1px solid var(--colorborder); color: var(--colortext); }
.action-btn { width: 100%; padding: 16px; font-size: 17px; font-weight: 600; border: none; border-radius: 3px; cursor: pointer; margin-top: 16px; text-transform: uppercase; }
.action-btn.btn-primary, .btn-primary { background: var(--butactionbg) !important; color: var(--textbutaction) !important; }
.stock-display { background: var(--colorbackline); border: 1px solid var(--colorborder); }
.stock-value { color: var(--colortextlink); }
.shop-link-btn { background: var(--colorbacktitle); border: 1px solid var(--colorborder); color: var(--colortext); }
.confirm-dialog { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 10000; padding: 20px; }
.confirm-content { background: var(--colorbackline); border-radius: 8px; padding: 24px; width: 100%; max-width: 320px; text-align: center; border: 1px solid var(--colorborder); }
.confirm-title { font-size: 18px; font-weight: 600; margin-bottom: 12px; color: var(--colortext); }
.confirm-message { font-size: 14px; color: var(--colortextmuted); margin-bottom: 24px; }
.confirm-buttons { display: flex; gap: 12px; }
.confirm-buttons .action-btn { flex: 1; margin-top: 0; }
.action-btn.btn-secondary, .btn-secondary { background: var(--colorbacktitle) !important; color: var(--colortext) !important; border: 1px solid var(--colorborder); }
</style>
</head>
<body>
<!-- Loading Overlay -->
<div id="loading-overlay" class="loading-overlay">
<div class="loading-spinner"></div>
</div>
<!-- Login Screen -->
<div id="login-screen" class="login-screen hidden">
<div class="login-box">
<div class="login-logo">
<svg viewBox="0 0 24 24"><path d="M2 6h2v12H2V6zm3 0h1v12H5V6zm2 0h2v12H7V6zm3 0h1v12h-1V6zm2 0h3v12h-3V6zm4 0h1v12h-1V6zm2 0h1v12h-1V6zm2 0h2v12h-2V6z"/></svg>
</div>
<h1 class="login-title">Barcode Scanner</h1>
<p class="login-subtitle">Anmelden um fortzufahren</p>
<div id="login-error" class="login-error"></div>
<form id="login-form" class="login-form">
<div class="form-group">
<label class="form-label">Benutzername</label>
<input type="text" id="login-user" class="form-input" autocomplete="username" autocapitalize="none" required>
</div>
<div class="form-group">
<label class="form-label">Passwort</label>
<input type="password" id="login-pass" class="form-input" autocomplete="current-password" required>
</div>
<div class="remember-row">
<input type="checkbox" id="login-remember" checked>
<label for="login-remember">Angemeldet bleiben (15 Tage)</label>
</div>
<button type="submit" id="login-btn" class="login-btn">Anmelden</button>
</form>
<div class="login-info">
Verwende deine Dolibarr-Zugangsdaten
</div>
</div>
</div>
<!-- Scanner App -->
<div id="scanner-app" class="app hidden">
<!-- Header -->
<header class="header">
<span class="header-title">Barcode Scanner</span>
<div class="header-user">
<span id="header-username" class="header-username"></span>
<button type="button" id="logout-btn" class="header-logout">Abmelden</button>
</div>
</header>
<!-- Mode Tabs (kein Seitenreload, Kamera bleibt aktiv) -->
<nav class="mode-tabs">
<?php if ($enableOrder): ?>
<button type="button" class="mode-tab scanner-tab <?php echo $mode === 'order' ? 'active' : ''; ?>" data-mode="order" onclick="scannerSwitchMode('order', this)">Bestellen</button>
<?php endif; ?>
<?php if ($enableShop): ?>
<button type="button" class="mode-tab scanner-tab <?php echo $mode === 'shop' ? 'active' : ''; ?>" data-mode="shop" onclick="scannerSwitchMode('shop', this)">Shop</button>
<?php endif; ?>
<?php if ($enableInventory): ?>
<button type="button" class="mode-tab scanner-tab <?php echo $mode === 'inventory' ? 'active' : ''; ?>" data-mode="inventory" onclick="scannerSwitchMode('inventory', this)">Inventur</button>
<?php endif; ?>
</nav>
<script>
function scannerSwitchMode(mode, btn) {
document.querySelectorAll('.scanner-tab').forEach(function(t) { t.classList.remove('active'); });
btn.classList.add('active');
if (typeof SCANNER_CONFIG !== 'undefined' && SCANNER_CONFIG) SCANNER_CONFIG.mode = mode;
if (typeof window.SCANNER_CONFIG !== 'undefined' && window.SCANNER_CONFIG) window.SCANNER_CONFIG.mode = mode;
history.replaceState(null, '', window.location.pathname + '?mode=' + mode);
if (typeof window._scannerHideResult === 'function') window._scannerHideResult();
if (window._scannerCurrentProduct && typeof window._scannerShowResult === 'function') {
window._scannerShowResult(window._scannerCurrentProduct);
}
}
</script>
<!-- Scanner Area -->
<main class="scanner-area">
<div class="scanner-box">
<div id="scanner-video-container" class="scanner-video-box">
<video id="scanner-video" playsinline></video>
<div class="scan-region-highlight"></div>
</div>
<div class="scanner-controls">
<button type="button" id="start-scan-btn" class="scan-btn start">Scannen starten</button>
<button type="button" id="stop-scan-btn" class="scan-btn stop hidden">Scannen stoppen</button>
</div>
</div>
<div class="scanner-last-scan">
<span class="last-scan-label">Letzter Scan:</span>
<span id="last-scan-code">-</span>
</div>
<div id="result-area" class="scanner-result hidden"></div>
</main>
</div>
<script>
// PWA Auth Manager
const PWA_AUTH = {
STORAGE_KEY: 'hbs_pwa_auth',
ajaxUrl: '<?php echo dol_buildpath('/handybarcodescanner/ajax/', 1); ?>',
// Gespeicherte Auth-Daten laden
getStoredAuth: function() {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
if (!data) return null;
const auth = JSON.parse(data);
// Ablauf pruefen
if (auth.expires && auth.expires < Date.now() / 1000) {
this.clearAuth();
return null;
}
return auth;
} catch (e) {
return null;
}
},
// Auth-Daten speichern
saveAuth: function(data) {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
},
// Auth-Daten loeschen
clearAuth: function() {
localStorage.removeItem(this.STORAGE_KEY);
},
// Login durchfuehren
login: function(username, password, remember) {
return fetch(this.ajaxUrl + 'pwa_login.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'login=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password)
})
.then(res => res.json())
.then(data => {
if (data.success && remember) {
this.saveAuth({
pwa_token: data.pwa_token,
csrf_token: data.csrf_token,
user: data.user,
expires: data.expires
});
}
return data;
});
},
// Token verifizieren und Session erneuern
verify: function() {
const auth = this.getStoredAuth();
if (!auth || !auth.pwa_token) {
return Promise.resolve({ success: false, need_login: true });
}
return fetch(this.ajaxUrl + 'pwa_verify.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-PWA-Token': auth.pwa_token
},
body: 'pwa_token=' + encodeURIComponent(auth.pwa_token)
})
.then(res => res.json())
.then(data => {
if (data.success) {
// CSRF-Token aktualisieren
auth.csrf_token = data.csrf_token;
this.saveAuth(auth);
}
return data;
});
},
// Logout
logout: function() {
this.clearAuth();
location.reload();
}
};
// UI Elements
const loadingOverlay = document.getElementById('loading-overlay');
const loginScreen = document.getElementById('login-screen');
const scannerApp = document.getElementById('scanner-app');
const loginForm = document.getElementById('login-form');
const loginError = document.getElementById('login-error');
const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn');
const headerUsername = document.getElementById('header-username');
// Scanner Config - wird nach Login gesetzt
var SCANNER_CONFIG = null;
// App initialisieren
async function initApp() {
// Gespeicherte Auth pruefen
const auth = PWA_AUTH.getStoredAuth();
if (auth && auth.pwa_token) {
// Token verifizieren
try {
const result = await PWA_AUTH.verify();
if (result.success) {
showScanner(auth, result.csrf_token);
return;
}
} catch (e) {
console.error('Verify error:', e);
}
}
// Login anzeigen
showLogin();
}
function showLogin() {
loadingOverlay.classList.add('hidden');
loginScreen.classList.remove('hidden');
scannerApp.classList.add('hidden');
document.getElementById('login-user').focus();
}
function showScanner(auth, csrfToken) {
loadingOverlay.classList.add('hidden');
loginScreen.classList.add('hidden');
scannerApp.classList.remove('hidden');
// Username anzeigen
const user = auth.user || {};
headerUsername.textContent = user.firstname ? (user.firstname + ' ' + user.lastname) : user.login;
// Scanner Config setzen
SCANNER_CONFIG = {
ajaxUrl: PWA_AUTH.ajaxUrl,
token: csrfToken || auth.csrf_token,
mode: '<?php echo $mode; ?>',
enableVibration: <?php echo getDolGlobalInt('HANDYBARCODESCANNER_ENABLE_VIBRATION', 1); ?>,
enableSound: <?php echo getDolGlobalInt('HANDYBARCODESCANNER_ENABLE_SOUND', 0); ?>,
lang: {
productNotFound: 'Produkt nicht gefunden',
added: 'Produkt eingestellt',
saved: 'Gespeichert',
error: 'Fehler',
selectSupplier: 'Lieferant waehlen',
noSupplier: 'Kein Lieferant fuer dieses Produkt hinterlegt',
quantity: 'Menge',
add: 'Hinzufuegen',
price: 'Preis',
stock: 'Lagerbestand',
currentStock: 'Aktueller Bestand',
newStock: 'Neuer Bestand',
save: 'Speichern',
confirmStockChange: 'Bestandsaenderung bestaetigen',
cancel: 'Abbrechen',
confirm: 'Bestaetigen',
openShop: 'Shop oeffnen',
cheapest: 'Guenstigster',
cameraError: 'Kamera-Zugriff fehlgeschlagen',
ref: 'Referenz',
product: 'Produkt',
supplierOrderRef: 'Lieferantenbestellnummer',
startScan: 'Scannen starten',
stopScan: 'Scannen stoppen'
}
};
// Scanner.js Config global setzen
window.SCANNER_CONFIG = SCANNER_CONFIG;
// Scanner.js initialisieren (mit kleiner Verzoegerung fuer DOM)
setTimeout(function() {
if (typeof window.initScanner === 'function') {
window.initScanner();
} else {
console.error('initScanner not found');
}
}, 100);
}
// Login Form Handler
loginForm.addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('login-user').value.trim();
const password = document.getElementById('login-pass').value;
const remember = document.getElementById('login-remember').checked;
if (!username || !password) {
loginError.textContent = 'Benutzername und Passwort erforderlich';
loginError.style.display = 'block';
return;
}
loginBtn.disabled = true;
loginBtn.textContent = 'Anmelden...';
loginError.style.display = 'none';
try {
const result = await PWA_AUTH.login(username, password, remember);
if (result.success) {
showScanner({
pwa_token: result.pwa_token,
csrf_token: result.csrf_token,
user: result.user,
expires: result.expires
}, result.csrf_token);
} else {
loginError.textContent = result.error || 'Login fehlgeschlagen';
loginError.style.display = 'block';
loginBtn.disabled = false;
loginBtn.textContent = 'Anmelden';
}
} catch (e) {
console.error('Login error:', e);
loginError.textContent = 'Verbindungsfehler';
loginError.style.display = 'block';
loginBtn.disabled = false;
loginBtn.textContent = 'Anmelden';
}
});
// Logout Handler
logoutBtn.addEventListener('click', function() {
PWA_AUTH.logout();
});
// Service Worker registrieren
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(function(err) {
console.log('ServiceWorker registration failed:', err);
});
}
// App starten
initApp();
</script>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
<script src="<?php echo dol_buildpath('/handybarcodescanner/js/scanner.js', 1); ?>"></script>
</body>
</html>