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\
|
WORKDIR /app
|
||||||
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
|
|
||||||
|
|
||||||
COPY index.html /usr/share/caddy/
|
COPY package*.json ./
|
||||||
COPY Getraenkekarte.pdf /usr/share/caddy/
|
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:
|
services:
|
||||||
getraenkekarte:
|
getraenkekarte:
|
||||||
image: gitea.hephex.de/daniel/getraenkekarte:latest
|
image: gitea.hephex.de/daniel/getraenkekarte:latest
|
||||||
|
build: .
|
||||||
container_name: getraenkekarte
|
container_name: getraenkekarte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
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