Compare commits

..

6 Commits

146 changed files with 8747 additions and 13552 deletions

12
.env Normal file
View File

@ -0,0 +1,12 @@
# Schlüssel zum Entschlüsseln der Config (WICHTIG!)
CONFIG_KEY=BitteHierEinSehrLangesGeheimesPasswortEintragen_123456789
# Session Secret
SESSION_SECRET="i\"qDjVmHCx3DFd.@*#3AifmK0`F"
# Umgebung
NODE_ENV=development
# Server
HOST=0.0.0.0
PORT=51777

12
.gitignore vendored
View File

@ -1,6 +1,6 @@
node_modules/ node_modules/
.env .env
uploads/ uploads/
documents/ documents/
logs/ logs/
*.log *.log

View File

@ -1,2 +1,2 @@
# privatarzt_software # privatarzt_software

View File

@ -1,2 +1,2 @@
const bcrypt = require("bcrypt"); const bcrypt = require("bcrypt");
bcrypt.hash("1234", 10).then(hash => console.log(hash)); bcrypt.hash("1234", 10).then(hash => console.log(hash));

View File

@ -1,231 +1,231 @@
/** /**
* import_medications.js * import_medications.js
* *
* Importiert Medikamente aus einer Word-Datei (.docx) * Importiert Medikamente aus einer Word-Datei (.docx)
* und speichert sie normalisiert in MySQL: * und speichert sie normalisiert in MySQL:
* - medications * - medications
* - medication_forms * - medication_forms
* - medication_variants * - medication_variants
* *
* JEDE Kombination aus * JEDE Kombination aus
* Medikament × Darreichungsform × Dosierung × Packung * Medikament × Darreichungsform × Dosierung × Packung
* wird als eigener Datensatz gespeichert. * wird als eigener Datensatz gespeichert.
*/ */
const mammoth = require("mammoth"); const mammoth = require("mammoth");
const mysql = require("mysql2/promise"); const mysql = require("mysql2/promise");
const path = require("path"); const path = require("path");
/* ============================== /* ==============================
KONFIGURATION KONFIGURATION
============================== */ ============================== */
// 🔹 Pfad zur Word-Datei (exakt!) // 🔹 Pfad zur Word-Datei (exakt!)
const WORD_FILE = path.join( const WORD_FILE = path.join(
__dirname, __dirname,
"MEDIKAMENTE 228.02.2024 docx.docx" "MEDIKAMENTE 228.02.2024 docx.docx"
); );
// 🔹 MySQL Zugangsdaten // 🔹 MySQL Zugangsdaten
const DB_CONFIG = { const DB_CONFIG = {
host: "85.215.63.122", host: "85.215.63.122",
user: "praxisuser", user: "praxisuser",
password: "praxisuser", password: "praxisuser",
database: "praxissoftware" database: "praxissoftware"
}; };
/* ============================== /* ==============================
HAUPTFUNKTION HAUPTFUNKTION
============================== */ ============================== */
async function importMedications() { async function importMedications() {
console.log("📄 Lese Word-Datei …"); console.log("📄 Lese Word-Datei …");
// 1⃣ Word-Datei lesen // 1⃣ Word-Datei lesen
const result = await mammoth.extractRawText({ path: WORD_FILE }); const result = await mammoth.extractRawText({ path: WORD_FILE });
// 2⃣ Text → saubere Zeilen // 2⃣ Text → saubere Zeilen
const lines = result.value const lines = result.value
.split("\n") .split("\n")
.map(l => l.trim()) .map(l => l.trim())
.filter(l => l.length > 0); .filter(l => l.length > 0);
console.log(`📑 ${lines.length} Zeilen gefunden`); console.log(`📑 ${lines.length} Zeilen gefunden`);
// 3⃣ DB verbinden // 3⃣ DB verbinden
const db = await mysql.createConnection(DB_CONFIG); const db = await mysql.createConnection(DB_CONFIG);
let currentMedication = null; let currentMedication = null;
// 4⃣ Zeilen verarbeiten // 4⃣ Zeilen verarbeiten
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
/* ------------------------------ /* ------------------------------
Medikamentenname erkennen Medikamentenname erkennen
(keine Zahlen → Name) (keine Zahlen → Name)
------------------------------ */ ------------------------------ */
if (!/\d/.test(line)) { if (!/\d/.test(line)) {
currentMedication = line; currentMedication = line;
await insertMedication(db, currentMedication); await insertMedication(db, currentMedication);
continue; continue;
} }
/* ------------------------------ /* ------------------------------
Sicherheit: keine Basis Sicherheit: keine Basis
------------------------------ */ ------------------------------ */
if (!currentMedication) { if (!currentMedication) {
console.warn("⚠️ Überspringe Zeile ohne Medikament:", line); console.warn("⚠️ Überspringe Zeile ohne Medikament:", line);
continue; continue;
} }
/* ------------------------------ /* ------------------------------
Dosierungen splitten Dosierungen splitten
z.B. "50mg / 100mg" z.B. "50mg / 100mg"
------------------------------ */ ------------------------------ */
const dosages = line const dosages = line
.split("/") .split("/")
.map(d => d.trim()) .map(d => d.trim())
.filter(d => d.length > 0); .filter(d => d.length > 0);
/* ------------------------------ /* ------------------------------
Packungen splitten Packungen splitten
z.B. "30 Comp. / 100 Comp." z.B. "30 Comp. / 100 Comp."
------------------------------ */ ------------------------------ */
const rawPackage = lines[i + 1] || ""; const rawPackage = lines[i + 1] || "";
const packages = rawPackage const packages = rawPackage
.split("/") .split("/")
.map(p => p.trim()) .map(p => p.trim())
.filter(p => p.length > 0); .filter(p => p.length > 0);
if (packages.length === 0) { if (packages.length === 0) {
console.warn("⚠️ Keine Packung für:", currentMedication, line); console.warn("⚠️ Keine Packung für:", currentMedication, line);
continue; continue;
} }
/* ------------------------------ /* ------------------------------
Darreichungsform ermitteln Darreichungsform ermitteln
------------------------------ */ ------------------------------ */
const form = detectForm(rawPackage); const form = detectForm(rawPackage);
/* ------------------------------ /* ------------------------------
JEDE Kombination speichern JEDE Kombination speichern
------------------------------ */ ------------------------------ */
for (const dosage of dosages) { for (const dosage of dosages) {
for (const packageInfo of packages) { for (const packageInfo of packages) {
await insertVariant( await insertVariant(
db, db,
currentMedication, currentMedication,
dosage, dosage,
form, form,
packageInfo packageInfo
); );
} }
} }
i++; // Packungszeile überspringen i++; // Packungszeile überspringen
} }
await db.end(); await db.end();
console.log("✅ Import abgeschlossen"); console.log("✅ Import abgeschlossen");
} }
/* ============================== /* ==============================
HILFSFUNKTIONEN HILFSFUNKTIONEN
============================== */ ============================== */
async function insertMedication(db, name) { async function insertMedication(db, name) {
await db.execute( await db.execute(
"INSERT IGNORE INTO medications (name) VALUES (?)", "INSERT IGNORE INTO medications (name) VALUES (?)",
[name] [name]
); );
} }
async function insertVariant(db, medicationName, dosage, formName, packageInfo) { async function insertVariant(db, medicationName, dosage, formName, packageInfo) {
// Medikament-ID holen // Medikament-ID holen
const [[med]] = await db.execute( const [[med]] = await db.execute(
"SELECT id FROM medications WHERE name = ?", "SELECT id FROM medications WHERE name = ?",
[medicationName] [medicationName]
); );
if (!med) { if (!med) {
console.warn("⚠️ Medikament nicht gefunden:", medicationName); console.warn("⚠️ Medikament nicht gefunden:", medicationName);
return; return;
} }
// Darreichungsform anlegen falls neu // Darreichungsform anlegen falls neu
await db.execute( await db.execute(
"INSERT IGNORE INTO medication_forms (name) VALUES (?)", "INSERT IGNORE INTO medication_forms (name) VALUES (?)",
[formName] [formName]
); );
const [[form]] = await db.execute( const [[form]] = await db.execute(
"SELECT id FROM medication_forms WHERE name = ?", "SELECT id FROM medication_forms WHERE name = ?",
[formName] [formName]
); );
if (!form) { if (!form) {
console.warn("⚠️ Darreichungsform nicht gefunden:", formName); console.warn("⚠️ Darreichungsform nicht gefunden:", formName);
return; return;
} }
// Variante speichern // Variante speichern
await db.execute( await db.execute(
`INSERT INTO medication_variants `INSERT INTO medication_variants
(medication_id, form_id, dosage, package) (medication_id, form_id, dosage, package)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?)`,
[ [
med.id, med.id,
form.id, form.id,
normalizeDosage(dosage), normalizeDosage(dosage),
normalizePackage(packageInfo) normalizePackage(packageInfo)
] ]
); );
} }
/* ============================== /* ==============================
NORMALISIERUNG NORMALISIERUNG
============================== */ ============================== */
function normalizeDosage(text) { function normalizeDosage(text) {
return text return text
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.replace(/mg/gi, " mg") .replace(/mg/gi, " mg")
.replace(/ml/gi, " ml") .replace(/ml/gi, " ml")
.trim(); .trim();
} }
function normalizePackage(text) { function normalizePackage(text) {
return text return text
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.replace(/comp\.?/gi, "Comp.") .replace(/comp\.?/gi, "Comp.")
.replace(/tabl\.?/gi, "Tbl.") .replace(/tabl\.?/gi, "Tbl.")
.trim(); .trim();
} }
/* ============================== /* ==============================
DARREICHUNGSFORM ERKENNEN DARREICHUNGSFORM ERKENNEN
============================== */ ============================== */
function detectForm(text) { function detectForm(text) {
if (!text) return "Unbekannt"; if (!text) return "Unbekannt";
const t = text.toLowerCase(); const t = text.toLowerCase();
if (t.includes("tabl") || t.includes("comp")) return "Tabletten"; if (t.includes("tabl") || t.includes("comp")) return "Tabletten";
if (t.includes("caps")) return "Kapseln"; if (t.includes("caps")) return "Kapseln";
if (t.includes("saft") || t.includes("ml")) return "Saft"; if (t.includes("saft") || t.includes("ml")) return "Saft";
if (t.includes("creme") || t.includes("salbe")) return "Creme"; if (t.includes("creme") || t.includes("salbe")) return "Creme";
if (t.includes("inj")) return "Injektion"; if (t.includes("inj")) return "Injektion";
return "Unbekannt"; return "Unbekannt";
} }
/* ============================== /* ==============================
START START
============================== */ ============================== */
importMedications().catch(err => { importMedications().catch(err => {
console.error("❌ Fehler beim Import:", err); console.error("❌ Fehler beim Import:", err);
}); });

View File

@ -1,116 +1,116 @@
/** /**
* Excel → MySQL Import * Excel → MySQL Import
* - importiert ALLE Sheets * - importiert ALLE Sheets
* - Sheet-Name wird als Kategorie gespeichert * - Sheet-Name wird als Kategorie gespeichert
* - Preise robust (Number, "55,00 €", Text, leer) * - Preise robust (Number, "55,00 €", Text, leer)
*/ */
const xlsx = require("xlsx"); const xlsx = require("xlsx");
const db = require("./db"); const db = require("./db");
// =============================== // ===============================
// KONFIG // KONFIG
// =============================== // ===============================
const FILE_PATH = "2024091001 Preisliste PRAXIS.xlsx"; const FILE_PATH = "2024091001 Preisliste PRAXIS.xlsx";
// =============================== // ===============================
// HILFSFUNKTIONEN // HILFSFUNKTIONEN
// =============================== // ===============================
function getColumn(row, name) { function getColumn(row, name) {
const key = Object.keys(row).find(k => const key = Object.keys(row).find(k =>
k.toLowerCase().includes(name.toLowerCase()) k.toLowerCase().includes(name.toLowerCase())
); );
return key ? row[key] : undefined; return key ? row[key] : undefined;
} }
function parsePrice(value) { function parsePrice(value) {
if (value === undefined || value === null) return 0.00; if (value === undefined || value === null) return 0.00;
// Excel-Währungsfeld → Number // Excel-Währungsfeld → Number
if (typeof value === "number") { if (typeof value === "number") {
return value; return value;
} }
// String → Zahl extrahieren // String → Zahl extrahieren
if (typeof value === "string") { if (typeof value === "string") {
const cleaned = value const cleaned = value
.replace(",", ".") .replace(",", ".")
.replace(/[^\d.]/g, ""); .replace(/[^\d.]/g, "");
const parsed = parseFloat(cleaned); const parsed = parseFloat(cleaned);
return isNaN(parsed) ? 0.00 : parsed; return isNaN(parsed) ? 0.00 : parsed;
} }
return 0.00; return 0.00;
} }
// =============================== // ===============================
// START // START
// =============================== // ===============================
console.log("📄 Lese Excel-Datei …"); console.log("📄 Lese Excel-Datei …");
const workbook = xlsx.readFile(FILE_PATH); const workbook = xlsx.readFile(FILE_PATH);
const sheetNames = workbook.SheetNames; const sheetNames = workbook.SheetNames;
console.log(`📑 ${sheetNames.length} Sheets gefunden:`, sheetNames); console.log(`📑 ${sheetNames.length} Sheets gefunden:`, sheetNames);
// =============================== // ===============================
// IMPORT ALLER SHEETS // IMPORT ALLER SHEETS
// =============================== // ===============================
sheetNames.forEach(sheetName => { sheetNames.forEach(sheetName => {
console.log(`➡️ Importiere Sheet: "${sheetName}"`); console.log(`➡️ Importiere Sheet: "${sheetName}"`);
const sheet = workbook.Sheets[sheetName]; const sheet = workbook.Sheets[sheetName];
const rows = xlsx.utils.sheet_to_json(sheet); const rows = xlsx.utils.sheet_to_json(sheet);
console.log(` ↳ ${rows.length} Zeilen gefunden`); console.log(` ↳ ${rows.length} Zeilen gefunden`);
rows.forEach((row, index) => { rows.forEach((row, index) => {
// =============================== // ===============================
// TEXTFELDER // TEXTFELDER
// =============================== // ===============================
const name_de = getColumn(row, "deutsch") const name_de = getColumn(row, "deutsch")
? getColumn(row, "deutsch").toString().trim() ? getColumn(row, "deutsch").toString().trim()
: "--"; : "--";
const name_es = getColumn(row, "spanisch") const name_es = getColumn(row, "spanisch")
? getColumn(row, "spanisch").toString().trim() ? getColumn(row, "spanisch").toString().trim()
: "--"; : "--";
// =============================== // ===============================
// PREISE // PREISE
// =============================== // ===============================
const price = parsePrice(getColumn(row, "preis")); const price = parsePrice(getColumn(row, "preis"));
const price_c70 = parsePrice(getColumn(row, "c70")); const price_c70 = parsePrice(getColumn(row, "c70"));
// =============================== // ===============================
// INSERT // INSERT
// =============================== // ===============================
db.query( db.query(
` `
INSERT INTO services INSERT INTO services
(name_de, name_es, category, price, price_c70) (name_de, name_es, category, price, price_c70)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`, `,
[ [
name_de, name_de,
name_es, name_es,
sheetName, // 👈 Kategorie = Sheet-Name sheetName, // 👈 Kategorie = Sheet-Name
price, price,
price_c70 price_c70
], ],
err => { err => {
if (err) { if (err) {
console.error( console.error(
`❌ Fehler in Sheet "${sheetName}", Zeile ${index + 2}:`, `❌ Fehler in Sheet "${sheetName}", Zeile ${index + 2}:`,
err.message err.message
); );
} }
} }
); );
}); });
}); });
console.log("✅ Import aller Sheets abgeschlossen"); console.log("✅ Import aller Sheets abgeschlossen");

962
app.js
View File

@ -1,440 +1,522 @@
require("dotenv").config(); require("dotenv").config();
const express = require("express"); const express = require("express");
const session = require("express-session"); const session = require("express-session");
const helmet = require("helmet"); const helmet = require("helmet");
const fs = require("fs"); const mysql = require("mysql2/promise");
const path = require("path"); const fs = require("fs");
const expressLayouts = require("express-ejs-layouts"); const path = require("path");
const expressLayouts = require("express-ejs-layouts");
// ✅ DB + Session Store
const db = require("./db"); // ✅ Verschlüsselte Config
const { getSessionStore } = require("./config/session"); const { configExists, saveConfig } = require("./config-manager");
// ✅ Setup Middleware + Setup Routes // ✅ DB + Session Reset
const requireSetup = require("./middleware/requireSetup"); const db = require("./db");
const setupRoutes = require("./routes/setup.routes"); const { getSessionStore, resetSessionStore } = require("./config/session");
// ✅ Routes (deine) // ✅ Routes (deine)
const adminRoutes = require("./routes/admin.routes"); const adminRoutes = require("./routes/admin.routes");
const dashboardRoutes = require("./routes/dashboard.routes"); const dashboardRoutes = require("./routes/dashboard.routes");
const patientRoutes = require("./routes/patient.routes"); const patientRoutes = require("./routes/patient.routes");
const medicationRoutes = require("./routes/medications.routes"); const medicationRoutes = require("./routes/medications.routes");
const patientMedicationRoutes = require("./routes/patientMedication.routes"); const patientMedicationRoutes = require("./routes/patientMedication.routes");
const waitingRoomRoutes = require("./routes/waitingRoom.routes"); const waitingRoomRoutes = require("./routes/waitingRoom.routes");
const serviceRoutes = require("./routes/service.routes"); const serviceRoutes = require("./routes/service.routes");
const patientServiceRoutes = require("./routes/patientService.routes"); const patientServiceRoutes = require("./routes/patientService.routes");
const invoiceRoutes = require("./routes/invoice.routes"); const invoiceRoutes = require("./routes/invoice.routes");
const patientFileRoutes = require("./routes/patientFile.routes"); const patientFileRoutes = require("./routes/patientFile.routes");
const companySettingsRoutes = require("./routes/companySettings.routes"); const companySettingsRoutes = require("./routes/companySettings.routes");
const authRoutes = require("./routes/auth.routes"); const authRoutes = require("./routes/auth.routes");
const reportRoutes = require("./routes/report.routes");
const calendarRoutes = require("./routes/calendar.routes"); const app = express();
const app = express(); /* ===============================
Seriennummer / Trial Konfiguration
/* =============================== ================================ */
Seriennummer / Trial Konfiguration const TRIAL_DAYS = 30;
================================ */
const TRIAL_DAYS = 30; /* ===============================
Seriennummer Helper Funktionen
/* =============================== ================================ */
Seriennummer Helper Funktionen function normalizeSerial(input) {
================================ */ return (input || "")
function normalizeSerial(input) { .toUpperCase()
return (input || "") .replace(/[^A-Z0-9-]/g, "")
.toUpperCase() .trim();
.replace(/[^A-Z0-9-]/g, "") }
.trim();
} // Format: AAAAA-AAAAA-AAAAA-AAAAA
function isValidSerialFormat(serial) {
// Format: AAAAA-AAAAA-AAAAA-AAAAA return /^[A-Z0-9]{5}(-[A-Z0-9]{5}){3}$/.test(serial);
function isValidSerialFormat(serial) { }
return /^[A-Z0-9]{5}(-[A-Z0-9]{5}){3}$/.test(serial);
} // Modulo-3 Check (Summe aller Zeichenwerte % 3 === 0)
function passesModulo3(serial) {
// Modulo-3 Check (Summe aller Zeichenwerte % 3 === 0) const raw = serial.replace(/-/g, "");
function passesModulo3(serial) { let sum = 0;
const raw = serial.replace(/-/g, "");
let sum = 0; for (const ch of raw) {
if (/[0-9]/.test(ch)) sum += parseInt(ch, 10);
for (const ch of raw) { else sum += ch.charCodeAt(0) - 55; // A=10
if (/[0-9]/.test(ch)) sum += parseInt(ch, 10); }
else sum += ch.charCodeAt(0) - 55; // A=10
} return sum % 3 === 0;
}
return sum % 3 === 0;
} /* ===============================
SETUP HTML
/* =============================== ================================ */
MIDDLEWARE function setupHtml(error = "") {
================================ */ return `
app.use(express.urlencoded({ extended: true })); <!doctype html>
app.use(express.json()); <html lang="de">
<head>
app.use( <meta charset="utf-8" />
helmet({ <meta name="viewport" content="width=device-width, initial-scale=1" />
contentSecurityPolicy: { <title>Praxissoftware Setup</title>
directives: { <style>
defaultSrc: ["'self'"], body{font-family:Arial;background:#f4f4f4;padding:30px}
scriptSrc: ["'self'"], .card{max-width:520px;margin:auto;background:#fff;padding:22px;border-radius:14px}
styleSrc: ["'self'", "'unsafe-inline'"], input{width:100%;padding:10px;margin:6px 0;border:1px solid #ccc;border-radius:8px}
imgSrc: ["'self'", "data:"], button{padding:10px 14px;border:0;border-radius:8px;background:#111;color:#fff;cursor:pointer}
}, .err{color:#b00020;margin:10px 0}
}, .hint{color:#666;font-size:13px;margin-top:12px}
}), </style>
); </head>
<body>
app.use( <div class="card">
session({ <h2>🔧 Datenbank Einrichtung</h2>
name: "praxis.sid", ${error ? `<div class="err">❌ ${error}</div>` : ""}
secret: process.env.SESSION_SECRET || "dev-secret",
store: getSessionStore(), <form method="POST" action="/setup">
resave: false, <label>DB Host</label>
saveUninitialized: false, <input name="host" placeholder="85.215.63.122" required />
}),
); <label>DB Benutzer</label>
<input name="user" placeholder="praxisuser" required />
// ✅ i18n Middleware (SAFE)
app.use((req, res, next) => { <label>DB Passwort</label>
try { <input name="password" type="password" required />
const lang = req.session.lang || "de";
const filePath = path.join(__dirname, "locales", `${lang}.json`); <label>DB Name</label>
<input name="name" placeholder="praxissoftware" required />
let data = {};
if (fs.existsSync(filePath)) { <button type="submit"> Speichern</button>
data = JSON.parse(fs.readFileSync(filePath, "utf-8")); </form>
}
<div class="hint">
res.locals.t = data; Die Daten werden verschlüsselt gespeichert (<b>config.enc</b>).<br/>
res.locals.lang = lang; Danach wirst du automatisch auf die Loginseite weitergeleitet.
next(); </div>
} catch (err) { </div>
console.error("❌ i18n Fehler:", err.message); </body>
res.locals.t = {}; </html>
res.locals.lang = "de"; `;
next(); }
}
}); /* ===============================
MIDDLEWARE
const flashMiddleware = require("./middleware/flash.middleware"); ================================ */
app.use(flashMiddleware); app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(express.static("public")); app.use(helmet());
app.use("/uploads", express.static("uploads"));
app.use(
app.set("view engine", "ejs"); session({
app.set("views", path.join(__dirname, "views")); name: "praxis.sid",
app.use(expressLayouts); secret: process.env.SESSION_SECRET,
app.set("layout", "layout"); store: getSessionStore(),
resave: false,
app.use((req, res, next) => { saveUninitialized: false,
res.locals.user = req.session.user || null; }),
next(); );
});
// ✅ i18n Middleware 1 (setzt res.locals.t + lang)
/* =============================== app.use((req, res, next) => {
SETUP ROUTES + SETUP GATE const lang = req.session.lang || "de";
WICHTIG: /setup zuerst mounten, danach requireSetup
================================ */ const filePath = path.join(__dirname, "locales", `${lang}.json`);
app.use("/setup", setupRoutes); const raw = fs.readFileSync(filePath, "utf-8");
app.use(requireSetup);
res.locals.t = JSON.parse(raw);
/* =============================== res.locals.lang = lang;
LICENSE/TRIAL GATE
================================ */ next();
app.use(async (req, res, next) => { });
try {
// Setup muss erreichbar bleiben const flashMiddleware = require("./middleware/flash.middleware");
if (req.path.startsWith("/setup")) return next(); app.use(flashMiddleware);
// Login muss erreichbar bleiben app.use(express.static("public"));
if (req.path === "/" || req.path.startsWith("/login")) return next(); app.use("/uploads", express.static("uploads"));
// Serial Seiten müssen erreichbar bleiben app.set("view engine", "ejs");
if (req.path.startsWith("/serial-number")) return next(); app.use(expressLayouts);
if (req.path.startsWith("/admin/serial-number")) return next(); app.set("layout", "layout"); // verwendet views/layout.ejs
// Sprache ändern erlauben app.use((req, res, next) => {
if (req.path.startsWith("/lang/")) return next(); res.locals.user = req.session.user || null;
next();
// Nicht eingeloggt -> auth regelt das });
if (!req.session?.user) return next();
/* ===============================
const [rowsSettings] = await db.promise().query( LICENSE/TRIAL GATE
`SELECT id, serial_number, trial_started_at - Trial startet automatisch, wenn noch NULL
FROM company_settings - Wenn abgelaufen:
ORDER BY id ASC Admin -> /admin/serial-number
LIMIT 1`, Arzt/Member -> /serial-number
); ================================ */
app.use(async (req, res, next) => {
const settings = rowsSettings?.[0]; try {
// Setup muss erreichbar bleiben
// ✅ Seriennummer vorhanden -> alles OK if (req.path.startsWith("/setup")) return next();
if (settings?.serial_number) return next();
// Login muss erreichbar bleiben
// ✅ Trial Start setzen wenn leer if (req.path === "/" || req.path.startsWith("/login")) return next();
if (settings?.id && !settings?.trial_started_at) {
await db // Serial Seiten müssen erreichbar bleiben
.promise() if (req.path.startsWith("/serial-number")) return next();
.query( if (req.path.startsWith("/admin/serial-number")) return next();
`UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`,
[settings.id], // Sprache ändern erlauben
); if (req.path.startsWith("/lang/")) return next();
return next();
} // Nicht eingeloggt -> auth regelt das
if (!req.session?.user) return next();
// Wenn noch immer kein trial start: nicht blockieren
if (!settings?.trial_started_at) return next(); const [rowsSettings] = await db.promise().query(
`SELECT id, serial_number, trial_started_at
const trialStart = new Date(settings.trial_started_at); FROM company_settings
const now = new Date(); ORDER BY id ASC
const diffDays = Math.floor((now - trialStart) / (1000 * 60 * 60 * 24)); LIMIT 1`,
);
// ✅ Trial läuft noch
if (diffDays < TRIAL_DAYS) return next(); const settings = rowsSettings?.[0];
// ❌ Trial abgelaufen // ✅ Seriennummer vorhanden -> alles OK
if (req.session.user.role === "admin") { if (settings?.serial_number) return next();
return res.redirect("/admin/serial-number");
} // ✅ Trial Start setzen wenn leer
if (settings?.id && !settings?.trial_started_at) {
return res.redirect("/serial-number"); await db
} catch (err) { .promise()
console.error("❌ LicenseGate Fehler:", err.message); .query(
return next(); `UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`,
} [settings.id],
}); );
return next();
/* =============================== }
Sprache ändern
================================ */ // Wenn noch immer kein trial start: nicht blockieren
app.get("/lang/:lang", (req, res) => { if (!settings?.trial_started_at) return next();
const newLang = req.params.lang;
const trialStart = new Date(settings.trial_started_at);
if (!["de", "es"].includes(newLang)) { const now = new Date();
return res.redirect(req.get("Referrer") || "/dashboard"); const diffDays = Math.floor((now - trialStart) / (1000 * 60 * 60 * 24));
}
// ✅ Trial läuft noch
req.session.lang = newLang; if (diffDays < TRIAL_DAYS) return next();
req.session.save((err) => { // ❌ Trial abgelaufen
if (err) console.error("❌ Session save error:", err); if (req.session.user.role === "admin") {
return res.redirect(req.get("Referrer") || "/dashboard"); return res.redirect("/admin/serial-number");
}); }
});
return res.redirect("/serial-number");
/* =============================== } catch (err) {
SERIAL PAGES console.error("❌ LicenseGate Fehler:", err.message);
================================ */ return next();
app.get("/serial-number", async (req, res) => { }
try { });
if (!req.session?.user) return res.redirect("/");
/* ===============================
const [rowsSettings] = await db.promise().query( SETUP ROUTES
`SELECT id, serial_number, trial_started_at ================================ */
FROM company_settings app.get("/setup", (req, res) => {
ORDER BY id ASC if (configExists()) return res.redirect("/");
LIMIT 1`, return res.status(200).send(setupHtml());
); });
const settings = rowsSettings?.[0]; app.post("/setup", async (req, res) => {
try {
// ✅ Seriennummer da -> ab ins Dashboard const { host, user, password, name } = req.body;
if (settings?.serial_number) return res.redirect("/dashboard");
if (!host || !user || !password || !name) {
// ✅ Trial Start setzen wenn leer return res.status(400).send(setupHtml("Bitte alle Felder ausfüllen."));
if (settings?.id && !settings?.trial_started_at) { }
await db
.promise() const conn = await mysql.createConnection({
.query( host,
`UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`, user,
[settings.id], password,
); database: name,
settings.trial_started_at = new Date(); });
}
await conn.query("SELECT 1");
// ✅ Resttage berechnen await conn.end();
let daysLeft = TRIAL_DAYS;
saveConfig({
if (settings?.trial_started_at) { db: { host, user, password, name },
const trialStart = new Date(settings.trial_started_at); });
const now = new Date();
const diffDays = Math.floor((now - trialStart) / (1000 * 60 * 60 * 24)); if (typeof db.resetPool === "function") {
daysLeft = Math.max(0, TRIAL_DAYS - diffDays); db.resetPool();
} }
resetSessionStore();
// ❌ Trial abgelaufen
if (daysLeft <= 0) { return res.redirect("/");
if (req.session.user.role === "admin") { } catch (err) {
return res.redirect("/admin/serial-number"); return res
} .status(500)
.send(setupHtml("DB Verbindung fehlgeschlagen: " + err.message));
return res.render("trial_expired", { }
user: req.session.user, });
lang: req.session.lang || "de",
}); // Wenn keine config.enc → alles außer /setup auf Setup umleiten
} app.use((req, res, next) => {
if (!configExists() && req.path !== "/setup") {
// ✅ Trial aktiv return res.redirect("/setup");
return res.render("serial_number_info", { }
user: req.session.user, next();
lang: req.session.lang || "de", });
daysLeft,
}); /* ===============================
} catch (err) { Sprache ändern
console.error(err); ================================ */
return res.status(500).send("Interner Serverfehler"); app.get("/lang/:lang", (req, res) => {
} const newLang = req.params.lang;
});
if (!["de", "es"].includes(newLang)) {
app.get("/admin/serial-number", async (req, res) => { return res.redirect(req.get("Referrer") || "/dashboard");
try { }
if (!req.session?.user) return res.redirect("/");
if (req.session.user.role !== "admin") req.session.lang = newLang;
return res.status(403).send("Forbidden");
req.session.save((err) => {
const [rowsSettings] = await db if (err) console.error("❌ Session save error:", err);
.promise() return res.redirect(req.get("Referrer") || "/dashboard");
.query( });
`SELECT serial_number FROM company_settings ORDER BY id ASC LIMIT 1`, });
);
/* ===============================
const currentSerial = rowsSettings?.[0]?.serial_number || ""; SERIAL PAGES
================================ */
return res.render("serial_number_admin", {
user: req.session.user, /**
lang: req.session.lang || "de", * /serial-number
active: "serialnumber", * - Trial aktiv: zeigt Resttage + Button Dashboard
currentSerial, * - Trial abgelaufen:
error: null, * Admin -> redirect /admin/serial-number
success: null, * Arzt/Member -> trial_expired.ejs
}); */
} catch (err) { app.get("/serial-number", async (req, res) => {
console.error(err); try {
return res.status(500).send("Interner Serverfehler"); if (!req.session?.user) return res.redirect("/");
}
}); const [rowsSettings] = await db.promise().query(
`SELECT id, serial_number, trial_started_at
app.post("/admin/serial-number", async (req, res) => { FROM company_settings
try { ORDER BY id ASC
if (!req.session?.user) return res.redirect("/"); LIMIT 1`,
if (req.session.user.role !== "admin") );
return res.status(403).send("Forbidden");
const settings = rowsSettings?.[0];
let serial = normalizeSerial(req.body.serial_number);
// ✅ Seriennummer da -> ab ins Dashboard
if (!serial) { if (settings?.serial_number) return res.redirect("/dashboard");
return res.render("serial_number_admin", {
user: req.session.user, // ✅ Trial Start setzen wenn leer
lang: req.session.lang || "de", if (settings?.id && !settings?.trial_started_at) {
active: "serialnumber", await db
currentSerial: "", .promise()
error: "Bitte Seriennummer eingeben.", .query(
success: null, `UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`,
}); [settings.id],
} );
settings.trial_started_at = new Date();
if (!isValidSerialFormat(serial)) { }
return res.render("serial_number_admin", {
user: req.session.user, // ✅ Resttage berechnen
lang: req.session.lang || "de", let daysLeft = TRIAL_DAYS;
active: "serialnumber",
currentSerial: serial, if (settings?.trial_started_at) {
error: "Ungültiges Format. Beispiel: ABC12-3DE45-FG678-HI901", const trialStart = new Date(settings.trial_started_at);
success: null, const now = new Date();
}); const diffDays = Math.floor((now - trialStart) / (1000 * 60 * 60 * 24));
} daysLeft = Math.max(0, TRIAL_DAYS - diffDays);
}
if (!passesModulo3(serial)) {
return res.render("serial_number_admin", { // ❌ Trial abgelaufen
user: req.session.user, if (daysLeft <= 0) {
lang: req.session.lang || "de", if (req.session.user.role === "admin") {
active: "serialnumber", return res.redirect("/admin/serial-number");
currentSerial: serial, }
error: "Modulo-3 Prüfung fehlgeschlagen. Seriennummer ungültig.",
success: null, return res.render("trial_expired", {
}); user: req.session.user,
} lang: req.session.lang || "de",
});
await db }
.promise()
.query(`UPDATE company_settings SET serial_number = ? WHERE id = 1`, [ // ✅ Trial aktiv
serial, return res.render("serial_number_info", {
]); user: req.session.user,
lang: req.session.lang || "de",
return res.render("serial_number_admin", { daysLeft,
user: req.session.user, });
lang: req.session.lang || "de", } catch (err) {
active: "serialnumber", console.error(err);
currentSerial: serial, return res.status(500).send("Interner Serverfehler");
error: null, }
success: "✅ Seriennummer gespeichert!", });
});
} catch (err) { /**
console.error(err); * Admin Seite: Seriennummer eingeben
*/
let msg = "Fehler beim Speichern."; app.get("/admin/serial-number", async (req, res) => {
if (err.code === "ER_DUP_ENTRY") try {
msg = "Diese Seriennummer ist bereits vergeben."; if (!req.session?.user) return res.redirect("/");
if (req.session.user.role !== "admin")
return res.render("serial_number_admin", { return res.status(403).send("Forbidden");
user: req.session.user,
lang: req.session.lang || "de", const [rowsSettings] = await db
active: "serialnumber", .promise()
currentSerial: req.body.serial_number || "", .query(
error: msg, `SELECT serial_number FROM company_settings ORDER BY id ASC LIMIT 1`,
success: null, );
});
} const currentSerial = rowsSettings?.[0]?.serial_number || "";
});
return res.render("serial_number_admin", {
/* =============================== user: req.session.user,
DEINE ROUTES (unverändert) lang: req.session.lang || "de",
================================ */ active: "serialnumber",
app.use(companySettingsRoutes); currentSerial,
app.use("/", authRoutes); error: null,
app.use("/dashboard", dashboardRoutes); success: null,
app.use("/admin", adminRoutes); });
} catch (err) {
app.use("/patients", patientRoutes); console.error(err);
app.use("/patients", patientMedicationRoutes); return res.status(500).send("Interner Serverfehler");
app.use("/patients", patientServiceRoutes); }
});
app.use("/medications", medicationRoutes);
console.log("🧪 /medications Router mounted"); /**
* Admin Seite: Seriennummer speichern
app.use("/services", serviceRoutes); */
app.post("/admin/serial-number", async (req, res) => {
app.use("/", patientFileRoutes); try {
app.use("/", waitingRoomRoutes); if (!req.session?.user) return res.redirect("/");
app.use("/invoices", invoiceRoutes); if (req.session.user.role !== "admin")
return res.status(403).send("Forbidden");
app.use("/reportview", reportRoutes);
let serial = normalizeSerial(req.body.serial_number);
app.use("/calendar", calendarRoutes);
if (!serial) {
app.get("/logout", (req, res) => { return res.render("serial_number_admin", {
req.session.destroy(() => res.redirect("/")); user: req.session.user,
}); lang: req.session.lang || "de",
active: "serialnumber",
/* =============================== currentSerial: "",
ERROR HANDLING error: "Bitte Seriennummer eingeben.",
================================ */ success: null,
app.use((err, req, res, next) => { });
console.error(err); }
res.status(500).send("Interner Serverfehler");
}); if (!isValidSerialFormat(serial)) {
return res.render("serial_number_admin", {
/* =============================== user: req.session.user,
SERVER lang: req.session.lang || "de",
================================ */ active: "serialnumber",
const PORT = process.env.PORT || 51777; currentSerial: serial,
const HOST = process.env.HOST || "0.0.0.0"; error: "Ungültiges Format. Beispiel: ABC12-3DE45-FG678-HI901",
success: null,
console.log("DB HOST:", process.env.DBSERVER_HOST); });
console.log("DB PORT:", process.env.DBSERVER_PORT); }
app.listen(PORT, HOST, () => { if (!passesModulo3(serial)) {
console.log(`Server läuft auf http://${HOST}:${PORT}`); return res.render("serial_number_admin", {
}); user: req.session.user,
lang: req.session.lang || "de",
active: "serialnumber",
currentSerial: serial,
error: "Modulo-3 Prüfung fehlgeschlagen. Seriennummer ungültig.",
success: null,
});
}
await db
.promise()
.query(`UPDATE company_settings SET serial_number = ? WHERE id = 1`, [
serial,
]);
return res.render("serial_number_admin", {
user: req.session.user,
lang: req.session.lang || "de",
active: "serialnumber",
currentSerial: serial,
error: null,
success: "✅ Seriennummer gespeichert!",
});
} catch (err) {
console.error(err);
let msg = "Fehler beim Speichern.";
if (err.code === "ER_DUP_ENTRY")
msg = "Diese Seriennummer ist bereits vergeben.";
return res.render("serial_number_admin", {
user: req.session.user,
lang: req.session.lang || "de",
active: "serialnumber",
currentSerial: req.body.serial_number || "",
error: msg,
success: null,
});
}
});
/* ===============================
DEINE ROUTES (unverändert)
================================ */
app.use(companySettingsRoutes);
app.use("/", authRoutes);
app.use("/dashboard", dashboardRoutes);
app.use("/admin", adminRoutes);
app.use("/patients", patientRoutes);
app.use("/patients", patientMedicationRoutes);
app.use("/patients", patientServiceRoutes);
app.use("/medications", medicationRoutes);
console.log("🧪 /medications Router mounted");
app.use("/services", serviceRoutes);
app.use("/", patientFileRoutes);
app.use("/", waitingRoomRoutes);
app.use("/", invoiceRoutes);
app.get("/logout", (req, res) => {
req.session.destroy(() => res.redirect("/"));
});
/* ===============================
ERROR HANDLING
================================ */
app.use((err, req, res, next) => {
console.error(err);
res.status(500).send("Interner Serverfehler");
});
/* ===============================
SERVER
================================ */
const PORT = process.env.PORT || 51777;
const HOST = "127.0.0.1";
app.listen(PORT, HOST, () => {
console.log(`Server läuft auf http://${HOST}:${PORT}`);
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,71 +1,71 @@
const fs = require("fs"); const fs = require("fs");
const crypto = require("crypto"); const crypto = require("crypto");
const path = require("path"); const path = require("path");
const CONFIG_FILE = path.join(__dirname, "config.enc"); const CONFIG_FILE = path.join(__dirname, "config.enc");
function getKey() { function getKey() {
const key = process.env.CONFIG_KEY; const key = process.env.CONFIG_KEY;
if (!key) throw new Error("CONFIG_KEY fehlt in .env"); if (!key) throw new Error("CONFIG_KEY fehlt in .env");
// stabil auf 32 bytes // stabil auf 32 bytes
return crypto.createHash("sha256").update(key).digest(); return crypto.createHash("sha256").update(key).digest();
} }
function encryptConfig(obj) { function encryptConfig(obj) {
const key = getKey(); const key = getKey();
const iv = crypto.randomBytes(12); const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const json = JSON.stringify(obj); const json = JSON.stringify(obj);
const encrypted = Buffer.concat([ const encrypted = Buffer.concat([
cipher.update(json, "utf8"), cipher.update(json, "utf8"),
cipher.final(), cipher.final(),
]); ]);
const tag = cipher.getAuthTag(); const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString("base64"); return Buffer.concat([iv, tag, encrypted]).toString("base64");
} }
function decryptConfig(str) { function decryptConfig(str) {
const raw = Buffer.from(str, "base64"); const raw = Buffer.from(str, "base64");
const iv = raw.subarray(0, 12); const iv = raw.subarray(0, 12);
const tag = raw.subarray(12, 28); const tag = raw.subarray(12, 28);
const encrypted = raw.subarray(28); const encrypted = raw.subarray(28);
const key = getKey(); const key = getKey();
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag); decipher.setAuthTag(tag);
const decrypted = Buffer.concat([ const decrypted = Buffer.concat([
decipher.update(encrypted), decipher.update(encrypted),
decipher.final(), decipher.final(),
]); ]);
return JSON.parse(decrypted.toString("utf8")); return JSON.parse(decrypted.toString("utf8"));
} }
function configExists() { function configExists() {
return fs.existsSync(CONFIG_FILE); return fs.existsSync(CONFIG_FILE);
} }
function loadConfig() { function loadConfig() {
if (!configExists()) return null; if (!configExists()) return null;
const enc = fs.readFileSync(CONFIG_FILE, "utf8").trim(); const enc = fs.readFileSync(CONFIG_FILE, "utf8").trim();
if (!enc) return null; if (!enc) return null;
return decryptConfig(enc); return decryptConfig(enc);
} }
function saveConfig(obj) { function saveConfig(obj) {
const enc = encryptConfig(obj); const enc = encryptConfig(obj);
fs.writeFileSync(CONFIG_FILE, enc, "utf8"); fs.writeFileSync(CONFIG_FILE, enc, "utf8");
return true; return true;
} }
module.exports = { module.exports = {
configExists, configExists,
loadConfig, loadConfig,
saveConfig, saveConfig,
}; };

View File

@ -1 +1 @@
MgmDGURt7NfYtetWb79ghkifQA6ztKwK/7Hl1BNBG2QA+kIbDtHM+1R8XPRiTtDtBHPo+T8UmzvmOuztdphLvMnMW7/Jlqo+VAg4mbYDRLz8WQja5KBmIQJf1eF5riHPu0zQDjY7VU1AX2mzR8xfWrB+CngkagEHXv7OsigsRmxlrB3oGTd6GY6PeAYq3jTblo4kjDDg6GWeDJoF 4PsgCvoOJLNXPpxOHOvm+KbVYz3pNxg8oOXO7zoH3MPffEhZLI7i5qf3o6oqZDI04us8xSSz9j3KIN+Atno/VFlYzSoq3ki1F+WSTz37LfcE3goPqhm6UaH8c9lHdulemH9tqgGq/DxgbKaup5t/ZJnLseaHHpdyTZok1jWULN0nlDuL/HvVVtqw5sboPqU=

View File

@ -1,31 +1,31 @@
const session = require("express-session"); const session = require("express-session");
const { configExists } = require("../config-manager"); const { configExists } = require("../config-manager");
let store = null; let store = null;
function getSessionStore() { function getSessionStore() {
if (store) return store; if (store) return store;
// ✅ Setup-Modus (keine DB) // ✅ Setup-Modus (keine DB)
if (!configExists()) { if (!configExists()) {
console.log("⚠️ Setup-Modus aktiv → SessionStore = MemoryStore"); console.log("⚠️ Setup-Modus aktiv → SessionStore = MemoryStore");
store = new session.MemoryStore(); store = new session.MemoryStore();
return store; return store;
} }
// ✅ Normalbetrieb (mit DB) // ✅ Normalbetrieb (mit DB)
const MySQLStore = require("express-mysql-session")(session); const MySQLStore = require("express-mysql-session")(session);
const db = require("../db"); const db = require("../db");
store = new MySQLStore({}, db); store = new MySQLStore({}, db);
return store; return store;
} }
function resetSessionStore() { function resetSessionStore() {
store = null; store = null;
} }
module.exports = { module.exports = {
getSessionStore, getSessionStore,
resetSessionStore, resetSessionStore,
}; };

View File

@ -1,343 +1,343 @@
const db = require("../db"); const db = require("../db");
const bcrypt = require("bcrypt"); const bcrypt = require("bcrypt");
const { const {
createUser, createUser,
getAllUsers, getAllUsers,
updateUserById, updateUserById,
} = require("../services/admin.service"); } = require("../services/admin.service");
async function listUsers(req, res) { async function listUsers(req, res) {
const { q } = req.query; const { q } = req.query;
try { try {
let users; let users;
if (q) { if (q) {
users = await getAllUsers(db, q); users = await getAllUsers(db, q);
} else { } else {
users = await getAllUsers(db); users = await getAllUsers(db);
} }
res.render("admin_users", { res.render("admin_users", {
title: "Benutzer", title: "Benutzer",
sidebarPartial: "partials/admin-sidebar", sidebarPartial: "partials/admin-sidebar",
active: "users", active: "users",
user: req.session.user, user: req.session.user,
lang: req.session.lang || "de", lang: req.session.lang || "de",
users, users,
currentUser: req.session.user, currentUser: req.session.user,
query: { q }, query: { q },
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.send("Datenbankfehler"); res.send("Datenbankfehler");
} }
} }
function showCreateUser(req, res) { function showCreateUser(req, res) {
res.render("admin_create_user", { res.render("admin_create_user", {
error: null, error: null,
user: req.session.user, user: req.session.user,
}); });
} }
async function postCreateUser(req, res) { async function postCreateUser(req, res) {
let { let {
title, title,
first_name, first_name,
last_name, last_name,
username, username,
password, password,
role, role,
fachrichtung, fachrichtung,
arztnummer, arztnummer,
} = req.body; } = req.body;
title = title?.trim(); title = title?.trim();
first_name = first_name?.trim(); first_name = first_name?.trim();
last_name = last_name?.trim(); last_name = last_name?.trim();
username = username?.trim(); username = username?.trim();
fachrichtung = fachrichtung?.trim(); fachrichtung = fachrichtung?.trim();
arztnummer = arztnummer?.trim(); arztnummer = arztnummer?.trim();
// 🔴 Grundvalidierung // 🔴 Grundvalidierung
if (!first_name || !last_name || !username || !password || !role) { if (!first_name || !last_name || !username || !password || !role) {
return res.render("admin_create_user", { return res.render("admin_create_user", {
error: "Alle Pflichtfelder müssen ausgefüllt sein", error: "Alle Pflichtfelder müssen ausgefüllt sein",
user: req.session.user, user: req.session.user,
}); });
} }
// 🔴 Arzt-spezifische Validierung // 🔴 Arzt-spezifische Validierung
if (role === "arzt") { if (role === "arzt") {
if (!fachrichtung || !arztnummer) { if (!fachrichtung || !arztnummer) {
return res.render("admin_create_user", { return res.render("admin_create_user", {
error: "Für Ärzte sind Fachrichtung und Arztnummer Pflicht", error: "Für Ärzte sind Fachrichtung und Arztnummer Pflicht",
user: req.session.user, user: req.session.user,
}); });
} }
} else { } else {
// Sicherheit: Mitarbeiter dürfen keine Arzt-Daten haben // Sicherheit: Mitarbeiter dürfen keine Arzt-Daten haben
fachrichtung = null; fachrichtung = null;
arztnummer = null; arztnummer = null;
title = null; title = null;
} }
try { try {
await createUser( await createUser(
db, db,
title, title,
first_name, first_name,
last_name, last_name,
username, username,
password, password,
role, role,
fachrichtung, fachrichtung,
arztnummer, arztnummer,
); );
req.session.flash = { req.session.flash = {
type: "success", type: "success",
message: "Benutzer erfolgreich angelegt", message: "Benutzer erfolgreich angelegt",
}; };
res.redirect("/admin/users"); res.redirect("/admin/users");
} catch (error) { } catch (error) {
res.render("admin_create_user", { res.render("admin_create_user", {
error, error,
user: req.session.user, user: req.session.user,
}); });
} }
} }
async function changeUserRole(req, res) { async function changeUserRole(req, res) {
const userId = req.params.id; const userId = req.params.id;
const { role } = req.body; const { role } = req.body;
if (!["arzt", "mitarbeiter"].includes(role)) { if (!["arzt", "mitarbeiter"].includes(role)) {
req.session.flash = { type: "danger", message: "Ungültige Rolle" }; req.session.flash = { type: "danger", message: "Ungültige Rolle" };
return res.redirect("/admin/users"); return res.redirect("/admin/users");
} }
db.query("UPDATE users SET role = ? WHERE id = ?", [role, userId], (err) => { db.query("UPDATE users SET role = ? WHERE id = ?", [role, userId], (err) => {
if (err) { if (err) {
console.error(err); console.error(err);
req.session.flash = { req.session.flash = {
type: "danger", type: "danger",
message: "Fehler beim Ändern der Rolle", message: "Fehler beim Ändern der Rolle",
}; };
} else { } else {
req.session.flash = { req.session.flash = {
type: "success", type: "success",
message: "Rolle erfolgreich geändert", message: "Rolle erfolgreich geändert",
}; };
} }
res.redirect("/admin/users"); res.redirect("/admin/users");
}); });
} }
async function resetUserPassword(req, res) { async function resetUserPassword(req, res) {
const userId = req.params.id; const userId = req.params.id;
const { password } = req.body; const { password } = req.body;
if (!password || password.length < 4) { if (!password || password.length < 4) {
req.session.flash = { type: "warning", message: "Passwort zu kurz" }; req.session.flash = { type: "warning", message: "Passwort zu kurz" };
return res.redirect("/admin/users"); return res.redirect("/admin/users");
} }
const hash = await bcrypt.hash(password, 10); const hash = await bcrypt.hash(password, 10);
db.query( db.query(
"UPDATE users SET password = ? WHERE id = ?", "UPDATE users SET password = ? WHERE id = ?",
[hash, userId], [hash, userId],
(err) => { (err) => {
if (err) { if (err) {
console.error(err); console.error(err);
req.session.flash = { req.session.flash = {
type: "danger", type: "danger",
message: "Fehler beim Zurücksetzen", message: "Fehler beim Zurücksetzen",
}; };
} else { } else {
req.session.flash = { req.session.flash = {
type: "success", type: "success",
message: "Passwort zurückgesetzt", message: "Passwort zurückgesetzt",
}; };
} }
res.redirect("/admin/users"); res.redirect("/admin/users");
}, },
); );
} }
function activateUser(req, res) { function activateUser(req, res) {
const userId = req.params.id; const userId = req.params.id;
db.query("UPDATE users SET active = 1 WHERE id = ?", [userId], (err) => { db.query("UPDATE users SET active = 1 WHERE id = ?", [userId], (err) => {
if (err) { if (err) {
console.error(err); console.error(err);
req.session.flash = { req.session.flash = {
type: "danger", type: "danger",
message: "Benutzer konnte nicht aktiviert werden", message: "Benutzer konnte nicht aktiviert werden",
}; };
} else { } else {
req.session.flash = { req.session.flash = {
type: "success", type: "success",
message: "Benutzer wurde aktiviert", message: "Benutzer wurde aktiviert",
}; };
} }
res.redirect("/admin/users"); res.redirect("/admin/users");
}); });
} }
function deactivateUser(req, res) { function deactivateUser(req, res) {
const userId = req.params.id; const userId = req.params.id;
db.query("UPDATE users SET active = 0 WHERE id = ?", [userId], (err) => { db.query("UPDATE users SET active = 0 WHERE id = ?", [userId], (err) => {
if (err) { if (err) {
console.error(err); console.error(err);
req.session.flash = { req.session.flash = {
type: "danger", type: "danger",
message: "Benutzer konnte nicht deaktiviert werden", message: "Benutzer konnte nicht deaktiviert werden",
}; };
} else { } else {
req.session.flash = { req.session.flash = {
type: "success", type: "success",
message: "Benutzer wurde deaktiviert", message: "Benutzer wurde deaktiviert",
}; };
} }
res.redirect("/admin/users"); res.redirect("/admin/users");
}); });
} }
async function showInvoiceOverview(req, res) { async function showInvoiceOverview(req, res) {
const search = req.query.q || ""; const search = req.query.q || "";
const view = req.query.view || "year"; const view = req.query.view || "year";
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const fromYear = req.query.fromYear || currentYear; const fromYear = req.query.fromYear || currentYear;
const toYear = req.query.toYear || currentYear; const toYear = req.query.toYear || currentYear;
try { try {
const [yearly] = await db.promise().query(` const [yearly] = await db.promise().query(`
SELECT SELECT
YEAR(invoice_date) AS year, YEAR(invoice_date) AS year,
SUM(total_amount) AS total SUM(total_amount) AS total
FROM invoices FROM invoices
WHERE status IN ('paid','open') WHERE status IN ('paid','open')
GROUP BY YEAR(invoice_date) GROUP BY YEAR(invoice_date)
ORDER BY year DESC ORDER BY year DESC
`); `);
const [quarterly] = await db.promise().query(` const [quarterly] = await db.promise().query(`
SELECT SELECT
YEAR(invoice_date) AS year, YEAR(invoice_date) AS year,
QUARTER(invoice_date) AS quarter, QUARTER(invoice_date) AS quarter,
SUM(total_amount) AS total SUM(total_amount) AS total
FROM invoices FROM invoices
WHERE status IN ('paid','open') WHERE status IN ('paid','open')
GROUP BY YEAR(invoice_date), QUARTER(invoice_date) GROUP BY YEAR(invoice_date), QUARTER(invoice_date)
ORDER BY year DESC, quarter DESC ORDER BY year DESC, quarter DESC
`); `);
const [monthly] = await db.promise().query(` const [monthly] = await db.promise().query(`
SELECT SELECT
DATE_FORMAT(invoice_date, '%Y-%m') AS month, DATE_FORMAT(invoice_date, '%Y-%m') AS month,
SUM(total_amount) AS total SUM(total_amount) AS total
FROM invoices FROM invoices
WHERE status IN ('paid','open') WHERE status IN ('paid','open')
GROUP BY month GROUP BY month
ORDER BY month DESC ORDER BY month DESC
`); `);
const [patients] = await db.promise().query( const [patients] = await db.promise().query(
` `
SELECT SELECT
CONCAT(p.firstname, ' ', p.lastname) AS patient, CONCAT(p.firstname, ' ', p.lastname) AS patient,
SUM(i.total_amount) AS total SUM(i.total_amount) AS total
FROM invoices i FROM invoices i
JOIN patients p ON p.id = i.patient_id JOIN patients p ON p.id = i.patient_id
WHERE i.status IN ('paid','open') WHERE i.status IN ('paid','open')
AND CONCAT(p.firstname, ' ', p.lastname) LIKE ? AND CONCAT(p.firstname, ' ', p.lastname) LIKE ?
GROUP BY p.id GROUP BY p.id
ORDER BY total DESC ORDER BY total DESC
`, `,
[`%${search}%`], [`%${search}%`],
); );
res.render("admin/admin_invoice_overview", { res.render("admin/admin_invoice_overview", {
title: "Rechnungsübersicht", title: "Rechnungsübersicht",
sidebarPartial: "partials/admin-sidebar", // ✅ keine Sidebar sidebarPartial: "partials/sidebar-empty", // ✅ keine Sidebar
active: "invoices", active: "",
user: req.session.user, user: req.session.user,
lang: req.session.lang || "de", lang: req.session.lang || "de",
yearly, yearly,
quarterly, quarterly,
monthly, monthly,
patients, patients,
search, search,
fromYear, fromYear,
toYear, toYear,
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).send("Fehler beim Laden der Rechnungsübersicht"); res.status(500).send("Fehler beim Laden der Rechnungsübersicht");
} }
} }
async function updateUser(req, res) { async function updateUser(req, res) {
const userId = req.params.id; const userId = req.params.id;
let { title, first_name, last_name, username, role } = req.body; let { title, first_name, last_name, username, role } = req.body;
title = title?.trim() || null; title = title?.trim() || null;
first_name = first_name?.trim(); first_name = first_name?.trim();
last_name = last_name?.trim(); last_name = last_name?.trim();
username = username?.trim(); username = username?.trim();
role = role?.trim(); role = role?.trim();
try { try {
// ✅ Fehlende Felder aus DB holen (weil disabled inputs nicht gesendet werden) // ✅ Fehlende Felder aus DB holen (weil disabled inputs nicht gesendet werden)
const [rows] = await db const [rows] = await db
.promise() .promise()
.query("SELECT * FROM users WHERE id = ?", [userId]); .query("SELECT * FROM users WHERE id = ?", [userId]);
if (!rows.length) { if (!rows.length) {
req.session.flash = { type: "danger", message: "User nicht gefunden" }; req.session.flash = { type: "danger", message: "User nicht gefunden" };
return res.redirect("/admin/users"); return res.redirect("/admin/users");
} }
const current = rows[0]; const current = rows[0];
// ✅ Fallback: wenn Felder nicht gesendet wurden -> alte Werte behalten // ✅ Fallback: wenn Felder nicht gesendet wurden -> alte Werte behalten
const updatedData = { const updatedData = {
title: title ?? current.title, title: title ?? current.title,
first_name: first_name ?? current.first_name, first_name: first_name ?? current.first_name,
last_name: last_name ?? current.last_name, last_name: last_name ?? current.last_name,
username: username ?? current.username, username: username ?? current.username,
role: role ?? current.role, role: role ?? current.role,
}; };
await updateUserById(db, userId, updatedData); await updateUserById(db, userId, updatedData);
req.session.flash = { type: "success", message: "User aktualisiert ✅" }; req.session.flash = { type: "success", message: "User aktualisiert ✅" };
return res.redirect("/admin/users"); return res.redirect("/admin/users");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
req.session.flash = { type: "danger", message: "Fehler beim Speichern" }; req.session.flash = { type: "danger", message: "Fehler beim Speichern" };
return res.redirect("/admin/users"); return res.redirect("/admin/users");
} }
} }
module.exports = { module.exports = {
listUsers, listUsers,
showCreateUser, showCreateUser,
postCreateUser, postCreateUser,
changeUserRole, changeUserRole,
resetUserPassword, resetUserPassword,
activateUser, activateUser,
deactivateUser, deactivateUser,
showInvoiceOverview, showInvoiceOverview,
updateUser, updateUser,
}; };

View File

@ -1,62 +1,62 @@
const { loginUser } = require("../services/auth.service"); const { loginUser } = require("../services/auth.service");
const db = require("../db"); const db = require("../db");
const LOCK_TIME_MINUTES = 5; const LOCK_TIME_MINUTES = 5;
async function postLogin(req, res) { async function postLogin(req, res) {
const { username, password } = req.body; const { username, password } = req.body;
try { try {
const user = await loginUser(db, username, password, LOCK_TIME_MINUTES); const user = await loginUser(db, username, password, LOCK_TIME_MINUTES);
/* req.session.user = user; /* req.session.user = user;
res.redirect("/dashboard"); */ res.redirect("/dashboard"); */
req.session.user = user; req.session.user = user;
// ✅ Trial Start setzen falls leer // ✅ Trial Start setzen falls leer
const [rowsSettings] = await db.promise().query( const [rowsSettings] = await db.promise().query(
`SELECT id, trial_started_at, serial_number `SELECT id, trial_started_at, serial_number
FROM company_settings FROM company_settings
ORDER BY id ASC ORDER BY id ASC
LIMIT 1`, LIMIT 1`,
); );
const settingsTrail = rowsSettings?.[0]; const settingsTrail = rowsSettings?.[0];
if (settingsTrail?.id && !settingsTrail.trial_started_at) { if (settingsTrail?.id && !settingsTrail.trial_started_at) {
await db await db
.promise() .promise()
.query( .query(
`UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`, `UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`,
[settingsTrail.id], [settingsTrail.id],
); );
} }
// ✅ Direkt nach Login check: // ✅ Direkt nach Login check:
const [rows] = await db const [rows] = await db
.promise() .promise()
.query( .query(
`SELECT serial_number, trial_started_at FROM company_settings ORDER BY id ASC LIMIT 1`, `SELECT serial_number, trial_started_at FROM company_settings ORDER BY id ASC LIMIT 1`,
); );
const settings = rows?.[0]; const settings = rows?.[0];
if (!settings?.serial_number) { if (!settings?.serial_number) {
return res.redirect("/serial-number"); return res.redirect("/serial-number");
} }
res.redirect("/dashboard"); res.redirect("/dashboard");
} catch (error) { } catch (error) {
res.render("login", { error }); res.render("login", { error });
} }
} }
function getLogin(req, res) { function getLogin(req, res) {
res.render("login", { error: null }); res.render("login", { error: null });
} }
module.exports = { module.exports = {
getLogin, getLogin,
postLogin, postLogin,
}; };

View File

@ -1,257 +0,0 @@
/**
* controllers/calendar.controller.js
*/
const db = require("../db");
const Holidays = require("date-holidays");
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
function pad(n) {
return String(n).padStart(2, "0");
}
function toISO(d) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
// ── Hauptseite (EJS rendern) ─────────────────────────────────────────────────
exports.index = async (req, res) => {
try {
// Alle aktiven Ärzte (users mit role = 'arzt')
const [doctors] = await db.promise().query(`
SELECT id, username AS name, doctor_color AS color
FROM users
WHERE role = 'arzt' AND active = 1
ORDER BY username
`);
const today = toISO(new Date());
return res.render("calendar/index", {
active: "calendar",
doctors,
today,
user: req.session.user,
});
} catch (err) {
console.error("❌ calendar.index:", err.message);
return res.status(500).send("Interner Serverfehler");
}
};
// ── API: Termine eines Tages ─────────────────────────────────────────────────
exports.getAppointments = async (req, res) => {
try {
const { date } = req.params; // YYYY-MM-DD
const [rows] = await db.promise().query(
`SELECT
a.id, a.doctor_id, a.date,
TIME_FORMAT(a.time, '%H:%i') AS time,
a.duration, a.patient_name, a.notes, a.status,
u.username AS doctor_name,
u.doctor_color AS doctor_color
FROM appointments a
JOIN users u ON u.id = a.doctor_id
WHERE a.date = ?
ORDER BY a.time, u.username`,
[date]
);
return res.json(rows);
} catch (err) {
console.error("❌ getAppointments:", err.message);
return res.status(500).json({ error: "Datenbankfehler" });
}
};
// ── API: Termin erstellen ────────────────────────────────────────────────────
exports.createAppointment = async (req, res) => {
try {
const { doctor_id, date, time, duration = 15, patient_name, notes = "" } =
req.body;
if (!doctor_id || !date || !time || !patient_name?.trim()) {
return res
.status(400)
.json({ error: "doctor_id, date, time und patient_name sind Pflicht" });
}
// Kollisionsprüfung
const [conflict] = await db.promise().query(
`SELECT id FROM appointments
WHERE doctor_id = ? AND date = ? AND time = ? AND status != 'cancelled'`,
[doctor_id, date, time]
);
if (conflict.length > 0) {
return res.status(409).json({ error: "Dieser Zeitslot ist bereits belegt" });
}
const [result] = await db.promise().query(
`INSERT INTO appointments (doctor_id, date, time, duration, patient_name, notes)
VALUES (?, ?, ?, ?, ?, ?)`,
[doctor_id, date, time, duration, patient_name.trim(), notes]
);
return res.status(201).json({ id: result.insertId });
} catch (err) {
console.error("❌ createAppointment:", err.message);
return res.status(500).json({ error: "Datenbankfehler" });
}
};
// ── API: Termin aktualisieren ────────────────────────────────────────────────
exports.updateAppointment = async (req, res) => {
try {
const { id } = req.params;
const { doctor_id, date, time, duration, patient_name, notes, status } =
req.body;
await db.promise().query(
`UPDATE appointments
SET doctor_id = ?, date = ?, time = ?, duration = ?,
patient_name = ?, notes = ?, status = ?
WHERE id = ?`,
[doctor_id, date, time, duration, patient_name, notes, status, id]
);
return res.json({ success: true });
} catch (err) {
console.error("❌ updateAppointment:", err.message);
return res.status(500).json({ error: "Datenbankfehler" });
}
};
// ── API: Termin löschen ──────────────────────────────────────────────────────
exports.deleteAppointment = async (req, res) => {
try {
await db.promise().query("DELETE FROM appointments WHERE id = ?", [
req.params.id,
]);
return res.json({ success: true });
} catch (err) {
console.error("❌ deleteAppointment:", err.message);
return res.status(500).json({ error: "Datenbankfehler" });
}
};
// ── API: Status ändern ───────────────────────────────────────────────────────
exports.patchStatus = async (req, res) => {
try {
const allowed = ["scheduled", "completed", "cancelled"];
const { status } = req.body;
if (!allowed.includes(status)) {
return res.status(400).json({ error: "Ungültiger Status" });
}
await db
.promise()
.query("UPDATE appointments SET status = ? WHERE id = ?", [
status,
req.params.id,
]);
return res.json({ success: true });
} catch (err) {
console.error("❌ patchStatus:", err.message);
return res.status(500).json({ error: "Datenbankfehler" });
}
};
// ── API: Feiertage eines Jahres ──────────────────────────────────────────────
exports.getHolidays = (req, res) => {
try {
const year = parseInt(req.params.year);
const country = (req.query.country || process.env.HOLIDAY_COUNTRY || "DE").toUpperCase();
const state = (req.query.state || process.env.HOLIDAY_STATE || "").toUpperCase();
if (isNaN(year) || year < 1900 || year > 2100) {
return res.status(400).json({ error: "Ungültiges Jahr" });
}
const hd = new Holidays();
const inited = state ? hd.init(country, state) : hd.init(country);
if (!inited) {
return res.status(400).json({ error: `Unbekanntes Land/Bundesland: ${country}/${state}` });
}
const holidays = hd
.getHolidays(year)
.filter((h) => ["public", "bank"].includes(h.type))
.map((h) => ({
date: h.date.substring(0, 10),
name: h.name,
type: h.type,
}));
return res.json({ country, state, year, holidays });
} catch (err) {
console.error("❌ getHolidays:", err.message);
return res.status(500).json({ error: "Fehler beim Laden der Feiertage" });
}
};
// ── API: Patienten-Suche (Autocomplete) ─────────────────────────────────────
exports.searchPatients = async (req, res) => {
try {
const q = (req.query.q || "").trim();
if (q.length < 1) return res.json([]);
const like = `%${q}%`;
const [rows] = await db.promise().query(
`SELECT
id,
firstname,
lastname,
birthdate,
CONCAT(firstname, ' ', lastname) AS full_name
FROM patients
WHERE active = 1
AND (
firstname LIKE ? OR
lastname LIKE ? OR
CONCAT(firstname, ' ', lastname) LIKE ?
)
ORDER BY lastname, firstname
LIMIT 10`,
[like, like, like]
);
return res.json(rows);
} catch (err) {
console.error("❌ searchPatients:", err.message);
return res.status(500).json({ error: "Datenbankfehler" });
}
};
// ── API: Arzt-Farbe speichern ────────────────────────────────────────────────
exports.updateDoctorColor = async (req, res) => {
try {
const { color } = req.body;
await db
.promise()
.query("UPDATE users SET doctor_color = ? WHERE id = ?", [
color,
req.params.id,
]);
return res.json({ success: true });
} catch (err) {
console.error("❌ updateDoctorColor:", err.message);
return res.status(500).json({ error: "Datenbankfehler" });
}
};

View File

@ -1,175 +1,162 @@
const db = require("../db"); const db = require("../db");
/** /**
* Helper: leere Strings NULL * Helper: leere Strings NULL
*/ */
const safe = (v) => { const safe = (v) => {
if (typeof v !== "string") return null; if (typeof v !== "string") return null;
const t = v.trim(); const t = v.trim();
return t.length > 0 ? t : null; return t.length > 0 ? t : null;
}; };
/** /**
* GET: Firmendaten anzeigen * GET: Firmendaten anzeigen
*/ */
async function getCompanySettings(req, res) { async function getCompanySettings(req, res) {
try { const [[company]] = await db.promise().query(
const [[company]] = await db "SELECT * FROM company_settings LIMIT 1"
.promise() );
.query("SELECT * FROM company_settings LIMIT 1");
res.render("admin/company-settings", {
res.render("admin/company-settings", { user: req.user,
layout: "layout", // 🔥 wichtig company: company || {}
title: "Firmendaten", // 🔥 DAS FEHLTE });
active: "companySettings", // 🔥 Sidebar aktiv }
sidebarPartial: "partials/admin-sidebar",
/**
company: company || {}, * POST: Firmendaten speichern (INSERT oder UPDATE)
*/
user: req.session.user, // 🔥 konsistent async function saveCompanySettings(req, res) {
lang: req.session.lang || "de" try {
// t kommt aus res.locals const data = req.body;
});
} catch (err) { // 🔒 Pflichtfeld
console.error(err); if (!data.company_name || data.company_name.trim() === "") {
res.status(500).send("Datenbankfehler"); return res.status(400).send("Firmenname darf nicht leer sein");
} }
}
// 🖼 Logo (optional)
/** let logoPath = null;
* POST: Firmendaten speichern (INSERT oder UPDATE) if (req.file) {
*/ logoPath = "/images/" + req.file.filename;
async function saveCompanySettings(req, res) { }
try {
const data = req.body; // 🔍 Existierenden Datensatz laden
const [[existing]] = await db.promise().query(
// 🔒 Pflichtfeld "SELECT * FROM company_settings LIMIT 1"
if (!data.company_name || data.company_name.trim() === "") { );
return res.status(400).send("Firmenname darf nicht leer sein");
} const oldData = existing ? { ...existing } : null;
// 🖼 Logo (optional) if (existing) {
let logoPath = null; // 🔁 UPDATE
if (req.file) { await db.promise().query(
logoPath = "/images/" + req.file.filename; `
} UPDATE company_settings SET
company_name = ?,
// 🔍 Existierenden Datensatz laden company_legal_form = ?,
const [[existing]] = await db.promise().query( company_owner = ?,
"SELECT * FROM company_settings LIMIT 1" street = ?,
); house_number = ?,
postal_code = ?,
const oldData = existing ? { ...existing } : null; city = ?,
country = ?,
if (existing) { phone = ?,
// 🔁 UPDATE email = ?,
await db.promise().query( vat_id = ?,
` bank_name = ?,
UPDATE company_settings SET iban = ?,
company_name = ?, bic = ?,
company_legal_form = ?, invoice_footer_text = ?,
company_owner = ?, invoice_logo_path = ?
street = ?, WHERE id = ?
house_number = ?, `,
postal_code = ?, [
city = ?, data.company_name.trim(), // NOT NULL
country = ?, safe(data.company_legal_form),
phone = ?, safe(data.company_owner),
email = ?, safe(data.street),
vat_id = ?, safe(data.house_number),
bank_name = ?, safe(data.postal_code),
iban = ?, safe(data.city),
bic = ?, safe(data.country),
invoice_footer_text = ?, safe(data.phone),
invoice_logo_path = ? safe(data.email),
WHERE id = ? safe(data.vat_id),
`, safe(data.bank_name),
[ safe(data.iban),
data.company_name.trim(), // NOT NULL safe(data.bic),
safe(data.company_legal_form), safe(data.invoice_footer_text),
safe(data.company_owner), logoPath || existing.invoice_logo_path,
safe(data.street), existing.id
safe(data.house_number), ]
safe(data.postal_code), );
safe(data.city), } else {
safe(data.country), // INSERT
safe(data.phone), await db.promise().query(
safe(data.email), `
safe(data.vat_id), INSERT INTO company_settings (
safe(data.bank_name), company_name,
safe(data.iban), company_legal_form,
safe(data.bic), company_owner,
safe(data.invoice_footer_text), street,
logoPath || existing.invoice_logo_path, house_number,
existing.id postal_code,
] city,
); country,
} else { phone,
// INSERT email,
await db.promise().query( vat_id,
` bank_name,
INSERT INTO company_settings ( iban,
company_name, bic,
company_legal_form, invoice_footer_text,
company_owner, invoice_logo_path
street, ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
house_number, `,
postal_code, [
city, data.company_name.trim(), // NOT NULL
country, safe(data.company_legal_form),
phone, safe(data.company_owner),
email, safe(data.street),
vat_id, safe(data.house_number),
bank_name, safe(data.postal_code),
iban, safe(data.city),
bic, safe(data.country),
invoice_footer_text, safe(data.phone),
invoice_logo_path safe(data.email),
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) safe(data.vat_id),
`, safe(data.bank_name),
[ safe(data.iban),
data.company_name.trim(), // NOT NULL safe(data.bic),
safe(data.company_legal_form), safe(data.invoice_footer_text),
safe(data.company_owner), logoPath
safe(data.street), ]
safe(data.house_number), );
safe(data.postal_code), }
safe(data.city),
safe(data.country), // 📝 Audit-Log
safe(data.phone), await db.promise().query(
safe(data.email), `
safe(data.vat_id), INSERT INTO company_settings_logs (changed_by, old_data, new_data)
safe(data.bank_name), VALUES (?, ?, ?)
safe(data.iban), `,
safe(data.bic), [
safe(data.invoice_footer_text), req.user.id,
logoPath JSON.stringify(oldData || {}),
] JSON.stringify(data)
); ]
} );
// 📝 Audit-Log res.redirect("/admin/company-settings");
await db.promise().query(
` } catch (err) {
INSERT INTO company_settings_logs (changed_by, old_data, new_data) console.error("❌ COMPANY SETTINGS ERROR:", err);
VALUES (?, ?, ?) res.status(500).send("Fehler beim Speichern der Firmendaten");
`, }
[ }
req.user.id,
JSON.stringify(oldData || {}), module.exports = {
JSON.stringify(data) getCompanySettings,
] saveCompanySettings
); };
res.redirect("/admin/company-settings");
} catch (err) {
console.error("❌ COMPANY SETTINGS ERROR:", err);
res.status(500).send("Fehler beim Speichern der Firmendaten");
}
}
module.exports = {
getCompanySettings,
saveCompanySettings
};

View File

@ -1,29 +1,22 @@
const db = require("../db"); const db = require("../db");
const { const {
getWaitingPatients getWaitingPatients
} = require("../services/patient.service"); } = require("../services/patient.service");
async function showDashboard(req, res) { async function showDashboard(req, res) {
try { try {
const waitingPatients = await getWaitingPatients(db); const waitingPatients = await getWaitingPatients(db);
res.render("dashboard", { res.render("dashboard", {
layout: "layout", // 🔥 DAS FEHLTE user: req.session.user,
waitingPatients
title: "Dashboard", });
active: "dashboard", } catch (err) {
sidebarPartial: "partials/sidebar", console.error(err);
res.send("Datenbankfehler");
waitingPatients, }
user: req.session.user, }
lang: req.session.lang || "de"
}); module.exports = {
} catch (err) { showDashboard
console.error(err); };
res.send("Datenbankfehler");
}
}
module.exports = {
showDashboard
};

View File

@ -1,483 +0,0 @@
const db = require("../db");
const path = require("path");
const { rgb } = require("pdf-lib");
const { addWatermark } = require("../utils/pdfWatermark");
const { createCreditPdf } = require("../utils/creditPdf");
exports.openInvoices = async (req, res) => {
try {
const [rows] = await db.promise().query(`
SELECT
i.id,
i.invoice_date,
i.total_amount,
i.status,
p.firstname,
p.lastname
FROM invoices i
JOIN patients p ON p.id = i.patient_id
WHERE i.status = 'open'
ORDER BY i.invoice_date DESC
`);
const invoices = rows.map((inv) => {
let formattedDate = "";
if (inv.invoice_date) {
let dateObj;
// Falls String aus DB
if (typeof inv.invoice_date === "string") {
dateObj = new Date(inv.invoice_date + "T00:00:00");
}
// Falls Date-Objekt
else if (inv.invoice_date instanceof Date) {
dateObj = inv.invoice_date;
}
if (dateObj && !isNaN(dateObj)) {
formattedDate = dateObj.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
}
return {
...inv,
invoice_date_formatted: formattedDate,
total_amount_formatted: Number(inv.total_amount || 0).toFixed(2),
};
});
res.render("invoices/open-invoices", {
// ✅ wichtig für Layout
title: "Offene Rechnungen",
active: "open_invoices",
sidebarPartial: "partials/sidebar-invoices",
user: req.session.user,
invoices,
});
} catch (err) {
console.error("❌ openInvoices Fehler:", err);
res.status(500).send("Fehler beim Laden der offenen Rechnungen");
}
};
// Als bezahlt markieren
exports.markAsPaid = async (req, res) => {
try {
const id = req.params.id;
const userId = req.session.user.id;
// PDF-Pfad holen
const [[invoice]] = await db
.promise()
.query("SELECT file_path FROM invoices WHERE id = ?", [id]);
await db.promise().query(
`
UPDATE invoices
SET
status='paid',
paid_at = NOW(),
paid_by = ?
WHERE id = ?
`,
[userId, id],
);
// Wasserzeichen setzen
if (invoice?.file_path) {
const fullPath = path.join(__dirname, "..", "public", invoice.file_path);
await addWatermark(
fullPath,
"BEZAHLT",
rgb(0, 0.7, 0), // Grün
);
}
res.redirect("/invoices/open");
} catch (err) {
console.error("❌ markAsPaid:", err);
res.status(500).send("Fehler");
}
};
// Stornieren
exports.cancelInvoice = async (req, res) => {
try {
const id = req.params.id;
const userId = req.session.user.id;
const [[invoice]] = await db
.promise()
.query("SELECT file_path FROM invoices WHERE id = ?", [id]);
await db.promise().query(
`
UPDATE invoices
SET
status='cancelled',
cancelled_at = NOW(),
cancelled_by = ?
WHERE id = ?
`,
[userId, id],
);
// Wasserzeichen setzen
if (invoice?.file_path) {
const fullPath = path.join(__dirname, "..", "public", invoice.file_path);
await addWatermark(
fullPath,
"STORNIERT",
rgb(0.8, 0, 0), // Rot
);
}
res.redirect("/invoices/open");
} catch (err) {
console.error("❌ cancelInvoice:", err);
res.status(500).send("Fehler");
}
};
// Stornierte Rechnungen anzeigen
exports.cancelledInvoices = async (req, res) => {
try {
// Jahr aus Query (?year=2024)
const year = req.query.year || new Date().getFullYear();
const [rows] = await db.promise().query(
`
SELECT
i.id,
i.invoice_date,
i.total_amount,
p.firstname,
p.lastname
FROM invoices i
JOIN patients p ON p.id = i.patient_id
WHERE
i.status = 'cancelled'
AND YEAR(i.invoice_date) = ?
ORDER BY i.invoice_date DESC
`,
[year],
);
// Formatieren
const invoices = rows.map((inv) => {
let formattedDate = "";
if (inv.invoice_date) {
let dateObj;
// Falls String aus DB
if (typeof inv.invoice_date === "string") {
dateObj = new Date(inv.invoice_date + "T00:00:00");
}
// Falls Date-Objekt
else if (inv.invoice_date instanceof Date) {
dateObj = inv.invoice_date;
}
if (dateObj && !isNaN(dateObj)) {
formattedDate = dateObj.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
}
return {
...inv,
invoice_date_formatted: formattedDate,
total_amount_formatted: Number(inv.total_amount || 0).toFixed(2),
};
});
// verfügbare Jahre laden (für Dropdown)
const [years] = await db.promise().query(`
SELECT DISTINCT YEAR(invoice_date) AS year
FROM invoices
WHERE status = 'cancelled'
ORDER BY year DESC
`);
res.render("invoices/cancelled-invoices", {
title: "Stornierte Rechnungen",
user: req.session.user,
invoices,
years: years.map((y) => y.year),
selectedYear: year,
sidebarPartial: "partials/sidebar-invoices",
active: "cancelled_invoices",
});
} catch (err) {
console.error("❌ cancelledInvoices:", err);
res.status(500).send("Fehler beim Laden der stornierten Rechnungen");
}
};
// Auflistung bezahlter Rechnungen
exports.paidInvoices = async (req, res) => {
try {
const year = parseInt(req.query.year) || new Date().getFullYear();
const quarter = parseInt(req.query.quarter) || 0;
let where = `WHERE i.status = 'paid' AND i.type = 'invoice' AND c.id IS NULL`;
const params = [];
if (year) {
where += " AND YEAR(i.invoice_date) = ?";
params.push(year);
}
if (quarter) {
where += " AND QUARTER(i.invoice_date) = ?";
params.push(quarter);
}
const [rows] = await db.promise().query(
`
SELECT
i.id,
i.invoice_date,
i.total_amount,
p.firstname,
p.lastname,
c.id AS credit_id
FROM invoices i
JOIN patients p ON p.id = i.patient_id
LEFT JOIN invoices c
ON c.parent_invoice_id = i.id
AND c.type = 'credit'
${where}
ORDER BY i.invoice_date DESC
`,
params,
);
// Datum + Betrag formatieren
const invoices = rows.map((inv) => {
const d = new Date(inv.invoice_date);
return {
...inv,
invoice_date_formatted: d.toLocaleDateString("de-DE"),
total_amount_formatted: Number(inv.total_amount).toFixed(2),
};
});
// Jahre laden
const [years] = await db.promise().query(`
SELECT DISTINCT YEAR(invoice_date) AS year
FROM invoices
WHERE status='paid'
ORDER BY year DESC
`);
res.render("invoices/paid-invoices", {
title: "Bezahlte Rechnungen",
user: req.session.user,
invoices,
years: years.map((y) => y.year),
selectedYear: year,
selectedQuarter: quarter,
sidebarPartial: "partials/sidebar-invoices",
active: "paid_invoices",
query: req.query,
});
} catch (err) {
console.error("❌ paidInvoices:", err);
res.status(500).send("Fehler");
}
};
exports.createCreditNote = async (req, res) => {
try {
const invoiceId = req.params.id;
const userId = req.session.user.id;
// Originalrechnung
const [[invoice]] = await db.promise().query(
`
SELECT i.*, p.firstname, p.lastname
FROM invoices i
JOIN patients p ON p.id = i.patient_id
WHERE i.id = ? AND i.status = 'paid' AND i.type = 'invoice'
`,
[invoiceId],
);
if (!invoice) {
return res.status(400).send("Ungültige Rechnung");
}
// Prüfen: Gibt es schon eine Gutschrift?
const [[existing]] = await db
.promise()
.query(
`SELECT id FROM invoices WHERE parent_invoice_id = ? AND type = 'credit'`,
[invoiceId],
);
if (existing) {
return res.redirect("/invoices/paid?error=already_credited");
}
// Gutschrift anlegen
const [result] = await db.promise().query(
`
INSERT INTO invoices
(
type,
parent_invoice_id,
patient_id,
invoice_date,
total_amount,
created_by,
status,
paid_at,
paid_by
)
VALUES
('credit', ?, ?, CURDATE(), ?, ?, 'paid', NOW(), ?)
`,
[
invoice.id,
invoice.patient_id,
-Math.abs(invoice.total_amount),
userId,
userId,
],
);
const creditId = result.insertId;
// PDF erzeugen
const pdfPath = await createCreditPdf({
creditId,
originalInvoice: invoice,
creditAmount: -Math.abs(invoice.total_amount),
patient: invoice,
});
// PDF-Pfad speichern
await db
.promise()
.query(`UPDATE invoices SET file_path = ? WHERE id = ?`, [
pdfPath,
creditId,
]);
res.redirect("/invoices/paid");
} catch (err) {
console.error("❌ createCreditNote:", err);
res.status(500).send("Fehler");
}
};
exports.creditOverview = async (req, res) => {
try {
const year = parseInt(req.query.year) || 0;
let where = "WHERE c.type = 'credit'";
const params = [];
if (year) {
where += " AND YEAR(c.invoice_date) = ?";
params.push(year);
}
const [rows] = await db.promise().query(
`
SELECT
i.id AS invoice_id,
i.invoice_date AS invoice_date,
i.file_path AS invoice_file,
i.total_amount AS invoice_amount,
c.id AS credit_id,
c.invoice_date AS credit_date,
c.file_path AS credit_file,
c.total_amount AS credit_amount,
p.firstname,
p.lastname
FROM invoices c
JOIN invoices i
ON i.id = c.parent_invoice_id
JOIN patients p
ON p.id = i.patient_id
${where}
ORDER BY c.invoice_date DESC
`,
params,
);
// Formatieren
const items = rows.map((r) => {
const formatDate = (d) =>
d ? new Date(d).toLocaleDateString("de-DE") : "";
return {
...r,
invoice_date_fmt: formatDate(r.invoice_date),
credit_date_fmt: formatDate(r.credit_date),
invoice_amount_fmt: Number(r.invoice_amount).toFixed(2),
credit_amount_fmt: Number(r.credit_amount).toFixed(2),
};
});
// Jahre laden
const [years] = await db.promise().query(`
SELECT DISTINCT YEAR(invoice_date) AS year
FROM invoices
WHERE type='credit'
ORDER BY year DESC
`);
res.render("invoices/credit-overview", {
title: "Gutschriften-Übersicht",
user: req.session.user,
items,
years: years.map((y) => y.year),
selectedYear: year,
sidebarPartial: "partials/sidebar-invoices",
active: "credits",
});
} catch (err) {
console.error("❌ creditOverview:", err);
res.status(500).send("Fehler");
}
};

View File

@ -1,198 +1,198 @@
const db = require("../db"); const db = require("../db");
const ejs = require("ejs"); const ejs = require("ejs");
const path = require("path"); const path = require("path");
const htmlToPdf = require("html-pdf-node"); const htmlToPdf = require("html-pdf-node");
const fs = require("fs"); const fs = require("fs");
async function createInvoicePdf(req, res) { async function createInvoicePdf(req, res) {
const patientId = req.params.id; const patientId = req.params.id;
const connection = await db.promise().getConnection(); const connection = await db.promise().getConnection();
try { try {
await connection.beginTransaction(); await connection.beginTransaction();
const year = new Date().getFullYear(); const year = new Date().getFullYear();
// 🔒 Rechnungszähler sperren // 🔒 Rechnungszähler sperren
const [[counterRow]] = await connection.query( const [[counterRow]] = await connection.query(
"SELECT counter FROM invoice_counters WHERE year = ? FOR UPDATE", "SELECT counter FROM invoice_counters WHERE year = ? FOR UPDATE",
[year] [year]
); );
let counter; let counter;
if (!counterRow) { if (!counterRow) {
counter = 1; counter = 1;
await connection.query( await connection.query(
"INSERT INTO invoice_counters (year, counter) VALUES (?, ?)", "INSERT INTO invoice_counters (year, counter) VALUES (?, ?)",
[year, counter] [year, counter]
); );
} else { } else {
counter = counterRow.counter + 1; counter = counterRow.counter + 1;
await connection.query( await connection.query(
"UPDATE invoice_counters SET counter = ? WHERE year = ?", "UPDATE invoice_counters SET counter = ? WHERE year = ?",
[counter, year] [counter, year]
); );
} }
const invoiceNumber = `${year}-${String(counter).padStart(4, "0")}`; const invoiceNumber = `${year}-${String(counter).padStart(4, "0")}`;
// 🔹 Patient // 🔹 Patient
const [[patient]] = await connection.query( const [[patient]] = await connection.query(
"SELECT * FROM patients WHERE id = ?", "SELECT * FROM patients WHERE id = ?",
[patientId] [patientId]
); );
if (!patient) throw new Error("Patient nicht gefunden"); if (!patient) throw new Error("Patient nicht gefunden");
// 🔹 Leistungen // 🔹 Leistungen
const [rows] = await connection.query( const [rows] = await connection.query(
` `
SELECT SELECT
ps.quantity, ps.quantity,
COALESCE(ps.price_override, s.price) AS price, COALESCE(ps.price_override, s.price) AS price,
s.name_de AS name s.name_de AS name
FROM patient_services ps FROM patient_services ps
JOIN services s ON ps.service_id = s.id JOIN services s ON ps.service_id = s.id
WHERE ps.patient_id = ? WHERE ps.patient_id = ?
AND ps.invoice_id IS NULL AND ps.invoice_id IS NULL
`, `,
[patientId] [patientId]
); );
if (!rows.length) throw new Error("Keine Leistungen vorhanden"); if (!rows.length) throw new Error("Keine Leistungen vorhanden");
const services = rows.map((s) => ({ const services = rows.map((s) => ({
quantity: Number(s.quantity), quantity: Number(s.quantity),
name: s.name, name: s.name,
price: Number(s.price), price: Number(s.price),
total: Number(s.price) * Number(s.quantity), total: Number(s.price) * Number(s.quantity),
})); }));
const total = services.reduce((sum, s) => sum + s.total, 0); const total = services.reduce((sum, s) => sum + s.total, 0);
// 🔹 Arzt // 🔹 Arzt
const [[doctor]] = await connection.query( const [[doctor]] = await connection.query(
` `
SELECT first_name, last_name, fachrichtung, arztnummer SELECT first_name, last_name, fachrichtung, arztnummer
FROM users FROM users
WHERE id = ( WHERE id = (
SELECT created_by SELECT created_by
FROM patient_services FROM patient_services
WHERE patient_id = ? WHERE patient_id = ?
ORDER BY service_date DESC ORDER BY service_date DESC
LIMIT 1 LIMIT 1
) )
`, `,
[patientId] [patientId]
); );
// 🔹 Firma // 🔹 Firma
const [[company]] = await connection.query( const [[company]] = await connection.query(
"SELECT * FROM company_settings LIMIT 1" "SELECT * FROM company_settings LIMIT 1"
); );
// 🖼 Logo als Base64 // 🖼 Logo als Base64
let logoBase64 = null; let logoBase64 = null;
if (company && company.invoice_logo_path) { if (company && company.invoice_logo_path) {
const logoPath = path.join( const logoPath = path.join(
__dirname, __dirname,
"..", "..",
"public", "public",
company.invoice_logo_path company.invoice_logo_path
); );
if (fs.existsSync(logoPath)) { if (fs.existsSync(logoPath)) {
const buffer = fs.readFileSync(logoPath); const buffer = fs.readFileSync(logoPath);
const ext = path.extname(logoPath).toLowerCase(); const ext = path.extname(logoPath).toLowerCase();
const mime = const mime =
ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : "image/png"; ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : "image/png";
logoBase64 = `data:${mime};base64,${buffer.toString("base64")}`; logoBase64 = `data:${mime};base64,${buffer.toString("base64")}`;
} }
} }
// 📁 PDF-Pfad vorbereiten // 📁 PDF-Pfad vorbereiten
const invoiceDir = path.join( const invoiceDir = path.join(
__dirname, __dirname,
"..", "..",
"public", "public",
"invoices", "invoices",
String(year) String(year)
); );
if (!fs.existsSync(invoiceDir)) { if (!fs.existsSync(invoiceDir)) {
fs.mkdirSync(invoiceDir, { recursive: true }); fs.mkdirSync(invoiceDir, { recursive: true });
} }
const fileName = `invoice-${invoiceNumber}.pdf`; const fileName = `invoice-${invoiceNumber}.pdf`;
const absoluteFilePath = path.join(invoiceDir, fileName); const absoluteFilePath = path.join(invoiceDir, fileName);
const dbFilePath = `/invoices/${year}/${fileName}`; const dbFilePath = `/invoices/${year}/${fileName}`;
// 🔹 Rechnung speichern // 🔹 Rechnung speichern
const [result] = await connection.query( const [result] = await connection.query(
` `
INSERT INTO invoices INSERT INTO invoices
(patient_id, invoice_date, file_path, total_amount, created_by, status) (patient_id, invoice_date, file_path, total_amount, created_by, status)
VALUES (?, CURDATE(), ?, ?, ?, 'open') VALUES (?, CURDATE(), ?, ?, ?, 'open')
`, `,
[patientId, dbFilePath, total, req.session.user.id] [patientId, dbFilePath, total, req.session.user.id]
); );
const invoiceId = result.insertId; const invoiceId = result.insertId;
const invoice = { const invoice = {
number: invoiceNumber, number: invoiceNumber,
date: new Date().toLocaleDateString("de-DE"), date: new Date().toLocaleDateString("de-DE"),
}; };
// 🔹 HTML rendern // 🔹 HTML rendern
const html = await ejs.renderFile( const html = await ejs.renderFile(
path.join(__dirname, "../views/invoices/invoice.ejs"), path.join(__dirname, "../views/invoices/invoice.ejs"),
{ {
patient, patient,
services, services,
total, total,
invoice, invoice,
doctor, doctor,
company, company,
logoBase64, logoBase64,
} }
); );
// 🔹 PDF erzeugen // 🔹 PDF erzeugen
const pdfBuffer = await htmlToPdf.generatePdf( const pdfBuffer = await htmlToPdf.generatePdf(
{ content: html }, { content: html },
{ format: "A4", printBackground: true } { format: "A4", printBackground: true }
); );
// 💾 PDF speichern // 💾 PDF speichern
fs.writeFileSync(absoluteFilePath, pdfBuffer); fs.writeFileSync(absoluteFilePath, pdfBuffer);
// 🔗 Leistungen mit Rechnung verknüpfen // 🔗 Leistungen mit Rechnung verknüpfen
const [updateResult] = await connection.query( const [updateResult] = await connection.query(
` `
UPDATE patient_services UPDATE patient_services
SET invoice_id = ? SET invoice_id = ?
WHERE patient_id = ? WHERE patient_id = ?
AND invoice_id IS NULL AND invoice_id IS NULL
`, `,
[invoiceId, patientId] [invoiceId, patientId]
); );
const [[cid]] = await connection.query("SELECT CONNECTION_ID() AS cid"); const [[cid]] = await connection.query("SELECT CONNECTION_ID() AS cid");
console.log("🔌 INVOICE CID:", cid.cid); console.log("🔌 INVOICE CID:", cid.cid);
await connection.commit(); await connection.commit();
console.log("🔌 INVOICE CID:", cid.cid); console.log("🔌 INVOICE CID:", cid.cid);
// 📤 PDF anzeigen // 📤 PDF anzeigen
res.render("invoice_preview", { res.render("invoice_preview", {
pdfUrl: dbFilePath, pdfUrl: dbFilePath,
}); });
} catch (err) { } catch (err) {
await connection.rollback(); await connection.rollback();
console.error("❌ INVOICE ERROR:", err); console.error("❌ INVOICE ERROR:", err);
res.status(500).send(err.message || "Fehler beim Erstellen der Rechnung"); res.status(500).send(err.message || "Fehler beim Erstellen der Rechnung");
} finally { } finally {
connection.release(); connection.release();
} }
} }
module.exports = { createInvoicePdf }; module.exports = { createInvoicePdf };

View File

@ -1,109 +1,109 @@
const db = require("../db"); const db = require("../db");
const ejs = require("ejs"); const ejs = require("ejs");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const pdf = require("html-pdf-node"); const pdf = require("html-pdf-node");
async function createInvoicePdf(req, res) { async function createInvoicePdf(req, res) {
const patientId = req.params.id; const patientId = req.params.id;
try { try {
// 1⃣ Patient laden // 1⃣ Patient laden
const [[patient]] = await db.promise().query( const [[patient]] = await db.promise().query(
"SELECT * FROM patients WHERE id = ?", "SELECT * FROM patients WHERE id = ?",
[patientId] [patientId]
); );
if (!patient) { if (!patient) {
return res.status(404).send("Patient nicht gefunden"); return res.status(404).send("Patient nicht gefunden");
} }
// 2⃣ Leistungen laden (noch nicht abgerechnet) // 2⃣ Leistungen laden (noch nicht abgerechnet)
const [rows] = await db.promise().query(` const [rows] = await db.promise().query(`
SELECT SELECT
ps.quantity, ps.quantity,
COALESCE(ps.price_override, s.price) AS price, COALESCE(ps.price_override, s.price) AS price,
CASE CASE
WHEN UPPER(TRIM(?)) = 'ES' WHEN UPPER(TRIM(?)) = 'ES'
THEN COALESCE(NULLIF(s.name_es, ''), s.name_de) THEN COALESCE(NULLIF(s.name_es, ''), s.name_de)
ELSE s.name_de ELSE s.name_de
END AS name END AS name
FROM patient_services ps FROM patient_services ps
JOIN services s ON ps.service_id = s.id JOIN services s ON ps.service_id = s.id
WHERE ps.patient_id = ? WHERE ps.patient_id = ?
AND ps.invoice_id IS NULL AND ps.invoice_id IS NULL
`, [patient.country, patientId]); `, [patient.country, patientId]);
if (rows.length === 0) { if (rows.length === 0) {
return res.send("Keine Leistungen vorhanden"); return res.send("Keine Leistungen vorhanden");
} }
const services = rows.map(s => ({ const services = rows.map(s => ({
quantity: Number(s.quantity), quantity: Number(s.quantity),
name: s.name, name: s.name,
price: Number(s.price), price: Number(s.price),
total: Number(s.price) * Number(s.quantity) total: Number(s.price) * Number(s.quantity)
})); }));
const total = services.reduce((sum, s) => sum + s.total, 0); const total = services.reduce((sum, s) => sum + s.total, 0);
// 3⃣ HTML aus EJS erzeugen // 3⃣ HTML aus EJS erzeugen
const html = await ejs.renderFile( const html = await ejs.renderFile(
path.join(__dirname, "../views/invoices/invoice.ejs"), path.join(__dirname, "../views/invoices/invoice.ejs"),
{ patient, services, total } { patient, services, total }
); );
// 4⃣ PDF erzeugen // 4⃣ PDF erzeugen
const pdfBuffer = await pdf.generatePdf( const pdfBuffer = await pdf.generatePdf(
{ content: html }, { content: html },
{ format: "A4" } { format: "A4" }
); );
// 5⃣ Dateiname + Pfad // 5⃣ Dateiname + Pfad
const date = new Date().toISOString().split("T")[0]; // YYYY-MM-DD const date = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
const fileName = `invoice_${patientId}_${date}.pdf`; const fileName = `invoice_${patientId}_${date}.pdf`;
const outputPath = path.join(__dirname, "..", "documents", fileName); const outputPath = path.join(__dirname, "..", "documents", fileName);
// 6⃣ PDF speichern // 6⃣ PDF speichern
fs.writeFileSync(outputPath, pdfBuffer); fs.writeFileSync(outputPath, pdfBuffer);
// 7⃣ OPTIONAL: Rechnung in DB speichern (empfohlen) // 7⃣ OPTIONAL: Rechnung in DB speichern (empfohlen)
const [invoiceResult] = await db.promise().query(` const [invoiceResult] = await db.promise().query(`
INSERT INTO invoices INSERT INTO invoices
(patient_id, invoice_date, total_amount, file_path, created_by, status) (patient_id, invoice_date, total_amount, file_path, created_by, status)
VALUES (?, CURDATE(), ?, ?, ?, 'open') VALUES (?, CURDATE(), ?, ?, ?, 'open')
`, [ `, [
patientId, patientId,
total, total,
`documents/${fileName}`, `documents/${fileName}`,
req.session.user.id req.session.user.id
]); ]);
const invoiceId = invoiceResult.insertId; const invoiceId = invoiceResult.insertId;
// 8⃣ Leistungen verknüpfen // 8⃣ Leistungen verknüpfen
await db.promise().query(` await db.promise().query(`
UPDATE patient_services UPDATE patient_services
SET invoice_id = ? SET invoice_id = ?
WHERE patient_id = ? WHERE patient_id = ?
AND invoice_id IS NULL AND invoice_id IS NULL
`, [invoiceId, patientId]); `, [invoiceId, patientId]);
// 9⃣ PDF anzeigen // 9⃣ PDF anzeigen
res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Type", "application/pdf");
res.setHeader( res.setHeader(
"Content-Disposition", "Content-Disposition",
`inline; filename="${fileName}"` `inline; filename="${fileName}"`
); );
res.send(pdfBuffer); res.send(pdfBuffer);
} catch (err) { } catch (err) {
console.error("❌ PDF ERROR:", err); console.error("❌ PDF ERROR:", err);
res.status(500).send("Fehler beim Erstellen der Rechnung"); res.status(500).send("Fehler beim Erstellen der Rechnung");
} }
} }
module.exports = { createInvoicePdf }; module.exports = { createInvoicePdf };

View File

@ -5,7 +5,7 @@ function listMedications(req, res, next) {
const { q, onlyActive } = req.query; const { q, onlyActive } = req.query;
let sql = ` let sql = `
SELECT SELECT
v.id, v.id,
m.id AS medication_id, m.id AS medication_id,
m.name AS medication, m.name AS medication,
@ -44,10 +44,7 @@ function listMedications(req, res, next) {
res.render("medications", { res.render("medications", {
title: "Medikamentenübersicht", title: "Medikamentenübersicht",
sidebarPartial: "partials/sidebar-empty", // ✅ schwarzer Balken links
// ✅ IMMER patient-sidebar verwenden
sidebarPartial: "partials/sidebar-empty",
backUrl: "/dashboard",
active: "medications", active: "medications",
rows, rows,

File diff suppressed because it is too large Load Diff

View File

@ -1,56 +1,56 @@
const db = require("../db"); const db = require("../db");
function uploadPatientFile(req, res) { function uploadPatientFile(req, res) {
const patientId = req.params.id; const patientId = req.params.id;
console.log("📁 req.file:", req.file); console.log("📁 req.file:", req.file);
console.log("📁 req.body:", req.body); console.log("📁 req.body:", req.body);
if (!req.file) { if (!req.file) {
req.session.flash = { req.session.flash = {
type: "danger", type: "danger",
message: "Keine Datei ausgewählt" message: "Keine Datei ausgewählt"
}; };
return res.redirect("/patients"); return res.redirect("/patients");
} }
db.query(` db.query(`
INSERT INTO patient_files INSERT INTO patient_files
( (
patient_id, patient_id,
original_name, original_name,
file_name, file_name,
file_path, file_path,
mime_type, mime_type,
uploaded_by uploaded_by
) )
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`, `,
[ [
patientId, patientId,
req.file.originalname, // 👈 Originaler Dateiname req.file.originalname, // 👈 Originaler Dateiname
req.file.filename, // 👈 Gespeicherter Name req.file.filename, // 👈 Gespeicherter Name
req.file.path, // 👈 Pfad req.file.path, // 👈 Pfad
req.file.mimetype, // 👈 MIME-Type req.file.mimetype, // 👈 MIME-Type
req.session.user.id req.session.user.id
], ],
err => { err => {
if (err) { if (err) {
console.error(err); console.error(err);
req.session.flash = { req.session.flash = {
type: "danger", type: "danger",
message: "Datei konnte nicht gespeichert werden" message: "Datei konnte nicht gespeichert werden"
}; };
return res.redirect("/patients"); return res.redirect("/patients");
} }
req.session.flash = { req.session.flash = {
type: "success", type: "success",
message: "📎 Datei erfolgreich hochgeladen" message: "📎 Datei erfolgreich hochgeladen"
}; };
res.redirect("/patients"); res.redirect("/patients");
} }
); );
} }
module.exports = { uploadPatientFile }; module.exports = { uploadPatientFile };

View File

@ -1,109 +1,109 @@
const db = require("../db"); const db = require("../db");
function addMedication(req, res) { function addMedication(req, res) {
const patientId = req.params.id; const patientId = req.params.id;
const returnTo = req.query.returnTo; const returnTo = req.query.returnTo;
const { const {
medication_variant_id, medication_variant_id,
dosage_instruction, dosage_instruction,
start_date, start_date,
end_date end_date
} = req.body; } = req.body;
if (!medication_variant_id) { if (!medication_variant_id) {
return res.send("Medikament fehlt"); return res.send("Medikament fehlt");
} }
db.query( db.query(
` `
INSERT INTO patient_medications INSERT INTO patient_medications
(patient_id, medication_variant_id, dosage_instruction, start_date, end_date) (patient_id, medication_variant_id, dosage_instruction, start_date, end_date)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`, `,
[ [
patientId, patientId,
medication_variant_id, medication_variant_id,
dosage_instruction || null, dosage_instruction || null,
start_date || null, start_date || null,
end_date || null end_date || null
], ],
err => { err => {
if (err) return res.send("Fehler beim Speichern der Medikation"); if (err) return res.send("Fehler beim Speichern der Medikation");
if (returnTo === "overview") { if (returnTo === "overview") {
return res.redirect(`/patients/${patientId}/overview`); return res.redirect(`/patients/${patientId}/overview`);
} }
res.redirect(`/patients/${patientId}/medications`); res.redirect(`/patients/${patientId}/medications`);
} }
); );
} }
function endMedication(req, res) { function endMedication(req, res) {
const medicationId = req.params.id; const medicationId = req.params.id;
const returnTo = req.query.returnTo; const returnTo = req.query.returnTo;
db.query( db.query(
"SELECT patient_id FROM patient_medications WHERE id = ?", "SELECT patient_id FROM patient_medications WHERE id = ?",
[medicationId], [medicationId],
(err, results) => { (err, results) => {
if (err || results.length === 0) { if (err || results.length === 0) {
return res.send("Medikation nicht gefunden"); return res.send("Medikation nicht gefunden");
} }
const patientId = results[0].patient_id; const patientId = results[0].patient_id;
db.query( db.query(
"UPDATE patient_medications SET end_date = CURDATE() WHERE id = ?", "UPDATE patient_medications SET end_date = CURDATE() WHERE id = ?",
[medicationId], [medicationId],
err => { err => {
if (err) return res.send("Fehler beim Beenden der Medikation"); if (err) return res.send("Fehler beim Beenden der Medikation");
if (returnTo === "overview") { if (returnTo === "overview") {
return res.redirect(`/patients/${patientId}/overview`); return res.redirect(`/patients/${patientId}/overview`);
} }
res.redirect(`/patients/${patientId}/medications`); res.redirect(`/patients/${patientId}/medications`);
} }
); );
} }
); );
} }
function deleteMedication(req, res) { function deleteMedication(req, res) {
const medicationId = req.params.id; const medicationId = req.params.id;
const returnTo = req.query.returnTo; const returnTo = req.query.returnTo;
db.query( db.query(
"SELECT patient_id FROM patient_medications WHERE id = ?", "SELECT patient_id FROM patient_medications WHERE id = ?",
[medicationId], [medicationId],
(err, results) => { (err, results) => {
if (err || results.length === 0) { if (err || results.length === 0) {
return res.send("Medikation nicht gefunden"); return res.send("Medikation nicht gefunden");
} }
const patientId = results[0].patient_id; const patientId = results[0].patient_id;
db.query( db.query(
"DELETE FROM patient_medications WHERE id = ?", "DELETE FROM patient_medications WHERE id = ?",
[medicationId], [medicationId],
err => { err => {
if (err) return res.send("Fehler beim Löschen der Medikation"); if (err) return res.send("Fehler beim Löschen der Medikation");
if (returnTo === "overview") { if (returnTo === "overview") {
return res.redirect(`/patients/${patientId}/overview`); return res.redirect(`/patients/${patientId}/overview`);
} }
res.redirect(`/patients/${patientId}/medications`); res.redirect(`/patients/${patientId}/medications`);
} }
); );
} }
); );
} }
module.exports = { module.exports = {
addMedication, addMedication,
endMedication, endMedication,
deleteMedication deleteMedication
}; };

View File

@ -1,102 +1,102 @@
const db = require("../db"); const db = require("../db");
function addPatientService(req, res) { function addPatientService(req, res) {
const patientId = req.params.id; const patientId = req.params.id;
const { service_id, quantity } = req.body; const { service_id, quantity } = req.body;
if (!service_id) { if (!service_id) {
req.session.flash = { req.session.flash = {
type: "warning", type: "warning",
message: "Bitte eine Leistung auswählen" message: "Bitte eine Leistung auswählen"
}; };
return res.redirect(`/patients/${patientId}/overview`); return res.redirect(`/patients/${patientId}/overview`);
} }
db.query( db.query(
"SELECT price FROM services WHERE id = ?", "SELECT price FROM services WHERE id = ?",
[service_id], [service_id],
(err, results) => { (err, results) => {
if (err || results.length === 0) { if (err || results.length === 0) {
req.session.flash = { req.session.flash = {
type: "danger", type: "danger",
message: "Leistung nicht gefunden" message: "Leistung nicht gefunden"
}; };
return res.redirect(`/patients/${patientId}/overview`); return res.redirect(`/patients/${patientId}/overview`);
} }
const price = results[0].price; const price = results[0].price;
db.query( db.query(
`INSERT INTO patient_services `INSERT INTO patient_services
(patient_id, service_id, quantity, price, service_date, created_by) (patient_id, service_id, quantity, price, service_date, created_by)
VALUES (?, ?, ?, ?, CURDATE(), ?) `, VALUES (?, ?, ?, ?, CURDATE(), ?) `,
[ [
patientId, patientId,
service_id, service_id,
quantity || 1, quantity || 1,
price, price,
req.session.user.id // behandelnder Arzt req.session.user.id // behandelnder Arzt
], ],
err => { err => {
if (err) { if (err) {
req.session.flash = { req.session.flash = {
type: "danger", type: "danger",
message: "Fehler beim Speichern der Leistung" message: "Fehler beim Speichern der Leistung"
}; };
return res.redirect(`/patients/${patientId}/overview`); return res.redirect(`/patients/${patientId}/overview`);
} }
req.session.flash = { req.session.flash = {
type: "success", type: "success",
message: "Leistung hinzugefügt" message: "Leistung hinzugefügt"
}; };
res.redirect(`/patients/${patientId}/overview`); res.redirect(`/patients/${patientId}/overview`);
} }
); );
} }
); );
} }
function deletePatientService(req, res) { function deletePatientService(req, res) {
const id = req.params.id; const id = req.params.id;
db.query( db.query(
"DELETE FROM patient_services WHERE id = ?", "DELETE FROM patient_services WHERE id = ?",
[id], [id],
() => res.redirect("/services/open") () => res.redirect("/services/open")
); );
} }
function updatePatientServicePrice(req, res) { function updatePatientServicePrice(req, res) {
const id = req.params.id; const id = req.params.id;
const { price } = req.body; const { price } = req.body;
db.query( db.query(
"UPDATE patient_services SET price_override = ? WHERE id = ?", "UPDATE patient_services SET price_override = ? WHERE id = ?",
[price, id], [price, id],
() => res.redirect("/services/open") () => res.redirect("/services/open")
); );
} }
function updatePatientServiceQuantity(req, res) { function updatePatientServiceQuantity(req, res) {
const id = req.params.id; const id = req.params.id;
const { quantity } = req.body; const { quantity } = req.body;
if (!quantity || quantity < 1) { if (!quantity || quantity < 1) {
return res.redirect("/services/open"); return res.redirect("/services/open");
} }
db.query( db.query(
"UPDATE patient_services SET quantity = ? WHERE id = ?", "UPDATE patient_services SET quantity = ? WHERE id = ?",
[quantity, id], [quantity, id],
() => res.redirect("/services/open") () => res.redirect("/services/open")
); );
} }
module.exports = { module.exports = {
addPatientService, addPatientService,
deletePatientService, deletePatientService,
updatePatientServicePrice, updatePatientServicePrice,
updatePatientServiceQuantity updatePatientServiceQuantity
}; };

View File

@ -1,59 +0,0 @@
const db = require("../db");
exports.statusReport = async (req, res) => {
try {
// Filter aus URL
const year = parseInt(req.query.year) || new Date().getFullYear();
const quarter = parseInt(req.query.quarter) || 0; // 0 = alle
// WHERE-Teil dynamisch bauen
let where = "WHERE 1=1";
const params = [];
if (year) {
where += " AND YEAR(invoice_date) = ?";
params.push(year);
}
if (quarter) {
where += " AND QUARTER(invoice_date) = ?";
params.push(quarter);
}
// Report-Daten
const [stats] = await db.promise().query(`
SELECT
CONCAT(type, '_', status) AS status,
SUM(total_amount) AS total
FROM invoices
GROUP BY type, status
`);
// Verfügbare Jahre
const [years] = await db.promise().query(`
SELECT DISTINCT YEAR(invoice_date) AS year
FROM invoices
ORDER BY year DESC
`);
res.render("reportview", {
title: "Abrechnungsreport",
user: req.session.user,
stats,
years: years.map((y) => y.year),
selectedYear: year,
selectedQuarter: quarter,
sidebarPartial: "partials/sidebar-invoices",
active: "reports",
});
} catch (err) {
console.error("❌ Report:", err);
res.status(500).send("Fehler beim Report");
}
};

View File

@ -37,7 +37,6 @@ function listServices(req, res) {
res.render("services", { res.render("services", {
title: "Leistungen", title: "Leistungen",
sidebarPartial: "partials/sidebar-empty", sidebarPartial: "partials/sidebar-empty",
backUrl: "/dashboard",
active: "services", active: "services",
services, services,
@ -106,7 +105,6 @@ function listServicesAdmin(req, res) {
res.render("services", { res.render("services", {
title: "Leistungen (Admin)", title: "Leistungen (Admin)",
sidebarPartial: "partials/admin-sidebar", sidebarPartial: "partials/admin-sidebar",
backUrl: "/dashboard",
active: "services", active: "services",
services, services,
@ -121,7 +119,6 @@ function showCreateService(req, res) {
res.render("service_create", { res.render("service_create", {
title: "Leistung anlegen", title: "Leistung anlegen",
sidebarPartial: "partials/sidebar-empty", sidebarPartial: "partials/sidebar-empty",
backUrl: "/dashboard",
active: "services", active: "services",
user: req.session.user, user: req.session.user,
@ -138,7 +135,6 @@ function createService(req, res) {
return res.render("service_create", { return res.render("service_create", {
title: "Leistung anlegen", title: "Leistung anlegen",
sidebarPartial: "partials/sidebar-empty", sidebarPartial: "partials/sidebar-empty",
backUrl: "/dashboard",
active: "services", active: "services",
user: req.session.user, user: req.session.user,
@ -291,8 +287,7 @@ async function listOpenServices(req, res, next) {
res.render("open_services", { res.render("open_services", {
title: "Offene Leistungen", title: "Offene Leistungen",
sidebarPartial: "partials/sidebar-invoices", sidebarPartial: "partials/sidebar-empty",
backUrl: "/dashboard",
active: "services", active: "services",
rows, rows,

126
db.js
View File

@ -1,63 +1,63 @@
const mysql = require("mysql2"); const mysql = require("mysql2");
const { loadConfig } = require("./config-manager"); const { loadConfig } = require("./config-manager");
let pool = null; let pool = null;
function initPool() { function initPool() {
const config = loadConfig(); const config = loadConfig();
// ✅ Setup-Modus: noch keine config.enc → kein Pool // ✅ Setup-Modus: noch keine config.enc → kein Pool
if (!config || !config.db) return null; if (!config || !config.db) return null;
return mysql.createPool({ return mysql.createPool({
host: config.db.host, host: config.db.host,
port: config.db.port || 3306, port: config.db.port || 3306,
user: config.db.user, user: config.db.user,
password: config.db.password, password: config.db.password,
database: config.db.name, database: config.db.name,
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,
queueLimit: 0, queueLimit: 0,
}); });
} }
function getPool() { function getPool() {
if (!pool) pool = initPool(); if (!pool) pool = initPool();
return pool; return pool;
} }
function resetPool() { function resetPool() {
pool = null; pool = null;
} }
/** /**
* Proxy damit alter Code weitergeht: * Proxy damit alter Code weitergeht:
* const db = require("../db"); * const db = require("../db");
* await db.query(...) * await db.query(...)
*/ */
const dbProxy = new Proxy( const dbProxy = new Proxy(
{}, {},
{ {
get(target, prop) { get(target, prop) {
const p = getPool(); const p = getPool();
if (!p) { if (!p) {
throw new Error( throw new Error(
"❌ DB ist noch nicht konfiguriert (config.enc fehlt). Bitte zuerst Setup ausführen: http://127.0.0.1:51777/setup", "❌ DB ist noch nicht konfiguriert (config.enc fehlt). Bitte zuerst Setup ausführen: http://127.0.0.1:51777/setup",
); );
} }
const value = p[prop]; const value = p[prop];
if (typeof value === "function") { if (typeof value === "function") {
return value.bind(p); return value.bind(p);
} }
return value; return value;
}, },
}, },
); );
module.exports = dbProxy; module.exports = dbProxy;
module.exports.getPool = getPool; module.exports.getPool = getPool;
module.exports.resetPool = resetPool; module.exports.resetPool = resetPool;

View File

@ -1,65 +0,0 @@
/**
* calendar_migrate.js
* Führe einmalig aus: node db/calendar_migrate.js
*
* Erstellt die appointments-Tabelle für den Kalender.
* Ärzte werden aus der bestehenden `users`-Tabelle (role = 'arzt') gezogen.
*/
// ✅ MUSS als erstes stehen lädt CONFIG_KEY bevor config-manager greift
require("dotenv").config();
const db = require("../db");
async function migrate() {
const conn = db.promise();
console.log("→ Erstelle Kalender-Tabellen …");
// ── Termine ──────────────────────────────────────────────────────────────
await conn.query(`
CREATE TABLE IF NOT EXISTS appointments (
id INT AUTO_INCREMENT PRIMARY KEY,
doctor_id INT NOT NULL COMMENT 'Referenz auf users.id (role=arzt)',
date DATE NOT NULL,
time TIME NOT NULL,
duration INT NOT NULL DEFAULT 15 COMMENT 'Minuten',
patient_name VARCHAR(150) NOT NULL,
notes TEXT DEFAULT NULL,
status ENUM('scheduled','completed','cancelled') DEFAULT 'scheduled',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_date (date),
INDEX idx_doctor (doctor_id),
INDEX idx_date_doc (date, doctor_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
console.log("✓ Tabelle `appointments` bereit");
// ── Farben für Ärzte ─────────────────────────────────────────────────────
// Falls die users-Tabelle noch keine doctor_color-Spalte hat, fügen wir sie hinzu.
// Fehler = Spalte existiert schon → ignorieren.
try {
await conn.query(`
ALTER TABLE users
ADD COLUMN doctor_color VARCHAR(20) DEFAULT '#3B82F6'
AFTER role;
`);
console.log("✓ Spalte `users.doctor_color` hinzugefügt");
} catch (e) {
if (e.code === "ER_DUP_FIELDNAME") {
console.log(" Spalte `users.doctor_color` existiert bereits übersprungen");
} else {
throw e;
}
}
console.log("\n✅ Kalender-Migration abgeschlossen.\n");
process.exit(0);
}
migrate().catch((err) => {
console.error("❌ Migration fehlgeschlagen:", err.message);
process.exit(1);
});

View File

@ -1,208 +1,208 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<style> <style>
body { body {
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
font-size: 12px; font-size: 12px;
color: #000; color: #000;
} }
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
} }
.logo { .logo {
width: 160px; width: 160px;
} }
h1 { h1 {
text-align: center; text-align: center;
margin: 30px 0 20px; margin: 30px 0 20px;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-top: 10px; margin-top: 10px;
page-break-inside: auto; page-break-inside: auto;
} }
th, td { th, td {
border: 1px solid #333; border: 1px solid #333;
padding: 6px; padding: 6px;
} }
th { th {
background: #f0f0f0; background: #f0f0f0;
} }
.no-border td { .no-border td {
border: none; border: none;
padding: 4px 2px; padding: 4px 2px;
} }
.total { .total {
margin-top: 15px; margin-top: 15px;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
text-align: right; text-align: right;
} }
.footer { .footer {
margin-top: 30px; margin-top: 30px;
font-size: 10px; font-size: 10px;
} }
tr { tr {
page-break-inside: avoid; page-break-inside: avoid;
page-break-after: auto; page-break-after: auto;
} }
.page-break { .page-break {
page-break-before: always; page-break-before: always;
} }
</style> </style>
</head> </head>
<body> <body>
<!-- HEADER --> <!-- HEADER -->
<div class="header"> <div class="header">
<!-- LOGO --> <!-- LOGO -->
<div> <div>
<!-- HIER LOGO EINBINDEN --> <!-- HIER LOGO EINBINDEN -->
<!-- <img src="file:///ABSOLUTER/PFAD/logo.png" class="logo"> --> <!-- <img src="file:///ABSOLUTER/PFAD/logo.png" class="logo"> -->
</div> </div>
<!-- ADRESSE --> <!-- ADRESSE -->
<div> <div>
<strong>MedCenter Tenerife S.L.</strong><br> <strong>MedCenter Tenerife S.L.</strong><br>
C.I.F. B76766302<br><br> C.I.F. B76766302<br><br>
Praxis El Médano<br> Praxis El Médano<br>
Calle Teobaldo Power 5<br> Calle Teobaldo Power 5<br>
38612 El Médano<br> 38612 El Médano<br>
Fon: 922 157 527 / 657 497 996<br><br> Fon: 922 157 527 / 657 497 996<br><br>
Praxis Los Cristianos<br> Praxis Los Cristianos<br>
Avenida de Suecia 10<br> Avenida de Suecia 10<br>
38650 Los Cristianos<br> 38650 Los Cristianos<br>
Fon: 922 157 527 / 654 520 717 Fon: 922 157 527 / 654 520 717
</div> </div>
</div> </div>
<h1>RECHNUNG / FACTURA</h1> <h1>RECHNUNG / FACTURA</h1>
<!-- RECHNUNGSDATEN --> <!-- RECHNUNGSDATEN -->
<table class="no-border"> <table class="no-border">
<tr> <tr>
<td><strong>Factura número</strong></td> <td><strong>Factura número</strong></td>
<td></td> <td></td>
<td><strong>Fecha</strong></td> <td><strong>Fecha</strong></td>
<td>7.1.2026</td> <td>7.1.2026</td>
</tr> </tr>
<tr> <tr>
<td><strong>Rechnungsnummer</strong></td> <td><strong>Rechnungsnummer</strong></td>
<td></td> <td></td>
<td><strong>Datum</strong></td> <td><strong>Datum</strong></td>
<td>7.1.2026</td> <td>7.1.2026</td>
</tr> </tr>
<tr> <tr>
<td><strong>N.I.E. / DNI</strong></td> <td><strong>N.I.E. / DNI</strong></td>
<td></td> <td></td>
<td><strong>Geburtsdatum</strong></td> <td><strong>Geburtsdatum</strong></td>
<td> <td>
9.11.1968 9.11.1968
</td> </td>
</tr> </tr>
</table> </table>
<br> <br>
<!-- PATIENT --> <!-- PATIENT -->
<strong>Patient:</strong><br> <strong>Patient:</strong><br>
Cay Joksch<br> Cay Joksch<br>
Calle la Fuente 24<br> Calle la Fuente 24<br>
38628 San Miguel de Abina 38628 San Miguel de Abina
<br><br> <br><br>
<!-- DIAGNOSE --> <!-- DIAGNOSE -->
<strong>Diagnosis / Diagnose:</strong><br> <strong>Diagnosis / Diagnose:</strong><br>
<br><br> <br><br>
<!-- LEISTUNGEN --> <!-- LEISTUNGEN -->
<p>Für unsere Leistungen erlauben wir uns Ihnen folgendes in Rechnung zu stellen:</p> <p>Für unsere Leistungen erlauben wir uns Ihnen folgendes in Rechnung zu stellen:</p>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Menge</th> <th>Menge</th>
<th>Terapia / Behandlung</th> <th>Terapia / Behandlung</th>
<th>Preis (€)</th> <th>Preis (€)</th>
<th>Summe (€)</th> <th>Summe (€)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>1</td> <td>1</td>
<td>1 x Ampulle Benerva 100 mg (Vitamin B1)</td> <td>1 x Ampulle Benerva 100 mg (Vitamin B1)</td>
<td>3.00</td> <td>3.00</td>
<td>3.00</td> <td>3.00</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="total"> <div class="total">
T O T A L: 3.00 € T O T A L: 3.00 €
</div> </div>
<br> <br>
<div class="page-break"></div> <div class="page-break"></div>
<!-- ARZT --> <!-- ARZT -->
<strong>Behandelnder Arzt / Médico tratante:</strong><br> <strong>Behandelnder Arzt / Médico tratante:</strong><br>
Cay Joksch<br> Cay Joksch<br>
<strong>Fachrichtung / Especialidad:</strong> <strong>Fachrichtung / Especialidad:</strong>
Homoopath<br> Homoopath<br>
<strong>Arztnummer / Nº colegiado:</strong> <strong>Arztnummer / Nº colegiado:</strong>
6514.651.651.<br> 6514.651.651.<br>
<br> <br>
<!-- ZAHLUNGSART --> <!-- ZAHLUNGSART -->
<strong>Forma de pago / Zahlungsform:</strong><br> <strong>Forma de pago / Zahlungsform:</strong><br>
Efectivo □ &nbsp;&nbsp; Tarjeta □<br> Efectivo □ &nbsp;&nbsp; Tarjeta □<br>
Barzahlung &nbsp;&nbsp; EC/Kreditkarte Barzahlung &nbsp;&nbsp; EC/Kreditkarte
<br><br> <br><br>
<!-- BANK --> <!-- BANK -->
<strong>Santander</strong><br> <strong>Santander</strong><br>
IBAN: ES37 0049 4507 8925 1002 3301<br> IBAN: ES37 0049 4507 8925 1002 3301<br>
BIC: BSCHESMMXXX BIC: BSCHESMMXXX
<div class="footer"> <div class="footer">
Privatärztliche Rechnung gemäß spanischem und deutschem Recht Privatärztliche Rechnung gemäß spanischem und deutschem Recht
</div> </div>
</body> </body>
</html> </html>

View File

@ -4,405 +4,23 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"search": "Suchen", "search": "Suchen",
"reset": "Reset", "reset": "Reset",
"reset2": "Zurücksetzen", "dashboard": "Dashboard"
"dashboard": "Dashboard",
"logout": "Logout",
"title": "Titel",
"firstname": "Vorname",
"lastname": "Nachname",
"username": "Username",
"role": "Rolle",
"action": "Aktionen",
"status": "Status",
"you": "Du Selbst",
"newuser": "Neuer Benutzer",
"inactive": "Inaktiv",
"active": "Aktiv",
"closed": "Gesperrt",
"filter": "Filtern",
"yearcash": "Jahresumsatz",
"monthcash": "Monatsumsatz",
"quartalcash": "Quartalsumsatz",
"year": "Jahr",
"nodata": "Keine Daten",
"month": "Monat",
"patientcash": "Umsatz pro Patient",
"patient": "Patient",
"systeminfo": "Systeminformationen",
"table": "Tabelle",
"lines": "Zeilen",
"size": "Größe",
"errordatabase": "Fehler beim Auslesen der Datenbankinfos:",
"welcome": "Willkommen",
"waitingroomtext": "Wartezimmer-Monitor",
"waitingroomtextnopatient": "Keine Patienten im Wartezimmer.",
"gender": "Geschlecht",
"birthday": "Geburtstag",
"birthdate": "Geburtsdatum",
"email": "E-Mail",
"phone": "Telefon",
"address": "Adresse",
"country": "Land",
"notice": "Notizen",
"notes": "Notizen",
"create": "Erstellt",
"change": "Geändert",
"edit": "Bearbeiten",
"selection": "Auswahl",
"waiting": "Wartet bereits",
"towaitingroom": "Ins Wartezimmer",
"overview": "Übersicht",
"upload": "Hochladen",
"fileupload": "Hochladen",
"lock": "Sperren",
"unlock": "Entsperren",
"name": "Name",
"return": "Zurück",
"back": "Zurück",
"date": "Datum",
"amount": "Betrag",
"quantity": "Menge",
"price": "Preis (€)",
"sum": "Summe (€)",
"pdf": "PDF",
"open": "Öffnen",
"from": "Von",
"to": "Bis",
"street": "Straße",
"housenumber": "Hausnummer",
"zip": "PLZ",
"city": "Ort",
"dni": "N.I.E. / DNI",
"dosage": "Dosierung",
"form": "Darreichungsform",
"package": "Packung",
"specialty": "Fachrichtung",
"doctornumber": "Arztnummer",
"category": "Kategorie"
}, },
"sidebar": { "sidebar": {
"patients": "Patienten", "patients": "Patienten",
"medications": "Medikamente", "medications": "Medikamente",
"servicesOpen": "Patienten Rechnungen", "servicesOpen": "Offene Leistungen",
"billing": "Abrechnung", "billing": "Abrechnung",
"admin": "Verwaltung", "admin": "Verwaltung",
"logout": "Logout" "logout": "Logout"
}, },
"dashboard": { "dashboard": {
"welcome": "Willkommen", "welcome": "Willkommen",
"waitingRoom": "Wartezimmer-Monitor", "waitingRoom": "Wartezimmer-Monitor",
"noWaitingPatients": "Keine Patienten im Wartezimmer.", "noWaitingPatients": "Keine Patienten im Wartezimmer."
"title": "Dashboard"
}, },
"adminSidebar": { "adminSidebar": {
"users": "Userverwaltung", "users": "Userverwaltung",
"database": "Datenbankverwaltung", "database": "Datenbankverwaltung"
"user": "Benutzer",
"invocieoverview": "Rechnungsübersicht",
"seriennumber": "Seriennummer",
"databasetable": "Datenbank",
"companysettings": "Firmendaten"
},
"adminuseroverview": {
"useroverview": "Benutzerübersicht",
"usermanagement": "Benutzer Verwaltung",
"user": "Benutzer",
"invocieoverview": "Rechnungsübersicht",
"seriennumber": "Seriennummer",
"databasetable": "Datenbank"
},
"adminCreateUser": {
"title": "Benutzer anlegen",
"firstname": "Vorname",
"lastname": "Nachname",
"usertitle": "Titel (z.B. Dr., Prof.)",
"username": "Benutzername (Login)",
"password": "Passwort",
"specialty": "Fachrichtung",
"doctornumber": "Arztnummer",
"createuser": "Benutzer erstellen",
"back": "Zurück"
},
"adminServiceLogs": {
"title": "Service-Änderungsprotokoll",
"date": "Datum",
"user": "User",
"action": "Aktion",
"before": "Vorher",
"after": "Nachher"
},
"companySettings": {
"title": "Firmendaten",
"companyname": "Firmenname",
"legalform": "Rechtsform",
"owner": "Inhaber / Geschäftsführer",
"email": "E-Mail",
"street": "Straße",
"housenumber": "Hausnummer",
"zip": "PLZ",
"city": "Ort",
"country": "Land",
"taxid": "USt-ID / Steuernummer",
"bank": "Bank",
"iban": "IBAN",
"bic": "BIC",
"invoicefooter": "Rechnungs-Footer",
"companylogo": "Firmenlogo",
"currentlogo": "Aktuelles Logo:",
"back": "Zurück"
},
"databaseoverview": {
"title": "Datenbank Konfiguration",
"text": "Hier kannst du die DB-Verbindung testen und speichern.",
"host": "Host",
"port": "Port",
"database": "Datenbank",
"password": "Passwort",
"connectiontest": "Verbindung testen",
"tablecount": "Anzahl Tabellen",
"databasesize": "Datenbankgröße",
"tableoverview": "Tabellenübersicht",
"mysqlversion": "MySQL Version",
"nodbinfo": "Keine Systeminfos verfügbar (DB ist evtl. nicht konfiguriert oder Verbindung fehlgeschlagen)"
},
"invoiceAdmin": {
"fromyear": "Von Jahr",
"toyear": "Bis Jahr",
"searchpatient": "Patient suchen..."
},
"cancelledInvoices": {
"title": "Stornierte Rechnungen",
"year": "Jahr:",
"noinvoices": "Keine stornierten Rechnungen für dieses Jahr.",
"patient": "Patient",
"date": "Datum",
"amount": "Betrag"
},
"creditOverview": {
"title": "Gutschrift Übersicht",
"year": "Jahr:",
"invoice": "Rechnung",
"date": "Datum",
"pdf": "PDF",
"creditnote": "Gutschrift",
"patient": "Patient",
"amount": "Betrag",
"open": "Öffnen"
},
"invoice": {
"title": "RECHNUNG / FACTURA",
"invoicenumber": "Rechnungsnummer:",
"nie": "N.I.E / DNI:",
"birthdate": "Geburtsdatum:",
"patient": "Patient:",
"servicetext": "Für unsere Leistungen erlauben wir uns Ihnen folgendes in Rechnung zu stellen:",
"quantity": "Menge",
"treatment": "Behandlung",
"price": "Preis (€)",
"sum": "Summe (€)",
"doctor": "Behandelnder Arzt:",
"specialty": "Fachrichtung:",
"doctornumber": "Arztnummer:",
"legal": "Privatärztliche Rechnung"
},
"openInvoices": {
"title": "Offene Leistungen",
"noinvoices": "Keine offenen Rechnungen 🎉",
"patient": "Patient",
"date": "Datum",
"amount": "Betrag",
"status": "Status",
"open": "Offen"
},
"paidInvoices": {
"title": "Bezahlte Rechnungen",
"year": "Jahr",
"quarter": "Quartal",
"patient": "Patient",
"date": "Datum",
"amount": "Betrag"
},
"openinvoices": {
"openinvoices": "Offene Rechnungen",
"canceledinvoices": "Stornierte Rechnungen",
"report": "Umsatzreport",
"payedinvoices": "Bezahlte Rechnungen",
"creditoverview": "Gutschrift Übersicht"
},
"medications": {
"title": "Medikamentenübersicht",
"newmedication": "Neues Medikament",
"searchplaceholder": "Suche nach Medikament, Form, Dosierung",
"search": "Suchen",
"reset": "Reset",
"medication": "Medikament",
"form": "Darreichungsform",
"dosage": "Dosierung",
"package": "Packung",
"status": "Status",
"actions": "Aktionen"
},
"medicationCreate": {
"title": "Neues Medikament",
"medication": "Medikament",
"form": "Darreichungsform",
"dosage": "Dosierung",
"package": "Packung",
"save": "Speichern",
"cancel": "Abbrechen"
},
"openServices": {
"title": "Offene Leistungen",
"noopenservices": "Keine offenen Leistungen vorhanden"
},
"patienteoverview": {
"patienttitle": "Patientenübersicht",
"newpatient": "Neuer Patient",
"nopatientfound": "Keine Patienten gefunden",
"closepatient": "Patient sperren (inaktiv)",
"openpatient": "Patient entsperren (Aktiv)",
"active": "Aktiv",
"inactive": "Inaktiv",
"dni": "DNI"
},
"patientCreate": {
"title": "Neuer Patient",
"firstname": "Vorname",
"lastname": "Nachname",
"dni": "N.I.E. / DNI",
"email": "E-Mail",
"phone": "Telefon",
"street": "Straße",
"housenumber": "Hausnummer",
"zip": "PLZ",
"city": "Ort",
"country": "Land",
"notes": "Notizen"
},
"patientEdit": {
"firstname": "Vorname",
"lastname": "Nachname",
"email": "E-Mail",
"phone": "Telefon",
"street": "Straße",
"housenumber": "Hausnummer",
"zip": "PLZ",
"city": "Ort",
"country": "Land",
"notes": "Notizen",
"save": "Änderungen speichern"
},
"patientMedications": {
"selectmedication": "Medikament auswählen",
"dosageinstructions": "Dosierungsanweisung",
"example": "z.B. 1-0-1",
"startdate": "Startdatum",
"enddate": "Enddatum",
"save": "Speichern",
"backoverview": "Zur Übersicht",
"nomedication": "Keine Medikation vorhanden.",
"medication": "Medikament",
"form": "Form",
"dosage": "Dosierung",
"instruction": "Anweisung",
"from": "Von",
"to": "Bis"
},
"patientOverview": {
"patientdata": "Patientendaten",
"firstname": "Vorname",
"lastname": "Nachname",
"birthdate": "Geburtsdatum",
"email": "E-Mail",
"phone": "Telefon",
"notes": "Notizen",
"newnote": "Neue Notiz hinzufügen…",
"nonotes": "Keine Notizen vorhanden",
"createrecipe": "Rezept erstellen",
"searchservice": "Leistung suchen…",
"noservices": "Noch keine Leistungen für heute.",
"addservice": "Leistung hinzufügen"
},
"patientDashboard": {
"email": "E-Mail:",
"phone": "Telefon:",
"address": "Adresse:",
"medications": "Aktuelle Medikamente",
"nomedications": "Keine aktiven Medikamente",
"medication": "Medikament",
"variant": "Variante",
"instruction": "Anweisung",
"invoices": "Rechnungen",
"noinvoices": "Keine Rechnungen vorhanden",
"date": "Datum",
"amount": "Betrag",
"pdf": "PDF",
"open": "Öffnen"
},
"services": {
"title": "Leistungen",
"newservice": "Neue Leistung",
"searchplaceholder": "Suche nach Name oder Kategorie",
"namede": "Bezeichnung (DE)",
"namees": "Bezeichnung (ES)",
"price": "Preis",
"pricec70": "Preis C70",
"status": "Status",
"actions": "Aktionen",
"editunlock": "Bearbeiten freigeben"
},
"serviceCreate": {
"title": "Neue Leistung",
"back": "Zurück",
"newservice": "Neue Leistung anlegen",
"namede": "Bezeichnung (Deutsch) *",
"namees": "Bezeichnung (Spanisch)",
"category": "Kategorie",
"price": "Preis (€) *",
"pricec70": "Preis C70 (€)"
},
"reportview": {
"title": "Abrechnungsreport",
"year": "Jahr",
"quarter": "Quartal"
},
"seriennumber": {
"seriennumbertitle": "Seriennummer eingeben",
"seriennumbertext": "Bitte gib deine Lizenz-Seriennummer ein um die Software dauerhaft freizuschalten.",
"seriennumbershort": "Seriennummer (AAAAA-AAAAA-AAAAA-AAAAA)",
"seriennumberdeclaration": "Nur Buchstaben + Zahlen. Format: 4x5 Zeichen, getrennt mit Bindestrich.",
"saveseriennumber": "Seriennummer Speichern"
},
"patientoverview": {
"nopatientfound": "Keine Patienten gefunden"
} }
} }

View File

@ -4,405 +4,24 @@
"cancel": "Cancelar", "cancel": "Cancelar",
"search": "Buscar", "search": "Buscar",
"reset": "Resetear", "reset": "Resetear",
"reset2": "Restablecer", "dashboard": "Panel"
"dashboard": "Panel",
"logout": "Cerrar sesión",
"title": "Título",
"firstname": "Nombre",
"lastname": "Apellido",
"username": "Nombre de usuario",
"role": "Rol",
"action": "Acciones",
"status": "Estado",
"you": "Usted mismo",
"newuser": "Nuevo usuario",
"inactive": "Inactivo",
"active": "Activo",
"closed": "Bloqueado",
"filter": "Filtro",
"yearcash": "Facturación anual",
"monthcash": "Facturación mensual",
"quartalcash": "Facturación trimestral",
"year": "Año",
"nodata": "Sin datos",
"month": "Mes",
"patientcash": "Ingresos por paciente",
"patient": "Paciente",
"systeminfo": "Información del sistema",
"table": "Tabla",
"lines": "Líneas",
"size": "Tamaño",
"errordatabase": "Error al leer la información de la base de datos:",
"welcome": "Bienvenido",
"waitingroomtext": "Monitor de sala de espera",
"waitingroomtextnopatient": "No hay pacientes en la sala de espera.",
"gender": "Sexo",
"birthday": "Fecha de nacimiento",
"birthdate": "Fecha de nacimiento",
"email": "Correo electrónico",
"phone": "Teléfono",
"address": "Dirección",
"country": "País",
"notice": "Notas",
"notes": "Notas",
"create": "Creado",
"change": "Modificado",
"edit": "Editar",
"selection": "Selección",
"waiting": "Ya está esperando",
"towaitingroom": "A la sala de espera",
"overview": "Resumen",
"upload": "Subir archivo",
"fileupload": "Cargar",
"lock": "Bloquear",
"unlock": "Desbloquear",
"name": "Nombre",
"return": "Atrás",
"back": "Atrás",
"date": "Fecha",
"amount": "Importe",
"quantity": "Cantidad",
"price": "Precio (€)",
"sum": "Total (€)",
"pdf": "PDF",
"open": "Abrir",
"from": "Desde",
"to": "Hasta",
"street": "Calle",
"housenumber": "Número",
"zip": "Código postal",
"city": "Ciudad",
"dni": "N.I.E. / DNI",
"dosage": "Dosificación",
"form": "Forma farmacéutica",
"package": "Envase",
"specialty": "Especialidad",
"doctornumber": "Número de médico",
"category": "Categoría"
}, },
"sidebar": { "sidebar": {
"patients": "Pacientes", "patients": "Pacientes",
"medications": "Medicamentos", "medications": "Medicamentos",
"servicesOpen": "Facturas de pacientes", "servicesOpen": "Servicios abiertos",
"billing": "Facturación", "billing": "Facturación",
"admin": "Administración", "admin": "Administración",
"logout": "Cerrar sesión" "logout": "Cerrar sesión"
}, },
"dashboard": { "dashboard": {
"welcome": "Bienvenido", "welcome": "Bienvenido",
"waitingRoom": "Monitor sala de espera", "waitingRoom": "Monitor sala de espera",
"noWaitingPatients": "No hay pacientes en la sala de espera.", "noWaitingPatients": "No hay pacientes en la sala de espera."
"title": "Panel"
}, },
"adminSidebar": { "adminSidebar": {
"users": "Administración de usuarios", "users": "Administración de usuarios",
"database": "Administración de base de datos", "database": "Administración de base de datos"
"user": "Usuario",
"invocieoverview": "Resumen de facturas",
"seriennumber": "Número de serie",
"databasetable": "Base de datos",
"companysettings": "Datos de la empresa"
},
"adminuseroverview": {
"useroverview": "Resumen de usuarios",
"usermanagement": "Administración de usuarios",
"user": "Usuario",
"invocieoverview": "Resumen de facturas",
"seriennumber": "Número de serie",
"databasetable": "Base de datos"
},
"adminCreateUser": {
"title": "Crear usuario",
"firstname": "Nombre",
"lastname": "Apellido",
"usertitle": "Título (p. ej. Dr., Prof.)",
"username": "Nombre de usuario (login)",
"password": "Contraseña",
"specialty": "Especialidad",
"doctornumber": "Número de médico",
"createuser": "Crear usuario",
"back": "Atrás"
},
"adminServiceLogs": {
"title": "Registro de cambios de servicios",
"date": "Fecha",
"user": "Usuario",
"action": "Acción",
"before": "Antes",
"after": "Después"
},
"companySettings": {
"title": "Datos de la empresa",
"companyname": "Nombre de la empresa",
"legalform": "Forma jurídica",
"owner": "Propietario / Director",
"email": "Correo electrónico",
"street": "Calle",
"housenumber": "Número",
"zip": "Código postal",
"city": "Ciudad",
"country": "País",
"taxid": "NIF / Número fiscal",
"bank": "Banco",
"iban": "IBAN",
"bic": "BIC",
"invoicefooter": "Pie de factura",
"companylogo": "Logotipo de la empresa",
"currentlogo": "Logotipo actual:",
"back": "Atrás"
},
"databaseoverview": {
"title": "Configuración de la base de datos",
"text": "Aquí puedes probar y guardar la conexión a la base de datos.",
"host": "Host",
"port": "Puerto",
"database": "Base de datos",
"password": "Contraseña",
"connectiontest": "Probar conexión",
"tablecount": "Número de tablas",
"databasesize": "Tamaño de la base de datos",
"tableoverview": "Resumen de tablas",
"mysqlversion": "Versión de MySQL",
"nodbinfo": "No hay información del sistema disponible (la BD puede no estar configurada o la conexión falló)"
},
"invoiceAdmin": {
"fromyear": "Año desde",
"toyear": "Año hasta",
"searchpatient": "Buscar paciente..."
},
"cancelledInvoices": {
"title": "Facturas canceladas",
"year": "Año:",
"noinvoices": "No hay facturas canceladas para este año.",
"patient": "Paciente",
"date": "Fecha",
"amount": "Importe"
},
"creditOverview": {
"title": "Resumen de abonos",
"year": "Año:",
"invoice": "Factura",
"date": "Fecha",
"pdf": "PDF",
"creditnote": "Abono",
"patient": "Paciente",
"amount": "Importe",
"open": "Abrir"
},
"invoice": {
"title": "RECHNUNG / FACTURA",
"invoicenumber": "Número de factura:",
"nie": "N.I.E / DNI:",
"birthdate": "Fecha de nacimiento:",
"patient": "Paciente:",
"servicetext": "Por nuestros servicios, nos permitimos facturarle lo siguiente:",
"quantity": "Cantidad",
"treatment": "Tratamiento",
"price": "Precio (€)",
"sum": "Total (€)",
"doctor": "Médico responsable:",
"specialty": "Especialidad:",
"doctornumber": "Número de médico:",
"legal": "Factura médica privada"
},
"openInvoices": {
"title": "Servicios abiertos",
"noinvoices": "No hay facturas abiertas 🎉",
"patient": "Paciente",
"date": "Fecha",
"amount": "Importe",
"status": "Estado",
"open": "Abierto"
},
"paidInvoices": {
"title": "Facturas pagadas",
"year": "Año",
"quarter": "Trimestre",
"patient": "Paciente",
"date": "Fecha",
"amount": "Importe"
},
"openinvoices": {
"openinvoices": "Facturas de pacientes",
"canceledinvoices": "Facturas canceladas",
"report": "Informe de ventas",
"payedinvoices": "Facturas pagadas",
"creditoverview": "Resumen de abonos"
},
"medications": {
"title": "Resumen de medicamentos",
"newmedication": "Nuevo medicamento",
"searchplaceholder": "Buscar medicamento, forma, dosificación",
"search": "Buscar",
"reset": "Restablecer",
"medication": "Medicamento",
"form": "Forma farmacéutica",
"dosage": "Dosificación",
"package": "Envase",
"status": "Estado",
"actions": "Acciones"
},
"medicationCreate": {
"title": "Nuevo medicamento",
"medication": "Medicamento",
"form": "Forma farmacéutica",
"dosage": "Dosificación",
"package": "Envase",
"save": "Guardar",
"cancel": "Cancelar"
},
"openServices": {
"title": "Servicios abiertos",
"noopenservices": "No hay servicios abiertos"
},
"patienteoverview": {
"patienttitle": "Resumen de pacientes",
"newpatient": "Paciente nuevo",
"nopatientfound": "No se han encontrado pacientes.",
"closepatient": "Bloquear paciente (inactivo)",
"openpatient": "Desbloquear paciente (activo)",
"active": "Activo",
"inactive": "Inactivo",
"dni": "DNI"
},
"patientCreate": {
"title": "Nuevo paciente",
"firstname": "Nombre",
"lastname": "Apellido",
"dni": "N.I.E. / DNI",
"email": "Correo electrónico",
"phone": "Teléfono",
"street": "Calle",
"housenumber": "Número",
"zip": "Código postal",
"city": "Ciudad",
"country": "País",
"notes": "Notas"
},
"patientEdit": {
"firstname": "Nombre",
"lastname": "Apellido",
"email": "Correo electrónico",
"phone": "Teléfono",
"street": "Calle",
"housenumber": "Número",
"zip": "Código postal",
"city": "Ciudad",
"country": "País",
"notes": "Notas",
"save": "Guardar cambios"
},
"patientMedications": {
"selectmedication": "Seleccionar medicamento",
"dosageinstructions": "Instrucciones de dosificación",
"example": "p.ej. 1-0-1",
"startdate": "Fecha de inicio",
"enddate": "Fecha de fin",
"save": "Guardar",
"backoverview": "Volver al resumen",
"nomedication": "No hay medicación registrada.",
"medication": "Medicamento",
"form": "Forma",
"dosage": "Dosificación",
"instruction": "Instrucción",
"from": "Desde",
"to": "Hasta"
},
"patientOverview": {
"patientdata": "Datos del paciente",
"firstname": "Nombre",
"lastname": "Apellido",
"birthdate": "Fecha de nacimiento",
"email": "Correo electrónico",
"phone": "Teléfono",
"notes": "Notas",
"newnote": "Añadir nueva nota…",
"nonotes": "No hay notas",
"createrecipe": "Crear receta",
"searchservice": "Buscar servicio…",
"noservices": "Todavía no hay servicios para hoy.",
"addservice": "Añadir servicio"
},
"patientDashboard": {
"email": "Correo electrónico:",
"phone": "Teléfono:",
"address": "Dirección:",
"medications": "Medicamentos actuales",
"nomedications": "Sin medicamentos activos",
"medication": "Medicamento",
"variant": "Variante",
"instruction": "Instrucción",
"invoices": "Facturas",
"noinvoices": "No hay facturas",
"date": "Fecha",
"amount": "Importe",
"pdf": "PDF",
"open": "Abrir"
},
"services": {
"title": "Servicios",
"newservice": "Nuevo servicio",
"searchplaceholder": "Buscar por nombre o categoría",
"namede": "Denominación (DE)",
"namees": "Denominación (ES)",
"price": "Precio",
"pricec70": "Precio C70",
"status": "Estado",
"actions": "Acciones",
"editunlock": "Desbloquear edición"
},
"serviceCreate": {
"title": "Nuevo servicio",
"back": "Atrás",
"newservice": "Crear nuevo servicio",
"namede": "Denominación (Alemán) *",
"namees": "Denominación (Español)",
"category": "Categoría",
"price": "Precio (€) *",
"pricec70": "Precio C70 (€)"
},
"reportview": {
"title": "Informe de facturación",
"year": "Año",
"quarter": "Trimestre"
},
"seriennumber": {
"seriennumbertitle": "Introduce el número de serie",
"seriennumbertext": "Introduce el número de serie de tu licencia para activar el software de forma permanente.",
"seriennumbershort": "Número de serie (AAAAA-AAAAA-AAAAA-AAAAA)",
"seriennumberdeclaration": "Solo letras y números. Formato: 4x5 caracteres, separados por guion.",
"saveseriennumber": "Guardar número de serie"
},
"patientoverview": {
"nopatientfound": "No se han encontrado pacientes."
} }
} }

View File

@ -7,49 +7,40 @@ function requireLogin(req, res, next) {
next(); next();
} }
// ── Hilfsfunktion: Zugriff verweigern mit Flash + Redirect ──────────────────── // ✅ NEU: Arzt-only (das war früher dein requireAdmin)
function denyAccess(req, res, message) {
// Zurück zur vorherigen Seite, oder zum Dashboard
const back = req.get("Referrer") || "/dashboard";
req.session.flash = req.session.flash || [];
req.session.flash.push({ type: "danger", message });
return res.redirect(back);
}
// ✅ Arzt-only
function requireArzt(req, res, next) { function requireArzt(req, res, next) {
if (!req.session.user) return res.redirect("/"); console.log("ARZT CHECK:", req.session.user);
if (!req.session.user) {
return res.redirect("/");
}
if (req.session.user.role !== "arzt") { if (req.session.user.role !== "arzt") {
return denyAccess(req, res, "⛔ Kein Zugriff diese Seite ist nur für Ärzte."); return res
.status(403)
.send(
"⛔ Kein Zugriff (Arzt erforderlich). Rolle: " + req.session.user.role,
);
} }
req.user = req.session.user; req.user = req.session.user;
next(); next();
} }
// ✅ Admin-only // ✅ NEU: Admin-only
function requireAdmin(req, res, next) { function requireAdmin(req, res, next) {
if (!req.session.user) return res.redirect("/"); console.log("ADMIN CHECK:", req.session.user);
if (!req.session.user) {
return res.redirect("/");
}
if (req.session.user.role !== "admin") { if (req.session.user.role !== "admin") {
return denyAccess(req, res, "⛔ Kein Zugriff diese Seite ist nur für Administratoren."); return res
} .status(403)
.send(
req.user = req.session.user; "⛔ Kein Zugriff (Admin erforderlich). Rolle: " + req.session.user.role,
next(); );
}
// ✅ Arzt + Mitarbeiter
function requireArztOrMitarbeiter(req, res, next) {
if (!req.session.user) return res.redirect("/");
const allowed = ["arzt", "mitarbeiter"];
if (!allowed.includes(req.session.user.role)) {
return denyAccess(req, res, "⛔ Kein Zugriff diese Seite ist nur für Ärzte und Mitarbeiter.");
} }
req.user = req.session.user; req.user = req.session.user;
@ -60,5 +51,4 @@ module.exports = {
requireLogin, requireLogin,
requireArzt, requireArzt,
requireAdmin, requireAdmin,
requireArztOrMitarbeiter,
}; };

View File

@ -1,7 +1,7 @@
function flashMiddleware(req, res, next) { function flashMiddleware(req, res, next) {
res.locals.flash = req.session.flash || null; res.locals.flash = req.session.flash || null;
req.session.flash = null; req.session.flash = null;
next(); next();
} }
module.exports = flashMiddleware; module.exports = flashMiddleware;

View File

@ -1,52 +1,52 @@
const db = require("../db"); const db = require("../db");
const TRIAL_DAYS = 30; const TRIAL_DAYS = 30;
async function licenseGate(req, res, next) { async function licenseGate(req, res, next) {
// Login-Seiten immer erlauben // Login-Seiten immer erlauben
if (req.path === "/" || req.path.startsWith("/login")) return next(); if (req.path === "/" || req.path.startsWith("/login")) return next();
// Seriennummer-Seite immer erlauben // Seriennummer-Seite immer erlauben
if (req.path.startsWith("/serial-number")) return next(); if (req.path.startsWith("/serial-number")) return next();
// Wenn nicht eingeloggt -> normal weiter (auth middleware macht das) // Wenn nicht eingeloggt -> normal weiter (auth middleware macht das)
if (!req.session?.user) return next(); if (!req.session?.user) return next();
const [rows] = await db const [rows] = await db
.promise() .promise()
.query( .query(
`SELECT serial_number, trial_started_at FROM company_settings ORDER BY id ASC LIMIT 1`, `SELECT serial_number, trial_started_at FROM company_settings ORDER BY id ASC LIMIT 1`,
); );
const settings = rows?.[0]; const settings = rows?.[0];
// Wenn Seriennummer vorhanden -> alles ok // Wenn Seriennummer vorhanden -> alles ok
if (settings?.serial_number) return next(); if (settings?.serial_number) return next();
// Wenn keine Trial gestartet: jetzt starten // Wenn keine Trial gestartet: jetzt starten
if (!settings?.trial_started_at) { if (!settings?.trial_started_at) {
await db await db
.promise() .promise()
.query( .query(
`UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`, `UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`,
[settings?.id || 1], [settings?.id || 1],
); );
return next(); // Trial läuft ab jetzt return next(); // Trial läuft ab jetzt
} }
// Trial prüfen // Trial prüfen
const trialStart = new Date(settings.trial_started_at); const trialStart = new Date(settings.trial_started_at);
const now = new Date(); const now = new Date();
const diffMs = now - trialStart; const diffMs = now - trialStart;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < TRIAL_DAYS) { if (diffDays < TRIAL_DAYS) {
return next(); // Trial ist noch gültig return next(); // Trial ist noch gültig
} }
// ❌ Trial abgelaufen -> nur noch Seriennummer Seite // ❌ Trial abgelaufen -> nur noch Seriennummer Seite
return res.redirect("/serial-number"); return res.redirect("/serial-number");
} }
module.exports = { licenseGate }; module.exports = { licenseGate };

View File

@ -1,47 +0,0 @@
const { configExists, loadConfig } = require("../config-manager");
/**
* Leitet beim ersten Programmstart automatisch zu /setup um,
* solange config.enc fehlt oder DB-Daten unvollständig sind.
*/
module.exports = function requireSetup(req, res, next) {
// ✅ Setup immer erlauben
if (req.path.startsWith("/setup")) return next();
// ✅ Static niemals blockieren
if (req.path.startsWith("/public")) return next();
if (req.path.startsWith("/css")) return next();
if (req.path.startsWith("/js")) return next();
if (req.path.startsWith("/images")) return next();
if (req.path.startsWith("/uploads")) return next();
if (req.path.startsWith("/favicon")) return next();
// ✅ Login/Logout erlauben
if (req.path.startsWith("/login")) return next();
if (req.path.startsWith("/logout")) return next();
// ✅ Wenn config.enc fehlt -> Setup erzwingen
if (!configExists()) {
return res.redirect("/setup");
}
// ✅ Wenn config existiert aber DB Daten fehlen -> Setup erzwingen
let cfg = null;
try {
cfg = loadConfig();
} catch (e) {
cfg = null;
}
const ok =
cfg?.db?.host &&
cfg?.db?.user &&
cfg?.db?.password &&
cfg?.db?.name;
if (!ok) {
return res.redirect("/setup");
}
next();
};

View File

@ -1,26 +1,26 @@
const multer = require("multer"); const multer = require("multer");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
const patientId = req.params.id; const patientId = req.params.id;
const dir = path.join("uploads", "patients", String(patientId)); const dir = path.join("uploads", "patients", String(patientId));
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
cb(null, dir); cb(null, dir);
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
const safeName = file.originalname.replace(/\s+/g, "_"); const safeName = file.originalname.replace(/\s+/g, "_");
cb(null, Date.now() + "_" + safeName); cb(null, Date.now() + "_" + safeName);
} }
}); });
const upload = multer({ const upload = multer({
storage, storage,
limits: { fileSize: 20 * 1024 * 1024 } // 20 MB limits: { fileSize: 20 * 1024 * 1024 } // 20 MB
}); });
module.exports = upload; module.exports = upload;

View File

@ -1,24 +1,24 @@
const multer = require("multer"); const multer = require("multer");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
// 🔑 Zielordner: public/images // 🔑 Zielordner: public/images
const uploadDir = path.join(__dirname, "../public/images"); const uploadDir = path.join(__dirname, "../public/images");
// Ordner sicherstellen // Ordner sicherstellen
if (!fs.existsSync(uploadDir)) { if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true }); fs.mkdirSync(uploadDir, { recursive: true });
} }
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
cb(null, uploadDir); cb(null, uploadDir);
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
// immer gleicher Name // immer gleicher Name
cb(null, "logo" + path.extname(file.originalname)); cb(null, "logo" + path.extname(file.originalname));
} }
}); });
module.exports = multer({ storage }); module.exports = multer({ storage });

Binary file not shown.

343
package-lock.json generated
View File

@ -11,8 +11,6 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"date-holidays": "^3.26.11",
"docxtemplater": "^3.67.6", "docxtemplater": "^3.67.6",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"ejs": "^3.1.10", "ejs": "^3.1.10",
@ -25,8 +23,6 @@
"html-pdf-node": "^1.0.8", "html-pdf-node": "^1.0.8",
"multer": "^2.0.2", "multer": "^2.0.2",
"mysql2": "^3.16.0", "mysql2": "^3.16.0",
"node-ssh": "^13.2.1",
"pdf-lib": "^1.17.1",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
@ -1041,12 +1037,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -1083,24 +1073,6 @@
"@noble/hashes": "^1.1.5" "@noble/hashes": "^1.1.5"
} }
}, },
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -1676,23 +1648,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/astronomia": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/astronomia/-/astronomia-4.2.0.tgz",
"integrity": "sha512-mTvpBGyXB80aSsDhAAiuwza5VqAyqmj5yzhjBrFhRy17DcWDzJrb8Vdl4Sm+g276S+mY7bk/5hi6akZ5RQFeHg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/async": { "node_modules/async": {
"version": "3.2.6", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -1880,14 +1835,6 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -2115,15 +2062,6 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/busboy": { "node_modules/busboy": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -2144,18 +2082,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/caldate": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/caldate/-/caldate-2.0.5.tgz",
"integrity": "sha512-JndhrUuDuE975KUhFqJaVR1OQkCHZqpOrJur/CFXEIEhWhBMjxO85cRSK8q4FW+B+yyPq6GYua2u4KvNzTcq0w==",
"license": "ISC",
"dependencies": {
"moment-timezone": "^0.5.43"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -2289,18 +2215,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/cheerio": { "node_modules/cheerio": {
"version": "0.22.0", "version": "0.22.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz",
@ -2600,20 +2514,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/crc-32": { "node_modules/crc-32": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@ -2677,91 +2577,6 @@
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-bengali-revised": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/date-bengali-revised/-/date-bengali-revised-2.0.2.tgz",
"integrity": "sha512-q9iDru4+TSA9k4zfm0CFHJj6nBsxP7AYgWC/qodK/i7oOIlj5K2z5IcQDtESfs/Qwqt/xJYaP86tkazd/vRptg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/date-chinese": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/date-chinese/-/date-chinese-2.1.4.tgz",
"integrity": "sha512-WY+6+Qw92ZGWFvGtStmNQHEYpNa87b8IAQ5T8VKt4wqrn24lBXyyBnWI5jAIyy7h/KVwJZ06bD8l/b7yss82Ww==",
"license": "MIT",
"dependencies": {
"astronomia": "^4.1.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/date-easter": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/date-easter/-/date-easter-1.0.3.tgz",
"integrity": "sha512-aOViyIgpM4W0OWUiLqivznwTtuMlD/rdUWhc5IatYnplhPiWrLv75cnifaKYhmQwUBLAMWLNG4/9mlLIbXoGBQ==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/date-holidays": {
"version": "3.26.11",
"resolved": "https://registry.npmjs.org/date-holidays/-/date-holidays-3.26.11.tgz",
"integrity": "sha512-A8997Xv4k6fhpfu1xg2hEMfhB5MvWk/7TWIt1YmRFM2QPMENgL2WiaSe4zpSRzfnHSpkozcea9+R+Y5IvGJimQ==",
"license": "(ISC AND CC-BY-3.0)",
"dependencies": {
"date-holidays-parser": "^3.4.7",
"js-yaml": "^4.1.1",
"lodash": "^4.17.23",
"prepin": "^1.0.3"
},
"bin": {
"holidays2json": "scripts/holidays2json.cjs"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/date-holidays-parser": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/date-holidays-parser/-/date-holidays-parser-3.4.7.tgz",
"integrity": "sha512-h09ZEtM6u5cYM6m1bX+1Ny9f+nLO9KVZUKNPEnH7lhbXYTfqZogaGTnhONswGeIJFF91UImIftS3CdM9HLW5oQ==",
"license": "ISC",
"dependencies": {
"astronomia": "^4.1.1",
"caldate": "^2.0.5",
"date-bengali-revised": "^2.0.2",
"date-chinese": "^2.1.4",
"date-easter": "^1.0.3",
"deepmerge": "^4.3.1",
"jalaali-js": "^1.2.7",
"moment-timezone": "^0.5.47"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/date-holidays/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/date-holidays/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -2798,6 +2613,7 @@
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -4295,6 +4111,7 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -4437,12 +4254,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/jalaali-js": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
"integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==",
"license": "MIT"
},
"node_modules/jest": { "node_modules/jest": {
"version": "30.2.0", "version": "30.2.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz",
@ -5151,12 +4962,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.assignin": { "node_modules/lodash.assignin": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz",
@ -5438,27 +5243,6 @@
"mkdirp": "bin/cmd.js" "mkdirp": "bin/cmd.js"
} }
}, },
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -5515,12 +5299,6 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"optional": true
},
"node_modules/napi-postinstall": { "node_modules/napi-postinstall": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@ -5602,44 +5380,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-ssh": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/node-ssh/-/node-ssh-13.2.1.tgz",
"integrity": "sha512-rfl4GWMygQfzlExPkQ2LWyya5n2jOBm5vhEnup+4mdw7tQhNpJWbP5ldr09Jfj93k5SfY5lxcn8od5qrQ/6mBg==",
"dependencies": {
"is-stream": "^2.0.0",
"make-dir": "^3.1.0",
"sb-promise-queue": "^2.1.0",
"sb-scandir": "^3.1.0",
"shell-escape": "^0.2.0",
"ssh2": "^1.14.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/node-ssh/node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/node-ssh/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.11", "version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -5851,12 +5591,6 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-json": { "node_modules/parse-json": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -5943,24 +5677,6 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pend": { "node_modules/pend": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@ -6017,15 +5733,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/prepin": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/prepin/-/prepin-1.0.3.tgz",
"integrity": "sha512-0XL2hreherEEvUy0fiaGEfN/ioXFV+JpImqIzQjxk6iBg4jQ2ARKqvC4+BmRD8w/pnpD+lbxvh0Ub+z7yBEjvA==",
"license": "Unlicense",
"bin": {
"prepin": "bin/prepin.js"
}
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "30.2.0", "version": "30.2.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz",
@ -6329,25 +6036,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sb-promise-queue": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/sb-promise-queue/-/sb-promise-queue-2.1.1.tgz",
"integrity": "sha512-qXfdcJQMxMljxmPprn4Q4hl3pJmoljSCzUvvEBa9Kscewnv56n0KqrO6yWSrGLOL9E021wcGdPa39CHGKA6G0w==",
"engines": {
"node": ">= 8"
}
},
"node_modules/sb-scandir": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/sb-scandir/-/sb-scandir-3.1.1.tgz",
"integrity": "sha512-Q5xiQMtoragW9z8YsVYTAZcew+cRzdVBefPbb9theaIKw6cBo34WonP9qOCTKgyAmn/Ch5gmtAxT/krUgMILpA==",
"dependencies": {
"sb-promise-queue": "^2.1.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -6449,11 +6137,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/shell-escape": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz",
"integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw=="
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -6628,23 +6311,6 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/stack-utils": { "node_modules/stack-utils": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@ -7060,11 +6726,6 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
},
"node_modules/type-detect": { "node_modules/type-detect": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",

View File

@ -15,8 +15,6 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"date-holidays": "^3.26.11",
"docxtemplater": "^3.67.6", "docxtemplater": "^3.67.6",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"ejs": "^3.1.10", "ejs": "^3.1.10",
@ -29,8 +27,6 @@
"html-pdf-node": "^1.0.8", "html-pdf-node": "^1.0.8",
"multer": "^2.0.2", "multer": "^2.0.2",
"mysql2": "^3.16.0", "mysql2": "^3.16.0",
"node-ssh": "^13.2.1",
"pdf-lib": "^1.17.1",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,310 +1,287 @@
/* ========================= /* =========================
WARTEZIMMER MONITOR WARTEZIMMER MONITOR
========================= */ ========================= */
.waiting-monitor { .waiting-monitor {
border: 3px solid #343a40; border: 3px solid #343a40;
border-radius: 10px; border-radius: 10px;
padding: 15px; padding: 15px;
min-height: 45vh; /* untere Hälfte */ min-height: 45vh; /* untere Hälfte */
background-color: #f8f9fa; background-color: #f8f9fa;
} }
.waiting-grid { .waiting-grid {
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr);
gap: 10px; gap: 10px;
height: 100%; height: 100%;
} }
.waiting-slot { .waiting-slot {
border: 2px dashed #adb5bd; border: 2px dashed #adb5bd;
border-radius: 6px; border-radius: 6px;
padding: 10px; padding: 10px;
background-color: #ffffff; background-color: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
text-align: center; text-align: center;
} }
.waiting-slot.occupied { .waiting-slot.occupied {
border-style: solid; border-style: solid;
border-color: #198754; border-color: #198754;
background-color: #e9f7ef; background-color: #e9f7ef;
} }
.waiting-slot .name { .waiting-slot .name {
font-weight: bold; font-weight: bold;
} }
.waiting-slot .birthdate { .waiting-slot .birthdate {
font-size: 0.8rem; font-size: 0.8rem;
color: #6c757d; color: #6c757d;
} }
.waiting-slot .placeholder { .waiting-slot .placeholder {
color: #adb5bd; color: #adb5bd;
font-style: italic; font-style: italic;
} }
.waiting-slot.empty { .waiting-slot.empty {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #f8f9fa; background: #f8f9fa;
} }
.chair-icon { .chair-icon {
width: 48px; width: 48px;
opacity: 0.4; opacity: 0.4;
} }
/* ✅ Wartezimmer: Slots klickbar machen (wenn <a> benutzt wird) */ /* ✅ Wartezimmer: Slots klickbar machen (wenn <a> benutzt wird) */
.waiting-slot.clickable { .waiting-slot.clickable {
cursor: pointer; cursor: pointer;
transition: 0.15s ease; transition: 0.15s ease;
text-decoration: none; /* ❌ kein Link-Unterstrich */ text-decoration: none; /* ❌ kein Link-Unterstrich */
color: inherit; /* ✅ Textfarbe wie normal */ color: inherit; /* ✅ Textfarbe wie normal */
} }
/* ✅ Hover Effekt */ /* ✅ Hover Effekt */
.waiting-slot.clickable:hover { .waiting-slot.clickable:hover {
transform: scale(1.03); transform: scale(1.03);
box-shadow: 0 0 0 2px #2563eb; box-shadow: 0 0 0 2px #2563eb;
} }
/* ✅ damit der Link wirklich den ganzen Block klickbar macht */ /* ✅ damit der Link wirklich den ganzen Block klickbar macht */
a.waiting-slot { a.waiting-slot {
display: flex; display: flex;
} }
.auto-hide-flash { .auto-hide-flash {
animation: flashFadeOut 3s forwards; animation: flashFadeOut 3s forwards;
} }
@keyframes flashFadeOut { @keyframes flashFadeOut {
0% { 0% {
opacity: 1; opacity: 1;
} }
70% { 70% {
opacity: 1; opacity: 1;
} }
100% { 100% {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
} }
} }
/* ========================================================= /* =========================================================
PAGE HEADER (global) PAGE HEADER (global)
- Höhe ca. 4cm - Höhe ca. 4cm
- Hintergrund schwarz - Hintergrund schwarz
- Text in der Mitte - Text in der Mitte
- Button + Datum/Uhrzeit rechts - Button + Datum/Uhrzeit rechts
========================================================= */ ========================================================= */
/* ✅ Der komplette Header-Container */ /* ✅ Der komplette Header-Container */
.page-header { .page-header {
height: 150px; /* ca. 4cm */ height: 150px; /* ca. 4cm */
background: #000; /* Schwarz */ background: #000; /* Schwarz */
color: #fff; /* Weiße Schrift */ color: #fff; /* Weiße Schrift */
/* Wir nutzen Grid, damit Center wirklich immer mittig bleibt */ /* Wir nutzen Grid, damit Center wirklich immer mittig bleibt */
display: grid; display: grid;
/* 3 Spalten: /* 3 Spalten:
1) links = leer/optional 1) links = leer/optional
2) mitte = Text (center) 2) mitte = Text (center)
3) rechts = Dashboard + Uhrzeit 3) rechts = Dashboard + Uhrzeit
*/ */
grid-template-columns: 1fr 2fr 1fr; grid-template-columns: 1fr 2fr 1fr;
align-items: center; /* vertikal mittig */ align-items: center; /* vertikal mittig */
padding: 0 20px; /* links/rechts Abstand */ padding: 0 20px; /* links/rechts Abstand */
box-sizing: border-box; box-sizing: border-box;
} }
/* ✅ Linke Header-Spalte (kann leer bleiben oder später Logo) */ /* ✅ Linke Header-Spalte (kann leer bleiben oder später Logo) */
.page-header-left { .page-header-left {
justify-self: start; /* ganz links */ justify-self: start; /* ganz links */
} }
/* ✅ Mittlere Header-Spalte (Text zentriert) */ /* ✅ Mittlere Header-Spalte (Text zentriert) */
.page-header-center { .page-header-center {
justify-self: center; /* wirklich zentriert in der Mitte */ justify-self: center; /* wirklich zentriert in der Mitte */
text-align: center; text-align: center;
display: flex; display: flex;
flex-direction: column; /* Username oben, Titel darunter */ flex-direction: column; /* Username oben, Titel darunter */
gap: 6px; /* Abstand zwischen den Zeilen */ gap: 6px; /* Abstand zwischen den Zeilen */
} }
/* ✅ Rechte Header-Spalte (Button + Uhrzeit rechts) */ /* ✅ Rechte Header-Spalte (Button + Uhrzeit rechts) */
.page-header-right { .page-header-right {
justify-self: end; /* ganz rechts */ justify-self: end; /* ganz rechts */
display: flex; display: flex;
flex-direction: column; /* Button oben, Uhrzeit unten */ flex-direction: column; /* Button oben, Uhrzeit unten */
align-items: flex-end; /* alles rechts ausrichten */ align-items: flex-end; /* alles rechts ausrichten */
gap: 10px; /* Abstand Button / Uhrzeit */ gap: 10px; /* Abstand Button / Uhrzeit */
} }
/* ✅ Username-Zeile (z.B. Willkommen, admin) */ /* ✅ Username-Zeile (z.B. Willkommen, admin) */
.page-header-username { .page-header-username {
font-size: 22px; font-size: 22px;
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
} }
/* ✅ Titel-Zeile (z.B. Seriennummer) */ /* ✅ Titel-Zeile (z.B. Seriennummer) */
.page-header-title { .page-header-title {
font-size: 18px; font-size: 18px;
opacity: 0.9; opacity: 0.9;
} }
/* ✅ Subtitle Bereich (optional) */ /* ✅ Subtitle Bereich (optional) */
.page-header-subtitle { .page-header-subtitle {
opacity: 0.75; opacity: 0.75;
} }
/* ✅ Uhrzeit (oben rechts unter dem Button) */ /* ✅ Uhrzeit (oben rechts unter dem Button) */
.page-header-datetime { .page-header-datetime {
font-size: 24px; font-size: 14px;
opacity: 0.85; opacity: 0.85;
} }
/* ✅ Dashboard Button (weißer Rahmen) */ /* ✅ Dashboard Button (weißer Rahmen) */
.page-header .btn-outline-light { .page-header .btn-outline-light {
border-color: #fff !important; border-color: #fff !important;
color: #fff !important; color: #fff !important;
} }
/* ✅ Dashboard Button: keine Unterstreichung + Rahmen + rund */ /* ✅ Dashboard Button: keine Unterstreichung + Rahmen + rund */
.page-header a.btn { .page-header a.btn {
text-decoration: none !important; /* keine Unterstreichung */ text-decoration: none !important; /* keine Unterstreichung */
border: 2px solid #fff !important; /* Rahmen */ border: 2px solid #fff !important; /* Rahmen */
border-radius: 12px; /* abgerundete Ecken */ border-radius: 12px; /* abgerundete Ecken */
padding: 6px 12px; /* schöner Innenabstand */ padding: 6px 12px; /* schöner Innenabstand */
display: inline-block; /* saubere Button-Form */ display: inline-block; /* saubere Button-Form */
} }
/* ✅ Dashboard Button (Hovereffekt) */ /* ✅ Dashboard Button (Hovereffekt) */
.page-header a.btn:hover { .page-header a.btn:hover {
background: #fff !important; background: #fff !important;
color: #000 !important; color: #000 !important;
} }
/* ✅ Sidebar Lock: verhindert Klick ohne Inline-JS (Helmet CSP safe) */ /* ✅ Sidebar Lock: verhindert Klick ohne Inline-JS (Helmet CSP safe) */
.nav-item.locked { .nav-item.locked {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
pointer-events: none; /* verhindert klicken komplett */ pointer-events: none; /* verhindert klicken komplett */
} }
/* ========================================================= /* =========================================================
Admin Sidebar Admin Sidebar
- Hintergrund schwarz - Hintergrund schwarz
========================================================= */ ========================================================= */
.layout { .layout {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
} }
.sidebar { .sidebar {
width: 260px; width: 260px;
background: #111; background: #111;
color: #fff; color: #fff;
padding: 20px; padding: 20px;
} }
.nav-item { .nav-item {
display: flex; display: flex;
gap: 10px; gap: 10px;
padding: 10px; padding: 10px;
text-decoration: none; text-decoration: none;
color: #ddd; color: #ddd;
} }
.nav-item:hover { .nav-item:hover {
background: #222; background: #222;
color: #fff; color: #fff;
} }
.nav-item.active { .nav-item.active {
background: #0d6efd; background: #0d6efd;
color: #fff; color: #fff;
} }
.main { .main {
flex: 1; flex: 1;
} }
/* ========================================================= /* =========================================================
Leere Sidebar Leere Sidebar
- Hintergrund schwarz - Hintergrund schwarz
========================================================= */ ========================================================= */
/* ✅ Leere Sidebar (nur schwarzer Balken) */ /* ✅ Leere Sidebar (nur schwarzer Balken) */
.sidebar-empty { .sidebar-empty {
background: #000; background: #000;
width: 260px; /* gleiche Breite wie normale Sidebar */ width: 260px; /* gleiche Breite wie normale Sidebar */
padding: 0; padding: 0;
} }
/* ========================================================= /* =========================================================
Logo Sidebar Logo Sidebar
- links oben - links oben
========================================================= */ ========================================================= */
.logo { .logo {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
margin-bottom: 15px; margin-bottom: 15px;
} }
/* ========================================================= /* =========================================================
Patientendaten maximal so breit wie die maximalen Daten sind Patientendaten maximal so breit wie die maximalen Daten sind
========================================================= */ ========================================================= */
.patient-data-box { .patient-data-box {
max-width: 900px; /* ✅ maximale Breite (kannst du ändern) */ max-width: 900px; /* ✅ maximale Breite (kannst du ändern) */
width: 100%; width: 100%;
margin: 0 auto; /* ✅ zentriert */ margin: 0 auto; /* ✅ zentriert */
} }
/* ✅ Button im Wartezimmer-Monitor soll aussehen wie ein Link-Block */ /* ✅ Button im Wartezimmer-Monitor soll aussehen wie ein Link-Block */
.waiting-btn { .waiting-btn {
width: 100%; width: 100%;
border: none; border: none;
background: transparent; background: transparent;
padding: 10px; /* genau wie waiting-slot vorher */ padding: 10px; /* genau wie waiting-slot vorher */
margin: 0; margin: 0;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
} }
/* ✅ Entfernt Focus-Rahmen (falls Browser blau umrandet) */ /* ✅ Entfernt Focus-Rahmen (falls Browser blau umrandet) */
.waiting-btn:focus { .waiting-btn:focus {
outline: none; outline: none;
box-shadow: none; box-shadow: none;
} }
/* ✅ Legende im Report */
.chart-legend {
margin-top: 20px;
text-align: left;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.legend-color {
width: 14px;
height: 14px;
border-radius: 3px;
}
.legend-text {
font-size: 14px;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,15 +1,15 @@
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const roleSelect = document.getElementById("roleSelect"); const roleSelect = document.getElementById("roleSelect");
const arztFields = document.getElementById("arztFields"); const arztFields = document.getElementById("arztFields");
if (!roleSelect || !arztFields) return; if (!roleSelect || !arztFields) return;
function toggleArztFields() { function toggleArztFields() {
arztFields.style.display = roleSelect.value === "arzt" ? "block" : "none"; arztFields.style.display = roleSelect.value === "arzt" ? "block" : "none";
} }
roleSelect.addEventListener("change", toggleArztFields); roleSelect.addEventListener("change", toggleArztFields);
// Beim Laden prüfen // Beim Laden prüfen
toggleArztFields(); toggleArztFields();
}); });

View File

@ -1,506 +0,0 @@
(function () {
'use strict';
/* ── Daten aus DOM (CSP-sicher via <script type="application/json">) ──── */
const ALL_DOCTORS = JSON.parse(
document.getElementById('calDoctorsData').textContent
);
const BASE = '/calendar/api';
/* ── State ──────────────────────────────────────────────────────────────── */
let currentDate = new Date();
let appointments = [];
let holidays = {};
let visibleDocs = new Set(ALL_DOCTORS.map(d => d.id));
let editingId = null;
/* ── Hilfsfunktionen ────────────────────────────────────────────────────── */
const pad = n => String(n).padStart(2, '0');
const toISO = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r; };
const WDAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
const MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const TIME_SLOTS = (() => {
const s = [];
for (let h = 0; h < 24; h++)
for (let m = 0; m < 60; m += 15)
s.push(`${pad(h)}:${pad(m)}`);
return s;
})();
async function apiFetch(path, opts = {}) {
const res = await fetch(BASE + path, {
headers: { 'Content-Type': 'application/json' },
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'API-Fehler');
return data;
}
function showToast(msg, isError = false) {
const el = document.getElementById('calToast');
const txt = document.getElementById('calToastMsg');
txt.textContent = msg;
el.className = `toast align-items-center border-0 ${isError ? 'text-bg-danger' : 'text-bg-dark'}`;
bootstrap.Toast.getOrCreateInstance(el, { delay: 2800 }).show();
}
/* ── Tages-Daten laden ──────────────────────────────────────────────────── */
async function loadDay() {
const iso = toISO(currentDate);
appointments = await apiFetch(`/appointments/${iso}`);
await ensureHolidays(currentDate.getFullYear());
renderToolbar();
renderHolidayBanner();
renderColumns();
renderMiniCal();
}
async function ensureHolidays(year) {
if (holidays[year] !== undefined) return;
try {
const data = await apiFetch(`/holidays/${year}`);
holidays[year] = {};
for (const h of data.holidays) {
if (!holidays[year][h.date]) holidays[year][h.date] = [];
holidays[year][h.date].push(h);
}
} catch { holidays[year] = {}; }
}
/* ── Toolbar ────────────────────────────────────────────────────────────── */
function renderToolbar() {
const wd = WDAYS[currentDate.getDay()];
const day = currentDate.getDate();
const mon = MONTHS[currentDate.getMonth()];
const yr = currentDate.getFullYear();
document.getElementById('btnDateDisplay').textContent =
`${wd}, ${day}. ${mon} ${yr}`;
}
/* ── Feiertagsbanner ────────────────────────────────────────────────────── */
function renderHolidayBanner() {
const iso = toISO(currentDate);
const list = holidays[currentDate.getFullYear()]?.[iso];
const el = document.getElementById('calHolidayBanner');
if (list?.length) {
document.getElementById('calHolidayText').textContent =
'Feiertag: ' + list.map(h => h.name).join(' · ');
el.style.display = 'flex';
} else {
el.style.display = 'none';
}
}
/* ── Zeitachse ──────────────────────────────────────────────────────────── */
function buildTimeAxis() {
const ax = document.getElementById('calTimeAxis');
ax.innerHTML = TIME_SLOTS.map(t => {
const h = t.endsWith(':00');
return `<div class="cal-time-label ${h ? 'hour' : ''}">${h ? t : ''}</div>`;
}).join('');
}
/* ── Spalten rendern ────────────────────────────────────────────────────── */
function renderColumns() {
const visible = ALL_DOCTORS.filter(d => visibleDocs.has(d.id));
const headers = document.getElementById('calColHeadersInner');
const cols = document.getElementById('calColumnsInner');
const iso = toISO(currentDate);
const isWEnd = [0, 6].includes(currentDate.getDay());
const countMap = {};
for (const a of appointments)
countMap[a.doctor_id] = (countMap[a.doctor_id] || 0) + 1;
if (!visible.length) {
headers.innerHTML = '';
cols.innerHTML = `
<div class="d-flex flex-column align-items-center justify-content-center w-100 text-muted py-5">
<i class="bi bi-person-x fs-1 mb-2"></i>
<div>Keine Ärzte ausgewählt</div>
</div>`;
return;
}
headers.innerHTML = visible.map(d => `
<div class="col-header">
<span class="doc-dot" style="background:${d.color}"></span>
<div>
<div class="col-header-name">${esc(d.name)}</div>
</div>
<span class="col-header-count">${countMap[d.id] || 0}</span>
<input type="color" class="col-header-color ms-1" value="${d.color}"
title="Farbe ändern" data-doc="${d.id}">
</div>
`).join('');
cols.innerHTML = visible.map(d => `
<div class="doc-col" id="docCol-${d.id}" data-doc="${d.id}">
${TIME_SLOTS.map(t => `
<div class="slot-row ${t.endsWith(':00') ? 'hour-start' : ''} ${isWEnd ? 'weekend' : ''}"
data-time="${t}" data-doc="${d.id}"></div>
`).join('')}
</div>
`).join('');
/* Termin-Blöcke */
const byDoc = {};
for (const a of appointments) {
if (!byDoc[a.doctor_id]) byDoc[a.doctor_id] = [];
byDoc[a.doctor_id].push(a);
}
for (const d of visible) {
const col = document.getElementById(`docCol-${d.id}`);
if (col) (byDoc[d.id] || []).forEach(a => renderApptBlock(col, a, d.color));
}
updateNowLine();
/* Slot-Klick */
cols.querySelectorAll('.slot-row').forEach(slot =>
slot.addEventListener('click', () =>
openApptModal(null, slot.dataset.doc, iso, slot.dataset.time))
);
/* Farb-Picker */
headers.querySelectorAll('.col-header-color').forEach(inp => {
inp.addEventListener('change', async () => {
const docId = parseInt(inp.dataset.doc);
const color = inp.value;
await apiFetch(`/doctors/${docId}/color`, { method: 'PATCH', body: { color } });
const doc = ALL_DOCTORS.find(d => d.id === docId);
if (doc) doc.color = color;
renderDocList();
renderColumns();
});
});
}
function renderApptBlock(col, a, color) {
const idx = TIME_SLOTS.indexOf(a.time);
if (idx < 0) return;
const slots = Math.max(1, Math.round(a.duration / 15));
const block = document.createElement('div');
block.className = `appt-block status-${a.status}`;
block.style.cssText =
`top:${idx * 40 + 2}px; height:${slots * 40 - 4}px; background:${color}28; border-color:${color};`;
block.innerHTML = `
<div class="appt-patient">${esc(a.patient_name)}</div>
${slots > 1 ? `<div class="appt-time">${a.time} · ${a.duration} min</div>` : ''}
`;
block.addEventListener('click', e => { e.stopPropagation(); openApptModal(a); });
col.appendChild(block);
}
function updateNowLine() {
document.querySelectorAll('.now-line').forEach(n => n.remove());
if (toISO(new Date()) !== toISO(currentDate)) return;
const mins = new Date().getHours() * 60 + new Date().getMinutes();
const top = (mins / 15) * 40;
document.querySelectorAll('.doc-col').forEach(col => {
const line = document.createElement('div');
line.className = 'now-line';
line.style.top = `${top}px`;
line.innerHTML = '<div class="now-dot"></div>';
col.appendChild(line);
});
}
setInterval(updateNowLine, 30000);
/* ── Arztliste (Sidebar) ────────────────────────────────────────────────── */
function renderDocList() {
const el = document.getElementById('docList');
el.innerHTML = ALL_DOCTORS.map(d => `
<div class="doc-item ${visibleDocs.has(d.id) ? 'active' : ''}" data-id="${d.id}">
<span class="doc-dot" style="background:${d.color}"></span>
<span style="font-size:13px; flex:1;">${esc(d.name)}</span>
<span class="doc-check">
${visibleDocs.has(d.id)
? '<i class="bi bi-check text-white" style="font-size:11px;"></i>'
: ''}
</span>
</div>
`).join('');
el.querySelectorAll('.doc-item').forEach(item => {
item.addEventListener('click', () => {
const id = parseInt(item.dataset.id);
visibleDocs.has(id) ? visibleDocs.delete(id) : visibleDocs.add(id);
renderDocList();
renderColumns();
});
});
}
/* ── Mini-Kalender ──────────────────────────────────────────────────────── */
let miniYear = new Date().getFullYear();
let miniMonth = new Date().getMonth();
async function renderMiniCal(yr, mo) {
if (yr !== undefined) { miniYear = yr; miniMonth = mo; }
await ensureHolidays(miniYear);
const first = new Date(miniYear, miniMonth, 1);
const last = new Date(miniYear, miniMonth + 1, 0);
const startWd = (first.getDay() + 6) % 7;
let html = `
<div class="d-flex align-items-center justify-content-between mb-2">
<button class="btn btn-sm btn-link p-0 text-muted" id="miniPrev">
<i class="bi bi-chevron-left"></i>
</button>
<small class="fw-semibold">${MONTHS[miniMonth].substring(0,3)} ${miniYear}</small>
<button class="btn btn-sm btn-link p-0 text-muted" id="miniNext">
<i class="bi bi-chevron-right"></i>
</button>
</div>
<div class="mini-cal-grid">
${['Mo','Di','Mi','Do','Fr','Sa','So'].map(w => `<div class="mini-wd">${w}</div>`).join('')}
`;
for (let i = 0; i < startWd; i++) html += '<div></div>';
for (let day = 1; day <= last.getDate(); day++) {
const d2 = new Date(miniYear, miniMonth, day);
const iso = toISO(d2);
const tod = toISO(new Date()) === iso;
const sel = toISO(currentDate) === iso;
const hol = !!(holidays[miniYear]?.[iso]);
html += `<div class="mini-day ${tod?'today':''} ${sel?'selected':''} ${hol?'holiday':''}"
data-iso="${iso}">${day}</div>`;
}
html += '</div>';
const mc = document.getElementById('miniCal');
mc.innerHTML = html;
mc.querySelector('#miniPrev').addEventListener('click', () => {
let m = miniMonth - 1, y = miniYear;
if (m < 0) { m = 11; y--; }
renderMiniCal(y, m);
});
mc.querySelector('#miniNext').addEventListener('click', () => {
let m = miniMonth + 1, y = miniYear;
if (m > 11) { m = 0; y++; }
renderMiniCal(y, m);
});
mc.querySelectorAll('.mini-day[data-iso]').forEach(el => {
el.addEventListener('click', () => {
const [y, m, d] = el.dataset.iso.split('-').map(Number);
currentDate = new Date(y, m - 1, d);
loadDay();
});
});
}
/* ── Patienten-Autocomplete ─────────────────────────────────────────────── */
let acTimer = null;
function initPatientAutocomplete() {
const input = document.getElementById('fPatient');
const dropdown = document.getElementById('patientDropdown');
const hiddenId = document.getElementById('fPatientId');
function hideDropdown() {
dropdown.style.display = 'none';
dropdown.innerHTML = '';
}
function selectPatient(p) {
input.value = `${p.firstname} ${p.lastname}`;
hiddenId.value = p.id;
hideDropdown();
}
input.addEventListener('input', () => {
clearTimeout(acTimer);
hiddenId.value = ''; // Freitext → ID zurücksetzen
const q = input.value.trim();
if (q.length < 1) { hideDropdown(); return; }
acTimer = setTimeout(async () => {
try {
const results = await apiFetch(
`/patients/search?q=${encodeURIComponent(q)}`
);
if (!results.length) { hideDropdown(); return; }
dropdown.innerHTML = results.map(p => {
const bd = p.birthdate
? new Date(p.birthdate).toLocaleDateString('de-DE')
: '';
return `
<div class="ac-item d-flex align-items-center gap-2 px-3 py-2"
style="cursor:pointer; font-size:13px; border-bottom:1px solid #f0f0f0;"
data-id="${p.id}"
data-name="${esc(p.firstname)} ${esc(p.lastname)}">
<i class="bi bi-person text-muted"></i>
<div>
<div class="fw-semibold">${esc(p.firstname)} ${esc(p.lastname)}</div>
${bd ? `<div class="text-muted" style="font-size:11px;">*${bd}</div>` : ''}
</div>
</div>`;
}).join('');
dropdown.style.display = 'block';
dropdown.querySelectorAll('.ac-item').forEach(item => {
// Hover-Effekt
item.addEventListener('mouseenter', () =>
item.style.background = '#f0f5ff'
);
item.addEventListener('mouseleave', () =>
item.style.background = ''
);
// Auswahl
item.addEventListener('mousedown', e => {
e.preventDefault(); // verhindert blur vor click
selectPatient({
id: parseInt(item.dataset.id),
firstname: item.dataset.name.split(' ')[0],
lastname: item.dataset.name.split(' ').slice(1).join(' '),
});
});
});
} catch { hideDropdown(); }
}, 220);
});
// Dropdown schließen wenn Fokus woanders hin geht
input.addEventListener('blur', () => {
setTimeout(hideDropdown, 200);
});
// Modal schließt → Dropdown aufräumen
document.getElementById('apptModal').addEventListener('hidden.bs.modal', hideDropdown);
}
/* ── Termin-Modal ───────────────────────────────────────────────────────── */
function populateTimeSelect() {
const sel = document.getElementById('fTime');
sel.innerHTML = TIME_SLOTS.map(t =>
`<option value="${t}">${t}</option>`
).join('');
}
function populateDoctorSelect() {
const sel = document.getElementById('fDoctor');
sel.innerHTML = ALL_DOCTORS.map(d =>
`<option value="${d.id}">${esc(d.name)}</option>`
).join('');
}
function openApptModal(appt, docId, date, time) {
editingId = appt?.id ?? null;
document.getElementById('apptModalTitle').textContent =
appt ? 'Termin bearbeiten' : 'Neuer Termin';
document.getElementById('btnApptDelete').style.display = appt ? '' : 'none';
populateTimeSelect();
populateDoctorSelect();
document.getElementById('fDate').value = appt?.date ?? (date || toISO(currentDate));
document.getElementById('fTime').value = appt?.time ?? (time || '08:00');
document.getElementById('fDoctor').value = appt?.doctor_id ?? (docId || ALL_DOCTORS[0]?.id || '');
document.getElementById('fPatient').value = appt?.patient_name ?? '';
document.getElementById('fPatientId').value = ''; // ← immer zurücksetzen
document.getElementById('fDuration').value = appt?.duration ?? 15;
document.getElementById('fStatus').value = appt?.status ?? 'scheduled';
document.getElementById('fNotes').value = appt?.notes ?? '';
bootstrap.Modal.getOrCreateInstance(
document.getElementById('apptModal')
).show();
setTimeout(() => document.getElementById('fPatient').focus(), 300);
}
async function saveAppt() {
const payload = {
doctor_id: parseInt(document.getElementById('fDoctor').value),
date: document.getElementById('fDate').value,
time: document.getElementById('fTime').value,
duration: parseInt(document.getElementById('fDuration').value),
patient_name: document.getElementById('fPatient').value.trim(),
notes: document.getElementById('fNotes').value.trim(),
status: document.getElementById('fStatus').value,
};
if (!payload.patient_name) { showToast('Patientenname fehlt', true); return; }
try {
if (editingId) {
await apiFetch(`/appointments/${editingId}`, { method: 'PUT', body: payload });
showToast('Termin gespeichert');
} else {
await apiFetch('/appointments', { method: 'POST', body: payload });
showToast('Termin erstellt');
}
bootstrap.Modal.getInstance(document.getElementById('apptModal')).hide();
await loadDay();
} catch (e) { showToast(e.message, true); }
}
async function deleteAppt() {
if (!confirm('Termin wirklich löschen?')) return;
try {
await apiFetch(`/appointments/${editingId}`, { method: 'DELETE' });
showToast('Termin gelöscht');
bootstrap.Modal.getInstance(document.getElementById('apptModal')).hide();
await loadDay();
} catch (e) { showToast(e.message, true); }
}
/* ── Events ─────────────────────────────────────────────────────────────── */
function setupEvents() {
document.getElementById('btnPrev').addEventListener('click', () => {
currentDate = addDays(currentDate, -1); loadDay();
});
document.getElementById('btnNext').addEventListener('click', () => {
currentDate = addDays(currentDate, 1); loadDay();
});
document.getElementById('btnToday').addEventListener('click', () => {
currentDate = new Date(); loadDay();
});
document.getElementById('btnNewAppt').addEventListener('click', () =>
openApptModal(null)
);
document.getElementById('btnApptSave').addEventListener('click', saveAppt);
document.getElementById('btnApptDelete').addEventListener('click', deleteAppt);
document.addEventListener('keydown', e => {
if (document.querySelector('.modal.show')) return;
if (e.key === 'ArrowLeft') { currentDate = addDays(currentDate, -1); loadDay(); }
if (e.key === 'ArrowRight') { currentDate = addDays(currentDate, 1); loadDay(); }
if (e.key === 't') { currentDate = new Date(); loadDay(); }
});
}
function esc(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/* ── Start ──────────────────────────────────────────────────────────────── */
buildTimeAxis();
renderDocList();
setupEvents();
initPatientAutocomplete();
loadDay()
.then(() => {
// Scroll zu 07:00 (Slot 28)
document.getElementById('calScroll').scrollTop = 28 * 40 - 60;
})
.catch(err => {
console.error(err);
showToast('Verbindung zum Server fehlgeschlagen', true);
});
})();

File diff suppressed because one or more lines are too long

View File

@ -1,21 +1,10 @@
(function () { (function () {
function updateDateTime() { function updateDateTime() {
const el = document.getElementById("datetime"); const el = document.getElementById("datetime");
if (!el) return; if (!el) return;
el.textContent = new Date().toLocaleString("de-DE");
const now = new Date(); }
const date = now.toLocaleDateString("de-DE"); updateDateTime();
setInterval(updateDateTime, 1000);
const time = now.toLocaleTimeString("de-DE", { })();
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
el.textContent = `${date} - ${time}`;
}
updateDateTime();
setInterval(updateDateTime, 1000);
})();

View File

@ -1,16 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const alerts = document.querySelectorAll(".auto-hide-flash");
if (!alerts.length) return;
setTimeout(() => {
alerts.forEach((el) => {
el.classList.add("flash-hide");
// nach der Animation aus dem DOM entfernen
setTimeout(() => {
el.remove();
}, 700);
});
}, 3000); // ✅ 3 Sekunden
});

View File

@ -1,15 +1,15 @@
/* document.addEventListener("DOMContentLoaded", () => { /* document.addEventListener("DOMContentLoaded", () => {
const invoiceForms = document.querySelectorAll(".invoice-form"); const invoiceForms = document.querySelectorAll(".invoice-form");
invoiceForms.forEach(form => { invoiceForms.forEach(form => {
form.addEventListener("submit", () => { form.addEventListener("submit", () => {
console.log("🧾 Rechnung erstellt Reload folgt"); console.log("🧾 Rechnung erstellt Reload folgt");
// kleiner Delay, damit Backend committen kann // kleiner Delay, damit Backend committen kann
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 1200); }, 1200);
}); });
}); });
}); });
*/ */

View File

@ -1,25 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const rows = document.querySelectorAll(".invoice-row");
const btn = document.getElementById("creditBtn");
const form = document.getElementById("creditForm");
let selectedId = null;
rows.forEach((row) => {
row.addEventListener("click", () => {
// Alte Markierung entfernen
rows.forEach((r) => r.classList.remove("table-active"));
// Neue markieren
row.classList.add("table-active");
selectedId = row.dataset.id;
// Button aktivieren
btn.disabled = false;
// Ziel setzen
form.action = `/invoices/${selectedId}/credit`;
});
});
});

View File

@ -1,14 +1,24 @@
/** document.addEventListener("DOMContentLoaded", () => {
* public/js/patient-select.js const radios = document.querySelectorAll(".patient-radio");
*
* Ersetzt den inline onchange="this.form.submit()" Handler if (!radios || radios.length === 0) return;
* an den Patienten-Radiobuttons (CSP-sicher).
*/ radios.forEach((radio) => {
document.addEventListener('DOMContentLoaded', function () { radio.addEventListener("change", async () => {
document.querySelectorAll('.patient-radio').forEach(function (radio) { const patientId = radio.value;
radio.addEventListener('change', function () {
var form = this.closest('form'); try {
if (form) form.submit(); await fetch("/patients/select", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ patientId }),
});
// ✅ neu laden -> Sidebar wird neu gerendert & Bearbeiten wird aktiv
window.location.reload();
} catch (err) {
console.error("❌ patient-select Fehler:", err);
}
}); });
}); });
}); });

View File

@ -1,124 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const radios = document.querySelectorAll(".patient-radio");
const sidebarPatientInfo = document.getElementById("sidebarPatientInfo");
const sbOverview = document.getElementById("sbOverview");
const sbHistory = document.getElementById("sbHistory");
const sbEdit = document.getElementById("sbEdit");
const sbMeds = document.getElementById("sbMeds");
const sbWaitingRoomWrapper = document.getElementById("sbWaitingRoomWrapper");
const sbActiveWrapper = document.getElementById("sbActiveWrapper");
const sbUploadForm = document.getElementById("sbUploadForm");
const sbUploadInput = document.getElementById("sbUploadInput");
const sbUploadBtn = document.getElementById("sbUploadBtn");
if (
!radios.length ||
!sidebarPatientInfo ||
!sbOverview ||
!sbHistory ||
!sbEdit ||
!sbMeds ||
!sbWaitingRoomWrapper ||
!sbActiveWrapper ||
!sbUploadForm ||
!sbUploadInput ||
!sbUploadBtn
) {
return;
}
// ✅ Sicherheit: Upload blocken falls nicht aktiv
sbUploadForm.addEventListener("submit", (e) => {
if (!sbUploadForm.action || sbUploadForm.action.endsWith("#")) {
e.preventDefault();
}
});
radios.forEach((radio) => {
radio.addEventListener("change", () => {
const id = radio.value;
const firstname = radio.dataset.firstname;
const lastname = radio.dataset.lastname;
const waiting = radio.dataset.waiting === "1";
const active = radio.dataset.active === "1";
// ✅ Patient Info
sidebarPatientInfo.innerHTML = `
<div class="patient-name">
<strong>${firstname} ${lastname}</strong>
</div>
<div class="patient-meta text-muted">
ID: ${id}
</div>
`;
// ✅ Übersicht
sbOverview.href = "/patients/" + id;
sbOverview.classList.remove("disabled");
// ✅ Verlauf
sbHistory.href = "/patients/" + id + "/overview";
sbHistory.classList.remove("disabled");
// ✅ Bearbeiten
sbEdit.href = "/patients/edit/" + id;
sbEdit.classList.remove("disabled");
// ✅ Medikamente
sbMeds.href = "/patients/" + id + "/medications";
sbMeds.classList.remove("disabled");
// ✅ Wartezimmer (NUR wenn Patient aktiv ist)
if (!active) {
sbWaitingRoomWrapper.innerHTML = `
<div class="nav-item disabled">
<i class="bi bi-door-open"></i> Ins Wartezimmer (Patient inaktiv)
</div>
`;
} else if (waiting) {
sbWaitingRoomWrapper.innerHTML = `
<div class="nav-item disabled">
<i class="bi bi-hourglass-split"></i> Wartet bereits
</div>
`;
} else {
sbWaitingRoomWrapper.innerHTML = `
<form method="POST" action="/patients/waiting-room/${id}" style="margin:0;">
<button type="submit" class="nav-item nav-btn">
<i class="bi bi-door-open"></i> Ins Wartezimmer
</button>
</form>
`;
}
// ✅ Sperren / Entsperren
if (active) {
sbActiveWrapper.innerHTML = `
<form method="POST" action="/patients/deactivate/${id}" style="margin:0;">
<button type="submit" class="nav-item nav-btn">
<i class="bi bi-lock-fill"></i> Sperren
</button>
</form>
`;
} else {
sbActiveWrapper.innerHTML = `
<form method="POST" action="/patients/activate/${id}" style="margin:0;">
<button type="submit" class="nav-item nav-btn">
<i class="bi bi-unlock-fill"></i> Entsperren
</button>
</form>
`;
}
// ✅ Upload nur aktiv wenn Patient ausgewählt
sbUploadForm.action = "/patients/" + id + "/files";
sbUploadInput.disabled = false;
sbUploadBtn.disabled = false;
});
});
});

View File

@ -1,101 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const canvas = document.getElementById("statusChart");
const dataEl = document.getElementById("stats-data");
const legendEl = document.getElementById("custom-legend");
if (!canvas || !dataEl || !legendEl) {
console.error("❌ Chart, Daten oder Legende fehlen");
return;
}
let data;
try {
data = JSON.parse(dataEl.textContent);
} catch (err) {
console.error("❌ JSON Fehler:", err);
return;
}
console.log("📊 REPORT DATA:", data);
// Labels & Werte vorbereiten
const labels = data.map((d) => d.status.replace("_", " ").toUpperCase());
const values = data.map((d) => Number(d.total));
// Euro Format
const formatEuro = (value) =>
value.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
});
// Farben passend zu Status
const colors = [
"#ffc107", // open
"#28a745", // paid
"#dc3545", // cancelled
"#6c757d", // credit
];
// Chart erzeugen
const chart = new Chart(canvas, {
type: "pie",
data: {
labels,
datasets: [
{
data: values,
backgroundColor: colors,
},
],
},
options: {
responsive: true,
plugins: {
// ❗ Eigene Legende → Chart-Legende aus
legend: {
display: false,
},
tooltip: {
callbacks: {
label(context) {
return formatEuro(context.parsed);
},
},
},
},
},
});
// ----------------------------
// Eigene Legende bauen (HTML)
// ----------------------------
legendEl.innerHTML = "";
labels.forEach((label, i) => {
const row = document.createElement("div");
row.className = "legend-row";
row.innerHTML = `
<span
class="legend-color"
style="background:${colors[i]}"
></span>
<span class="legend-text">
${label}: ${formatEuro(values[i])}
</span>
`;
legendEl.appendChild(row);
});
});

View File

@ -1,14 +1,14 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const searchInput = document.getElementById("serviceSearch"); const searchInput = document.getElementById("serviceSearch");
const select = document.getElementById("serviceSelect"); const select = document.getElementById("serviceSelect");
if (!searchInput || !select) return; if (!searchInput || !select) return;
searchInput.addEventListener("input", function () { searchInput.addEventListener("input", function () {
const filter = this.value.toLowerCase(); const filter = this.value.toLowerCase();
Array.from(select.options).forEach(option => { Array.from(select.options).forEach(option => {
option.hidden = !option.text.toLowerCase().includes(filter); option.hidden = !option.text.toLowerCase().includes(filter);
}); });
}); });
}); });

View File

@ -1,28 +1,28 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".lock-btn").forEach(btn => { document.querySelectorAll(".lock-btn").forEach(btn => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const row = btn.closest("tr"); const row = btn.closest("tr");
// Alle Zeilen sperren // Alle Zeilen sperren
document.querySelectorAll("tr").forEach(r => { document.querySelectorAll("tr").forEach(r => {
r.querySelectorAll("input").forEach(i => i.disabled = true); r.querySelectorAll("input").forEach(i => i.disabled = true);
const save = r.querySelector(".save-btn"); const save = r.querySelector(".save-btn");
if (save) save.disabled = true; if (save) save.disabled = true;
}); });
// Aktuelle Zeile entsperren // Aktuelle Zeile entsperren
row.querySelectorAll("input").forEach(i => i.disabled = false); row.querySelectorAll("input").forEach(i => i.disabled = false);
row.querySelector(".save-btn").disabled = false; row.querySelector(".save-btn").disabled = false;
// Button ändern // Button ändern
btn.textContent = "🔒"; btn.textContent = "🔒";
btn.title = "Bearbeitung gesperrt"; btn.title = "Bearbeitung gesperrt";
// Fokus // Fokus
const firstInput = row.querySelector("input"); const firstInput = row.querySelector("input");
if (firstInput) firstInput.focus(); if (firstInput) firstInput.focus();
}); });
}); });
}); });

View File

@ -1,24 +0,0 @@
/**
* public/js/sidebar-lock.js
*
* Fängt Klicks auf gesperrte Menüpunkte ab und zeigt einen
* Bootstrap-Toast statt auf eine Fehlerseite zu navigieren.
*
* Voraussetzung: bootstrap.bundle.min.js ist geladen.
*/
document.addEventListener('DOMContentLoaded', function () {
const toastEl = document.getElementById('lockToast');
const toastMsg = document.getElementById('lockToastMsg');
if (!toastEl || !toastMsg) return;
const toast = bootstrap.Toast.getOrCreateInstance(toastEl, { delay: 3000 });
document.querySelectorAll('.nav-item[data-locked]').forEach(function (link) {
link.addEventListener('click', function (e) {
e.preventDefault();
toastMsg.textContent = link.dataset.locked;
toast.show();
});
});
});

View File

@ -5,9 +5,6 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const { exec } = require("child_process"); const { exec } = require("child_process");
const multer = require("multer"); const multer = require("multer");
const { NodeSSH } = require("node-ssh");
const uploadLogo = require("../middleware/uploadLogo");
// ✅ Upload Ordner für Restore Dumps // ✅ Upload Ordner für Restore Dumps
const upload = multer({ dest: path.join(__dirname, "..", "uploads_tmp") }); const upload = multer({ dest: path.join(__dirname, "..", "uploads_tmp") });
@ -24,7 +21,7 @@ const {
updateUser, updateUser,
} = require("../controllers/admin.controller"); } = require("../controllers/admin.controller");
const { requireArztOrMitarbeiter, requireAdmin } = require("../middleware/auth.middleware"); const { requireArzt, requireAdmin } = require("../middleware/auth.middleware");
// ✅ config.enc Manager // ✅ config.enc Manager
const { loadConfig, saveConfig } = require("../config-manager"); const { loadConfig, saveConfig } = require("../config-manager");
@ -32,13 +29,6 @@ const { loadConfig, saveConfig } = require("../config-manager");
// ✅ DB (für resetPool) // ✅ DB (für resetPool)
const db = require("../db"); const db = require("../db");
// ✅ Firmendaten
const {
getCompanySettings,
saveCompanySettings
} = require("../controllers/companySettings.controller");
/* ========================== /* ==========================
VERWALTUNG (NUR ADMIN) VERWALTUNG (NUR ADMIN)
========================== */ ========================== */
@ -319,37 +309,33 @@ router.post("/database", requireAdmin, async (req, res) => {
/* ========================== /* ==========================
BACKUP (NUR ADMIN) BACKUP (NUR ADMIN)
========================== */ ========================== */
router.post("/database/backup", requireAdmin, async (req, res) => { router.post("/database/backup", requireAdmin, (req, res) => {
// ✅ Flash Safe (funktioniert auch ohne req.flash)
function flashSafe(type, msg) { function flashSafe(type, msg) {
if (typeof req.flash === "function") return req.flash(type, msg); if (typeof req.flash === "function") {
req.flash(type, msg);
return;
}
req.session.flash = req.session.flash || []; req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg }); req.session.flash.push({ type, message: msg });
console.log(`[FLASH-${type}]`, msg); console.log(`[FLASH-${type}]`, msg);
} }
try { try {
const cfg = loadConfig(); const cfg = loadConfig();
if (!cfg?.db) { if (!cfg?.db) {
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt)."); flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} }
const { host, port, user, password, name } = cfg.db; const { host, user, password, name } = cfg.db;
// ✅ Programmserver Backup Dir
const backupDir = path.join(__dirname, "..", "backups"); const backupDir = path.join(__dirname, "..", "backups");
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir); if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir);
// ✅ SSH Ziel (DB-Server)
const sshHost = process.env.DBSERVER_HOST;
const sshUser = process.env.DBSERVER_USER;
const sshPort = Number(process.env.DBSERVER_PORT || 22);
if (!sshHost || !sshUser) {
flashSafe("danger", "❌ SSH Config fehlt (DBSERVER_HOST / DBSERVER_USER).");
return res.redirect("/admin/database");
}
const stamp = new Date() const stamp = new Date()
.toISOString() .toISOString()
.replace(/T/, "_") .replace(/T/, "_")
@ -357,134 +343,120 @@ router.post("/database/backup", requireAdmin, async (req, res) => {
.split(".")[0]; .split(".")[0];
const fileName = `${name}_${stamp}.sql`; const fileName = `${name}_${stamp}.sql`;
const filePath = path.join(backupDir, fileName);
// ✅ Datei wird zuerst auf DB-Server erstellt (tmp) // ✅ mysqldump.exe im Root
const remoteTmpPath = `/tmp/${fileName}`; const mysqldumpPath = path.join(__dirname, "..", "mysqldump.exe");
// ✅ Datei wird dann lokal (Programmserver) gespeichert // ✅ plugin Ordner im Root (muss existieren)
const localPath = path.join(backupDir, fileName); const pluginDir = path.join(__dirname, "..", "plugin");
const ssh = new NodeSSH(); if (!fs.existsSync(mysqldumpPath)) {
await ssh.connect({ flashSafe("danger", "❌ mysqldump.exe nicht gefunden: " + mysqldumpPath);
host: sshHost,
username: sshUser,
port: sshPort,
privateKeyPath: "/home/cay/.ssh/id_ed25519",
});
// ✅ 1) Dump auf DB-Server erstellen
const dumpCmd =
`mysqldump -h "${host}" -P "${port || 3306}" -u "${user}" -p'${password}' "${name}" > "${remoteTmpPath}"`;
const dumpRes = await ssh.execCommand(dumpCmd);
if (dumpRes.code !== 0) {
ssh.dispose();
flashSafe("danger", "❌ Backup fehlgeschlagen: " + (dumpRes.stderr || "mysqldump Fehler"));
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} }
// ✅ 2) Dump Datei vom DB-Server auf Programmserver kopieren if (!fs.existsSync(pluginDir)) {
await ssh.getFile(localPath, remoteTmpPath); flashSafe("danger", "❌ plugin Ordner nicht gefunden: " + pluginDir);
return res.redirect("/admin/database");
}
// ✅ 3) Temp Datei auf DB-Server löschen const cmd = `"${mysqldumpPath}" --plugin-dir="${pluginDir}" -h ${host} -u ${user} -p${password} ${name} > "${filePath}"`;
await ssh.execCommand(`rm -f "${remoteTmpPath}"`);
ssh.dispose(); exec(cmd, (error, stdout, stderr) => {
if (error) {
console.error("❌ BACKUP ERROR:", error);
console.error("STDERR:", stderr);
flashSafe("success", `✅ Backup gespeichert (Programmserver): ${fileName}`); flashSafe(
return res.redirect("/admin/database"); "danger",
"❌ Backup fehlgeschlagen: " + (stderr || error.message),
);
return res.redirect("/admin/database");
}
flashSafe("success", `✅ Backup erstellt: ${fileName}`);
return res.redirect("/admin/database");
});
} catch (err) { } catch (err) {
console.error("❌ BACKUP SSH ERROR:", err); console.error("❌ BACKUP ERROR:", err);
flashSafe("danger", "❌ Backup fehlgeschlagen: " + err.message); flashSafe("danger", "❌ Backup fehlgeschlagen: " + err.message);
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} }
}); });
/* ========================== /* ==========================
RESTORE (NUR ADMIN) RESTORE (NUR ADMIN)
========================== */ ========================== */
router.post("/database/restore", requireAdmin, async (req, res) => { router.post("/database/restore", requireAdmin, (req, res) => {
function flashSafe(type, msg) { function flashSafe(type, msg) {
if (typeof req.flash === "function") return req.flash(type, msg); if (typeof req.flash === "function") {
req.flash(type, msg);
return;
}
req.session.flash = req.session.flash || []; req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg }); req.session.flash.push({ type, message: msg });
console.log(`[FLASH-${type}]`, msg); console.log(`[FLASH-${type}]`, msg);
} }
const ssh = new NodeSSH();
try { try {
const cfg = loadConfig(); const cfg = loadConfig();
if (!cfg?.db) { if (!cfg?.db) {
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt)."); flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} }
const { host, port, user, password, name } = cfg.db; const { host, user, password, name } = cfg.db;
const backupFile = req.body.backupFile;
if (!backupFile) {
flashSafe("danger", "❌ Kein Backup ausgewählt.");
return res.redirect("/admin/database");
}
if (backupFile.includes("..") || backupFile.includes("/") || backupFile.includes("\\")) {
flashSafe("danger", "❌ Ungültiger Dateiname.");
return res.redirect("/admin/database");
}
const backupDir = path.join(__dirname, "..", "backups"); const backupDir = path.join(__dirname, "..", "backups");
const localPath = path.join(backupDir, backupFile); const selectedFile = req.body.backupFile;
if (!fs.existsSync(localPath)) { if (!selectedFile) {
flashSafe("danger", "❌ Datei nicht gefunden (Programmserver): " + backupFile); flashSafe("danger", "❌ Bitte ein Backup auswählen.");
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} }
const sshHost = process.env.DBSERVER_HOST; const fullPath = path.join(backupDir, selectedFile);
const sshUser = process.env.DBSERVER_USER;
const sshPort = Number(process.env.DBSERVER_PORT || 22);
if (!sshHost || !sshUser) { if (!fs.existsSync(fullPath)) {
flashSafe("danger", "❌ SSH Config fehlt (DBSERVER_HOST / DBSERVER_USER)."); flashSafe("danger", "❌ Backup Datei nicht gefunden: " + selectedFile);
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} }
const remoteTmpPath = `/tmp/${backupFile}`; // ✅ mysql.exe im Root
const mysqlPath = path.join(__dirname, "..", "mysql.exe");
const pluginDir = path.join(__dirname, "..", "plugin");
await ssh.connect({ if (!fs.existsSync(mysqlPath)) {
host: sshHost, flashSafe("danger", "❌ mysql.exe nicht gefunden im Root: " + mysqlPath);
username: sshUser, return res.redirect("/admin/database");
port: sshPort, }
privateKeyPath: "/home/cay/.ssh/id_ed25519",
const cmd = `"${mysqlPath}" --plugin-dir="${pluginDir}" -h ${host} -u ${user} -p${password} ${name} < "${fullPath}"`;
exec(cmd, (error, stdout, stderr) => {
if (error) {
console.error("❌ RESTORE ERROR:", error);
console.error("STDERR:", stderr);
flashSafe(
"danger",
"❌ Restore fehlgeschlagen: " + (stderr || error.message),
);
return res.redirect("/admin/database");
}
flashSafe(
"success",
"✅ Restore erfolgreich abgeschlossen: " + selectedFile,
);
return res.redirect("/admin/database");
}); });
await ssh.putFile(localPath, remoteTmpPath);
const restoreCmd =
`mysql -h "${host}" -P "${port || 3306}" -u "${user}" -p'${password}' "${name}" < "${remoteTmpPath}"`;
const restoreRes = await ssh.execCommand(restoreCmd);
await ssh.execCommand(`rm -f "${remoteTmpPath}"`);
if (restoreRes.code !== 0) {
flashSafe("danger", "❌ Restore fehlgeschlagen: " + (restoreRes.stderr || "mysql Fehler"));
return res.redirect("/admin/database");
}
flashSafe("success", `✅ Restore erfolgreich: ${backupFile}`);
return res.redirect("/admin/database");
} catch (err) { } catch (err) {
console.error("❌ RESTORE SSH ERROR:", err); console.error("❌ RESTORE ERROR:", err);
flashSafe("danger", "❌ Restore fehlgeschlagen: " + err.message); flashSafe("danger", "❌ Restore fehlgeschlagen: " + err.message);
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} finally {
try {
ssh.dispose();
} catch (e) {}
} }
}); });
@ -493,20 +465,4 @@ router.post("/database/restore", requireAdmin, async (req, res) => {
========================== */ ========================== */
router.get("/invoices", requireAdmin, showInvoiceOverview); router.get("/invoices", requireAdmin, showInvoiceOverview);
/* ==========================
Firmendaten
========================== */
router.get(
"/company-settings",
requireAdmin,
getCompanySettings
);
router.post(
"/company-settings",
requireAdmin,
uploadLogo.single("logo"),
saveCompanySettings
);
module.exports = router; module.exports = router;

View File

@ -1,11 +1,11 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { const {
getLogin, getLogin,
postLogin postLogin
} = require("../controllers/auth.controller"); } = require("../controllers/auth.controller");
router.get("/", getLogin); router.get("/", getLogin);
router.post("/login", postLogin); router.post("/login", postLogin);
module.exports = router; module.exports = router;

View File

@ -1,33 +0,0 @@
/**
* routes/calendar.routes.js
*
* Einbinden in app.js:
* const calendarRoutes = require("./routes/calendar.routes");
* app.use("/calendar", calendarRoutes);
*/
const express = require("express");
const router = express.Router();
const { requireArztOrMitarbeiter } = require("../middleware/auth.middleware");
const ctrl = require("../controllers/calendar.controller");
// ── Seite ────────────────────────────────────────────────────────────────────
router.get("/", requireArztOrMitarbeiter, ctrl.index);
// ── Appointments API ─────────────────────────────────────────────────────────
router.get( "/api/appointments/:date", requireArztOrMitarbeiter, ctrl.getAppointments);
router.post("/api/appointments", requireArztOrMitarbeiter, ctrl.createAppointment);
router.put( "/api/appointments/:id", requireArztOrMitarbeiter, ctrl.updateAppointment);
router.patch("/api/appointments/:id/status", requireArztOrMitarbeiter, ctrl.patchStatus);
router.delete("/api/appointments/:id", requireArztOrMitarbeiter, ctrl.deleteAppointment);
// ── Patienten-Suche (Autocomplete) ───────────────────────────────────────────
router.get("/api/patients/search", requireArztOrMitarbeiter, ctrl.searchPatients);
// ── Feiertage API ─────────────────────────────────────────────────────────────
router.get("/api/holidays/:year", requireArztOrMitarbeiter, ctrl.getHolidays);
// ── Arzt-Farbe ────────────────────────────────────────────────────────────────
router.patch("/api/doctors/:id/color", requireArztOrMitarbeiter, ctrl.updateDoctorColor);
module.exports = router;

View File

@ -1,21 +1,19 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { requireAdmin } = require("../middleware/auth.middleware"); const { requireArzt } = require("../middleware/auth.middleware");
const uploadLogo = require("../middleware/uploadLogo"); const uploadLogo = require("../middleware/uploadLogo");
const { const {
getCompanySettings, getCompanySettings,
saveCompanySettings, saveCompanySettings,
} = require("../controllers/companySettings.controller"); } = require("../controllers/companySettings.controller");
// ✅ NUR der relative Pfad router.get("/admin/company-settings", requireArzt, getCompanySettings);
router.get("/company-settings", requireAdmin, getCompanySettings);
router.post(
router.post( "/admin/company-settings",
"/company-settings", requireArzt,
requireAdmin, uploadLogo.single("logo"), // 🔑 MUSS VOR DEM CONTROLLER KOMMEN
uploadLogo.single("logo"), saveCompanySettings,
saveCompanySettings );
);
module.exports = router;
module.exports = router;

View File

@ -1,14 +1,14 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { const {
showDashboard showDashboard
} = require("../controllers/dashboard.controller"); } = require("../controllers/dashboard.controller");
const { const {
requireLogin requireLogin
} = require("../middleware/auth.middleware"); } = require("../middleware/auth.middleware");
router.get("/", requireLogin, showDashboard); router.get("/", requireLogin, showDashboard);
module.exports = router; module.exports = router;

View File

@ -1,40 +1,8 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { requireArzt } = require("../middleware/auth.middleware");
const { requireArztOrMitarbeiter } = require("../middleware/auth.middleware"); const { createInvoicePdf } = require("../controllers/invoicePdf.controller");
const { createInvoicePdf } = require("../controllers/invoicePdf.controller");
const { router.post("/patients/:id/create-invoice", requireArzt, createInvoicePdf);
openInvoices,
markAsPaid, module.exports = router;
cancelInvoice,
cancelledInvoices,
paidInvoices,
createCreditNote,
creditOverview,
} = require("../controllers/invoice.controller");
// ✅ NEU: Offene Rechnungen anzeigen
router.get("/open", requireArztOrMitarbeiter, openInvoices);
// Bezahlt
router.post("/:id/pay", requireArztOrMitarbeiter, markAsPaid);
// Storno
router.post("/:id/cancel", requireArztOrMitarbeiter, cancelInvoice);
// Bestehend
router.post("/patients/:id/create-invoice", requireArztOrMitarbeiter, createInvoicePdf);
// Stornierte Rechnungen mit Jahr
router.get("/cancelled", requireArztOrMitarbeiter, cancelledInvoices);
// Bezahlte Rechnungen
router.get("/paid", requireArztOrMitarbeiter, paidInvoices);
// Gutschrift erstellen
router.post("/:id/credit", requireArztOrMitarbeiter, createCreditNote);
// Gutschriften-Übersicht
router.get("/credit-overview", requireArztOrMitarbeiter, creditOverview);
module.exports = router;

View File

@ -1,29 +1,29 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { requireLogin } = require("../middleware/auth.middleware"); const { requireLogin } = require("../middleware/auth.middleware");
const { const {
listMedications, listMedications,
updateMedication, updateMedication,
toggleMedication, toggleMedication,
showCreateMedication, showCreateMedication,
createMedication, createMedication,
} = require("../controllers/medication.controller"); } = require("../controllers/medication.controller");
console.log("✅ medication.routes geladen"); console.log("✅ medication.routes geladen");
router.get("/", requireLogin, listMedications); router.get("/", requireLogin, listMedications);
// 🆕 Formular anzeigen // 🆕 Formular anzeigen
router.get("/create", requireLogin, showCreateMedication); router.get("/create", requireLogin, showCreateMedication);
// 🆕 Speichern // 🆕 Speichern
router.post("/create", requireLogin, createMedication); router.post("/create", requireLogin, createMedication);
// 🆕 UPDATE pro Zeile // 🆕 UPDATE pro Zeile
router.post("/update/:id", requireLogin, updateMedication); router.post("/update/:id", requireLogin, updateMedication);
// 🆕 Toggle // 🆕 Toggle
router.post("/toggle/:id", requireLogin, toggleMedication); router.post("/toggle/:id", requireLogin, toggleMedication);
module.exports = router; module.exports = router;

View File

@ -1,89 +1,89 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { const {
listPatients, listPatients,
showCreatePatient, showCreatePatient,
createPatient, createPatient,
showEditPatient, showEditPatient,
updatePatient, updatePatient,
showPatientMedications, showPatientMedications,
moveToWaitingRoom, moveToWaitingRoom,
showWaitingRoom, showWaitingRoom,
showPatientOverview, showPatientOverview,
addPatientNote, addPatientNote,
callFromWaitingRoom, callFromWaitingRoom,
dischargePatient, dischargePatient,
showMedicationPlan, showMedicationPlan,
movePatientToWaitingRoom, movePatientToWaitingRoom,
deactivatePatient, deactivatePatient,
activatePatient, activatePatient,
showPatientOverviewDashborad, showPatientOverviewDashborad,
assignMedicationToPatient, assignMedicationToPatient,
} = require("../controllers/patient.controller"); } = require("../controllers/patient.controller");
// ✅ WICHTIG: middleware export ist ein Object → destructuring! // ✅ WICHTIG: middleware export ist ein Object → destructuring!
const { requireLogin } = require("../middleware/auth.middleware"); const { requireLogin } = require("../middleware/auth.middleware");
/* ========================================= /* =========================================
PATIENT SELECT (Radiobutton -> Session) PATIENT SELECT (Radiobutton -> Session)
========================================= */ ========================================= */
router.post("/select", requireLogin, (req, res) => { router.post("/select", requireLogin, (req, res) => {
try { try {
const patientId = req.body.patientId; const patientId = req.body.patientId;
if (!patientId) { if (!patientId) {
req.session.selectedPatientId = null; req.session.selectedPatientId = null;
return res.json({ ok: true, selectedPatientId: null }); return res.json({ ok: true, selectedPatientId: null });
} }
req.session.selectedPatientId = parseInt(patientId, 10); req.session.selectedPatientId = parseInt(patientId, 10);
return res.json({ return res.json({
ok: true, ok: true,
selectedPatientId: req.session.selectedPatientId, selectedPatientId: req.session.selectedPatientId,
}); });
} catch (err) { } catch (err) {
console.error("❌ Fehler /patients/select:", err); console.error("❌ Fehler /patients/select:", err);
return res.status(500).json({ ok: false }); return res.status(500).json({ ok: false });
} }
}); });
/* ========================================= /* =========================================
PATIENT ROUTES PATIENT ROUTES
========================================= */ ========================================= */
router.get("/", requireLogin, listPatients); router.get("/", requireLogin, listPatients);
router.get("/create", requireLogin, showCreatePatient); router.get("/create", requireLogin, showCreatePatient);
router.post("/create", requireLogin, createPatient); router.post("/create", requireLogin, createPatient);
router.get("/waiting-room", requireLogin, showWaitingRoom); router.get("/waiting-room", requireLogin, showWaitingRoom);
router.post("/waiting-room/:id", requireLogin, moveToWaitingRoom); router.post("/waiting-room/:id", requireLogin, moveToWaitingRoom);
router.post( router.post(
"/:id/back-to-waiting-room", "/:id/back-to-waiting-room",
requireLogin, requireLogin,
movePatientToWaitingRoom, movePatientToWaitingRoom,
); );
router.get("/edit/:id", requireLogin, showEditPatient); router.get("/edit/:id", requireLogin, showEditPatient);
router.post("/update/:id", requireLogin, updatePatient); router.post("/update/:id", requireLogin, updatePatient);
router.get("/:id/medications", requireLogin, showPatientMedications); router.get("/:id/medications", requireLogin, showPatientMedications);
router.post("/:id/medications", requireLogin, assignMedicationToPatient); router.post("/:id/medications", requireLogin, assignMedicationToPatient);
router.get("/:id/overview", requireLogin, showPatientOverview); router.get("/:id/overview", requireLogin, showPatientOverview);
router.post("/:id/notes", requireLogin, addPatientNote); router.post("/:id/notes", requireLogin, addPatientNote);
router.get("/:id/plan", requireLogin, showMedicationPlan); router.get("/:id/plan", requireLogin, showMedicationPlan);
router.post("/:id/call", requireLogin, callFromWaitingRoom); router.post("/:id/call", requireLogin, callFromWaitingRoom);
router.post("/:id/discharge", requireLogin, dischargePatient); router.post("/:id/discharge", requireLogin, dischargePatient);
router.post("/deactivate/:id", requireLogin, deactivatePatient); router.post("/deactivate/:id", requireLogin, deactivatePatient);
router.post("/activate/:id", requireLogin, activatePatient); router.post("/activate/:id", requireLogin, activatePatient);
// ✅ Patient Dashboard // ✅ Patient Dashboard
router.get("/:id", requireLogin, showPatientOverviewDashborad); router.get("/:id", requireLogin, showPatientOverviewDashborad);
module.exports = router; module.exports = router;

View File

@ -1,19 +1,19 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const upload = require("../middleware/upload.middleware"); const upload = require("../middleware/upload.middleware");
const { requireLogin } = require("../middleware/auth.middleware"); const { requireLogin } = require("../middleware/auth.middleware");
const { uploadPatientFile } = require("../controllers/patientFile.controller"); const { uploadPatientFile } = require("../controllers/patientFile.controller");
router.post( router.post(
"/patients/:id/files", "/patients/:id/files",
requireLogin, requireLogin,
(req, res, next) => { (req, res, next) => {
console.log("📥 UPLOAD ROUTE GETROFFEN"); console.log("📥 UPLOAD ROUTE GETROFFEN");
next(); next();
}, },
upload.single("file"), upload.single("file"),
uploadPatientFile uploadPatientFile
); );
module.exports = router; module.exports = router;

View File

@ -1,15 +1,15 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { requireArztOrMitarbeiter } = require("../middleware/auth.middleware"); const { requireArzt } = require("../middleware/auth.middleware");
const { const {
addMedication, addMedication,
endMedication, endMedication,
deleteMedication, deleteMedication,
} = require("../controllers/patientMedication.controller"); } = require("../controllers/patientMedication.controller");
router.post("/:id/medications", requireArztOrMitarbeiter, addMedication); router.post("/:id/medications", requireArzt, addMedication);
router.post("/patient-medications/end/:id", requireArztOrMitarbeiter, endMedication); router.post("/patient-medications/end/:id", requireArzt, endMedication);
router.post("/patient-medications/delete/:id", requireArztOrMitarbeiter, deleteMedication); router.post("/patient-medications/delete/:id", requireArzt, deleteMedication);
module.exports = router; module.exports = router;

View File

@ -1,7 +1,7 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { requireLogin, requireArztOrMitarbeiter } = require("../middleware/auth.middleware"); const { requireLogin, requireArzt } = require("../middleware/auth.middleware");
const { const {
addPatientService, addPatientService,
deletePatientService, deletePatientService,
@ -10,10 +10,10 @@ const {
} = require("../controllers/patientService.controller"); } = require("../controllers/patientService.controller");
router.post("/:id/services", requireLogin, addPatientService); router.post("/:id/services", requireLogin, addPatientService);
router.post("/services/delete/:id", requireArztOrMitarbeiter, deletePatientService); router.post("/services/delete/:id", requireArzt, deletePatientService);
router.post( router.post(
"/services/update-price/:id", "/services/update-price/:id",
requireArztOrMitarbeiter, requireArzt,
updatePatientServicePrice, updatePatientServicePrice,
); );
router.post("/services/update-quantity/:id", updatePatientServiceQuantity); router.post("/services/update-quantity/:id", updatePatientServiceQuantity);

View File

@ -1,8 +0,0 @@
const express = require("express");
const router = express.Router();
const { requireArztOrMitarbeiter } = require("../middleware/auth.middleware");
const { statusReport } = require("../controllers/report.controller");
router.get("/", requireArztOrMitarbeiter, statusReport);
module.exports = router;

View File

@ -1,7 +1,7 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { requireLogin, requireArztOrMitarbeiter } = require("../middleware/auth.middleware"); const { requireLogin, requireArzt } = require("../middleware/auth.middleware");
const { const {
listServices, listServices,
showCreateService, showCreateService,
@ -14,12 +14,12 @@ const {
} = require("../controllers/service.controller"); } = require("../controllers/service.controller");
router.get("/", requireLogin, listServicesAdmin); router.get("/", requireLogin, listServicesAdmin);
router.get("/", requireArztOrMitarbeiter, listServices); router.get("/", requireArzt, listServices);
router.get("/create", requireArztOrMitarbeiter, showCreateService); router.get("/create", requireArzt, showCreateService);
router.post("/create", requireArztOrMitarbeiter, createService); router.post("/create", requireArzt, createService);
router.post("/:id/update-price", requireArztOrMitarbeiter, updateServicePrice); router.post("/:id/update-price", requireArzt, updateServicePrice);
router.post("/:id/toggle", requireArztOrMitarbeiter, toggleService); router.post("/:id/toggle", requireArzt, toggleService);
router.get("/open", requireLogin, listOpenServices); router.get("/open", requireLogin, listOpenServices);
router.get("/logs", requireArztOrMitarbeiter, showServiceLogs); router.get("/logs", requireArzt, showServiceLogs);
module.exports = router; module.exports = router;

View File

@ -1,181 +0,0 @@
const express = require("express");
const router = express.Router();
const mysql = require("mysql2/promise");
// ✅ nutzt deinen bestehenden config-manager (NICHT utils/config!)
const { configExists, loadConfig, saveConfig } = require("../config-manager");
// ✅ DB + Session Reset (wie in deiner app.js)
const db = require("../db");
const { resetSessionStore } = require("../config/session");
/**
* Setup ist immer erreichbar auch wenn config.enc schon existiert.
* So kann die DB-Verbindung jederzeit korrigiert werden.
* Schutz: Nur wenn DB bereits erreichbar ist UND User eingeloggt ist blockieren.
*/
function blockIfInstalled(req, res, next) {
// Immer durchlassen Setup muss auch zur Korrektur nutzbar sein
next();
}
/**
* Setup Form anzeigen vorhandene Werte aus config.enc als Defaults laden
*/
router.get("/", blockIfInstalled, (req, res) => {
// Bestehende Config als Vorausfüllung laden (Passwort bleibt leer)
let existing = {};
try {
if (configExists()) {
const cfg = loadConfig();
existing = cfg?.db || {};
}
} catch (e) {
existing = {};
}
return res.render("setup/index", {
title: configExists() ? "DB-Verbindung ändern" : "Erstinstallation",
isUpdate: configExists(),
defaults: {
host: existing.host || "85.215.63.122",
port: existing.port || 3306,
user: existing.user || "",
password: "", // Passwort aus Sicherheitsgründen nie vorausfüllen
name: existing.name || "",
},
});
});
/**
* Passwort auflösen: wenn leer altes Passwort aus config.enc nehmen
*/
function resolvePassword(inputPassword) {
if (inputPassword && inputPassword.trim() !== "") {
return inputPassword;
}
// Passwort-Feld leer → altes Passwort aus bestehender Config beibehalten
try {
if (configExists()) {
const old = loadConfig();
return old?.db?.password || "";
}
} catch (e) {
/* ignore */
}
return "";
}
/**
* Verbindung testen (AJAX)
*/
router.post("/test", blockIfInstalled, async (req, res) => {
try {
const { host, port, user, name } = req.body;
const password = resolvePassword(req.body.password);
if (!host || !user || !name) {
return res.status(400).json({
ok: false,
message: "Bitte Host, Benutzer und Datenbankname ausfüllen.",
});
}
const connection = await mysql.createConnection({
host: host.trim(),
port: Number(port || 3306),
user: user.trim(),
password,
database: name.trim(),
connectTimeout: 6000,
});
await connection.query("SELECT 1");
await connection.end();
return res.json({ ok: true, message: "✅ Verbindung erfolgreich!" });
} catch (err) {
return res.status(500).json({
ok: false,
message: "❌ Verbindung fehlgeschlagen: " + err.message,
});
}
});
/**
* Setup speichern (DB Daten in config.enc)
*/
router.post("/", blockIfInstalled, async (req, res) => {
try {
const { host, port, user, name } = req.body;
// Passwort: leer = altes Passwort beibehalten
const password = resolvePassword(req.body.password);
if (!host || !user || !name) {
req.session.flash = req.session.flash || [];
req.session.flash.push({
type: "danger",
message: "❌ Bitte Host, Benutzer und Datenbankname ausfüllen.",
});
return res.redirect("/setup");
}
// ✅ Verbindung testen bevor speichern
let connection;
try {
connection = await mysql.createConnection({
host: host.trim(),
port: Number(port || 3306),
user: user.trim(),
password,
database: name.trim(),
connectTimeout: 6000,
});
await connection.query("SELECT 1");
await connection.end();
} catch (connErr) {
req.session.flash = req.session.flash || [];
req.session.flash.push({
type: "danger",
message: "❌ DB-Verbindung fehlgeschlagen: " + connErr.message,
});
return res.redirect("/setup");
}
// ✅ In config.enc speichern
saveConfig({
db: {
host: host.trim(),
port: Number(port || 3306),
user: user.trim(),
password,
name: name.trim(),
},
});
// ✅ DB Pool neu initialisieren (neue Config sofort aktiv)
if (typeof db.resetPool === "function") {
db.resetPool();
}
// ✅ Session Store neu initialisieren
resetSessionStore();
req.session.flash = req.session.flash || [];
req.session.flash.push({
type: "success",
message: "✅ DB-Verbindung gespeichert. Du kannst dich jetzt einloggen.",
});
return res.redirect("/login");
} catch (err) {
req.session.flash = req.session.flash || [];
req.session.flash.push({
type: "danger",
message: "❌ Setup fehlgeschlagen: " + err.message,
});
return res.redirect("/setup");
}
});
module.exports = router;

View File

@ -1,12 +1,12 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { requireLogin } = require("../middleware/auth.middleware"); const { requireLogin } = require("../middleware/auth.middleware");
const { const {
showWaitingRoom, showWaitingRoom,
movePatientToWaitingRoom movePatientToWaitingRoom
} = require("../controllers/patient.controller"); } = require("../controllers/patient.controller");
router.get("/waiting-room", requireLogin, showWaitingRoom); router.get("/waiting-room", requireLogin, showWaitingRoom);
router.post( "/patients/:id/waiting-room", requireLogin, movePatientToWaitingRoom); router.post( "/patients/:id/waiting-room", requireLogin, movePatientToWaitingRoom);
module.exports = router; module.exports = router;

View File

@ -1,93 +1,93 @@
const bcrypt = require("bcrypt"); const bcrypt = require("bcrypt");
async function createUser( async function createUser(
db, db,
title, title,
first_name, first_name,
last_name, last_name,
username, username,
password, password,
role, role,
fachrichtung, fachrichtung,
arztnummer arztnummer
) { ) {
const hash = await bcrypt.hash(password, 10); const hash = await bcrypt.hash(password, 10);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.query( db.query(
`INSERT INTO users `INSERT INTO users
(title, first_name, last_name, username, password, role, fachrichtung, arztnummer, active) (title, first_name, last_name, username, password, role, fachrichtung, arztnummer, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)`,
[ [
title, title,
first_name, first_name,
last_name, last_name,
username, username,
hash, hash,
role, role,
fachrichtung, fachrichtung,
arztnummer, arztnummer,
], ],
(err) => { (err) => {
if (err) { if (err) {
if (err.code === "ER_DUP_ENTRY") { if (err.code === "ER_DUP_ENTRY") {
return reject("Benutzername existiert bereits"); return reject("Benutzername existiert bereits");
} }
return reject("Datenbankfehler"); return reject("Datenbankfehler");
} }
resolve(); resolve();
} }
); );
}); });
} }
async function getAllUsers(db, search = null) { async function getAllUsers(db, search = null) {
let sql = ` let sql = `
SELECT * SELECT *
FROM users FROM users
WHERE 1=1 WHERE 1=1
`; `;
const params = []; const params = [];
if (search) { if (search) {
sql += ` sql += `
AND ( AND (
first_name LIKE ? first_name LIKE ?
OR last_name LIKE ? OR last_name LIKE ?
OR username LIKE ? OR username LIKE ?
) )
`; `;
const q = `%${search}%`; const q = `%${search}%`;
params.push(q, q, q); params.push(q, q, q);
} }
sql += " ORDER BY last_name, first_name"; sql += " ORDER BY last_name, first_name";
const [rows] = await db.promise().query(sql, params); const [rows] = await db.promise().query(sql, params);
return rows; return rows;
} }
async function updateUserById(db, userId, data) { async function updateUserById(db, userId, data) {
const { title, first_name, last_name, username, role } = data; const { title, first_name, last_name, username, role } = data;
const [result] = await db.promise().query( const [result] = await db.promise().query(
` `
UPDATE users UPDATE users
SET title = ?, SET title = ?,
first_name = ?, first_name = ?,
last_name = ?, last_name = ?,
username = ?, username = ?,
role = ? role = ?
WHERE id = ? WHERE id = ?
`, `,
[title, first_name, last_name, username, role, userId] [title, first_name, last_name, username, role, userId]
); );
return result; return result;
} }
module.exports = { module.exports = {
createUser, createUser,
getAllUsers, getAllUsers,
updateUserById, updateUserById,
}; };

View File

@ -1,53 +1,50 @@
const bcrypt = require("bcrypt"); const bcrypt = require("bcrypt");
async function loginUser(db, username, password, lockTimeMinutes) { async function loginUser(db, username, password, lockTimeMinutes) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.query( db.query(
"SELECT * FROM users WHERE username = ?", "SELECT * FROM users WHERE username = ?",
[username], [username],
async (err, results) => { async (err, results) => {
if (err || results.length === 0) { if (err || results.length === 0) {
return reject("Login fehlgeschlagen"); return reject("Login fehlgeschlagen");
} }
const user = results[0]; const user = results[0];
const now = new Date(); const now = new Date();
if (user.active === 0) { if (user.active === 0) {
return reject("Account deaktiviert"); return reject("Account deaktiviert");
} }
if (user.lock_until && new Date(user.lock_until) > now) { if (user.lock_until && new Date(user.lock_until) > now) {
return reject(`Account gesperrt bis ${user.lock_until}`); return reject(`Account gesperrt bis ${user.lock_until}`);
} }
const match = await bcrypt.compare(password, user.password); const match = await bcrypt.compare(password, user.password);
if (!match) { if (!match) {
let sql = "failed_attempts = failed_attempts + 1"; let sql = "failed_attempts = failed_attempts + 1";
if (user.failed_attempts + 1 >= 3) { if (user.failed_attempts + 1 >= 3) {
sql += `, lock_until = DATE_ADD(NOW(), INTERVAL ${lockTimeMinutes} MINUTE)`; sql += `, lock_until = DATE_ADD(NOW(), INTERVAL ${lockTimeMinutes} MINUTE)`;
} }
db.query(`UPDATE users SET ${sql} WHERE id = ?`, [user.id]); db.query(`UPDATE users SET ${sql} WHERE id = ?`, [user.id]);
return reject("Falsches Passwort"); return reject("Falsches Passwort");
} }
db.query( db.query(
"UPDATE users SET failed_attempts = 0, lock_until = NULL WHERE id = ?", "UPDATE users SET failed_attempts = 0, lock_until = NULL WHERE id = ?",
[user.id] [user.id]
); );
resolve({ resolve({
id: user.id, id: user.id,
username: user.username, username: user.username,
role: user.role, role: user.role
title: user.title, });
firstname: user.first_name, }
lastname: user.last_name );
}); });
} }
);
}); module.exports = { loginUser };
}
module.exports = { loginUser };

View File

@ -1,21 +1,21 @@
function getWaitingPatients(db) { function getWaitingPatients(db) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.query( db.query(
` `
SELECT id, firstname, lastname, birthdate SELECT id, firstname, lastname, birthdate
FROM patients FROM patients
WHERE waiting_room = 1 WHERE waiting_room = 1
AND active = 1 AND active = 1
ORDER BY updated_at ASC ORDER BY updated_at ASC
`, `,
(err, rows) => { (err, rows) => {
if (err) return reject(err); if (err) return reject(err);
resolve(rows); resolve(rows);
} }
); );
}); });
} }
module.exports = { module.exports = {
getWaitingPatients getWaitingPatients
}; };

View File

@ -1,8 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDgIWJidh
WoA1VkDl2ScVZmAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIM0AYnTTnboA6hm+
zQcoG121zH1s0Jv2eOAuk+BS1UbfAAAAoJyvcKBc26mTti/T2iAbdEATM15fgtMs9Nlsi4
itZSVPfQ7OdYY2QMQpaiw0whrcQIdWlxUrbcYpmwPj7DATYUP00szFN2KbdRdsarfPqHFQ
zi8P2p9bj7/naRyLIENsfTj8JERxItd4xHvwL01keBmTty2hnprXcZHw4SkyIOIZyigTgS
7gkivG/j9jIOn4g0p0J1eaHdFNvJScpIs19SI=
-----END OPENSSH PRIVATE KEY-----

View File

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0AYnTTnboA6hm+zQcoG121zH1s0Jv2eOAuk+BS1Ubf cay@Cay-Workstation-VM

View File

@ -1,17 +1,17 @@
const { loginUser } = require("../services/auth.service"); const { loginUser } = require("../services/auth.service");
test("loginUser wirft Fehler bei falschem Passwort", async () => { test("loginUser wirft Fehler bei falschem Passwort", async () => {
const fakeDb = { const fakeDb = {
query: (_, __, cb) => cb(null, [{ query: (_, __, cb) => cb(null, [{
id: 1, id: 1,
username: "test", username: "test",
password: "$2b$10$invalid", password: "$2b$10$invalid",
active: 1, active: 1,
failed_attempts: 0 failed_attempts: 0
}]) }])
}; };
await expect( await expect(
loginUser(fakeDb, "test", "wrong", 5) loginUser(fakeDb, "test", "wrong", 5)
).rejects.toBeDefined(); ).rejects.toBeDefined();
}); });

View File

@ -1,52 +0,0 @@
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const CONFIG_PATH = path.join(__dirname, "..", "config.enc");
function getKey() {
const raw = process.env.CONFIG_KEY;
if (!raw) {
throw new Error("CONFIG_KEY fehlt in .env");
}
return crypto.createHash("sha256").update(raw).digest(); // 32 bytes
}
function encrypt(obj) {
const iv = crypto.randomBytes(12);
const key = getKey();
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const data = Buffer.from(JSON.stringify(obj), "utf8");
const enc = Buffer.concat([cipher.update(data), cipher.final()]);
const tag = cipher.getAuthTag();
// [iv(12)] + [tag(16)] + [encData]
return Buffer.concat([iv, tag, enc]);
}
function decrypt(buf) {
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const enc = buf.subarray(28);
const key = getKey();
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const data = Buffer.concat([decipher.update(enc), decipher.final()]);
return JSON.parse(data.toString("utf8"));
}
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) return null;
const buf = fs.readFileSync(CONFIG_PATH);
return decrypt(buf);
}
function saveConfig(cfg) {
const buf = encrypt(cfg);
fs.writeFileSync(CONFIG_PATH, buf);
}
module.exports = { loadConfig, saveConfig, CONFIG_PATH };

View File

@ -1,70 +0,0 @@
const fs = require("fs");
const path = require("path");
const { PDFDocument, StandardFonts, rgb } = require("pdf-lib");
exports.createCreditPdf = async ({
creditId,
originalInvoice,
creditAmount,
patient,
}) => {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([595, 842]); // A4
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const bold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
let y = 800;
const draw = (text, size = 12, boldFont = false) => {
page.drawText(text, {
x: 50,
y,
size,
font: boldFont ? bold : font,
color: rgb(0, 0, 0),
});
y -= size + 6;
};
draw("GUTSCHRIFT", 20, true);
y -= 20;
draw(`Gutschrift-Nr.: ${creditId}`, 12, true);
draw(`Bezieht sich auf Rechnung Nr.: ${originalInvoice.id}`);
y -= 10;
draw(`Patient: ${patient.firstname} ${patient.lastname}`);
draw(`Rechnungsdatum: ${originalInvoice.invoice_date_formatted}`);
y -= 20;
draw("Gutschriftbetrag:", 12, true);
draw(`${creditAmount.toFixed(2)}`, 14, true);
// Wasserzeichen
page.drawText("GUTSCHRIFT", {
x: 150,
y: 400,
size: 80,
rotate: { type: "degrees", angle: -30 },
color: rgb(0.8, 0, 0),
opacity: 0.2,
});
const pdfBytes = await pdfDoc.save();
const dir = path.join(
__dirname,
"..",
"public",
"invoices",
new Date().getFullYear().toString(),
);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const filePath = `/invoices/${new Date().getFullYear()}/credit-${creditId}.pdf`;
fs.writeFileSync(path.join(__dirname, "..", "public", filePath), pdfBytes);
return filePath;
};

View File

@ -1,25 +1,25 @@
module.exports = async function generateInvoiceNumber(db) { module.exports = async function generateInvoiceNumber(db) {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const [rows] = await db.promise().query( const [rows] = await db.promise().query(
"SELECT counter FROM invoice_counters WHERE year = ?", "SELECT counter FROM invoice_counters WHERE year = ?",
[year] [year]
); );
let counter = 1; let counter = 1;
if (rows.length === 0) { if (rows.length === 0) {
await db.promise().query( await db.promise().query(
"INSERT INTO invoice_counters (year, counter) VALUES (?, 1)", "INSERT INTO invoice_counters (year, counter) VALUES (?, 1)",
[year] [year]
); );
} else { } else {
counter = rows[0].counter + 1; counter = rows[0].counter + 1;
await db.promise().query( await db.promise().query(
"UPDATE invoice_counters SET counter = ? WHERE year = ?", "UPDATE invoice_counters SET counter = ? WHERE year = ?",
[counter, year] [counter, year]
); );
} }
return `R-${year}-${String(counter).padStart(5, "0")}`; return `R-${year}-${String(counter).padStart(5, "0")}`;
}; };

View File

@ -1,34 +0,0 @@
const fs = require("fs");
const { PDFDocument, rgb, degrees } = require("pdf-lib");
exports.addWatermark = async (filePath, text, color) => {
try {
const existingPdfBytes = fs.readFileSync(filePath);
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const pages = pdfDoc.getPages();
pages.forEach((page) => {
const { width, height } = page.getSize();
page.drawText(text, {
x: width / 4,
y: height / 2,
size: 80,
rotate: degrees(-30),
color,
opacity: 0.25,
});
});
const pdfBytes = await pdfDoc.save();
fs.writeFileSync(filePath, pdfBytes);
} catch (err) {
console.error("❌ PDF Watermark Fehler:", err);
}
};

View File

@ -1,52 +1,71 @@
<%- include("../partials/page-header", { <%- include("../partials/page-header", {
user, user,
title: t.adminSidebar.invocieoverview, title: "Rechnungsübersicht",
subtitle: "", subtitle: "",
showUserName: true showUserName: true
}) %> }) %>
<div class="content p-4"> <div class="content p-4">
<div class="container-fluid mt-2">
<!-- FILTER: JAHR VON / BIS -->
<div class="container-fluid mt-2">
<form method="get" class="row g-2 mb-4"> <form method="get" class="row g-2 mb-4">
<div class="col-auto"> <div class="col-auto">
<input type="number" name="fromYear" class="form-control" <input
placeholder="<%= t.invoiceAdmin.fromyear %>" type="number"
value="<%= fromYear %>" /> name="fromYear"
class="form-control"
placeholder="Von Jahr"
value="<%= fromYear %>"
/>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<input type="number" name="toYear" class="form-control" <input
placeholder="<%= t.invoiceAdmin.toyear %>" type="number"
value="<%= toYear %>" /> name="toYear"
class="form-control"
placeholder="Bis Jahr"
value="<%= toYear %>"
/>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button class="btn btn-outline-secondary"><%= t.global.filter %></button> <button class="btn btn-outline-secondary">Filtern</button>
</div> </div>
</form> </form>
<!-- GRID 4 SPALTEN -->
<div class="row g-3"> <div class="row g-3">
<!-- Jahresumsatz --> <!-- JAHRESUMSATZ -->
<div class="col-xl-3 col-lg-6"> <div class="col-xl-3 col-lg-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.yearcash %></div> <div class="card-header fw-semibold">Jahresumsatz</div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-sm table-striped mb-0"> <table class="table table-sm table-striped mb-0">
<thead> <thead>
<tr> <tr>
<th><%= t.global.year %></th> <th>Jahr</th>
<th class="text-end">€</th> <th class="text-end">€</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% if (!yearly || yearly.length === 0) { %> <% if (yearly.length === 0) { %>
<tr><td colspan="2" class="text-center text-muted"><%= t.global.nodata %></td></tr> <tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %> <% } %>
<% (yearly || []).forEach(y => { %>
<tr> <% yearly.forEach(y => { %>
<td><%= y.year %></td> <tr>
<td class="text-end fw-semibold"><%= Number(y.total).toFixed(2) %></td> <td><%= y.year %></td>
</tr> <td class="text-end fw-semibold">
<%= Number(y.total).toFixed(2) %>
</td>
</tr>
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
@ -54,29 +73,36 @@
</div> </div>
</div> </div>
<!-- Quartalsumsatz --> <!-- QUARTALSUMSATZ -->
<div class="col-xl-3 col-lg-6"> <div class="col-xl-3 col-lg-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.quartalcash %></div> <div class="card-header fw-semibold">Quartalsumsatz</div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-sm table-striped mb-0"> <table class="table table-sm table-striped mb-0">
<thead> <thead>
<tr> <tr>
<th><%= t.global.year %></th> <th>Jahr</th>
<th>Q</th> <th>Q</th>
<th class="text-end">€</th> <th class="text-end">€</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% if (!quarterly || quarterly.length === 0) { %> <% if (quarterly.length === 0) { %>
<tr><td colspan="3" class="text-center text-muted"><%= t.global.nodata %></td></tr> <tr>
<td colspan="3" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %> <% } %>
<% (quarterly || []).forEach(q => { %>
<tr> <% quarterly.forEach(q => { %>
<td><%= q.year %></td> <tr>
<td>Q<%= q.quarter %></td> <td><%= q.year %></td>
<td class="text-end fw-semibold"><%= Number(q.total).toFixed(2) %></td> <td>Q<%= q.quarter %></td>
</tr> <td class="text-end fw-semibold">
<%= Number(q.total).toFixed(2) %>
</td>
</tr>
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
@ -84,27 +110,34 @@
</div> </div>
</div> </div>
<!-- Monatsumsatz --> <!-- MONATSUMSATZ -->
<div class="col-xl-3 col-lg-6"> <div class="col-xl-3 col-lg-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.monthcash %></div> <div class="card-header fw-semibold">Monatsumsatz</div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-sm table-striped mb-0"> <table class="table table-sm table-striped mb-0">
<thead> <thead>
<tr> <tr>
<th><%= t.global.month %></th> <th>Monat</th>
<th class="text-end">€</th> <th class="text-end">€</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% if (!monthly || monthly.length === 0) { %> <% if (monthly.length === 0) { %>
<tr><td colspan="2" class="text-center text-muted"><%= t.global.nodata %></td></tr> <tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %> <% } %>
<% (monthly || []).forEach(m => { %>
<tr> <% monthly.forEach(m => { %>
<td><%= m.month %></td> <tr>
<td class="text-end fw-semibold"><%= Number(m.total).toFixed(2) %></td> <td><%= m.month %></td>
</tr> <td class="text-end fw-semibold">
<%= Number(m.total).toFixed(2) %>
</td>
</tr>
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
@ -112,44 +145,67 @@
</div> </div>
</div> </div>
<!-- Umsatz pro Patient --> <!-- UMSATZ PRO PATIENT -->
<div class="col-xl-3 col-lg-6"> <div class="col-xl-3 col-lg-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.patientcash %></div> <div class="card-header fw-semibold">Umsatz pro Patient</div>
<div class="card-body p-2"> <div class="card-body p-2">
<!-- Suche -->
<form method="get" class="mb-2 d-flex gap-2"> <form method="get" class="mb-2 d-flex gap-2">
<input type="hidden" name="fromYear" value="<%= fromYear %>" /> <input type="hidden" name="fromYear" value="<%= fromYear %>" />
<input type="hidden" name="toYear" value="<%= toYear %>" /> <input type="hidden" name="toYear" value="<%= toYear %>" />
<input type="text" name="q" value="<%= search %>"
<input
type="text"
name="q"
value="<%= search %>"
class="form-control form-control-sm" class="form-control form-control-sm"
placeholder="<%= t.invoiceAdmin.searchpatient %>" /> placeholder="Patient suchen..."
<button class="btn btn-sm btn-outline-primary"><%= t.global.search %></button> />
<a href="/admin/invoices?fromYear=<%= fromYear %>&toYear=<%= toYear %>"
class="btn btn-sm btn-outline-secondary"><%= t.global.reset %></a> <button class="btn btn-sm btn-outline-primary">Suchen</button>
<a
href="/admin/invoices?fromYear=<%= fromYear %>&toYear=<%= toYear %>"
class="btn btn-sm btn-outline-secondary"
>
Reset
</a>
</form> </form>
<table class="table table-sm table-striped mb-0"> <table class="table table-sm table-striped mb-0">
<thead> <thead>
<tr> <tr>
<th><%= t.global.patient %></th> <th>Patient</th>
<th class="text-end">€</th> <th class="text-end">€</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% if (!patients || patients.length === 0) { %> <% if (patients.length === 0) { %>
<tr><td colspan="2" class="text-center text-muted"><%= t.global.nodata %></td></tr> <tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %> <% } %>
<% (patients || []).forEach(p => { %>
<tr> <% patients.forEach(p => { %>
<td><%= p.patient %></td> <tr>
<td class="text-end fw-semibold"><%= Number(p.total).toFixed(2) %></td> <td><%= p.patient %></td>
</tr> <td class="text-end fw-semibold">
<%= Number(p.total).toFixed(2) %>
</td>
</tr>
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,138 +1,132 @@
<%- include("../partials/page-header", { <!DOCTYPE html>
user, <html lang="de">
title: t.companySettings.title, <head>
subtitle: "", <meta charset="UTF-8">
showUserName: true <title>Firmendaten</title>
}) %> <link rel="stylesheet" href="/css/bootstrap.min.css">
</head>
<body class="bg-light">
<div class="content p-4"> <div class="container mt-4">
<h3 class="mb-4">🏢 Firmendaten</h3>
<%- include("../partials/flash") %> <form method="POST" action="/admin/company-settings" enctype="multipart/form-data">
<div class="container-fluid"> <div class="row g-3">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-4">
<i class="bi bi-building"></i>
<%= t.companySettings.title %>
</h5>
<form method="POST" action="/admin/company-settings" enctype="multipart/form-data">
<div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.companySettings.companyname %></label> <label class="form-label">Firmenname</label>
<input class="form-control" name="company_name" <input class="form-control" name="company_name"
value="<%= settings.company_name || '' %>" required> value="<%= company.company_name || '' %>" required>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.companySettings.legalform %></label> <label class="form-label">Rechtsform</label>
<input class="form-control" name="company_legal_form" <input class="form-control" name="company_legal_form"
value="<%= settings.company_legal_form || '' %>"> value="<%= company.company_legal_form || '' %>">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.companySettings.owner %></label> <label class="form-label">Inhaber / Geschäftsführer</label>
<input class="form-control" name="company_owner" <input class="form-control" name="company_owner"
value="<%= settings.company_owner || '' %>"> value="<%= company.company_owner || '' %>">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.companySettings.email %></label> <label class="form-label">E-Mail</label>
<input class="form-control" name="email" <input class="form-control" name="email"
value="<%= settings.email || '' %>"> value="<%= company.email || '' %>">
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
<label class="form-label"><%= t.companySettings.street %></label> <label class="form-label">Straße</label>
<input class="form-control" name="street" <input class="form-control" name="street"
value="<%= settings.street || '' %>"> value="<%= company.street || '' %>">
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label"><%= t.companySettings.housenumber %></label> <label class="form-label">Hausnummer</label>
<input class="form-control" name="house_number" <input class="form-control" name="house_number"
value="<%= settings.house_number || '' %>"> value="<%= company.house_number || '' %>">
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label"><%= t.companySettings.zip %></label> <label class="form-label">PLZ</label>
<input class="form-control" name="postal_code" <input class="form-control" name="postal_code"
value="<%= settings.postal_code || '' %>"> value="<%= company.postal_code || '' %>">
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
<label class="form-label"><%= t.companySettings.city %></label> <label class="form-label">Ort</label>
<input class="form-control" name="city" <input class="form-control" name="city"
value="<%= settings.city || '' %>"> value="<%= company.city || '' %>">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.companySettings.country %></label> <label class="form-label">Land</label>
<input class="form-control" name="country" <input class="form-control" name="country"
value="<%= settings.country || 'Deutschland' %>"> value="<%= company.country || 'Deutschland' %>">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.companySettings.taxid %></label> <label class="form-label">USt-ID / Steuernummer</label>
<input class="form-control" name="vat_id" <input class="form-control" name="vat_id"
value="<%= settings.vat_id || '' %>"> value="<%= company.vat_id || '' %>">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.companySettings.bank %></label> <label class="form-label">Bank</label>
<input class="form-control" name="bank_name" <input class="form-control" name="bank_name"
value="<%= settings.bank_name || '' %>"> value="<%= company.bank_name || '' %>">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.companySettings.iban %></label> <label class="form-label">IBAN</label>
<input class="form-control" name="iban" <input class="form-control" name="iban"
value="<%= settings.iban || '' %>"> value="<%= company.iban || '' %>">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.companySettings.bic %></label> <label class="form-label">BIC</label>
<input class="form-control" name="bic" <input class="form-control" name="bic"
value="<%= settings.bic || '' %>"> value="<%= company.bic || '' %>">
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label"><%= t.companySettings.invoicefooter %></label> <label class="form-label">Rechnungs-Footer</label>
<textarea class="form-control" rows="3" name="invoice_footer_text" <textarea class="form-control" rows="3"
><%= settings.invoice_footer_text || '' %></textarea> name="invoice_footer_text"><%= company.invoice_footer_text || '' %></textarea>
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label"><%= t.companySettings.companylogo %></label> <label class="form-label">Firmenlogo</label>
<input type="file" name="logo" class="form-control" <input
accept="image/png, image/jpeg"> type="file"
<% if (settings.invoice_logo_path) { %> name="logo"
<div class="mt-2"> class="form-control"
<small class="text-muted"><%= t.companySettings.currentlogo %></small><br> accept="image/png, image/jpeg"
<img src="<%= settings.invoice_logo_path %>" >
style="max-height:80px; border:1px solid #ccc; padding:4px;">
<% if (company.invoice_logo_path) { %>
<div class="mt-2">
<small class="text-muted">Aktuelles Logo:</small><br>
<img
src="<%= company.invoice_logo_path %>"
style="max-height:80px; border:1px solid #ccc; padding:4px;"
>
</div>
<% } %>
</div> </div>
<% } %>
</div>
</div> </div>
<div class="mt-4 d-flex gap-2"> <div class="mt-4">
<button class="btn btn-primary"> <button class="btn btn-primary">💾 Speichern</button>
<i class="bi bi-save"></i> <%= t.global.save %> <a href="/dashboard" class="btn btn-secondary">Zurück</a>
</button> </div>
<a href="/dashboard" class="btn btn-secondary">
<%= t.companySettings.back %>
</a>
</div>
</form> </form>
</div>
</div>
</div>
</div> </div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More