feat: POS Cloud Integration, SQLite Backend und Admin-Bereich
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
data.db
|
||||
data.db-wal
|
||||
data.db-shm
|
||||
.env
|
||||
.DS_Store
|
||||
23
Dockerfile
23
Dockerfile
@@ -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
54
db.js
Normal 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;
|
||||
@@ -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
1439
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
224
posClient.js
Normal 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
486
public/admin.html
Normal 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
281
public/index.html
Normal 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 →</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 => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"'
|
||||
}[tag] || tag));
|
||||
}
|
||||
|
||||
loadMenu();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
174
server.js
Normal file
174
server.js
Normal 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user