feat: POS Cloud Integration, SQLite Backend und Admin-Bereich

This commit is contained in:
DanielS
2026-09-22 21:51:26 +02:00
parent 68727a7a47
commit 89915bbd1d
10 changed files with 2697 additions and 14 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
data.db
data.db-wal
data.db-shm
.env
.DS_Store

View File

@@ -1,16 +1,13 @@
FROM caddy:2-alpine
FROM node:20-alpine
RUN echo -e ":80 {\n\
root * /usr/share/caddy\n\
encode zstd gzip\n\
file_server\n\
header {\n\
Cache-Control \"public, max-age=3600\"\n\
X-Content-Type-Options nosniff\n\
}\n\
}" > /etc/caddy/Caddyfile
WORKDIR /app
COPY index.html /usr/share/caddy/
COPY Getraenkekarte.pdf /usr/share/caddy/
COPY package*.json ./
RUN npm install --omit=dev
EXPOSE 80
COPY . .
EXPOSE 8085
ENV PORT=8085
CMD ["node", "server.js"]

54
db.js Normal file
View File

@@ -0,0 +1,54 @@
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const dbPath = path.join(__dirname, 'data.db');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
// Tabellen anlegen
db.exec(`
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS product_groups (
id TEXT PRIMARY KEY,
name TEXT,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
text1 TEXT NOT NULL,
text2 TEXT,
group_id TEXT,
preis1 REAL DEFAULT 0,
unit TEXT,
active INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sync_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT,
message TEXT,
items_count INTEGER DEFAULT 0
);
`);
// Standard Admin Initialisieren: admin / Kathi2022!
const adminUser = db.prepare('SELECT value FROM config WHERE key = ?').get('admin_user');
if (!adminUser) {
const hash = bcrypt.hashSync('Kathi2022!', 10);
db.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('admin_user', 'admin');
db.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('admin_password_hash', hash);
db.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('jwt_secret', 'secret_' + Math.random().toString(36).substring(2));
}
module.exports = db;

View File

@@ -1,7 +1,10 @@
services:
getraenkekarte:
image: gitea.hephex.de/daniel/getraenkekarte:latest
build: .
container_name: getraenkekarte
restart: unless-stopped
ports:
- "8085:80"
- "8085:8085"
volumes:
- ./data.db:/app/data.db

1439
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "getraenkekarte-pos",
"version": "1.0.0",
"description": "Getränkekarte mit POS Cloud Anbindung & Admin Page",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"adm-zip": "^0.5.16",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.8.1",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"xml2js": "^0.6.2"
}
}

224
posClient.js Normal file
View File

@@ -0,0 +1,224 @@
const crypto = require('crypto');
const AdmZip = require('adm-zip');
const xml2js = require('xml2js');
const db = require('./db');
function getConfig() {
const rows = db.prepare('SELECT key, value FROM config').all();
const config = {};
for (const r of rows) {
config[r.key] = r.value;
}
return config;
}
function generateHeaders(appId, passId) {
const ts = new Date().toISOString();
const reqId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2);
// POS Cloud standard hash: MD5/SHA256 über appId + passId + ts
const hash = crypto.createHash('sha256').update(`${appId}${passId || ''}${ts}`).digest('hex');
return {
'WWSVC-HASH': hash,
'WWSVC-TS': ts,
'WWSVC-REQID': reqId,
'User-Agent': 'Getraenkekarte-POS-Client/1.0'
};
}
async function performSync(syncType = 'ERSTEINRICHTUNG') {
const config = getConfig();
const { pos_url, pos_pass_id, pos_app_id, pos_cash_register } = config;
if (!pos_url) {
throw new Error('POS Cloud URL nicht konfiguriert');
}
const endpoint = new URL(pos_url);
endpoint.searchParams.set('ART', syncType);
if (pos_cash_register) {
endpoint.searchParams.set('MDCDEVICEID', pos_cash_register);
}
endpoint.searchParams.set('LAGER', '1');
endpoint.searchParams.set('LAGERORTE', '1');
endpoint.searchParams.set('VK1', '1');
const headers = generateHeaders(pos_app_id, pos_pass_id);
const response = await fetch(endpoint.toString(), {
method: 'GET',
headers: headers
});
if (!response.ok) {
throw new Error(`POS Cloud HTTP Error: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return parseAndStoreZip(buffer);
}
async function parseAndStoreZip(buffer) {
const zip = new AdmZip(buffer);
const zipEntries = zip.getEntries();
let productsCount = 0;
let groupsCount = 0;
const insertGroup = db.prepare(`
INSERT INTO product_groups (id, name, sort_order)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name
`);
const insertProduct = db.prepare(`
INSERT INTO products (id, text1, text2, group_id, preis1, unit, active)
VALUES (?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(id) DO UPDATE SET
text1=excluded.text1,
text2=excluded.text2,
group_id=excluded.group_id,
preis1=excluded.preis1,
unit=excluded.unit,
active=1,
updated_at=CURRENT_TIMESTAMP
`);
const deactivateProduct = db.prepare(`
UPDATE products SET active = 0 WHERE id = ?
`);
const transaction = db.transaction(() => {
for (const entry of zipEntries) {
const fileName = entry.entryName.toLowerCase();
// Gruppen
if (fileName.includes('productgroup') || fileName.includes('warengruppe')) {
const content = entry.getData().toString('utf8');
let groups = [];
if (fileName.endsWith('.json')) {
try { groups = JSON.parse(content); } catch(e) {}
}
if (Array.isArray(groups)) {
for (const g of groups) {
const id = String(g.id || g.groupId || g.nr || '');
const name = g.name || g.text || g.description || 'Kategorie ' + id;
if (id) {
insertGroup.run(id, name, g.sortOrder || 0);
groupsCount++;
}
}
}
}
// Artikel
if (fileName.includes('product') && !fileName.includes('productgroup')) {
const content = entry.getData().toString('utf8');
let items = [];
if (fileName.endsWith('.json')) {
try { items = JSON.parse(content); } catch(e) {}
}
if (Array.isArray(items)) {
for (const item of items) {
const id = String(item.id || item.articleNo || item.barcode || '');
const text1 = item.text1 || item.name || item.title || 'Unbekannt';
const text2 = item.text2 || item.description || '';
const groupId = String(item.groupId || item.warengruppe || '');
// Preis 1 ermitteln
let preis1 = 0;
if (typeof item.prices === 'object' && item.prices !== null) {
preis1 = parseFloat(item.prices.vk1 || item.prices.price1 || item.prices.standard || 0);
} else if (item.preis1 !== undefined) {
preis1 = parseFloat(item.preis1);
} else if (item.price !== undefined) {
preis1 = parseFloat(item.price);
}
const unit = item.contentUnit || item.unit || '';
if (id) {
insertProduct.run(id, text1, text2, groupId, isNaN(preis1) ? 0 : preis1, unit);
productsCount++;
}
}
}
}
// Deletions
if (fileName.includes('deletion')) {
const content = entry.getData().toString('utf8');
try {
const deletions = JSON.parse(content);
if (Array.isArray(deletions)) {
for (const del of deletions) {
const id = String(del.id || del.articleNo || del);
deactivateProduct.run(id);
}
}
} catch(e) {}
}
}
db.prepare('INSERT INTO sync_logs (status, message, items_count) VALUES (?, ?, ?)')
.run('SUCCESS', `Importiert: ${productsCount} Artikel, ${groupsCount} Gruppen`, productsCount);
});
transaction();
return { productsCount, groupsCount };
}
// Manueller/Mock Import für Testzwecke & manuelle Pflege
function importJsonData(data) {
const insertProduct = db.prepare(`
INSERT INTO products (id, text1, text2, group_id, preis1, unit, active)
VALUES (?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(id) DO UPDATE SET
text1=excluded.text1,
text2=excluded.text2,
group_id=excluded.group_id,
preis1=excluded.preis1,
unit=excluded.unit,
active=1,
updated_at=CURRENT_TIMESTAMP
`);
const insertGroup = db.prepare(`
INSERT INTO product_groups (id, name, sort_order)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name
`);
let count = 0;
const tx = db.transaction(() => {
if (data.groups && Array.isArray(data.groups)) {
for (const g of data.groups) {
insertGroup.run(String(g.id), g.name, g.sortOrder || 0);
}
}
if (data.products && Array.isArray(data.products)) {
for (const p of data.products) {
insertProduct.run(
String(p.id || Math.random().toString(36).substring(2)),
p.text1 || 'Unbekannt',
p.text2 || '',
String(p.groupId || ''),
parseFloat(p.preis1 || p.price || 0),
p.unit || ''
);
count++;
}
}
});
tx();
return count;
}
module.exports = {
performSync,
parseAndStoreZip,
importJsonData,
getConfig
};

486
public/admin.html Normal file
View File

@@ -0,0 +1,486 @@
<!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">
<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, 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;
}
input: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-danger { background: var(--danger); color: #fff; }
.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; }
</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 & 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>
<!-- POS Cloud Konfiguration -->
<div class="card">
<h3 style="margin-bottom: 16px; color: var(--primary);">⚙️ 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,255,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;">
<button type="submit" class="btn">Speichern</button>
<button type="button" id="btn-sync-full" class="btn btn-sync">🔄 Ersteinrichtung Sync (CASPOS.GET)</button>
<button type="button" id="btn-mock-data" class="btn btn-secondary">📥 Musterdaten laden</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>
// Hilfsfunktionen
function showAlert(id, msg, isError = false) {
const el = document.getElementById(id);
el.textContent = msg;
el.className = 'notification ' + (isError ? 'error' : 'success');
setTimeout(() => { el.className = 'notification'; }, 5000);
}
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';
}
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 () => {
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 || '';
}
}
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
document.getElementById('btn-sync-full').addEventListener('click', async () => {
showAlert('dash-alert', 'Synchronisiere mit POS Cloud...');
try {
const res = await fetch('/api/admin/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'ERSTEINRICHTUNG' })
});
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);
}
});
// 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();
// Count
document.getElementById('product-count').textContent = `${data.products.length} Artikel`;
// Gruppen-Map
const groupMap = {};
for (const g of data.groups) groupMap[g.id] = g.name;
// Produkte Table
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('');
}
// Logs Table
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>

281
public/index.html Normal file
View File

@@ -0,0 +1,281 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GETRÄNKEKARTE</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;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #090b10;
--card-bg: rgba(22, 27, 34, 0.75);
--card-border: rgba(255, 255, 255, 0.08);
--primary: #f59e0b;
--primary-glow: rgba(245, 158, 11, 0.25);
--accent: #ec4899;
--accent-glow: rgba(236, 72, 153, 0.25);
--text: #f8fafc;
--text-muted: #94a3b8;
--tag-bg: rgba(245, 158, 11, 0.12);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
background-color: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
justify-content: center;
padding: 20px 16px 40px;
position: relative;
overflow-x: hidden;
}
/* Ambient Glow */
body::before {
content: "";
position: fixed;
top: -100px;
left: 50%;
transform: translateX(-50%);
width: 400px;
height: 400px;
background: radial-gradient(circle, var(--accent-glow) 0%, var(--primary-glow) 50%, transparent 70%);
filter: blur(80px);
z-index: 0;
pointer-events: none;
}
.container {
width: 100%;
max-width: 480px;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
header {
text-align: center;
padding: 20px 0 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 11px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--primary);
margin-bottom: 12px;
backdrop-filter: blur(10px);
}
h1 {
font-family: 'Space Grotesk', sans-serif;
font-size: 32px;
font-weight: 700;
letter-spacing: -0.5px;
background: linear-gradient(135deg, #fff 30%, #cbd5e1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px;
}
header p {
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
}
.section-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--primary);
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.section-title::after {
content: "";
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--card-border), transparent);
}
.menu-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 16px;
backdrop-filter: blur(16px);
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
position: relative;
overflow: hidden;
}
.card:active {
transform: scale(0.985);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 6px;
}
.card-name {
font-size: 16px;
font-weight: 700;
color: #fff;
}
.card-price {
font-family: 'Space Grotesk', sans-serif;
font-size: 16px;
font-weight: 700;
color: var(--primary);
background: rgba(245, 158, 11, 0.1);
padding: 4px 10px;
border-radius: 8px;
border: 1px solid rgba(245, 158, 11, 0.2);
}
.card-ingredients {
font-size: 12.5px;
color: #cbd5e1;
line-height: 1.45;
}
.loading-spinner {
text-align: center;
padding: 40px;
color: var(--text-muted);
font-size: 14px;
}
footer {
text-align: center;
padding-top: 16px;
font-size: 12px;
color: var(--text-muted);
}
footer a {
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s;
}
footer a:hover {
color: var(--primary);
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="badge">✦ EXKLUSIVE AUSWAHL ✦</div>
<h1>GETRÄNKEKARTE</h1>
<p>Frisch zubereitete Drinks, Spezialitäten & Erfrischungen</p>
</header>
<div id="menu-container">
<div class="loading-spinner">Getränkekarte wird geladen...</div>
</div>
<footer>
<p>© Bar & Lounge • Live POS Sync</p>
<p style="margin-top: 6px;"><a href="/admin">⚙️ Admin Bereich</a></p>
</footer>
</div>
<script>
async function loadMenu() {
const container = document.getElementById('menu-container');
try {
const res = await fetch('/api/menu');
const data = await res.json();
if (!data.categories || data.categories.length === 0) {
container.innerHTML = `
<div class="card" style="text-align:center; padding: 30px;">
<p style="color:var(--text-muted); margin-bottom: 12px;">Noch keine Getränke hinterlegt.</p>
<a href="/admin" style="color:var(--primary); font-weight:bold; font-size:14px; text-decoration:none;">Zum Admin-Bereich & Sync starten &rarr;</a>
</div>
`;
return;
}
let html = '';
for (const cat of data.categories) {
html += `
<section>
<div class="section-title">${cat.name}</div>
<div class="menu-grid">
${cat.items.map(item => `
<div class="card">
<div class="card-top">
<div class="card-name">${escapeHtml(item.text1)}</div>
<div class="card-price">${parseFloat(item.preis1).toFixed(2).replace('.', ',')} €</div>
</div>
${item.text2 ? `<div class="card-ingredients">${escapeHtml(item.text2)}</div>` : ''}
</div>
`).join('')}
</div>
</section>
`;
}
container.innerHTML = html;
} catch (err) {
container.innerHTML = '<div class="card" style="color:#ef4444; text-align:center;">Fehler beim Laden der Speisekarte.</div>';
}
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>'"]/g, tag => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;'
}[tag] || tag));
}
loadMenu();
</script>
</body>
</html>

174
server.js Normal file
View File

@@ -0,0 +1,174 @@
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const path = require('path');
const db = require('./db');
const posClient = require('./posClient');
const app = express();
const PORT = process.env.PORT || 8085;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// Static Files
app.use(express.static(path.join(__dirname, 'public')));
app.use('/Getraenkekarte.pdf', express.static(path.join(__dirname, 'Getraenkekarte.pdf')));
// JWT Auth Middleware
function getSecret() {
const row = db.prepare('SELECT value FROM config WHERE key = ?').get('jwt_secret');
return row ? row.value : 'default_secret_key';
}
function authMiddleware(req, res, next) {
const token = req.cookies.admin_token || req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
try {
const decoded = jwt.verify(token, getSecret());
req.user = decoded;
next();
} catch (err) {
res.status(401).json({ error: 'Ungültige Session' });
}
}
// ==================== Öffentliche API ====================
// 1. Produkte & Gruppen für die Speisekarte
app.get('/api/menu', (req, res) => {
const groups = db.prepare('SELECT * FROM product_groups ORDER BY sort_order ASC, name ASC').all();
const products = db.prepare('SELECT id, text1, text2, group_id, preis1, unit FROM products WHERE active = 1 ORDER BY sort_order ASC, text1 ASC').all();
// Gruppieren
const groupMap = {};
for (const g of groups) {
groupMap[g.id] = {
id: g.id,
name: g.name,
items: []
};
}
const ungrouped = [];
for (const p of products) {
if (p.group_id && groupMap[p.group_id]) {
groupMap[p.group_id].items.push(p);
} else {
ungrouped.push(p);
}
}
const result = Object.values(groupMap).filter(g => g.items.length > 0);
if (ungrouped.length > 0) {
result.push({
id: 'other',
name: 'Weitere Spezialitäten',
items: ungrouped
});
}
res.json({ categories: result, allProducts: products });
});
// ==================== Admin Auth ====================
// 2. Login
app.post('/api/admin/login', (req, res) => {
const { username, password } = req.body;
const adminUserRow = db.prepare('SELECT value FROM config WHERE key = ?').get('admin_user');
const hashRow = db.prepare('SELECT value FROM config WHERE key = ?').get('admin_password_hash');
const adminUser = adminUserRow ? adminUserRow.value : 'admin';
const hash = hashRow ? hashRow.value : '';
if (username === adminUser && bcrypt.compareSync(password, hash)) {
const token = jwt.sign({ user: username }, getSecret(), { expiresIn: '7d' });
res.cookie('admin_token', token, { httpOnly: true, maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'lax' });
return res.json({ success: true, message: 'Erfolgreich eingeloggt' });
}
res.status(401).json({ error: 'Benutzername oder Passwort falsch' });
});
// 3. Logout
app.post('/api/admin/logout', (req, res) => {
res.clearCookie('admin_token');
res.json({ success: true });
});
// 4. Auth-Check
app.get('/api/admin/check', authMiddleware, (req, res) => {
res.json({ authenticated: true, user: req.user.user });
});
// ==================== Admin POS Settings & Sync ====================
// 5. Config abrufen
app.get('/api/admin/config', authMiddleware, (req, res) => {
const config = posClient.getConfig();
// Passwort nicht im Klartext zurückgeben
delete config.admin_password_hash;
delete config.jwt_secret;
res.json(config);
});
// 6. Config speichern
app.post('/api/admin/config', authMiddleware, (req, res) => {
const { pos_url, pos_pass_id, pos_app_id, pos_cash_register, new_admin_password } = req.body;
const upsert = db.prepare('INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
if (pos_url !== undefined) upsert.run('pos_url', pos_url);
if (pos_pass_id !== undefined) upsert.run('pos_pass_id', pos_pass_id);
if (pos_app_id !== undefined) upsert.run('pos_app_id', pos_app_id);
if (pos_cash_register !== undefined) upsert.run('pos_cash_register', pos_cash_register);
if (new_admin_password && new_admin_password.trim().length > 0) {
const newHash = bcrypt.hashSync(new_admin_password.trim(), 10);
upsert.run('admin_password_hash', newHash);
}
res.json({ success: true, message: 'Einstellungen gespeichert' });
});
// 7. Sync auslösen
app.post('/api/admin/sync', authMiddleware, async (req, res) => {
const { type } = req.body; // 'ERSTEINRICHTUNG' oder 'AENDERUNGEN'
try {
const result = await posClient.performSync(type || 'ERSTEINRICHTUNG');
res.json({ success: true, result });
} catch (err) {
db.prepare('INSERT INTO sync_logs (status, message) VALUES (?, ?)').run('ERROR', err.message);
res.status(500).json({ error: err.message });
}
});
// 8. Logs & Produkte im Admin anzeigen
app.get('/api/admin/data', authMiddleware, (req, res) => {
const products = db.prepare('SELECT * FROM products ORDER BY updated_at DESC').all();
const groups = db.prepare('SELECT * FROM product_groups').all();
const logs = db.prepare('SELECT * FROM sync_logs ORDER BY timestamp DESC LIMIT 20').all();
res.json({ products, groups, logs });
});
// 9. Manueller Import (Test/Demo)
app.post('/api/admin/mock-import', authMiddleware, (req, res) => {
const mockData = req.body;
const count = posClient.importJsonData(mockData);
res.json({ success: true, imported: count });
});
// Admin Route Frontend
app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
});
// Server Start
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server läuft auf http://localhost:${PORT}`);
});