Files
getrankekarte/public/admin.html
DanielS 35730c22c3
All checks were successful
Build and Deploy / build (push) Successful in 56s
fix(scanner): Robuste QR-Erkennung mit jsQR (Kamera & Foto-Upload)
2026-09-22 22:18:17 +02:00

709 lines
25 KiB
HTML
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.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>POS Cloud Admin & Setup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
<!-- jsQR & Html5Qrcode für maximale Kompatibilität -->
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script>
<style>
:root {
--bg: #090b10;
--card-bg: rgba(22, 27, 34, 0.85);
--card-border: rgba(255, 255, 255, 0.1);
--primary: #f59e0b;
--primary-hover: #d97706;
--accent: #38bdf8;
--success: #10b981;
--danger: #ef4444;
--text: #f8fafc;
--text-muted: #94a3b8;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
justify-content: center;
padding: 24px 16px;
}
.container {
width: 100%;
max-width: 900px;
}
h1, h2, h3 {
font-family: 'Space Grotesk', sans-serif;
}
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 24px;
backdrop-filter: blur(16px);
margin-bottom: 24px;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input, textarea, select {
width: 100%;
padding: 12px 14px;
background: rgba(15, 23, 42, 0.8);
border: 1px solid var(--card-border);
border-radius: 10px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
font-family: inherit;
}
input:focus, textarea:focus {
border-color: var(--primary);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
border-radius: 10px;
font-weight: 700;
font-size: 14px;
cursor: pointer;
border: none;
transition: 0.2s;
color: #090b10;
background: var(--primary);
}
.btn:hover { background: var(--primary-hover); transform: translateY(-1px); }
.btn-secondary {
background: rgba(255,255,255,0.08);
color: #fff;
border: 1px solid var(--card-border);
}
.btn-secondary:hover {
background: rgba(255,255,255,0.15);
}
.btn-sync {
background: var(--accent);
color: #090b10;
}
.btn-sync:hover { background: #0284c7; }
.btn-qr {
background: #8b5cf6;
color: #fff;
}
.btn-qr:hover { background: #7c3aed; }
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 640px) {
.grid-2 { grid-template-columns: 1fr; }
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.table-container {
overflow-x: auto;
margin-top: 16px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
th {
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
font-size: 11px;
}
.badge {
padding: 3px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
}
.badge-success { background: rgba(16, 185, 129, 0.15); color: var(--success); }
.badge-error { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
.notification {
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 16px;
display: none;
font-size: 14px;
}
.notification.success { display: block; background: rgba(16, 185, 129, 0.2); border: 1px solid var(--success); color: #a7f3d0; }
.notification.error { display: block; background: rgba(239, 68, 68, 0.2); border: 1px solid var(--danger); color: #fecaca; }
.scanner-box {
width: 100%;
max-width: 500px;
margin: 16px auto;
border-radius: 14px;
overflow: hidden;
display: none;
position: relative;
background: #000;
border: 2px solid #8b5cf6;
}
#video {
width: 100%;
height: auto;
display: block;
}
.scan-guide {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 220px;
height: 220px;
border: 2px dashed #8b5cf6;
border-radius: 12px;
pointer-events: none;
box-shadow: 0 0 0 4000px rgba(0, 0, 0, 0.45);
}
.scan-line {
width: 100%;
height: 2px;
background: #ec4899;
position: absolute;
top: 0;
box-shadow: 0 0 8px #ec4899;
animation: scanAnim 2s infinite linear;
}
@keyframes scanAnim {
0% { top: 0; }
50% { top: 100%; }
100% { top: 0; }
}
</style>
</head>
<body>
<div class="container">
<!-- Login Ansicht -->
<div id="login-section" class="card" style="max-width: 420px; margin: 60px auto;">
<h2 style="margin-bottom: 8px; color: var(--primary);">Admin Login</h2>
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 20px;">Melde dich an um POS Cloud Einstellungen zu verwalten.</p>
<div id="login-alert" class="notification"></div>
<form id="login-form">
<div class="form-group">
<label>Benutzername</label>
<input type="text" id="login-username" value="admin" required />
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="login-password" value="Kathi2022!" required />
</div>
<button type="submit" class="btn" style="width: 100%;">Anmelden</button>
</form>
</div>
<!-- Dashboard Ansicht -->
<div id="dashboard-section" style="display: none;">
<div class="header-bar">
<div>
<h2>POS Cloud Administration</h2>
<p style="font-size: 13px; color: var(--text-muted);">Schnittstelle, Live QR-Scanner & Speisekarten-Synchronisation</p>
</div>
<div style="display: flex; gap: 10px;">
<a href="/" target="_blank" class="btn btn-secondary">🍽️ Zur Karte</a>
<button id="btn-logout" class="btn btn-secondary">Abmelden</button>
</div>
</div>
<div id="dash-alert" class="notification"></div>
<!-- QR-Code Scanner / Setup -->
<div class="card" style="border-left: 4px solid #8b5cf6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; flex-wrap: wrap; gap: 10px;">
<h3 style="color: #a78bfa;">📷 QR-Code Setup (Kamera / Bild / JSON)</h3>
<div style="display: flex; gap: 8px;">
<label class="btn btn-secondary" style="margin: 0; padding: 8px 14px; font-size: 13px; cursor: pointer;">
📁 Foto hochladen <input type="file" id="qr-file-input" accept="image/*" style="display: none;" />
</label>
<button type="button" id="btn-toggle-camera" class="btn btn-qr" style="padding: 8px 14px; font-size: 13px;">
🎥 Kamera starten
</button>
</div>
</div>
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px;">
QR-Code vor die Kamera halten, als Screenshot hochladen oder JSON direkt einfügen.
</p>
<!-- Kamera Viewport -->
<div id="scanner-box" class="scanner-box">
<video id="video" playsinline></video>
<canvas id="canvas" style="display: none;"></canvas>
<div class="scan-guide">
<div class="scan-line"></div>
</div>
</div>
<form id="qr-form" style="margin-top: 12px;">
<div class="form-group">
<textarea id="qr-json" rows="3" placeholder='{"$type": "ww-setup", "url": "https://...", "servicepasslink": "...", "mdcDeviceId": "1"}' required></textarea>
</div>
<div style="display: flex; gap: 10px;">
<button type="submit" class="btn btn-qr">🚀 QR-Handshake ausführen</button>
</div>
</form>
</div>
<!-- POS Cloud Konfiguration -->
<div class="card">
<h3 style="margin-bottom: 16px; color: var(--primary);">⚙️ Aktive POS Cloud Verbindungsparameter</h3>
<form id="config-form">
<div class="grid-2">
<div class="form-group">
<label>ServicePass URL (CASPOS Endpoint)</label>
<input type="url" id="cfg-url" placeholder="https://poscloud.example.com/api" />
</div>
<div class="form-group">
<label>App-ID (appId)</label>
<input type="text" id="cfg-app-id" placeholder="z.B. APP_SPEISEKARTE_01" />
</div>
</div>
<div class="grid-2">
<div class="form-group">
<label>Passwort / Key (passId)</label>
<input type="password" id="cfg-pass-id" placeholder="Passwort aus ServicePass" />
</div>
<div class="form-group">
<label>Kassennummer (cashRegisterNumber / MDCDEVICEID)</label>
<input type="text" id="cfg-register" placeholder="z.B. 1" />
</div>
</div>
<div class="form-group" style="margin-top: 8px; border-top: 1px solid rgba(255,255,200,0.06); padding-top: 16px;">
<label>Admin-Passwort ändern (Optional)</label>
<input type="password" id="cfg-admin-pass" placeholder="Neues Passwort vergeben..." />
</div>
<div style="display: flex; gap: 12px; margin-top: 20px; flex-wrap: wrap;">
<button type="submit" class="btn">Speichern</button>
<button type="button" id="btn-sync-full" class="btn btn-sync">🔄 Voll-Sync (ERSTEINRICHTUNG)</button>
<button type="button" id="btn-sync-diff" class="btn btn-secondary">⚡ Diff-Sync (AENDERUNGEN)</button>
<button type="button" id="btn-mock-data" class="btn btn-secondary">📥 Musterdaten</button>
</div>
</form>
</div>
<!-- Gespeicherte Artikel & Preise -->
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 style="color: var(--primary);">📋 Importierte Artikel (Live aus DB)</h3>
<span id="product-count" class="badge badge-success">0 Artikel</span>
</div>
<div class="table-container">
<table id="products-table">
<thead>
<tr>
<th>Art.-Nr</th>
<th>Titel (text1)</th>
<th>Beschreibung (text2)</th>
<th>Gruppe</th>
<th>Preis (preis1)</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr><td colspan="6" style="text-align: center; color: var(--text-muted);">Keine Daten geladen</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Sync Logs -->
<div class="card">
<h3 style="margin-bottom: 12px; color: var(--text-muted);">📜 Sync Protokoll</h3>
<table id="logs-table">
<thead>
<tr>
<th>Zeitstempel</th>
<th>Status</th>
<th>Meldung</th>
</tr>
</thead>
<tbody>
<tr><td colspan="3" style="text-align: center; color: var(--text-muted);">Keine Logs vorhanden</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
let videoStream = null;
let scanAnimationId = null;
function showAlert(id, msg, isError = false) {
const el = document.getElementById(id);
el.textContent = msg;
el.className = 'notification ' + (isError ? 'error' : 'success');
setTimeout(() => { el.className = 'notification'; }, 6000);
}
async function checkAuth() {
try {
const res = await fetch('/api/admin/check');
if (res.ok) {
showDashboard();
} else {
showLogin();
}
} catch(e) {
showLogin();
}
}
function showLogin() {
document.getElementById('login-section').style.display = 'block';
document.getElementById('dashboard-section').style.display = 'none';
stopCamera();
}
function showDashboard() {
document.getElementById('login-section').style.display = 'none';
document.getElementById('dashboard-section').style.display = 'block';
loadConfig();
loadData();
}
// Login Form
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
const res = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok) {
showDashboard();
} else {
showAlert('login-alert', data.error || 'Fehler beim Login', true);
}
} catch(e) {
showAlert('login-alert', 'Netzwerkfehler', true);
}
});
// Logout
document.getElementById('btn-logout').addEventListener('click', async () => {
stopCamera();
await fetch('/api/admin/logout', { method: 'POST' });
showLogin();
});
// Config laden & speichern
async function loadConfig() {
const res = await fetch('/api/admin/config');
if (res.ok) {
const cfg = await res.json();
document.getElementById('cfg-url').value = cfg.pos_url || '';
document.getElementById('cfg-app-id').value = cfg.pos_app_id || '';
document.getElementById('cfg-pass-id').value = cfg.pos_pass_id || '';
document.getElementById('cfg-register').value = cfg.pos_cash_register || '';
}
}
// ==================== Robuster jsQR Scanner ====================
document.getElementById('btn-toggle-camera').addEventListener('click', () => {
if (videoStream) {
stopCamera();
} else {
startCamera();
}
});
async function startCamera() {
const scannerBox = document.getElementById('scanner-box');
const video = document.getElementById('video');
const btn = document.getElementById('btn-toggle-camera');
scannerBox.style.display = 'block';
btn.textContent = '⏹️ Kamera stoppen';
btn.style.background = '#ef4444';
try {
videoStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
video.srcObject = videoStream;
video.setAttribute('playsinline', true);
await video.play();
scanAnimationId = requestAnimationFrame(scanTick);
} catch (err) {
showAlert('dash-alert', 'Kamerazugriff verweigert oder HTTPS fehlt: ' + err.message, true);
stopCamera();
}
}
function scanTick() {
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.height = video.videoHeight;
canvas.width = video.videoWidth;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'attemptBoth'
});
if (code && code.data) {
console.log('QR Code erkannt:', code.data);
document.getElementById('qr-json').value = code.data;
stopCamera();
submitQrPayload(code.data);
return;
}
}
if (videoStream) {
scanAnimationId = requestAnimationFrame(scanTick);
}
}
function stopCamera() {
if (scanAnimationId) cancelAnimationFrame(scanAnimationId);
if (videoStream) {
videoStream.getTracks().forEach(track => track.stop());
videoStream = null;
}
document.getElementById('scanner-box').style.display = 'none';
const btn = document.getElementById('btn-toggle-camera');
if (btn) {
btn.textContent = '🎥 Kamera starten';
btn.style.background = '#8b5cf6';
}
}
// QR Foto Upload
document.getElementById('qr-file-input').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const img = new Image();
img.onload = () => {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'attemptBoth'
});
if (code && code.data) {
document.getElementById('qr-json').value = code.data;
submitQrPayload(code.data);
} else {
showAlert('dash-alert', 'Kein QR-Code im Bild gefunden. Bitte JSON manuell einfügen.', true);
}
};
img.src = URL.createObjectURL(file);
});
async function submitQrPayload(qrContent) {
showAlert('dash-alert', 'QR-Code Handshake läuft...');
try {
const res = await fetch('/api/admin/qr-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qrContent })
});
const data = await res.json();
if (res.ok) {
showAlert('dash-alert', 'QR-Code Handshake erfolgreich! Parameter übernommen & aktiviert.');
loadConfig();
loadData();
} else {
showAlert('dash-alert', 'QR-Code Fehler: ' + data.error, true);
}
} catch (err) {
showAlert('dash-alert', 'Netzwerkfehler beim QR-Handshake', true);
}
}
document.getElementById('qr-form').addEventListener('submit', (e) => {
e.preventDefault();
const qrContent = document.getElementById('qr-json').value;
submitQrPayload(qrContent);
});
// Manuelle Config
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = {
pos_url: document.getElementById('cfg-url').value,
pos_app_id: document.getElementById('cfg-app-id').value,
pos_pass_id: document.getElementById('cfg-pass-id').value,
pos_cash_register: document.getElementById('cfg-register').value,
new_admin_password: document.getElementById('cfg-admin-pass').value
};
const res = await fetch('/api/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (res.ok) {
showAlert('dash-alert', 'Einstellungen erfolgreich gespeichert!');
document.getElementById('cfg-admin-pass').value = '';
} else {
showAlert('dash-alert', data.error || 'Fehler', true);
}
});
// POS Sync Button
async function triggerSync(type) {
showAlert('dash-alert', `Synchronisiere mit POS Cloud (${type})...`);
try {
const res = await fetch('/api/admin/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type })
});
const data = await res.json();
if (res.ok) {
showAlert('dash-alert', `Sync erfolgreich: ${data.result.productsCount} Artikel importiert.`);
loadData();
} else {
showAlert('dash-alert', `Sync Fehler: ${data.error}`, true);
loadData();
}
} catch(e) {
showAlert('dash-alert', 'Verbindung zur POS Cloud fehlgeschlagen', true);
}
}
document.getElementById('btn-sync-full').addEventListener('click', () => triggerSync('ERSTEINRICHTUNG'));
document.getElementById('btn-sync-diff').addEventListener('click', () => triggerSync('AENDERUNGEN'));
// Mock-Daten laden für schnellen Test
document.getElementById('btn-mock-data').addEventListener('click', async () => {
const mockData = {
groups: [
{ id: "1", name: "Cocktails & Signature Drinks", sortOrder: 1 },
{ id: "2", name: "Alkoholfreie Cocktails", sortOrder: 2 },
{ id: "3", name: "Longdrinks & Highballs", sortOrder: 3 },
{ id: "4", name: "Bier & Wein", sortOrder: 4 },
{ id: "5", name: "Softdrinks & Shots", sortOrder: 5 }
],
products: [
{ id: "101", text1: "Wild Berry Lillet", text2: "Lillet Blanc, Schweppes Wild Berry, Beeren", groupId: "1", preis1: 7.50 },
{ id: "102", text1: "Aperol Spritz", text2: "Aperol, Prosecco, Soda, frische Orange", groupId: "1", preis1: 7.50 },
{ id: "103", text1: "Mojito Classic", text2: "Weißer Rum, Minze, Limette, Rohrzucker, Soda", groupId: "1", preis1: 8.50 },
{ id: "104", text1: "Sex on the Beach", text2: "Vodka, Pfirsichlikör, Cranberry, Orangensaft", groupId: "1", preis1: 8.50 },
{ id: "105", text1: "Ipanema (Alkoholfrei)", text2: "Limette, Rohrzucker, Maracujanektar, Ginger Ale", groupId: "2", preis1: 6.50 },
{ id: "106", text1: "Virgin Colada (Alkoholfrei)", text2: "Ananassaft, Kokossirup, Sahne", groupId: "2", preis1: 6.50 },
{ id: "107", text1: "Gin Tonic (Bombay)", text2: "Bombay Sapphire, Tonic Water, Zitrone", groupId: "3", preis1: 8.00 },
{ id: "108", text1: "Vodka Red Bull", text2: "Absolut Vodka, Red Bull Energy", groupId: "3", preis1: 8.00 },
{ id: "109", text1: "Frisches Fassbier 0,33l", text2: "Kalt gezapftes Pils", groupId: "4", preis1: 3.80 },
{ id: "110", text1: "Haus-Shot (Berliner Luft)", text2: "Pfefferminzlikör 2cl", groupId: "5", preis1: 2.50 }
]
};
const res = await fetch('/api/admin/mock-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mockData)
});
if (res.ok) {
showAlert('dash-alert', 'Musterartikel erfolgreich eingespielt!');
loadData();
}
});
// Daten & Logs laden
async function loadData() {
const res = await fetch('/api/admin/data');
if (res.ok) {
const data = await res.json();
document.getElementById('product-count').textContent = `${data.products.length} Artikel`;
const groupMap = {};
for (const g of data.groups) groupMap[g.id] = g.name;
const tbody = document.querySelector('#products-table tbody');
if (data.products.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-muted);">Keine Artikel in DB</td></tr>';
} else {
tbody.innerHTML = data.products.map(p => `
<tr>
<td><code>${p.id}</code></td>
<td><strong>${p.text1}</strong></td>
<td style="color:var(--text-muted);">${p.text2 || '-'}</td>
<td><span class="badge" style="background:rgba(245,158,11,0.15); color:var(--primary);">${groupMap[p.group_id] || 'Gruppe ' + (p.group_id || '–')}</span></td>
<td><strong style="color:#fff;">${parseFloat(p.preis1).toFixed(2).replace('.', ',')} €</strong></td>
<td>${p.active ? '<span class="badge badge-success">Aktiv</span>' : '<span class="badge badge-error">Inaktiv</span>'}</td>
</tr>
`).join('');
}
const logBody = document.querySelector('#logs-table tbody');
if (data.logs.length === 0) {
logBody.innerHTML = '<tr><td colspan="3" style="text-align:center; color:var(--text-muted);">Keine Logs</td></tr>';
} else {
logBody.innerHTML = data.logs.map(l => `
<tr>
<td style="color:var(--text-muted);">${new Date(l.timestamp).toLocaleString('de-DE')}</td>
<td><span class="badge ${l.status === 'SUCCESS' ? 'badge-success' : 'badge-error'}">${l.status}</span></td>
<td>${l.message}</td>
</tr>
`).join('');
}
}
}
checkAuth();
</script>
</body>
</html>