Compare commits

...

11 Commits

135 changed files with 14200 additions and 10544 deletions

12
.gitignore vendored
View File

@ -1,6 +1,6 @@
node_modules/
.env
uploads/
documents/
logs/
*.log
node_modules/
.env
uploads/
documents/
logs/
*.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));

View File

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

View File

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

697
app.js
View File

@ -1,263 +1,434 @@
require("dotenv").config();
const express = require("express");
const session = require("express-session");
const helmet = require("helmet");
const mysql = require("mysql2/promise");
const fs = require("fs");
const path = require("path");
// ✅ Verschlüsselte Config
const { configExists, saveConfig } = require("./config-manager");
// ✅ Reset-Funktionen (Soft-Restart)
const db = require("./db");
const { getSessionStore, resetSessionStore } = require("./config/session");
// ✅ Deine Routes (unverändert)
const adminRoutes = require("./routes/admin.routes");
const dashboardRoutes = require("./routes/dashboard.routes");
const patientRoutes = require("./routes/patient.routes");
const medicationRoutes = require("./routes/medications.routes");
const patientMedicationRoutes = require("./routes/patientMedication.routes");
const waitingRoomRoutes = require("./routes/waitingRoom.routes");
const serviceRoutes = require("./routes/service.routes");
const patientServiceRoutes = require("./routes/patientService.routes");
const invoiceRoutes = require("./routes/invoice.routes");
const patientFileRoutes = require("./routes/patientFile.routes");
const companySettingsRoutes = require("./routes/companySettings.routes");
const authRoutes = require("./routes/auth.routes");
const app = express();
/* ===============================
SETUP HTML
================================ */
function setupHtml(error = "") {
return `
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Praxissoftware Setup</title>
<style>
body{font-family:Arial;background:#f4f4f4;padding:30px}
.card{max-width:520px;margin:auto;background:#fff;padding:22px;border-radius:14px}
input{width:100%;padding:10px;margin:6px 0;border:1px solid #ccc;border-radius:8px}
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>
<div class="card">
<h2>🔧 Datenbank Einrichtung</h2>
${error ? `<div class="err">❌ ${error}</div>` : ""}
<form method="POST" action="/setup">
<label>DB Host</label>
<input name="host" placeholder="85.215.63.122" required />
<label>DB Benutzer</label>
<input name="user" placeholder="praxisuser" required />
<label>DB Passwort</label>
<input name="password" type="password" required />
<label>DB Name</label>
<input name="name" placeholder="praxissoftware" required />
<button type="submit"> Speichern</button>
</form>
<div class="hint">
Die Daten werden verschlüsselt gespeichert (<b>config.enc</b>).<br/>
Danach wirst du automatisch auf die Loginseite weitergeleitet.
</div>
</div>
</body>
</html>
`;
}
/* ===============================
MIDDLEWARE
================================ */
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(helmet());
// ✅ SessionStore dynamisch (Setup: MemoryStore, normal: MySQLStore)
app.use(
session({
name: "praxis.sid",
secret: process.env.SESSION_SECRET,
store: getSessionStore(),
resave: false,
saveUninitialized: false,
}),
);
// ✅ i18n Middleware
app.use((req, res, next) => {
const lang = req.session.lang || "de"; // Standard DE
const filePath = path.join(__dirname, "locales", `${lang}.json`);
const raw = fs.readFileSync(filePath, "utf-8");
res.locals.t = JSON.parse(raw); // t = translations
res.locals.lang = lang;
next();
});
const flashMiddleware = require("./middleware/flash.middleware");
app.use(flashMiddleware);
app.use(express.static("public"));
app.use("/uploads", express.static("uploads"));
app.set("view engine", "ejs");
app.use((req, res, next) => {
res.locals.user = req.session.user || null;
next();
});
/* ===============================
SETUP ROUTES
================================ */
// Setup-Seite
app.get("/setup", (req, res) => {
if (configExists()) return res.redirect("/");
return res.status(200).send(setupHtml());
});
// Setup speichern + DB testen + Soft-Restart + Login redirect
app.post("/setup", async (req, res) => {
try {
const { host, user, password, name } = req.body;
if (!host || !user || !password || !name) {
return res.status(400).send(setupHtml("Bitte alle Felder ausfüllen."));
}
// ✅ DB Verbindung testen
const conn = await mysql.createConnection({
host,
user,
password,
database: name,
});
await conn.query("SELECT 1");
await conn.end();
// ✅ verschlüsselt speichern
saveConfig({
db: { host, user, password, name },
});
// ✅ Soft-Restart (DB Pool + SessionStore neu laden)
if (typeof db.resetPool === "function") {
db.resetPool();
}
resetSessionStore();
// ✅ automatisch zurück zur Loginseite
return res.redirect("/");
} catch (err) {
return res
.status(500)
.send(setupHtml("DB Verbindung fehlgeschlagen: " + err.message));
}
});
// Wenn keine config.enc → alles außer /setup auf Setup umleiten
app.use((req, res, next) => {
if (!configExists() && req.path !== "/setup") {
return res.redirect("/setup");
}
next();
});
//Sprachen Route
// ✅ i18n Middleware (Sprache pro Benutzer über Session)
app.use((req, res, next) => {
const lang = req.session.lang || "de"; // Standard: Deutsch
let translations = {};
try {
const filePath = path.join(__dirname, "locales", `${lang}.json`);
translations = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch (err) {
console.error("❌ i18n Fehler:", err.message);
}
// ✅ In EJS verfügbar machen
res.locals.t = translations;
res.locals.lang = lang;
next();
});
app.get("/lang/:lang", (req, res) => {
const newLang = req.params.lang;
if (!["de", "es"].includes(newLang)) {
return res.redirect(req.get("Referrer") || "/dashboard");
}
req.session.lang = newLang;
// ✅ WICHTIG: Session speichern bevor redirect
req.session.save((err) => {
if (err) console.error("❌ Session save error:", err);
return res.redirect(req.get("Referrer") || "/dashboard");
});
});
/* ===============================
DEINE LOGIK (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}`);
});
require("dotenv").config();
const express = require("express");
const session = require("express-session");
const helmet = require("helmet");
const fs = require("fs");
const path = require("path");
const expressLayouts = require("express-ejs-layouts");
// ✅ DB + Session Store
const db = require("./db");
const { getSessionStore } = require("./config/session");
// ✅ Setup Middleware + Setup Routes
const requireSetup = require("./middleware/requireSetup");
const setupRoutes = require("./routes/setup.routes");
// ✅ Routes (deine)
const adminRoutes = require("./routes/admin.routes");
const dashboardRoutes = require("./routes/dashboard.routes");
const patientRoutes = require("./routes/patient.routes");
const medicationRoutes = require("./routes/medications.routes");
const patientMedicationRoutes = require("./routes/patientMedication.routes");
const waitingRoomRoutes = require("./routes/waitingRoom.routes");
const serviceRoutes = require("./routes/service.routes");
const patientServiceRoutes = require("./routes/patientService.routes");
const invoiceRoutes = require("./routes/invoice.routes");
const patientFileRoutes = require("./routes/patientFile.routes");
const companySettingsRoutes = require("./routes/companySettings.routes");
const authRoutes = require("./routes/auth.routes");
const reportRoutes = require("./routes/report.routes");
const app = express();
/* ===============================
Seriennummer / Trial Konfiguration
================================ */
const TRIAL_DAYS = 30;
/* ===============================
Seriennummer Helper Funktionen
================================ */
function normalizeSerial(input) {
return (input || "")
.toUpperCase()
.replace(/[^A-Z0-9-]/g, "")
.trim();
}
// Format: AAAAA-AAAAA-AAAAA-AAAAA
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) {
const raw = serial.replace(/-/g, "");
let sum = 0;
for (const ch of raw) {
if (/[0-9]/.test(ch)) sum += parseInt(ch, 10);
else sum += ch.charCodeAt(0) - 55; // A=10
}
return sum % 3 === 0;
}
/* ===============================
MIDDLEWARE
================================ */
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
},
},
}),
);
app.use(
session({
name: "praxis.sid",
secret: process.env.SESSION_SECRET || "dev-secret",
store: getSessionStore(),
resave: false,
saveUninitialized: false,
}),
);
// ✅ i18n Middleware (SAFE)
app.use((req, res, next) => {
try {
const lang = req.session.lang || "de";
const filePath = path.join(__dirname, "locales", `${lang}.json`);
let data = {};
if (fs.existsSync(filePath)) {
data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
res.locals.t = data;
res.locals.lang = lang;
next();
} catch (err) {
console.error("❌ i18n Fehler:", err.message);
res.locals.t = {};
res.locals.lang = "de";
next();
}
});
const flashMiddleware = require("./middleware/flash.middleware");
app.use(flashMiddleware);
app.use(express.static("public"));
app.use("/uploads", express.static("uploads"));
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
app.use(expressLayouts);
app.set("layout", "layout");
app.use((req, res, next) => {
res.locals.user = req.session.user || null;
next();
});
/* ===============================
SETUP ROUTES + SETUP GATE
WICHTIG: /setup zuerst mounten, danach requireSetup
================================ */
app.use("/setup", setupRoutes);
app.use(requireSetup);
/* ===============================
LICENSE/TRIAL GATE
================================ */
app.use(async (req, res, next) => {
try {
// Setup muss erreichbar bleiben
if (req.path.startsWith("/setup")) return next();
// Login muss erreichbar bleiben
if (req.path === "/" || req.path.startsWith("/login")) return next();
// Serial Seiten müssen erreichbar bleiben
if (req.path.startsWith("/serial-number")) return next();
if (req.path.startsWith("/admin/serial-number")) return next();
// Sprache ändern erlauben
if (req.path.startsWith("/lang/")) return next();
// Nicht eingeloggt -> auth regelt das
if (!req.session?.user) return next();
const [rowsSettings] = await db.promise().query(
`SELECT id, serial_number, trial_started_at
FROM company_settings
ORDER BY id ASC
LIMIT 1`,
);
const settings = rowsSettings?.[0];
// ✅ Seriennummer vorhanden -> alles OK
if (settings?.serial_number) return next();
// ✅ Trial Start setzen wenn leer
if (settings?.id && !settings?.trial_started_at) {
await db
.promise()
.query(
`UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`,
[settings.id],
);
return next();
}
// Wenn noch immer kein trial start: nicht blockieren
if (!settings?.trial_started_at) return next();
const trialStart = new Date(settings.trial_started_at);
const now = new Date();
const diffDays = Math.floor((now - trialStart) / (1000 * 60 * 60 * 24));
// ✅ Trial läuft noch
if (diffDays < TRIAL_DAYS) return next();
// ❌ Trial abgelaufen
if (req.session.user.role === "admin") {
return res.redirect("/admin/serial-number");
}
return res.redirect("/serial-number");
} catch (err) {
console.error("❌ LicenseGate Fehler:", err.message);
return next();
}
});
/* ===============================
Sprache ändern
================================ */
app.get("/lang/:lang", (req, res) => {
const newLang = req.params.lang;
if (!["de", "es"].includes(newLang)) {
return res.redirect(req.get("Referrer") || "/dashboard");
}
req.session.lang = newLang;
req.session.save((err) => {
if (err) console.error("❌ Session save error:", err);
return res.redirect(req.get("Referrer") || "/dashboard");
});
});
/* ===============================
SERIAL PAGES
================================ */
app.get("/serial-number", async (req, res) => {
try {
if (!req.session?.user) return res.redirect("/");
const [rowsSettings] = await db.promise().query(
`SELECT id, serial_number, trial_started_at
FROM company_settings
ORDER BY id ASC
LIMIT 1`,
);
const settings = rowsSettings?.[0];
// ✅ Seriennummer da -> ab ins Dashboard
if (settings?.serial_number) return res.redirect("/dashboard");
// ✅ Trial Start setzen wenn leer
if (settings?.id && !settings?.trial_started_at) {
await db
.promise()
.query(
`UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`,
[settings.id],
);
settings.trial_started_at = new Date();
}
// ✅ Resttage berechnen
let daysLeft = TRIAL_DAYS;
if (settings?.trial_started_at) {
const trialStart = new Date(settings.trial_started_at);
const now = new Date();
const diffDays = Math.floor((now - trialStart) / (1000 * 60 * 60 * 24));
daysLeft = Math.max(0, TRIAL_DAYS - diffDays);
}
// ❌ Trial abgelaufen
if (daysLeft <= 0) {
if (req.session.user.role === "admin") {
return res.redirect("/admin/serial-number");
}
return res.render("trial_expired", {
user: req.session.user,
lang: req.session.lang || "de",
});
}
// ✅ Trial aktiv
return res.render("serial_number_info", {
user: req.session.user,
lang: req.session.lang || "de",
daysLeft,
});
} catch (err) {
console.error(err);
return res.status(500).send("Interner Serverfehler");
}
});
app.get("/admin/serial-number", async (req, res) => {
try {
if (!req.session?.user) return res.redirect("/");
if (req.session.user.role !== "admin")
return res.status(403).send("Forbidden");
const [rowsSettings] = await db
.promise()
.query(
`SELECT serial_number FROM company_settings ORDER BY id ASC LIMIT 1`,
);
const currentSerial = rowsSettings?.[0]?.serial_number || "";
return res.render("serial_number_admin", {
user: req.session.user,
lang: req.session.lang || "de",
active: "serialnumber",
currentSerial,
error: null,
success: null,
});
} catch (err) {
console.error(err);
return res.status(500).send("Interner Serverfehler");
}
});
app.post("/admin/serial-number", async (req, res) => {
try {
if (!req.session?.user) return res.redirect("/");
if (req.session.user.role !== "admin")
return res.status(403).send("Forbidden");
let serial = normalizeSerial(req.body.serial_number);
if (!serial) {
return res.render("serial_number_admin", {
user: req.session.user,
lang: req.session.lang || "de",
active: "serialnumber",
currentSerial: "",
error: "Bitte Seriennummer eingeben.",
success: null,
});
}
if (!isValidSerialFormat(serial)) {
return res.render("serial_number_admin", {
user: req.session.user,
lang: req.session.lang || "de",
active: "serialnumber",
currentSerial: serial,
error: "Ungültiges Format. Beispiel: ABC12-3DE45-FG678-HI901",
success: null,
});
}
if (!passesModulo3(serial)) {
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("/invoices", invoiceRoutes);
app.use("/reportview", reportRoutes);
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 = process.env.HOST || "0.0.0.0";
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 crypto = require("crypto");
const path = require("path");
const CONFIG_FILE = path.join(__dirname, "config.enc");
function getKey() {
const key = process.env.CONFIG_KEY;
if (!key) throw new Error("CONFIG_KEY fehlt in .env");
// stabil auf 32 bytes
return crypto.createHash("sha256").update(key).digest();
}
function encryptConfig(obj) {
const key = getKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const json = JSON.stringify(obj);
const encrypted = Buffer.concat([
cipher.update(json, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
function decryptConfig(str) {
const raw = Buffer.from(str, "base64");
const iv = raw.subarray(0, 12);
const tag = raw.subarray(12, 28);
const encrypted = raw.subarray(28);
const key = getKey();
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
return JSON.parse(decrypted.toString("utf8"));
}
function configExists() {
return fs.existsSync(CONFIG_FILE);
}
function loadConfig() {
if (!configExists()) return null;
const enc = fs.readFileSync(CONFIG_FILE, "utf8").trim();
if (!enc) return null;
return decryptConfig(enc);
}
function saveConfig(obj) {
const enc = encryptConfig(obj);
fs.writeFileSync(CONFIG_FILE, enc, "utf8");
return true;
}
module.exports = {
configExists,
loadConfig,
saveConfig,
};
const fs = require("fs");
const crypto = require("crypto");
const path = require("path");
const CONFIG_FILE = path.join(__dirname, "config.enc");
function getKey() {
const key = process.env.CONFIG_KEY;
if (!key) throw new Error("CONFIG_KEY fehlt in .env");
// stabil auf 32 bytes
return crypto.createHash("sha256").update(key).digest();
}
function encryptConfig(obj) {
const key = getKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const json = JSON.stringify(obj);
const encrypted = Buffer.concat([
cipher.update(json, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
function decryptConfig(str) {
const raw = Buffer.from(str, "base64");
const iv = raw.subarray(0, 12);
const tag = raw.subarray(12, 28);
const encrypted = raw.subarray(28);
const key = getKey();
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
return JSON.parse(decrypted.toString("utf8"));
}
function configExists() {
return fs.existsSync(CONFIG_FILE);
}
function loadConfig() {
if (!configExists()) return null;
const enc = fs.readFileSync(CONFIG_FILE, "utf8").trim();
if (!enc) return null;
return decryptConfig(enc);
}
function saveConfig(obj) {
const enc = encryptConfig(obj);
fs.writeFileSync(CONFIG_FILE, enc, "utf8");
return true;
}
module.exports = {
configExists,
loadConfig,
saveConfig,
};

View File

@ -1 +1 @@
G/kDLEJ/LddnnNnginIGYSM4Ax0g5pJaF0lrdOXke51cz3jSTrZxP7rjTXRlqLcoUJhPaVLvjb/DcyNYB/C339a+PFWyIdWYjSb6G4aPkD8J21yFWDDLpc08bXvoAx2PeE+Fc9v5mJUGDVv2wQoDvkHqIpN8ewrfRZ6+JF3OfQ==
4PsgCvoOJLNXPpxOHOvm+KbVYz3pNxg8oOXO7zoH3MPffEhZLI7i5qf3o6oqZDI04us8xSSz9j3KIN+Atno/VFlYzSoq3ki1F+WSTz37LfcE3goPqhm6UaH8c9lHdulemH9tqgGq/DxgbKaup5t/ZJnLseaHHpdyTZok1jWULN0nlDuL/HvVVtqw5sboPqU=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,137 +1,144 @@
const db = require("../db");
// 📋 LISTE
function listMedications(req, res, next) {
const { q, onlyActive } = req.query;
let sql = `
SELECT
v.id,
m.id AS medication_id,
m.name AS medication,
m.active,
f.name AS form,
v.dosage,
v.package
FROM medication_variants v
JOIN medications m ON v.medication_id = m.id
JOIN medication_forms f ON v.form_id = f.id
WHERE 1=1
`;
const params = [];
if (q) {
sql += `
AND (
m.name LIKE ?
OR f.name LIKE ?
OR v.dosage LIKE ?
OR v.package LIKE ?
)
`;
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
}
if (onlyActive === "1") {
sql += " AND m.active = 1";
}
sql += " ORDER BY m.name, v.dosage";
db.query(sql, params, (err, rows) => {
if (err) return next(err);
res.render("medications", {
rows,
query: { q, onlyActive },
user: req.session.user,
});
});
}
// 💾 UPDATE
function updateMedication(req, res, next) {
const { medication, form, dosage, package: pkg } = req.body;
const id = req.params.id;
const sql = `
UPDATE medication_variants
SET
dosage = ?,
package = ?
WHERE id = ?
`;
db.query(sql, [dosage, pkg, id], (err) => {
if (err) return next(err);
req.session.flash = { type: "success", message: "Medikament gespeichert" };
res.redirect("/medications");
});
}
function toggleMedication(req, res, next) {
const id = req.params.id;
db.query(
"UPDATE medications SET active = NOT active WHERE id = ?",
[id],
(err) => {
if (err) return next(err);
res.redirect("/medications");
}
);
}
function showCreateMedication(req, res) {
const sql = "SELECT id, name FROM medication_forms ORDER BY name";
db.query(sql, (err, forms) => {
if (err) return res.send("DB Fehler");
res.render("medication_create", {
forms,
user: req.session.user,
error: null,
});
});
}
function createMedication(req, res) {
const { name, form_id, dosage, package: pkg } = req.body;
if (!name || !form_id || !dosage) {
return res.send("Pflichtfelder fehlen");
}
db.query(
"INSERT INTO medications (name, active) VALUES (?, 1)",
[name],
(err, result) => {
if (err) return res.send("Fehler Medikament");
const medicationId = result.insertId;
db.query(
`INSERT INTO medication_variants
(medication_id, form_id, dosage, package)
VALUES (?, ?, ?, ?)`,
[medicationId, form_id, dosage, pkg || null],
(err) => {
if (err) return res.send("Fehler Variante");
res.redirect("/medications");
}
);
}
);
}
module.exports = {
listMedications,
updateMedication,
toggleMedication,
showCreateMedication,
createMedication,
};
const db = require("../db");
// 📋 LISTE
function listMedications(req, res, next) {
const { q, onlyActive } = req.query;
let sql = `
SELECT
v.id,
m.id AS medication_id,
m.name AS medication,
m.active,
f.name AS form,
v.dosage,
v.package
FROM medication_variants v
JOIN medications m ON v.medication_id = m.id
JOIN medication_forms f ON v.form_id = f.id
WHERE 1=1
`;
const params = [];
if (q) {
sql += `
AND (
m.name LIKE ?
OR f.name LIKE ?
OR v.dosage LIKE ?
OR v.package LIKE ?
)
`;
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
}
if (onlyActive === "1") {
sql += " AND m.active = 1";
}
sql += " ORDER BY m.name, v.dosage";
db.query(sql, params, (err, rows) => {
if (err) return next(err);
res.render("medications", {
title: "Medikamentenübersicht",
// ✅ IMMER patient-sidebar verwenden
sidebarPartial: "partials/sidebar-empty",
active: "medications",
rows,
query: { q, onlyActive },
user: req.session.user,
lang: req.session.lang || "de",
});
});
}
// 💾 UPDATE
function updateMedication(req, res, next) {
const { medication, form, dosage, package: pkg } = req.body;
const id = req.params.id;
const sql = `
UPDATE medication_variants
SET
dosage = ?,
package = ?
WHERE id = ?
`;
db.query(sql, [dosage, pkg, id], (err) => {
if (err) return next(err);
req.session.flash = { type: "success", message: "Medikament gespeichert" };
res.redirect("/medications");
});
}
function toggleMedication(req, res, next) {
const id = req.params.id;
db.query(
"UPDATE medications SET active = NOT active WHERE id = ?",
[id],
(err) => {
if (err) return next(err);
res.redirect("/medications");
},
);
}
function showCreateMedication(req, res) {
const sql = "SELECT id, name FROM medication_forms ORDER BY name";
db.query(sql, (err, forms) => {
if (err) return res.send("DB Fehler");
res.render("medication_create", {
forms,
user: req.session.user,
error: null,
});
});
}
function createMedication(req, res) {
const { name, form_id, dosage, package: pkg } = req.body;
if (!name || !form_id || !dosage) {
return res.send("Pflichtfelder fehlen");
}
db.query(
"INSERT INTO medications (name, active) VALUES (?, 1)",
[name],
(err, result) => {
if (err) return res.send("Fehler Medikament");
const medicationId = result.insertId;
db.query(
`INSERT INTO medication_variants
(medication_id, form_id, dosage, package)
VALUES (?, ?, ?, ?)`,
[medicationId, form_id, dosage, pkg || null],
(err) => {
if (err) return res.send("Fehler Variante");
res.redirect("/medications");
},
);
},
);
}
module.exports = {
listMedications,
updateMedication,
toggleMedication,
showCreateMedication,
createMedication,
};

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -0,0 +1,59 @@
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

@ -1,319 +1,342 @@
const db = require("../db");
function listServices(req, res) {
const { q, onlyActive, patientId } = req.query;
// 🔹 Standard: Deutsch
let serviceNameField = "name_de";
const loadServices = () => {
let sql = `
SELECT id, ${serviceNameField} AS name, category, price, active
FROM services
WHERE 1=1
`;
const params = [];
if (q) {
sql += `
AND (
name_de LIKE ?
OR name_es LIKE ?
OR category LIKE ?
)
`;
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
}
if (onlyActive === "1") {
sql += " AND active = 1";
}
sql += ` ORDER BY ${serviceNameField}`;
db.query(sql, params, (err, services) => {
if (err) return res.send("Datenbankfehler");
res.render("services", {
services,
user: req.session.user,
query: { q, onlyActive, patientId }
});
});
};
// 🔹 Wenn Patient angegeben → Country prüfen
if (patientId) {
db.query(
"SELECT country FROM patients WHERE id = ?",
[patientId],
(err, rows) => {
if (!err && rows.length && rows[0].country === "ES") {
serviceNameField = "name_es";
}
loadServices();
}
);
} else {
// 🔹 Kein Patient → Deutsch
loadServices();
}
}
function listServicesAdmin(req, res) {
const { q, onlyActive } = req.query;
let sql = `
SELECT
id,
name_de,
name_es,
category,
price,
price_c70,
active
FROM services
WHERE 1=1
`;
const params = [];
if (q) {
sql += `
AND (
name_de LIKE ?
OR name_es LIKE ?
OR category LIKE ?
)
`;
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
}
if (onlyActive === "1") {
sql += " AND active = 1";
}
sql += " ORDER BY name_de";
db.query(sql, params, (err, services) => {
if (err) return res.send("Datenbankfehler");
res.render("services", {
services,
user: req.session.user,
query: { q, onlyActive }
});
});
}
function showCreateService(req, res) {
res.render("service_create", {
user: req.session.user,
error: null
});
}
function createService(req, res) {
const { name_de, name_es, category, price, price_c70 } = req.body;
const userId = req.session.user.id;
if (!name_de || !price) {
return res.render("service_create", {
user: req.session.user,
error: "Bezeichnung (DE) und Preis sind Pflichtfelder"
});
}
db.query(
`
INSERT INTO services
(name_de, name_es, category, price, price_c70, active)
VALUES (?, ?, ?, ?, ?, 1)
`,
[name_de, name_es || "--", category || "--", price, price_c70 || 0],
(err, result) => {
if (err) return res.send("Fehler beim Anlegen der Leistung");
db.query(
`
INSERT INTO service_logs
(service_id, user_id, action, new_value)
VALUES (?, ?, 'CREATE', ?)
`,
[result.insertId, userId, JSON.stringify(req.body)]
);
res.redirect("/services");
}
);
}
function updateServicePrice(req, res) {
const serviceId = req.params.id;
const { price, price_c70 } = req.body;
const userId = req.session.user.id;
db.query(
"SELECT price, price_c70 FROM services WHERE id = ?",
[serviceId],
(err, oldRows) => {
if (err || oldRows.length === 0) return res.send("Service nicht gefunden");
const oldData = oldRows[0];
db.query(
"UPDATE services SET price = ?, price_c70 = ? WHERE id = ?",
[price, price_c70, serviceId],
err => {
if (err) return res.send("Update fehlgeschlagen");
db.query(
`
INSERT INTO service_logs
(service_id, user_id, action, old_value, new_value)
VALUES (?, ?, 'UPDATE_PRICE', ?, ?)
`,
[
serviceId,
userId,
JSON.stringify(oldData),
JSON.stringify({ price, price_c70 })
]
);
res.redirect("/services");
}
);
}
);
}
function toggleService(req, res) {
const serviceId = req.params.id;
const userId = req.session.user.id;
db.query(
"SELECT active FROM services WHERE id = ?",
[serviceId],
(err, rows) => {
if (err || rows.length === 0) return res.send("Service nicht gefunden");
const oldActive = rows[0].active;
const newActive = oldActive ? 0 : 1;
db.query(
"UPDATE services SET active = ? WHERE id = ?",
[newActive, serviceId],
err => {
if (err) return res.send("Update fehlgeschlagen");
db.query(
`
INSERT INTO service_logs
(service_id, user_id, action, old_value, new_value)
VALUES (?, ?, 'TOGGLE_ACTIVE', ?, ?)
`,
[serviceId, userId, oldActive, newActive]
);
res.redirect("/services");
}
);
}
);
}
async function listOpenServices(req, res, next) {
res.set("Cache-Control", "no-store, no-cache, must-revalidate, private");
res.set("Pragma", "no-cache");
res.set("Expires", "0");
const sql = `
SELECT
p.id AS patient_id,
p.firstname,
p.lastname,
p.country,
ps.id AS patient_service_id,
ps.quantity,
COALESCE(ps.price_override, s.price) AS price,
CASE
WHEN UPPER(TRIM(p.country)) = 'ES'
THEN COALESCE(NULLIF(s.name_es, ''), s.name_de)
ELSE s.name_de
END AS name
FROM patient_services ps
JOIN patients p ON ps.patient_id = p.id
JOIN services s ON ps.service_id = s.id
WHERE ps.invoice_id IS NULL
ORDER BY p.lastname, p.firstname, name
`;
let connection;
try {
// 🔌 EXAKT EINE Connection holen
connection = await db.promise().getConnection();
// 🔒 Isolation Level für DIESE Connection
await connection.query(
"SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED"
);
const [[cid]] = await connection.query(
"SELECT CONNECTION_ID() AS cid"
);
console.log("🔌 OPEN SERVICES CID:", cid.cid);
const [rows] = await connection.query(sql);
console.log("🧾 OPEN SERVICES ROWS:", rows.length);
res.render("open_services", {
rows,
user: req.session.user
});
} catch (err) {
next(err);
} finally {
if (connection) connection.release();
}
}
function showServiceLogs(req, res) {
db.query(
`
SELECT
l.created_at,
u.username,
l.action,
l.old_value,
l.new_value
FROM service_logs l
JOIN users u ON l.user_id = u.id
ORDER BY l.created_at DESC
`,
(err, logs) => {
if (err) return res.send("Datenbankfehler");
res.render("admin_service_logs", {
logs,
user: req.session.user
});
}
);
}
module.exports = {
listServices,
showCreateService,
createService,
updateServicePrice,
toggleService,
listOpenServices,
showServiceLogs,
listServicesAdmin
};
const db = require("../db");
function listServices(req, res) {
const { q, onlyActive, patientId } = req.query;
// 🔹 Standard: Deutsch
let serviceNameField = "name_de";
const loadServices = () => {
let sql = `
SELECT id, ${serviceNameField} AS name, category, price, active
FROM services
WHERE 1=1
`;
const params = [];
if (q) {
sql += `
AND (
name_de LIKE ?
OR name_es LIKE ?
OR category LIKE ?
)
`;
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
}
if (onlyActive === "1") {
sql += " AND active = 1";
}
sql += ` ORDER BY ${serviceNameField}`;
db.query(sql, params, (err, services) => {
if (err) return res.send("Datenbankfehler");
res.render("services", {
title: "Leistungen",
sidebarPartial: "partials/sidebar-empty",
active: "services",
services,
user: req.session.user,
lang: req.session.lang || "de",
query: { q, onlyActive, patientId },
});
});
};
// 🔹 Wenn Patient angegeben → Country prüfen
if (patientId) {
db.query(
"SELECT country FROM patients WHERE id = ?",
[patientId],
(err, rows) => {
if (!err && rows.length && rows[0].country === "ES") {
serviceNameField = "name_es";
}
loadServices();
},
);
} else {
// 🔹 Kein Patient → Deutsch
loadServices();
}
}
function listServicesAdmin(req, res) {
const { q, onlyActive } = req.query;
let sql = `
SELECT
id,
name_de,
name_es,
category,
price,
price_c70,
active
FROM services
WHERE 1=1
`;
const params = [];
if (q) {
sql += `
AND (
name_de LIKE ?
OR name_es LIKE ?
OR category LIKE ?
)
`;
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
}
if (onlyActive === "1") {
sql += " AND active = 1";
}
sql += " ORDER BY name_de";
db.query(sql, params, (err, services) => {
if (err) return res.send("Datenbankfehler");
res.render("services", {
title: "Leistungen (Admin)",
sidebarPartial: "partials/admin-sidebar",
active: "services",
services,
user: req.session.user,
lang: req.session.lang || "de",
query: { q, onlyActive },
});
});
}
function showCreateService(req, res) {
res.render("service_create", {
title: "Leistung anlegen",
sidebarPartial: "partials/sidebar-empty",
active: "services",
user: req.session.user,
lang: req.session.lang || "de",
error: null,
});
}
function createService(req, res) {
const { name_de, name_es, category, price, price_c70 } = req.body;
const userId = req.session.user.id;
if (!name_de || !price) {
return res.render("service_create", {
title: "Leistung anlegen",
sidebarPartial: "partials/sidebar-empty",
active: "services",
user: req.session.user,
lang: req.session.lang || "de",
error: "Bezeichnung (DE) und Preis sind Pflichtfelder",
});
}
db.query(
`
INSERT INTO services
(name_de, name_es, category, price, price_c70, active)
VALUES (?, ?, ?, ?, ?, 1)
`,
[name_de, name_es || "--", category || "--", price, price_c70 || 0],
(err, result) => {
if (err) return res.send("Fehler beim Anlegen der Leistung");
db.query(
`
INSERT INTO service_logs
(service_id, user_id, action, new_value)
VALUES (?, ?, 'CREATE', ?)
`,
[result.insertId, userId, JSON.stringify(req.body)],
);
res.redirect("/services");
},
);
}
function updateServicePrice(req, res) {
const serviceId = req.params.id;
const { price, price_c70 } = req.body;
const userId = req.session.user.id;
db.query(
"SELECT price, price_c70 FROM services WHERE id = ?",
[serviceId],
(err, oldRows) => {
if (err || oldRows.length === 0)
return res.send("Service nicht gefunden");
const oldData = oldRows[0];
db.query(
"UPDATE services SET price = ?, price_c70 = ? WHERE id = ?",
[price, price_c70, serviceId],
(err) => {
if (err) return res.send("Update fehlgeschlagen");
db.query(
`
INSERT INTO service_logs
(service_id, user_id, action, old_value, new_value)
VALUES (?, ?, 'UPDATE_PRICE', ?, ?)
`,
[
serviceId,
userId,
JSON.stringify(oldData),
JSON.stringify({ price, price_c70 }),
],
);
res.redirect("/services");
},
);
},
);
}
function toggleService(req, res) {
const serviceId = req.params.id;
const userId = req.session.user.id;
db.query(
"SELECT active FROM services WHERE id = ?",
[serviceId],
(err, rows) => {
if (err || rows.length === 0) return res.send("Service nicht gefunden");
const oldActive = rows[0].active;
const newActive = oldActive ? 0 : 1;
db.query(
"UPDATE services SET active = ? WHERE id = ?",
[newActive, serviceId],
(err) => {
if (err) return res.send("Update fehlgeschlagen");
db.query(
`
INSERT INTO service_logs
(service_id, user_id, action, old_value, new_value)
VALUES (?, ?, 'TOGGLE_ACTIVE', ?, ?)
`,
[serviceId, userId, oldActive, newActive],
);
res.redirect("/services");
},
);
},
);
}
async function listOpenServices(req, res, next) {
res.set("Cache-Control", "no-store, no-cache, must-revalidate, private");
res.set("Pragma", "no-cache");
res.set("Expires", "0");
const sql = `
SELECT
p.id AS patient_id,
p.firstname,
p.lastname,
p.country,
ps.id AS patient_service_id,
ps.quantity,
COALESCE(ps.price_override, s.price) AS price,
CASE
WHEN UPPER(TRIM(p.country)) = 'ES'
THEN COALESCE(NULLIF(s.name_es, ''), s.name_de)
ELSE s.name_de
END AS name
FROM patient_services ps
JOIN patients p ON ps.patient_id = p.id
JOIN services s ON ps.service_id = s.id
WHERE ps.invoice_id IS NULL
ORDER BY p.lastname, p.firstname, name
`;
let connection;
try {
connection = await db.promise().getConnection();
await connection.query(
"SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED",
);
const [[cid]] = await connection.query("SELECT CONNECTION_ID() AS cid");
console.log("🔌 OPEN SERVICES CID:", cid.cid);
const [rows] = await connection.query(sql);
console.log("🧾 OPEN SERVICES ROWS:", rows.length);
res.render("open_services", {
title: "Offene Leistungen",
sidebarPartial: "partials/sidebar-invoices",
active: "services",
rows,
user: req.session.user,
lang: req.session.lang || "de",
});
} catch (err) {
next(err);
} finally {
if (connection) connection.release();
}
}
function showServiceLogs(req, res) {
db.query(
`
SELECT
l.created_at,
u.username,
l.action,
l.old_value,
l.new_value
FROM service_logs l
JOIN users u ON l.user_id = u.id
ORDER BY l.created_at DESC
`,
(err, logs) => {
if (err) return res.send("Datenbankfehler");
res.render("admin_service_logs", {
title: "Service Logs",
sidebarPartial: "partials/admin-sidebar",
active: "services",
logs,
user: req.session.user,
lang: req.session.lang || "de",
});
},
);
}
module.exports = {
listServices,
showCreateService,
createService,
updateServicePrice,
toggleService,
listOpenServices,
showServiceLogs,
listServicesAdmin,
};

125
db.js
View File

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

View File

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

View File

@ -1,26 +1,132 @@
{
"global": {
"save": "Speichern",
"cancel": "Abbrechen",
"search": "Suchen",
"reset": "Reset",
"dashboard": "Dashboard"
},
"sidebar": {
"patients": "Patienten",
"medications": "Medikamente",
"servicesOpen": "Offene Leistungen",
"billing": "Abrechnung",
"admin": "Verwaltung",
"logout": "Logout"
},
"dashboard": {
"welcome": "Willkommen",
"waitingRoom": "Wartezimmer-Monitor",
"noWaitingPatients": "Keine Patienten im Wartezimmer."
},
"adminSidebar": {
"users": "Userverwaltung",
"database": "Datenbankverwaltung"
}
}
{
"global": {
"save": "Speichern",
"cancel": "Abbrechen",
"search": "Suchen",
"reset": "Reset",
"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": "inaktive",
"active": "aktive",
"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össe",
"errordatabase": "Fehler beim Auslesen der Datenbankinfos:",
"welcome": "Willkommen",
"waitingroomtext": "Wartezimmer-Monitor",
"waitingroomtextnopatient": "Keine Patienten im Wartezimmer.",
"gender": "Geschlecht",
"birthday": "Geburtstag",
"email": "E-Mail",
"phone": "Telefon",
"address": "Adresse",
"country": "Land",
"notice": "Notizen",
"create": "Erstellt",
"change": "Geändert",
"reset2": "Zurücksetzen",
"edit": "Bearbeiten",
"selection": "Auswahl",
"waiting": "Wartet bereits",
"towaitingroom": "Ins Wartezimmer",
"overview": "Übersicht",
"upload": "Hochladen",
"lock": "Sperren",
"unlock": "Enrsperren",
"name": "Name",
"return": "Zurück",
"fileupload": "Hochladen"
},
"sidebar": {
"patients": "Patienten",
"medications": "Medikamente",
"servicesOpen": "Patienten Rechnungen",
"billing": "Abrechnung",
"admin": "Verwaltung",
"logout": "Logout"
},
"dashboard": {
"welcome": "Willkommen",
"waitingRoom": "Wartezimmer-Monitor",
"noWaitingPatients": "Keine Patienten im Wartezimmer.",
"title": "Dashboard"
},
"adminSidebar": {
"users": "Userverwaltung",
"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"
},
"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: 4×5 Zeichen, getrennt mit „-“. ",
"saveseriennumber": "Seriennummer Speichern"
},
"databaseoverview": {
"title": "Datenbank Konfiguration",
"text": "Hier kannst du die DB-Verbindung testen und speichern. ",
"host": "Host",
"port": "Port",
"database": "Datenbank",
"password": "Password",
"connectiontest": "Verbindung testen",
"tablecount": "Anzahl Tabellen",
"databasesize": "Datenbankgrösse",
"tableoverview": "Tabellenübersicht"
},
"patienteoverview": {
"patienttitle": "Patientenübersicht",
"newpatient": "Neuer Patient",
"nopatientfound": "Keine Patienten gefunden",
"closepatient": "Patient sperren ( inaktiv)",
"openpatient": "Patient entsperren (Aktiv)"
},
"openinvoices": {
"openinvoices": "Offene Rechnungen",
"canceledinvoices": "Stornierte Rechnungen",
"report": "Umsatzreport",
"payedinvoices": "Bezahlte Rechnungen",
"creditoverview": "Gutschrift Übersicht"
}
}

View File

@ -1,27 +1,132 @@
{
"global": {
"save": "Guardar",
"cancel": "Cancelar",
"search": "Buscar",
"reset": "Resetear",
"dashboard": "Panel"
},
"sidebar": {
"patients": "Pacientes",
"medications": "Medicamentos",
"servicesOpen": "Servicios abiertos",
"billing": "Facturación",
"admin": "Administración",
"logout": "Cerrar sesión"
},
"dashboard": {
"welcome": "Bienvenido",
"waitingRoom": "Monitor sala de espera",
"noWaitingPatients": "No hay pacientes en la sala de espera."
},
"adminSidebar": {
"users": "Administración de usuarios",
"database": "Administración de base de datos"
}
}
{
"global": {
"save": "Guardar",
"cancel": "Cancelar",
"search": "Buscar",
"reset": "Resetear",
"dashboard": "Panel",
"logout": "cerrar sesión",
"title": "Título",
"firstname": "Nombre",
"lastname": "apellido",
"username": "Nombre de usuario",
"role": "desempeñar",
"action": "acción",
"status": "Estado",
"you": "su mismo",
"newuser": "Nuevo usuario",
"inactive": "inactivo",
"active": "activo",
"closed": "bloqueado",
"filter": "Filtro",
"yearcash": "volumen de negocios anual",
"monthcash": "volumen de negocios mensual",
"quartalcash": "volumen de negocios trimestral",
"year": "ano",
"nodata": "sin datos",
"month": "mes",
"patientcash": "Ingresos por paciente",
"patient": "paciente",
"systeminfo": "Información del sistema",
"table": "tablas",
"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",
"email": "Correo electrónico",
"phone": "Teléfono",
"address": "Dirección",
"country": "País",
"notice": "Notas",
"create": "Creado",
"change": "Modificado",
"reset2": "Restablecer",
"edit": "Editar",
"selection": "Selección",
"waiting": "Ya está esperando",
"towaitingroom": "A la sala de espera",
"overview": "Resumen",
"upload": "Subir archivo",
"lock": "bloquear",
"unlock": "desbloquear",
"name": "Nombre",
"return": "Atrás",
"fileupload": "Cargar"
},
"sidebar": {
"patients": "Pacientes",
"medications": "Medicamentos",
"servicesOpen": "Servicios abiertos",
"billing": "Facturación",
"admin": "Administración",
"logout": "Cerrar sesión"
},
"dashboard": {
"welcome": "Bienvenido",
"waitingRoom": "Monitor sala de espera",
"noWaitingPatients": "No hay pacientes en la sala de espera.",
"title": "Dashboard"
},
"adminSidebar": {
"users": "Administración de usuarios",
"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"
},
"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: 4×5 caracteres, separados por «-». ",
"saveseriennumber": "Guardar número de serie"
},
"databaseoverview": {
"title": "Configuración de la base de datos",
"host": "Host",
"port": "Puerto",
"database": "Base de datos",
"password": "Contraseña",
"connectiontest": "Probar conexión",
"text": "Aquí puedes probar y guardar la conexión a la base de datos. ",
"tablecount": "Número de tablas",
"databasesize": "Tamaño de la base de datos",
"tableoverview": "Resumen de tablas"
},
"patienteoverview": {
"patienttitle": "Resumen de pacientes",
"newpatient": "Paciente nuevo",
"nopatientfound": "No se han encontrado pacientes.",
"closepatient": "Bloquear paciente (inactivo)",
"openpatient": "Desbloquear paciente (activo)"
},
"openinvoices": {
"openinvoices": "Facturas de pacientes",
"canceledinvoices": "Facturas canceladas",
"report": "Informe de ventas",
"payedinvoices": "Facturas pagadas",
"creditoverview": "Resumen de abonos"
}
}

View File

@ -1,54 +1,54 @@
function requireLogin(req, res, next) {
if (!req.session.user) {
return res.redirect("/");
}
req.user = req.session.user;
next();
}
// ✅ NEU: Arzt-only (das war früher dein requireAdmin)
function requireArzt(req, res, next) {
console.log("ARZT CHECK:", req.session.user);
if (!req.session.user) {
return res.redirect("/");
}
if (req.session.user.role !== "arzt") {
return res
.status(403)
.send(
"⛔ Kein Zugriff (Arzt erforderlich). Rolle: " + req.session.user.role,
);
}
req.user = req.session.user;
next();
}
// ✅ NEU: Admin-only
function requireAdmin(req, res, next) {
console.log("ADMIN CHECK:", req.session.user);
if (!req.session.user) {
return res.redirect("/");
}
if (req.session.user.role !== "admin") {
return res
.status(403)
.send(
"⛔ Kein Zugriff (Admin erforderlich). Rolle: " + req.session.user.role,
);
}
req.user = req.session.user;
next();
}
module.exports = {
requireLogin,
requireArzt,
requireAdmin,
};
function requireLogin(req, res, next) {
if (!req.session.user) {
return res.redirect("/");
}
req.user = req.session.user;
next();
}
// ✅ NEU: Arzt-only (das war früher dein requireAdmin)
function requireArzt(req, res, next) {
console.log("ARZT CHECK:", req.session.user);
if (!req.session.user) {
return res.redirect("/");
}
if (req.session.user.role !== "arzt") {
return res
.status(403)
.send(
"⛔ Kein Zugriff (Arzt erforderlich). Rolle: " + req.session.user.role,
);
}
req.user = req.session.user;
next();
}
// ✅ NEU: Admin-only
function requireAdmin(req, res, next) {
console.log("ADMIN CHECK:", req.session.user);
if (!req.session.user) {
return res.redirect("/");
}
if (req.session.user.role !== "admin") {
return res
.status(403)
.send(
"⛔ Kein Zugriff (Admin erforderlich). Rolle: " + req.session.user.role,
);
}
req.user = req.session.user;
next();
}
module.exports = {
requireLogin,
requireArzt,
requireAdmin,
};

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

199
package-lock.json generated
View File

@ -11,10 +11,12 @@
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"docxtemplater": "^3.67.6",
"dotenv": "^17.2.3",
"ejs": "^3.1.10",
"express": "^4.19.2",
"express-ejs-layouts": "^2.5.1",
"express-mysql-session": "^3.0.3",
"express-session": "^1.18.2",
"fs-extra": "^11.3.3",
@ -22,6 +24,8 @@
"html-pdf-node": "^1.0.8",
"multer": "^2.0.2",
"mysql2": "^3.16.0",
"node-ssh": "^13.2.1",
"pdf-lib": "^1.17.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
@ -1036,6 +1040,12 @@
"@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": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -1072,6 +1082,24 @@
"@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": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -1647,6 +1675,14 @@
"dev": true,
"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/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -1834,6 +1870,14 @@
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -2061,6 +2105,15 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"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": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -2214,6 +2267,18 @@
"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": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz",
@ -2513,6 +2578,20 @@
"dev": true,
"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": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@ -3045,6 +3124,11 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-ejs-layouts": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/express-ejs-layouts/-/express-ejs-layouts-2.5.1.tgz",
"integrity": "sha512-IXROv9n3xKga7FowT06n1Qn927JR8ZWDn5Dc9CJQoiiaaDqbhW5PDmWShzbpAa2wjWT1vJqaIM1S6vJwwX11gA=="
},
"node_modules/express-mysql-session": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/express-mysql-session/-/express-mysql-session-3.0.3.tgz",
@ -4105,7 +4189,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -5293,6 +5376,12 @@
"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": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@ -5374,6 +5463,44 @@
"dev": true,
"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": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -5585,6 +5712,12 @@
"dev": true,
"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": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -5671,6 +5804,24 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"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": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@ -6030,6 +6181,25 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"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": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -6131,6 +6301,11 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -6305,6 +6480,23 @@
"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": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@ -6720,6 +6912,11 @@
"license": "0BSD",
"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": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",

View File

@ -15,10 +15,12 @@
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"docxtemplater": "^3.67.6",
"dotenv": "^17.2.3",
"ejs": "^3.1.10",
"express": "^4.19.2",
"express-ejs-layouts": "^2.5.1",
"express-mysql-session": "^3.0.3",
"express-session": "^1.18.2",
"fs-extra": "^11.3.3",
@ -26,6 +28,8 @@
"html-pdf-node": "^1.0.8",
"multer": "^2.0.2",
"mysql2": "^3.16.0",
"node-ssh": "^13.2.1",
"pdf-lib": "^1.17.1",
"xlsx": "^0.18.5"
},
"devDependencies": {

View File

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

Binary file not shown.

Binary file not shown.

View File

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

14
public/js/chart.js Normal file

File diff suppressed because one or more lines are too long

21
public/js/datetime.js Normal file
View File

@ -0,0 +1,21 @@
(function () {
function updateDateTime() {
const el = document.getElementById("datetime");
if (!el) return;
const now = new Date();
const date = now.toLocaleDateString("de-DE");
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

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

View File

@ -0,0 +1,25 @@
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

@ -0,0 +1,24 @@
document.addEventListener("DOMContentLoaded", () => {
const radios = document.querySelectorAll(".patient-radio");
if (!radios || radios.length === 0) return;
radios.forEach((radio) => {
radio.addEventListener("change", async () => {
const patientId = radio.value;
try {
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

@ -0,0 +1,124 @@
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;
});
});
});

101
public/js/reports.js Normal file
View File

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

View File

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

View File

@ -1,382 +1,512 @@
const express = require("express");
const router = express.Router();
const mysql = require("mysql2/promise");
const fs = require("fs");
const path = require("path");
const { exec } = require("child_process");
const multer = require("multer");
// ✅ Upload Ordner für Restore Dumps
const upload = multer({ dest: path.join(__dirname, "..", "uploads_tmp") });
const {
listUsers,
showCreateUser,
postCreateUser,
changeUserRole,
resetUserPassword,
activateUser,
deactivateUser,
showInvoiceOverview,
updateUser,
} = require("../controllers/admin.controller");
const { requireArzt, requireAdmin } = require("../middleware/auth.middleware");
// ✅ config.enc Manager
const { loadConfig, saveConfig } = require("../config-manager");
// ✅ DB (für resetPool)
const db = require("../db");
/* ==========================
VERWALTUNG (NUR ADMIN)
========================== */
router.get("/users", requireAdmin, listUsers);
router.get("/create-user", requireAdmin, showCreateUser);
router.post("/create-user", requireAdmin, postCreateUser);
router.post("/users/change-role/:id", requireAdmin, changeUserRole);
router.post("/users/reset-password/:id", requireAdmin, resetUserPassword);
router.post("/users/activate/:id", requireAdmin, activateUser);
router.post("/users/deactivate/:id", requireAdmin, deactivateUser);
router.post("/users/update/:id", requireAdmin, updateUser);
/* ==========================
DATENBANKVERWALTUNG (NUR ADMIN)
========================== */
// ✅ Seite anzeigen + aktuelle DB Config aus config.enc anzeigen
router.get("/database", requireAdmin, async (req, res) => {
const cfg = loadConfig();
const backupDir = path.join(__dirname, "..", "backups");
let backupFiles = [];
try {
if (fs.existsSync(backupDir)) {
backupFiles = fs
.readdirSync(backupDir)
.filter((f) => f.toLowerCase().endsWith(".sql"))
.sort()
.reverse(); // ✅ neueste zuerst
}
} catch (err) {
console.error("❌ Backup Ordner Fehler:", err);
}
let systemInfo = null;
try {
if (cfg?.db) {
const conn = await mysql.createConnection({
host: cfg.db.host,
user: cfg.db.user,
password: cfg.db.password,
database: cfg.db.name,
});
// ✅ Version
const [v] = await conn.query("SELECT VERSION() AS version");
// ✅ Anzahl Tabellen
const [tablesCount] = await conn.query(
"SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = ?",
[cfg.db.name],
);
// ✅ DB Größe (Bytes)
const [dbSize] = await conn.query(
`
SELECT IFNULL(SUM(data_length + index_length),0) AS bytes
FROM information_schema.tables
WHERE table_schema = ?
`,
[cfg.db.name],
);
// ✅ Tabellen Details
const [tables] = await conn.query(
`
SELECT
table_name AS name,
table_rows AS row_count,
ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb
FROM information_schema.tables
WHERE table_schema = ?
ORDER BY (data_length + index_length) DESC
`,
[cfg.db.name],
);
await conn.end();
systemInfo = {
version: v?.[0]?.version || "unbekannt",
tableCount: tablesCount?.[0]?.count || 0,
dbSizeMB:
Math.round(((dbSize?.[0]?.bytes || 0) / 1024 / 1024) * 100) / 100,
tables,
};
}
} catch (err) {
console.error("❌ SYSTEMINFO ERROR:", err);
systemInfo = {
error: err.message,
};
}
res.render("admin/database", {
user: req.session.user,
dbConfig: cfg?.db || null,
testResult: null,
backupFiles,
systemInfo, // ✅ DAS HAT GEFEHLT
});
});
// ✅ Nur testen (ohne speichern)
router.post("/database/test", requireAdmin, async (req, res) => {
try {
const { host, user, password, name } = req.body;
if (!host || !user || !password || !name) {
const cfg = loadConfig();
return res.render("admin/database", {
user: req.session.user,
dbConfig: cfg?.db || null,
testResult: { ok: false, message: "❌ Bitte alle Felder ausfüllen." },
});
}
const conn = await mysql.createConnection({
host,
user,
password,
database: name,
});
await conn.query("SELECT 1");
await conn.end();
return res.render("admin/database", {
user: req.session.user,
dbConfig: { host, user, password, name },
testResult: { ok: true, message: "✅ Verbindung erfolgreich!" },
});
} catch (err) {
console.error("❌ DB TEST ERROR:", err);
return res.render("admin/database", {
user: req.session.user,
dbConfig: req.body,
testResult: {
ok: false,
message: "❌ Verbindung fehlgeschlagen: " + err.message,
},
});
}
});
// ✅ DB Settings speichern + Verbindung testen
router.post("/database", requireAdmin, async (req, res) => {
try {
const { host, user, password, name } = req.body;
if (!host || !user || !password || !name) {
req.flash("error", "❌ Bitte alle Felder ausfüllen.");
return res.redirect("/admin/database");
}
const conn = await mysql.createConnection({
host,
user,
password,
database: name,
});
await conn.query("SELECT 1");
await conn.end();
// ✅ Speichern in config.enc
const current = loadConfig() || {};
current.db = { host, user, password, name };
saveConfig(current);
// ✅ DB Pool resetten (falls vorhanden)
if (typeof db.resetPool === "function") {
db.resetPool();
}
req.flash(
"success",
"✅ DB Einstellungen gespeichert + Verbindung erfolgreich getestet.",
);
return res.redirect("/admin/database");
} catch (err) {
console.error("❌ DB UPDATE ERROR:", err);
req.flash("error", "❌ Verbindung fehlgeschlagen: " + err.message);
return res.redirect("/admin/database");
}
});
/* ==========================
BACKUP (NUR ADMIN)
========================== */
router.post("/database/backup", requireAdmin, (req, res) => {
// ✅ Flash Safe (funktioniert auch ohne req.flash)
function flashSafe(type, msg) {
if (typeof req.flash === "function") {
req.flash(type, msg);
return;
}
req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg });
console.log(`[FLASH-${type}]`, msg);
}
try {
const cfg = loadConfig();
if (!cfg?.db) {
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
return res.redirect("/admin/database");
}
const { host, user, password, name } = cfg.db;
const backupDir = path.join(__dirname, "..", "backups");
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir);
const stamp = new Date()
.toISOString()
.replace(/T/, "_")
.replace(/:/g, "-")
.split(".")[0];
const fileName = `${name}_${stamp}.sql`;
const filePath = path.join(backupDir, fileName);
// ✅ mysqldump.exe im Root
const mysqldumpPath = path.join(__dirname, "..", "mysqldump.exe");
// ✅ plugin Ordner im Root (muss existieren)
const pluginDir = path.join(__dirname, "..", "plugin");
if (!fs.existsSync(mysqldumpPath)) {
flashSafe("danger", "❌ mysqldump.exe nicht gefunden: " + mysqldumpPath);
return res.redirect("/admin/database");
}
if (!fs.existsSync(pluginDir)) {
flashSafe("danger", "❌ plugin Ordner nicht gefunden: " + pluginDir);
return res.redirect("/admin/database");
}
const cmd = `"${mysqldumpPath}" --plugin-dir="${pluginDir}" -h ${host} -u ${user} -p${password} ${name} > "${filePath}"`;
exec(cmd, (error, stdout, stderr) => {
if (error) {
console.error("❌ BACKUP ERROR:", error);
console.error("STDERR:", stderr);
flashSafe(
"danger",
"❌ Backup fehlgeschlagen: " + (stderr || error.message),
);
return res.redirect("/admin/database");
}
flashSafe("success", `✅ Backup erstellt: ${fileName}`);
return res.redirect("/admin/database");
});
} catch (err) {
console.error("❌ BACKUP ERROR:", err);
flashSafe("danger", "❌ Backup fehlgeschlagen: " + err.message);
return res.redirect("/admin/database");
}
});
/* ==========================
RESTORE (NUR ADMIN)
========================== */
router.post("/database/restore", requireAdmin, (req, res) => {
function flashSafe(type, msg) {
if (typeof req.flash === "function") {
req.flash(type, msg);
return;
}
req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg });
console.log(`[FLASH-${type}]`, msg);
}
try {
const cfg = loadConfig();
if (!cfg?.db) {
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
return res.redirect("/admin/database");
}
const { host, user, password, name } = cfg.db;
const backupDir = path.join(__dirname, "..", "backups");
const selectedFile = req.body.backupFile;
if (!selectedFile) {
flashSafe("danger", "❌ Bitte ein Backup auswählen.");
return res.redirect("/admin/database");
}
const fullPath = path.join(backupDir, selectedFile);
if (!fs.existsSync(fullPath)) {
flashSafe("danger", "❌ Backup Datei nicht gefunden: " + selectedFile);
return res.redirect("/admin/database");
}
// ✅ mysql.exe im Root
const mysqlPath = path.join(__dirname, "..", "mysql.exe");
const pluginDir = path.join(__dirname, "..", "plugin");
if (!fs.existsSync(mysqlPath)) {
flashSafe("danger", "❌ mysql.exe nicht gefunden im Root: " + mysqlPath);
return res.redirect("/admin/database");
}
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");
});
} catch (err) {
console.error("❌ RESTORE ERROR:", err);
flashSafe("danger", "❌ Restore fehlgeschlagen: " + err.message);
return res.redirect("/admin/database");
}
});
/* ==========================
ABRECHNUNG (NUR ARZT)
========================== */
router.get("/invoices", requireArzt, showInvoiceOverview);
module.exports = router;
const express = require("express");
const router = express.Router();
const mysql = require("mysql2/promise");
const fs = require("fs");
const path = require("path");
const { exec } = require("child_process");
const multer = require("multer");
const { NodeSSH } = require("node-ssh");
const uploadLogo = require("../middleware/uploadLogo");
// ✅ Upload Ordner für Restore Dumps
const upload = multer({ dest: path.join(__dirname, "..", "uploads_tmp") });
const {
listUsers,
showCreateUser,
postCreateUser,
changeUserRole,
resetUserPassword,
activateUser,
deactivateUser,
showInvoiceOverview,
updateUser,
} = require("../controllers/admin.controller");
const { requireArzt, requireAdmin } = require("../middleware/auth.middleware");
// ✅ config.enc Manager
const { loadConfig, saveConfig } = require("../config-manager");
// ✅ DB (für resetPool)
const db = require("../db");
// ✅ Firmendaten
const {
getCompanySettings,
saveCompanySettings
} = require("../controllers/companySettings.controller");
/* ==========================
VERWALTUNG (NUR ADMIN)
========================== */
router.get("/users", requireAdmin, listUsers);
router.get("/create-user", requireAdmin, showCreateUser);
router.post("/create-user", requireAdmin, postCreateUser);
router.post("/users/change-role/:id", requireAdmin, changeUserRole);
router.post("/users/reset-password/:id", requireAdmin, resetUserPassword);
router.post("/users/activate/:id", requireAdmin, activateUser);
router.post("/users/deactivate/:id", requireAdmin, deactivateUser);
router.post("/users/update/:id", requireAdmin, updateUser);
/* ==========================
DATENBANKVERWALTUNG (NUR ADMIN)
========================== */
// ✅ Seite anzeigen + aktuelle DB Config aus config.enc anzeigen
router.get("/database", requireAdmin, async (req, res) => {
const cfg = loadConfig();
const backupDir = path.join(__dirname, "..", "backups");
let backupFiles = [];
try {
if (fs.existsSync(backupDir)) {
backupFiles = fs
.readdirSync(backupDir)
.filter((f) => f.toLowerCase().endsWith(".sql"))
.sort()
.reverse(); // ✅ neueste zuerst
}
} catch (err) {
console.error("❌ Backup Ordner Fehler:", err);
}
let systemInfo = null;
try {
if (cfg?.db) {
const conn = await mysql.createConnection({
host: cfg.db.host,
port: Number(cfg.db.port || 3306), // ✅ WICHTIG: Port nutzen
user: cfg.db.user,
password: cfg.db.password,
database: cfg.db.name,
});
// ✅ Version
const [v] = await conn.query("SELECT VERSION() AS version");
// ✅ Anzahl Tabellen
const [tablesCount] = await conn.query(
"SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = ?",
[cfg.db.name],
);
// ✅ DB Größe (Bytes)
const [dbSize] = await conn.query(
`
SELECT IFNULL(SUM(data_length + index_length),0) AS bytes
FROM information_schema.tables
WHERE table_schema = ?
`,
[cfg.db.name],
);
// ✅ Tabellen Details
const [tables] = await conn.query(
`
SELECT
table_name AS name,
table_rows AS row_count,
ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb
FROM information_schema.tables
WHERE table_schema = ?
ORDER BY (data_length + index_length) DESC
`,
[cfg.db.name],
);
await conn.end();
systemInfo = {
version: v?.[0]?.version || "unbekannt",
tableCount: tablesCount?.[0]?.count || 0,
dbSizeMB:
Math.round(((dbSize?.[0]?.bytes || 0) / 1024 / 1024) * 100) / 100,
tables,
};
}
} catch (err) {
console.error("❌ SYSTEMINFO ERROR:", err);
systemInfo = {
error: err.message,
};
}
res.render("admin/database", {
user: req.session.user,
dbConfig: cfg?.db || null,
testResult: null,
backupFiles,
systemInfo,
});
});
// ✅ Nur testen (ohne speichern)
router.post("/database/test", requireAdmin, async (req, res) => {
const backupDir = path.join(__dirname, "..", "backups");
function getBackupFiles() {
try {
if (fs.existsSync(backupDir)) {
return fs
.readdirSync(backupDir)
.filter((f) => f.toLowerCase().endsWith(".sql"))
.sort()
.reverse();
}
} catch (err) {
console.error("❌ Backup Ordner Fehler:", err);
}
return [];
}
try {
const { host, port, user, password, name } = req.body;
if (!host || !port || !user || !password || !name) {
const cfg = loadConfig();
return res.render("admin/database", {
user: req.session.user,
dbConfig: cfg?.db || null,
testResult: { ok: false, message: "❌ Bitte alle Felder ausfüllen." },
backupFiles: getBackupFiles(),
systemInfo: null,
});
}
const conn = await mysql.createConnection({
host,
port: Number(port),
user,
password,
database: name,
});
await conn.query("SELECT 1");
await conn.end();
return res.render("admin/database", {
user: req.session.user,
dbConfig: { host, port: Number(port), user, password, name }, // ✅ PORT bleibt drin!
testResult: { ok: true, message: "✅ Verbindung erfolgreich!" },
backupFiles: getBackupFiles(),
systemInfo: null,
});
} catch (err) {
console.error("❌ DB TEST ERROR:", err);
return res.render("admin/database", {
user: req.session.user,
dbConfig: req.body,
testResult: {
ok: false,
message: "❌ Verbindung fehlgeschlagen: " + err.message,
},
backupFiles: getBackupFiles(),
systemInfo: null,
});
}
});
// ✅ DB Settings speichern + Verbindung testen
router.post("/database", requireAdmin, async (req, res) => {
function flashSafe(type, msg) {
if (typeof req.flash === "function") {
req.flash(type, msg);
return;
}
req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg });
}
const backupDir = path.join(__dirname, "..", "backups");
// ✅ backupFiles immer bereitstellen
function getBackupFiles() {
try {
if (fs.existsSync(backupDir)) {
return fs
.readdirSync(backupDir)
.filter((f) => f.toLowerCase().endsWith(".sql"))
.sort()
.reverse();
}
} catch (err) {
console.error("❌ Backup Ordner Fehler:", err);
}
return [];
}
try {
const { host, port, user, password, name } = req.body;
if (!host || !port || !user || !password || !name) {
flashSafe("danger", "❌ Bitte alle Felder ausfüllen.");
return res.render("admin/database", {
user: req.session.user,
dbConfig: req.body,
testResult: { ok: false, message: "❌ Bitte alle Felder ausfüllen." },
backupFiles: getBackupFiles(),
systemInfo: null,
});
}
// ✅ Verbindung testen
const conn = await mysql.createConnection({
host,
port: Number(port),
user,
password,
database: name,
});
await conn.query("SELECT 1");
await conn.end();
// ✅ Speichern inkl. Port
const current = loadConfig() || {};
current.db = {
host,
port: Number(port),
user,
password,
name,
};
saveConfig(current);
// ✅ Pool reset
if (typeof db.resetPool === "function") {
db.resetPool();
}
flashSafe("success", "✅ DB Einstellungen gespeichert!");
// ✅ DIREKT NEU LADEN aus config.enc (damit wirklich die gespeicherten Werte drin stehen)
const freshCfg = loadConfig();
return res.render("admin/database", {
user: req.session.user,
dbConfig: freshCfg?.db || null,
testResult: {
ok: true,
message: "✅ Gespeichert und Verbindung getestet.",
},
backupFiles: getBackupFiles(),
systemInfo: null,
});
} catch (err) {
console.error("❌ DB UPDATE ERROR:", err);
flashSafe("danger", "❌ Verbindung fehlgeschlagen: " + err.message);
return res.render("admin/database", {
user: req.session.user,
dbConfig: req.body,
testResult: {
ok: false,
message: "❌ Verbindung fehlgeschlagen: " + err.message,
},
backupFiles: getBackupFiles(),
systemInfo: null,
});
}
});
/* ==========================
BACKUP (NUR ADMIN)
========================== */
router.post("/database/backup", requireAdmin, async (req, res) => {
function flashSafe(type, msg) {
if (typeof req.flash === "function") return req.flash(type, msg);
req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg });
console.log(`[FLASH-${type}]`, msg);
}
try {
const cfg = loadConfig();
if (!cfg?.db) {
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
return res.redirect("/admin/database");
}
const { host, port, user, password, name } = cfg.db;
// ✅ Programmserver Backup Dir
const backupDir = path.join(__dirname, "..", "backups");
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()
.toISOString()
.replace(/T/, "_")
.replace(/:/g, "-")
.split(".")[0];
const fileName = `${name}_${stamp}.sql`;
// ✅ Datei wird zuerst auf DB-Server erstellt (tmp)
const remoteTmpPath = `/tmp/${fileName}`;
// ✅ Datei wird dann lokal (Programmserver) gespeichert
const localPath = path.join(backupDir, fileName);
const ssh = new NodeSSH();
await ssh.connect({
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");
}
// ✅ 2) Dump Datei vom DB-Server auf Programmserver kopieren
await ssh.getFile(localPath, remoteTmpPath);
// ✅ 3) Temp Datei auf DB-Server löschen
await ssh.execCommand(`rm -f "${remoteTmpPath}"`);
ssh.dispose();
flashSafe("success", `✅ Backup gespeichert (Programmserver): ${fileName}`);
return res.redirect("/admin/database");
} catch (err) {
console.error("❌ BACKUP SSH ERROR:", err);
flashSafe("danger", "❌ Backup fehlgeschlagen: " + err.message);
return res.redirect("/admin/database");
}
});
/* ==========================
RESTORE (NUR ADMIN)
========================== */
router.post("/database/restore", requireAdmin, async (req, res) => {
function flashSafe(type, msg) {
if (typeof req.flash === "function") return req.flash(type, msg);
req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg });
console.log(`[FLASH-${type}]`, msg);
}
const ssh = new NodeSSH();
try {
const cfg = loadConfig();
if (!cfg?.db) {
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
return res.redirect("/admin/database");
}
const { host, port, 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 localPath = path.join(backupDir, backupFile);
if (!fs.existsSync(localPath)) {
flashSafe("danger", "❌ Datei nicht gefunden (Programmserver): " + backupFile);
return res.redirect("/admin/database");
}
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 remoteTmpPath = `/tmp/${backupFile}`;
await ssh.connect({
host: sshHost,
username: sshUser,
port: sshPort,
privateKeyPath: "/home/cay/.ssh/id_ed25519",
});
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) {
console.error("❌ RESTORE SSH ERROR:", err);
flashSafe("danger", "❌ Restore fehlgeschlagen: " + err.message);
return res.redirect("/admin/database");
} finally {
try {
ssh.dispose();
} catch (e) {}
}
});
/* ==========================
ABRECHNUNG (NUR ARZT)
========================== */
router.get("/invoices", requireAdmin, showInvoiceOverview);
/* ==========================
Firmendaten
========================== */
router.get(
"/company-settings",
requireAdmin,
getCompanySettings
);
router.post(
"/company-settings",
requireAdmin,
uploadLogo.single("logo"),
saveCompanySettings
);
module.exports = router;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

8
routes/report.routes.js Normal file
View File

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

View File

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

139
routes/setup.routes.js Normal file
View File

@ -0,0 +1,139 @@
const express = require("express");
const router = express.Router();
const mysql = require("mysql2/promise");
// ✅ nutzt deinen bestehenden config-manager (NICHT utils/config!)
const { configExists, saveConfig } = require("../config-manager");
// ✅ DB + Session Reset (wie in deiner app.js)
const db = require("../db");
const { resetSessionStore } = require("../config/session");
/**
* Setup darf nur laufen, wenn config.enc NICHT existiert
* (sonst könnte jeder die DB später überschreiben)
*/
function blockIfInstalled(req, res, next) {
if (configExists()) {
return res.redirect("/");
}
next();
}
/**
* Setup Form anzeigen
*/
router.get("/", blockIfInstalled, (req, res) => {
return res.render("setup/index", {
title: "Erstinstallation",
defaults: {
host: "127.0.0.1",
port: 3306,
user: "",
password: "",
name: "",
},
});
});
/**
* Verbindung testen (AJAX)
*/
router.post("/test", blockIfInstalled, async (req, res) => {
try {
const { host, port, user, password, name } = req.body;
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,
port: Number(port || 3306),
user,
password,
database: name,
connectTimeout: 5000,
});
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, password, name } = req.body;
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
const connection = await mysql.createConnection({
host,
port: Number(port || 3306),
user,
password,
database: name,
connectTimeout: 5000,
});
await connection.query("SELECT 1");
await connection.end();
// ✅ speichern
saveConfig({
db: {
host,
port: Number(port || 3306),
user,
password,
name,
},
});
// ✅ DB Pool neu starten (damit neue config sofort aktiv ist)
if (typeof db.resetPool === "function") {
db.resetPool();
}
// ✅ Session Store neu starten
resetSessionStore();
req.session.flash = req.session.flash || [];
req.session.flash.push({
type: "success",
message: "✅ Setup abgeschlossen. 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 router = express.Router();
const { requireLogin } = require("../middleware/auth.middleware");
const {
showWaitingRoom,
movePatientToWaitingRoom
} = require("../controllers/patient.controller");
router.get("/waiting-room", requireLogin, showWaitingRoom);
router.post( "/patients/:id/waiting-room", requireLogin, movePatientToWaitingRoom);
module.exports = router;
const express = require("express");
const router = express.Router();
const { requireLogin } = require("../middleware/auth.middleware");
const {
showWaitingRoom,
movePatientToWaitingRoom
} = require("../controllers/patient.controller");
router.get("/waiting-room", requireLogin, showWaitingRoom);
router.post( "/patients/:id/waiting-room", requireLogin, movePatientToWaitingRoom);
module.exports = router;

View File

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

View File

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

View File

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

8
ssh_fuer_db_Server Normal file
View File

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

1
ssh_fuer_db_Server.pub Normal file
View File

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

View File

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

52
utils/config.js Normal file
View File

@ -0,0 +1,52 @@
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 };

70
utils/creditPdf.js Normal file
View File

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

34
utils/pdfWatermark.js Normal file
View File

@ -0,0 +1,34 @@
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,233 +1,213 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Rechnungsübersicht</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/bootstrap.min.css" />
<link rel="stylesheet" href="/bootstrap-icons/bootstrap-icons.min.css" />
</head>
<body class="bg-light">
<!-- =========================
NAVBAR
========================== -->
<nav class="navbar navbar-dark bg-dark position-relative px-3">
<div
class="position-absolute top-50 start-50 translate-middle d-flex align-items-center gap-2 text-white"
>
<i class="bi bi-calculator fs-4"></i>
<span class="fw-semibold fs-5">Rechnungsübersicht</span>
</div>
<!-- 🔵 RECHTS: DASHBOARD -->
<div class="ms-auto">
<a href="/dashboard" class="btn btn-outline-primary btn-sm">
⬅️ Dashboard
</a>
</div>
</nav>
<!-- =========================
FILTER: JAHR VON / BIS
========================== -->
<div class="container-fluid mt-4">
<form method="get" class="row g-2 mb-4">
<div class="col-auto">
<input
type="number"
name="fromYear"
class="form-control"
placeholder="Von Jahr"
value="<%= fromYear %>"
/>
</div>
<div class="col-auto">
<input
type="number"
name="toYear"
class="form-control"
placeholder="Bis Jahr"
value="<%= toYear %>"
/>
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary">Filtern</button>
</div>
</form>
<!-- =========================
GRID 4 SPALTEN
========================== -->
<div class="row g-3">
<!-- =========================
JAHRESUMSATZ
========================== -->
<div class="col-xl-3 col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Jahresumsatz</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Jahr</th>
<th class="text-end">€</th>
</tr>
</thead>
<tbody>
<% if (yearly.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %> <% yearly.forEach(y => { %>
<tr>
<td><%= y.year %></td>
<td class="text-end fw-semibold">
<%= Number(y.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
<!-- =========================
QUARTALSUMSATZ
========================== -->
<div class="col-xl-3 col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Quartalsumsatz</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Jahr</th>
<th>Q</th>
<th class="text-end">€</th>
</tr>
</thead>
<tbody>
<% if (quarterly.length === 0) { %>
<tr>
<td colspan="3" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %> <% quarterly.forEach(q => { %>
<tr>
<td><%= q.year %></td>
<td>Q<%= q.quarter %></td>
<td class="text-end fw-semibold">
<%= Number(q.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
<!-- =========================
MONATSUMSATZ
========================== -->
<div class="col-xl-3 col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Monatsumsatz</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Monat</th>
<th class="text-end">€</th>
</tr>
</thead>
<tbody>
<% if (monthly.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %> <% monthly.forEach(m => { %>
<tr>
<td><%= m.month %></td>
<td class="text-end fw-semibold">
<%= Number(m.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
<!-- =========================
UMSATZ PRO PATIENT
========================== -->
<div class="col-xl-3 col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Umsatz pro Patient</div>
<div class="card-body p-2">
<!-- 🔍 Suche -->
<form method="get" class="mb-2 d-flex gap-2">
<input type="hidden" name="fromYear" value="<%= fromYear %>" />
<input type="hidden" name="toYear" value="<%= toYear %>" />
<input
type="text"
name="q"
value="<%= search %>"
class="form-control form-control-sm"
placeholder="Patient suchen..."
/>
<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>
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Patient</th>
<th class="text-end">€</th>
</tr>
</thead>
<tbody>
<% if (patients.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %> <% patients.forEach(p => { %>
<tr>
<td><%= p.patient %></td>
<td class="text-end fw-semibold">
<%= Number(p.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
<!-- ✅ Header -->
<%- include("../partials/page-header", {
user,
title: t.adminSidebar.invocieoverview,
subtitle: "",
showUserName: true
}) %>
<div class="content p-4">
<!-- FILTER: JAHR VON / BIS -->
<div class="container-fluid mt-2">
<form method="get" class="row g-2 mb-4">
<div class="col-auto">
<input
type="number"
name="fromYear"
class="form-control"
placeholder="Von Jahr"
value="<%= fromYear %>"
/>
</div>
<div class="col-auto">
<input
type="number"
name="toYear"
class="form-control"
placeholder="Bis Jahr"
value="<%= toYear %>"
/>
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary"><%= t.global.filter %></button>
</div>
</form>
<!-- GRID 4 SPALTEN -->
<div class="row g-3">
<!-- JAHRESUMSATZ -->
<div class="col-xl-3 col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.yearcash%></div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th><%= t.global.year%></th>
<th class="text-end">€</th>
</tr>
</thead>
<tbody>
<% if (!yearly || yearly.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
<%= t.global.nodata%>
</td>
</tr>
<% } %>
<% (yearly || []).forEach(y => { %>
<tr>
<td><%= y.year %></td>
<td class="text-end fw-semibold">
<%= Number(y.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
<!-- QUARTALSUMSATZ -->
<div class="col-xl-3 col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.quartalcash%></div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th><%= t.global.year%></th>
<th>Q</th>
<th class="text-end">€</th>
</tr>
</thead>
<tbody>
<% if (!quarterly || quarterly.length === 0) { %>
<tr>
<td colspan="3" class="text-center text-muted">
<%= t.global.nodata%>
</td>
</tr>
<% } %>
<% (quarterly || []).forEach(q => { %>
<tr>
<td><%= q.year %></td>
<td>Q<%= q.quarter %></td>
<td class="text-end fw-semibold">
<%= Number(q.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
<!-- MONATSUMSATZ -->
<div class="col-xl-3 col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.monthcash%></div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th><%= t.global.month%></th>
<th class="text-end">€</th>
</tr>
</thead>
<tbody>
<% if (!monthly || monthly.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
<%= t.global.nodata%>
</td>
</tr>
<% } %>
<% (monthly || []).forEach(m => { %>
<tr>
<td><%= m.month %></td>
<td class="text-end fw-semibold">
<%= Number(m.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
<!-- UMSATZ PRO PATIENT -->
<div class="col-xl-3 col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.patientcash%></div>
<div class="card-body p-2">
<!-- Suche -->
<form method="get" class="mb-2 d-flex gap-2">
<input type="hidden" name="fromYear" value="<%= fromYear %>" />
<input type="hidden" name="toYear" value="<%= toYear %>" />
<input
type="text"
name="q"
value="<%= search %>"
class="form-control form-control-sm"
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>
</form>
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th><%= t.global.patient%></th>
<th class="text-end">€</th>
</tr>
</thead>
<tbody>
<% if (!patients || patients.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
<%= t.global.nodata%>
</td>
</tr>
<% } %>
<% (patients || []).forEach(p => { %>
<tr>
<td><%= p.patient %></td>
<td class="text-end fw-semibold">
<%= Number(p.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>

View File

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

View File

@ -1,466 +1,263 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Datenbankverwaltung</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/bootstrap-icons/bootstrap-icons.min.css">
<script src="/js/bootstrap.bundle.min.js"></script>
<style>
body {
margin: 0;
background: #f4f6f9;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu;
}
.layout {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 240px;
background: #111827;
color: white;
padding: 20px;
display: flex;
flex-direction: column;
}
.logo {
font-size: 18px;
font-weight: 700;
margin-bottom: 30px;
display: flex;
align-items: center;
gap: 10px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 15px;
border-radius: 8px;
color: #cbd5e1;
text-decoration: none;
margin-bottom: 6px;
font-size: 14px;
}
.nav-item:hover {
background: #1f2937;
color: white;
}
.nav-item.active {
background: #2563eb;
color: white;
}
.sidebar .spacer {
flex: 1;
}
.nav-item.locked {
opacity: 0.5;
cursor: not-allowed;
}
.nav-item.locked:hover {
background: transparent;
color: #cbd5e1;
}
/* Main */
.main {
flex: 1;
padding: 24px;
overflow: auto;
}
/* ✅ Systeminfo Tabelle kompakt */
.table-systeminfo {
table-layout: auto;
width: 100%;
font-size: 13px;
}
.table-systeminfo th,
.table-systeminfo td {
padding: 6px 8px;
}
.table-systeminfo th:first-child,
.table-systeminfo td:first-child {
width: 1%;
white-space: nowrap;
}
</style>
</head>
<body>
<div class="layout">
<!-- ✅ ADMIN SIDEBAR -->
<%- include("../partials/admin-sidebar", { user, active: "database" }) %>
<!-- ✅ MAIN CONTENT -->
<div class="main">
<nav class="navbar navbar-dark bg-dark position-relative px-3 rounded mb-4">
<div class="position-absolute top-50 start-50 translate-middle d-flex align-items-center gap-2 text-white">
<i class="bi bi-hdd-stack fs-4"></i>
<span class="fw-semibold fs-5">Datenbankverwaltung</span>
</div>
<div class="ms-auto">
<a href="/dashboard" class="btn btn-outline-light btn-sm">⬅️ Dashboard</a>
</div>
</nav>
<div class="container-fluid">
<!-- ✅ Flash Messages -->
<%- include("../partials/flash") %>
<!-- ✅ Statusanzeige (Verbindung OK / Fehler) -->
<% if (testResult) { %>
<div class="alert <%= testResult.ok ? 'alert-success' : 'alert-danger' %>">
<%= testResult.message %>
</div>
<% } %>
<div class="card shadow">
<div class="card-body">
<h4 class="mb-3">Datenbank Tools</h4>
<div class="alert alert-warning">
<b>Hinweis:</b> Diese Funktionen sind nur für <b>Admins</b> sichtbar und sollten mit Vorsicht benutzt werden.
</div>
<!-- ✅ DB Einstellungen -->
<div class="card border mb-4">
<div class="card-body">
<div class="mb-3">
<h5 class="card-title m-0">🔧 Datenbankverbindung ändern</h5>
</div>
<% if (!dbConfig) { %>
<div class="alert alert-danger">
❌ Keine Datenbank-Konfiguration gefunden (config.enc fehlt oder ungültig).
</div>
<% } %>
<!-- ✅ Speichern + testen -->
<form id="dbForm" method="POST" action="/admin/database" class="row g-3">
<div class="col-md-6">
<label class="form-label">DB Host</label>
<input
type="text"
name="host"
class="form-control db-input"
value="<%= dbConfig?.host || '' %>"
required
disabled
/>
</div>
<div class="col-md-6">
<label class="form-label">DB Name</label>
<input
type="text"
name="name"
class="form-control db-input"
value="<%= dbConfig?.name || '' %>"
required
disabled
/>
</div>
<div class="col-md-6">
<label class="form-label">DB User</label>
<input
type="text"
name="user"
class="form-control db-input"
value="<%= dbConfig?.user || '' %>"
required
disabled
/>
</div>
<div class="col-md-6">
<label class="form-label">DB Passwort</label>
<input
type="password"
name="password"
class="form-control db-input"
value="<%= dbConfig?.password || '' %>"
required
disabled
/>
</div>
<!-- ✅ BUTTON LEISTE -->
<div class="col-12 d-flex align-items-center gap-2 flex-wrap">
<!-- 🔒 Bearbeiten -->
<button id="toggleEditBtn" type="button" class="btn btn-outline-warning">
<i class="bi bi-lock-fill"></i> Bearbeiten
</button>
<!-- ✅ Speichern -->
<button id="saveBtn" class="btn btn-primary" disabled>
✅ Speichern & testen
</button>
<!-- 🔍 Nur testen -->
<button id="testBtn" type="button" class="btn btn-outline-success" disabled>
🔍 Nur testen
</button>
<!-- ↩ Zurücksetzen direkt neben "Nur testen" -->
<a href="/admin/database" class="btn btn-outline-secondary ms-2">
Zurücksetzen
</a>
</div>
<div class="col-12">
<div class="text-muted small">
Standardmäßig sind die Felder gesperrt. Erst auf <b>Bearbeiten</b> klicken.
</div>
</div>
</form>
<!-- ✅ Hidden Form für Test -->
<form id="testForm" method="POST" action="/admin/database/test"></form>
</div>
</div>
<!-- ✅ Backup + Restore + Systeminfo -->
<div class="row g-3">
<!-- ✅ Backup -->
<div class="col-md-6">
<div class="card border">
<div class="card-body">
<h5 class="card-title">📦 Backup</h5>
<p class="text-muted small mb-3">
Erstellt ein SQL Backup der kompletten Datenbank.
</p>
<form method="POST" action="/admin/database/backup">
<button class="btn btn-outline-primary">
Backup erstellen
</button>
</form>
</div>
</div>
</div>
<!-- ✅ Restore -->
<div class="col-md-6">
<div class="card border">
<div class="card-body">
<h5 class="card-title">♻️ Restore</h5>
<p class="text-muted small mb-3">
Wähle ein Backup aus dem Ordner <b>/backups</b> und stelle die Datenbank wieder her.
</p>
<% if (!backupFiles || backupFiles.length === 0) { %>
<div class="alert alert-secondary mb-2">
Keine Backups im Ordner <b>/backups</b> gefunden.
</div>
<% } %>
<form method="POST" action="/admin/database/restore">
<!-- ✅ Scroll Box -->
<div
class="border rounded p-2 mb-2"
style="max-height: 210px; overflow-y: auto; background: #fff;"
>
<% (backupFiles || []).forEach((f, index) => { %>
<label
class="d-flex align-items-center gap-2 p-2 rounded"
style="cursor:pointer;"
>
<input
type="radio"
name="backupFile"
value="<%= f %>"
<%= index === 0 ? "checked" : "" %>
<%= (!backupFiles || backupFiles.length === 0) ? "disabled" : "" %>
/>
<span style="font-size: 14px;"><%= f %></span>
</label>
<% }) %>
</div>
<button
class="btn btn-outline-danger"
onclick="return confirm('⚠️ Achtung! Restore überschreibt Datenbankdaten. Wirklich fortfahren?');"
<%= (!backupFiles || backupFiles.length === 0) ? "disabled" : "" %>
>
Restore starten
</button>
</form>
<div class="text-muted small mt-2">
Es werden die neuesten Backups zuerst angezeigt. Wenn mehr vorhanden sind, kannst du scrollen.
</div>
</div>
</div>
</div>
<!-- ✅ Systeminfo (kompakt wie gewünscht) -->
<div class="col-md-12">
<div class="card border">
<div class="card-body">
<h5 class="card-title">🔍 Systeminfo</h5>
<% if (!systemInfo) { %>
<p class="text-muted small mb-0">Keine Systeminfos verfügbar.</p>
<% } else if (systemInfo.error) { %>
<div class="alert alert-danger">
❌ Systeminfo konnte nicht geladen werden: <%= systemInfo.error %>
</div>
<% } else { %>
<div class="row g-3">
<!-- ✅ LINKS: Quick Infos -->
<div class="col-lg-4">
<div class="border rounded p-3 bg-white h-100">
<div class="mb-3">
<div class="text-muted small">DB Version</div>
<div class="fw-semibold"><%= systemInfo.version %></div>
</div>
<div class="mb-3">
<div class="text-muted small">Tabellen</div>
<div class="fw-semibold"><%= systemInfo.tableCount %></div>
</div>
<div>
<div class="text-muted small">DB Größe</div>
<div class="fw-semibold"><%= systemInfo.dbSizeMB %> MB</div>
</div>
</div>
</div>
<!-- ✅ RECHTS: Tabellenübersicht -->
<div class="col-lg-8">
<div class="border rounded p-3 bg-white h-100">
<div class="text-muted small mb-2">Tabellenübersicht</div>
<div style="max-height: 220px; overflow-y: auto;">
<table class="table table-sm table-bordered align-middle mb-0 table-systeminfo">
<thead class="table-light">
<tr>
<th>Tabellenname</th>
<th style="width: 90px;" class="text-end">Rows</th>
<th style="width: 110px;" class="text-end">MB</th>
</tr>
</thead>
<tbody>
<% systemInfo.tables.forEach(t => { %>
<tr>
<td><%= t.name %></td>
<td class="text-end"><%= t.row_count %></td>
<td class="text-end"><%= t.size_mb %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<% } %>
</div>
</div>
</div>
</div> <!-- row g-3 -->
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
const toggleBtn = document.getElementById("toggleEditBtn");
const inputs = document.querySelectorAll(".db-input");
const saveBtn = document.getElementById("saveBtn");
const testBtn = document.getElementById("testBtn");
const testForm = document.getElementById("testForm");
let editMode = false;
function updateUI() {
inputs.forEach((inp) => {
inp.disabled = !editMode;
});
saveBtn.disabled = !editMode;
testBtn.disabled = !editMode;
if (editMode) {
toggleBtn.innerHTML = '<i class="bi bi-unlock-fill"></i> Sperren';
toggleBtn.classList.remove("btn-outline-warning");
toggleBtn.classList.add("btn-outline-success");
} else {
toggleBtn.innerHTML = '<i class="bi bi-lock-fill"></i> Bearbeiten';
toggleBtn.classList.remove("btn-outline-success");
toggleBtn.classList.add("btn-outline-warning");
}
}
toggleBtn.addEventListener("click", () => {
editMode = !editMode;
updateUI();
});
// ✅ „Nur testen“ Button -> hidden form füllen -> submit
testBtn.addEventListener("click", () => {
testForm.querySelectorAll("input[type='hidden']").forEach((x) => x.remove());
inputs.forEach((inp) => {
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = inp.name;
hidden.value = inp.value;
testForm.appendChild(hidden);
});
testForm.submit();
});
updateUI();
})();
</script>
</body>
</html>
<div class="layout">
<!-- ✅ SIDEBAR (wie Dashboard, aber Admin Sidebar) -->
<%- include("../partials/admin-sidebar", { user, active: "database", lang }) %>
<!-- ✅ MAIN -->
<div class="main">
<!-- ✅ HEADER (wie Dashboard) -->
<%- include("../partials/page-header", {
user,
title: t.adminSidebar.database,
subtitle: "",
showUserName: true,
hideDashboardButton: true
}) %>
<div class="content p-4">
<!-- Flash Messages -->
<%- include("../partials/flash") %>
<div class="container-fluid p-0">
<div class="row g-3">
<!-- ✅ DB Konfiguration -->
<div class="col-12">
<div class="card shadow mb-3">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-sliders"></i> <%= t.databaseoverview.title%>
</h4>
<p class="text-muted mb-4">
<%= t.databaseoverview.tittexte%>
</p>
<!-- ✅ TEST + SPEICHERN -->
<form method="POST" action="/admin/database/test" class="row g-3 mb-3" autocomplete="off">
<div class="col-md-6">
<label class="form-label"><%= t.databaseoverview.host%> / IP</label>
<input
type="text"
name="host"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.host) ? dbConfig.host : '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-3">
<label class="form-label"><%= t.databaseoverview.port%></label>
<input
type="number"
name="port"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.port) ? dbConfig.port : 3306 %>"
autocomplete="off"
required
>
</div>
<div class="col-md-3">
<label class="form-label"><%= t.databaseoverview.database%></label>
<input
type="text"
name="name"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.name) ? dbConfig.name : '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-6">
<label class="form-label"><%= t.global.user%></label>
<input
type="text"
name="user"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.user) ? dbConfig.user : '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-6">
<label class="form-label"><%= t.databaseoverview.password%></label>
<input
type="password"
name="password"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.password) ? dbConfig.password : '' %>"
autocomplete="off"
required
>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-plug"></i> <%= t.databaseoverview.connectiontest%>
</button>
<button
type="submit"
class="btn btn-success"
formaction="/admin/database"
>
<i class="bi bi-save"></i> <%= t.global.save%>
</button>
</div>
</form>
<% if (typeof testResult !== "undefined" && testResult) { %>
<div class="alert <%= testResult.ok ? 'alert-success' : 'alert-danger' %> mb-0">
<%= testResult.message %>
</div>
<% } %>
</div>
</div>
</div>
<!-- ✅ System Info -->
<div class="col-12">
<div class="card shadow mb-3">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-info-circle"></i> <%=t.global.systeminfo%>
</h4>
<% if (typeof systemInfo !== "undefined" && systemInfo && systemInfo.error) { %>
<div class="alert alert-danger mb-0">
❌ <%=t.global.errordatabase%>
<div class="mt-2"><code><%= systemInfo.error %></code></div>
</div>
<% } else if (typeof systemInfo !== "undefined" && systemInfo) { %>
<div class="row g-3">
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small">MySQL Version</div>
<div class="fw-bold"><%= systemInfo.version %></div>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small"><%=t.databaseoverview.tablecount%></div>
<div class="fw-bold"><%= systemInfo.tableCount %></div>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small"><%=t.databaseoverview.databasesize%></div>
<div class="fw-bold"><%= systemInfo.dbSizeMB %> MB</div>
</div>
</div>
</div>
<% if (systemInfo.tables && systemInfo.tables.length > 0) { %>
<hr>
<h6 class="mb-2"><%=t.databaseoverview.tableoverview%></h6>
<div class="table-responsive">
<table class="table table-sm table-bordered table-hover align-middle">
<thead class="table-dark">
<tr>
<th><%=t.global.table%></th>
<th class="text-end"><%=t.global.lines%></th>
<th class="text-end"><%=t.global.size%> (MB)</th>
</tr>
</thead>
<tbody>
<% systemInfo.tables.forEach(t => { %>
<tr>
<td><%= t.name %></td>
<td class="text-end"><%= t.row_count %></td>
<td class="text-end"><%= t.size_mb %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
<% } else { %>
<div class="alert alert-warning mb-0">
⚠️ Keine Systeminfos verfügbar (DB ist evtl. nicht konfiguriert oder Verbindung fehlgeschlagen).
</div>
<% } %>
</div>
</div>
</div>
<!-- ✅ Backup & Restore -->
<div class="col-12">
<div class="card shadow">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-hdd-stack"></i> Backup & Restore
</h4>
<div class="d-flex flex-wrap gap-3">
<!-- ✅ Backup erstellen -->
<form action="/admin/database/backup" method="POST">
<button type="submit" class="btn btn-primary">
<i class="bi bi-download"></i> Backup erstellen
</button>
</form>
<!-- ✅ Restore auswählen -->
<form action="/admin/database/restore" method="POST">
<div class="input-group">
<select name="backupFile" class="form-select" required>
<option value="">Backup auswählen...</option>
<% (typeof backupFiles !== "undefined" && backupFiles ? backupFiles : []).forEach(file => { %>
<option value="<%= file %>"><%= file %></option>
<% }) %>
</select>
<button type="submit" class="btn btn-warning">
<i class="bi bi-upload"></i> Restore starten
</button>
</div>
</form>
</div>
<% if (typeof backupFiles === "undefined" || !backupFiles || backupFiles.length === 0) { %>
<div class="alert alert-secondary mt-3 mb-0">
Noch keine Backups vorhanden.
</div>
<% } %>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,108 +1,108 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Benutzer anlegen</title>
<link rel="stylesheet" href="/css/bootstrap.min.css" />
</head>
<body class="bg-light">
<div class="container mt-5">
<%- include("partials/flash") %>
<div class="card shadow mx-auto" style="max-width: 500px">
<div class="card-body">
<h3 class="text-center mb-3">Benutzer anlegen</h3>
<% if (error) { %>
<div class="alert alert-danger"><%= error %></div>
<% } %>
<form method="POST" action="/admin/create-user">
<!-- VORNAME -->
<input
class="form-control mb-3"
name="first_name"
placeholder="Vorname"
required
/>
<!-- NACHNAME -->
<input
class="form-control mb-3"
name="last_name"
placeholder="Nachname"
required
/>
<!-- TITEL -->
<input
class="form-control mb-3"
name="title"
placeholder="Titel (z.B. Dr., Prof.)"
/>
<!-- BENUTZERNAME (LOGIN) -->
<input
class="form-control mb-3"
name="username"
placeholder="Benutzername (Login)"
required
/>
<!-- PASSWORT -->
<input
class="form-control mb-3"
type="password"
name="password"
placeholder="Passwort"
required
/>
<!-- ROLLE -->
<select
class="form-select mb-3"
name="role"
id="roleSelect"
required
>
<option value="">Rolle wählen</option>
<option value="mitarbeiter">Mitarbeiter</option>
<option value="arzt">Arzt</option>
</select>
<!-- ARZT-FELDER -->
<div id="arztFields" style="display: none">
<input
class="form-control mb-3"
name="fachrichtung"
placeholder="Fachrichtung"
/>
<input
class="form-control mb-3"
name="arztnummer"
placeholder="Arztnummer"
/>
</div>
<button class="btn btn-primary w-100">Benutzer erstellen</button>
</form>
<div class="text-center mt-3">
<a href="/dashboard">Zurück</a>
</div>
</div>
</div>
</div>
<script>
document
.getElementById("roleSelect")
.addEventListener("change", function () {
const arztFields = document.getElementById("arztFields");
arztFields.style.display = this.value === "arzt" ? "block" : "none";
});
</script>
<script src="/js/admin_create_user.js" defer></script>
</body>
</html>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Benutzer anlegen</title>
<link rel="stylesheet" href="/css/bootstrap.min.css" />
</head>
<body class="bg-light">
<div class="container mt-5">
<%- include("partials/flash") %>
<div class="card shadow mx-auto" style="max-width: 500px">
<div class="card-body">
<h3 class="text-center mb-3">Benutzer anlegen</h3>
<% if (error) { %>
<div class="alert alert-danger"><%= error %></div>
<% } %>
<form method="POST" action="/admin/create-user">
<!-- VORNAME -->
<input
class="form-control mb-3"
name="first_name"
placeholder="Vorname"
required
/>
<!-- NACHNAME -->
<input
class="form-control mb-3"
name="last_name"
placeholder="Nachname"
required
/>
<!-- TITEL -->
<input
class="form-control mb-3"
name="title"
placeholder="Titel (z.B. Dr., Prof.)"
/>
<!-- BENUTZERNAME (LOGIN) -->
<input
class="form-control mb-3"
name="username"
placeholder="Benutzername (Login)"
required
/>
<!-- PASSWORT -->
<input
class="form-control mb-3"
type="password"
name="password"
placeholder="Passwort"
required
/>
<!-- ROLLE -->
<select
class="form-select mb-3"
name="role"
id="roleSelect"
required
>
<option value="">Rolle wählen</option>
<option value="mitarbeiter">Mitarbeiter</option>
<option value="arzt">Arzt</option>
</select>
<!-- ARZT-FELDER -->
<div id="arztFields" style="display: none">
<input
class="form-control mb-3"
name="fachrichtung"
placeholder="Fachrichtung"
/>
<input
class="form-control mb-3"
name="arztnummer"
placeholder="Arztnummer"
/>
</div>
<button class="btn btn-primary w-100">Benutzer erstellen</button>
</form>
<div class="text-center mt-3">
<a href="/dashboard">Zurück</a>
</div>
</div>
</div>
</div>
<script>
document
.getElementById("roleSelect")
.addEventListener("change", function () {
const arztFields = document.getElementById("arztFields");
arztFields.style.display = this.value === "arzt" ? "block" : "none";
});
</script>
<script src="/js/admin_create_user.js" defer></script>
</body>
</html>

View File

@ -1,58 +1,59 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Service-Logs</title>
<link rel="stylesheet" href="/css/bootstrap.min.css">
</head>
<body>
<nav class="navbar navbar-dark bg-dark position-relative px-3">
<!-- 🟢 ZENTRIERTER TITEL -->
<div class="position-absolute top-50 start-50 translate-middle
d-flex align-items-center gap-2 text-white">
<span style="font-size:1.3rem;">📜</span>
<span class="fw-semibold fs-5">Service-Änderungsprotokoll</span>
</div>
<!-- 🔵 RECHTS: DASHBOARD -->
<div class="ms-auto">
<a href="/dashboard" class="btn btn-outline-light btn-sm">
⬅️ Dashboard
</a>
</div>
</nav>
<div class="container mt-4">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Datum</th>
<th>User</th>
<th>Aktion</th>
<th>Vorher</th>
<th>Nachher</th>
</tr>
</thead>
<tbody>
<% logs.forEach(l => { %>
<tr>
<td><%= new Date(l.created_at).toLocaleString("de-DE") %></td>
<td><%= l.username %></td>
<td><%= l.action %></td>
<td><pre><%= l.old_value || "-" %></pre></td>
<td><pre><%= l.new_value || "-" %></pre></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Service-Logs</title>
<link rel="stylesheet" href="/css/bootstrap.min.css">
</head>
<body>
<nav class="navbar navbar-dark bg-dark position-relative px-3">
<!-- 🟢 ZENTRIERTER TITEL -->
<div class="position-absolute top-50 start-50 translate-middle
d-flex align-items-center gap-2 text-white">
<span style="font-size:1.3rem;">📜</span>
<span class="fw-semibold fs-5">Service-Änderungsprotokoll</span>
</div>
<!-- 🔵 RECHTS: DASHBOARD -->
<div class="ms-auto">
<a href="/dashboard" class="btn btn-outline-light btn-sm">
⬅️ Dashboard
</a>
</div>
</nav>
<div class="container mt-4">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Datum</th>
<th>User</th>
<th>Aktion</th>
<th>Vorher</th>
<th>Nachher</th>
</tr>
</thead>
<tbody>
<% logs.forEach(l => { %>
<tr>
<td><%= new Date(l.created_at).toLocaleString("de-DE") %></td>
<td><%= l.username %></td>
<td><%= l.action %></td>
<td><pre><%= l.old_value || "-" %></pre></td>
<td><pre><%= l.new_value || "-" %></pre></td>
</tr>
<% }) %>
</tbody>
</table>
<br>
</div>
</body>
</html>

View File

@ -1,440 +1,130 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>User Verwaltung</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/bootstrap-icons/bootstrap-icons.min.css">
<script src="/js/bootstrap.bundle.min.js"></script>
<!-- ✅ Inline Edit -->
<script src="/js/services-lock.js"></script>
<style>
body {
margin: 0;
background: #f4f6f9;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu;
}
.layout {
display: flex;
min-height: 100vh;
}
.main {
flex: 1;
padding: 24px;
overflow: auto;
}
/* ✅ Header */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #111827;
color: #fff;
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 18px;
}
.page-header .title {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
font-weight: 600;
}
.page-header .title i {
font-size: 20px;
}
/* ✅ Tabelle optisch besser */
.table thead th {
background: #111827 !important;
color: #fff !important;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.table td {
vertical-align: middle;
font-size: 13px;
}
/* ✅ Inline edit Inputs */
input.form-control {
box-shadow: none !important;
font-size: 13px;
}
input.form-control:disabled {
background-color: transparent !important;
border: none !important;
padding-left: 0 !important;
padding-right: 0 !important;
color: #111827 !important;
}
select.form-select {
font-size: 13px;
}
select.form-select:disabled {
background-color: transparent !important;
border: none !important;
padding-left: 0 !important;
padding-right: 0 !important;
color: #111827 !important;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
/* ✅ Inaktive User rot */
tr.table-secondary > td {
background-color: #f8d7da !important;
}
/* ✅ Icon Buttons */
.icon-btn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
padding: 0;
}
.badge-soft {
font-size: 12px;
padding: 6px 10px;
border-radius: 999px;
}
/* ✅ Tabelle soll sich an Inhalt anpassen */
.table-auto {
table-layout: auto !important;
width: auto !important;
}
.table-auto th,
.table-auto td {
white-space: nowrap;
}
/* ✅ Inputs sollen nicht zu klein werden */
.table-auto td input,
.table-auto td select {
min-width: 110px;
}
/* Username darf umbrechen wenn extrem lang */
.table-auto td:nth-child(5) {
white-space: normal;
}
/* ✅ Wrapper: sorgt dafür dass Suche & Tabelle exakt gleich breit sind */
.table-wrapper {
width: fit-content;
max-width: 100%;
margin: 0 auto;
}
.toolbar {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.searchbar {
flex: 1;
display: flex;
gap: 10px;
align-items: center;
min-width: 320px;
}
.searchbar input {
flex: 1;
}
.sidebar {
width: 240px;
background: #111827;
color: white;
padding: 20px;
display: flex;
flex-direction: column;
}
.logo {
font-size: 18px;
font-weight: 700;
margin-bottom: 30px;
display: flex;
align-items: center;
gap: 10px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 15px;
border-radius: 8px;
color: #cbd5e1;
text-decoration: none;
margin-bottom: 6px;
font-size: 14px;
}
.nav-item:hover {
background: #1f2937;
color: white;
}
.nav-item.active {
background: #2563eb;
color: white;
}
.sidebar .spacer {
flex: 1;
}
.nav-item.locked {
opacity: 0.5;
cursor: not-allowed;
}
.nav-item.locked:hover {
background: transparent;
color: #cbd5e1;
}
</style>
</head>
<body>
<div class="layout">
<!-- ✅ ADMIN SIDEBAR -->
<%- include("partials/admin-sidebar", { active: "users" }) %>
<div class="main">
<!-- ✅ TOP HEADER -->
<div class="page-header">
<div class="title">
<i class="bi bi-shield-lock"></i>
User Verwaltung
</div>
<div>
<a href="/dashboard" class="btn btn-outline-light btn-sm">
⬅️ Dashboard
</a>
</div>
</div>
<div class="container-fluid p-0">
<%- include("partials/flash") %>
<div class="card shadow border-0 rounded-3">
<div class="card-body">
<h4 class="mb-3">Benutzerübersicht</h4>
<!-- ✅ Suche + Tabelle zusammen breit -->
<div class="table-wrapper">
<!-- ✅ Toolbar: Suche links, Button rechts -->
<div class="toolbar">
<form method="GET" action="/admin/users" class="searchbar">
<input
type="text"
name="q"
class="form-control"
placeholder="🔍 Benutzer suchen (Name oder Username)"
value="<%= query?.q || '' %>"
>
<button class="btn btn-outline-primary">
<i class="bi bi-search"></i>
Suchen
</button>
<% if (query?.q) { %>
<a href="/admin/users" class="btn btn-outline-secondary">
Reset
</a>
<% } %>
</form>
<div class="actions">
<a href="/admin/create-user" class="btn btn-primary">
<i class="bi bi-plus-circle"></i>
Neuer Benutzer
</a>
</div>
</div>
<!-- ✅ Tabelle -->
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm align-middle mb-0 table-auto">
<thead>
<tr>
<th style="width: 60px;">ID</th>
<th>Titel</th>
<th>Vorname</th>
<th>Nachname</th>
<th>Username</th>
<th style="width: 180px;">Rolle</th>
<th style="width: 110px;" class="text-center">Status</th>
<th style="width: 200px;">Aktionen</th>
</tr>
</thead>
<tbody>
<% users.forEach(u => { %>
<tr class="<%= u.active ? '' : 'table-secondary' %>">
<!-- ✅ Update Form -->
<form method="POST" action="/admin/users/update/<%= u.id %>">
<td class="fw-semibold"><%= u.id %></td>
<td>
<input
type="text"
name="title"
value="<%= u.title || '' %>"
class="form-control form-control-sm"
disabled
>
</td>
<td>
<input
type="text"
name="first_name"
value="<%= u.first_name %>"
class="form-control form-control-sm"
disabled
>
</td>
<td>
<input
type="text"
name="last_name"
value="<%= u.last_name %>"
class="form-control form-control-sm"
disabled
>
</td>
<td>
<input
type="text"
name="username"
value="<%= u.username %>"
class="form-control form-control-sm"
disabled
>
</td>
<td>
<select name="role" class="form-select form-select-sm" disabled>
<option value="mitarbeiter" <%= u.role === "mitarbeiter" ? "selected" : "" %>>
Mitarbeiter
</option>
<option value="arzt" <%= u.role === "arzt" ? "selected" : "" %>>
Arzt
</option>
<option value="admin" <%= u.role === "admin" ? "selected" : "" %>>
Admin
</option>
</select>
</td>
<td class="text-center">
<% if (u.active === 0) { %>
<span class="badge bg-secondary badge-soft">Inaktiv</span>
<% } else if (u.lock_until && new Date(u.lock_until) > new Date()) { %>
<span class="badge bg-danger badge-soft">Gesperrt</span>
<% } else { %>
<span class="badge bg-success badge-soft">Aktiv</span>
<% } %>
</td>
<td class="d-flex gap-2 align-items-center">
<!-- ✅ Save -->
<button
class="btn btn-outline-success icon-btn save-btn"
disabled
title="Speichern"
>
<i class="bi bi-save"></i>
</button>
<!-- ✅ Unlock -->
<button
type="button"
class="btn btn-outline-warning icon-btn lock-btn"
title="Bearbeiten aktivieren"
>
<i class="bi bi-pencil-square"></i>
</button>
</form>
<!-- ✅ Aktiv / Deaktiv -->
<% if (u.id !== currentUser.id) { %>
<form method="POST" action="/admin/users/<%= u.active ? "deactivate" : "activate" %>/<%= u.id %>">
<button
class="btn icon-btn <%= u.active ? "btn-outline-danger" : "btn-outline-success" %>"
title="<%= u.active ? "Deaktivieren" : "Aktivieren" %>"
>
<i class="bi <%= u.active ? "bi-person-x" : "bi-person-check" %>"></i>
</button>
</form>
<% } else { %>
<span class="badge bg-light text-dark border">
👤 Du selbst
</span>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div><!-- /table-wrapper -->
</div>
</div>
</div>
</div>
</div>
</body>
</html>
<div class="layout">
<div class="main">
<!-- ✅ HEADER -->
<%- include("partials/page-header", {
user,
title: t.adminuseroverview.usermanagement,
subtitle: "",
showUserName: true
}) %>
<div class="content">
<%- include("partials/flash") %>
<div class="container-fluid">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0"><%= t.adminuseroverview.useroverview %></h4>
<a href="/admin/create-user" class="btn btn-primary">
<i class="bi bi-plus-circle"></i>
<%= t.global.newuser %>
</a>
</div>
<!-- ✅ Tabelle -->
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm align-middle mb-0">
<thead>
<tr>
<th>ID</th>
<th><%= t.global.title %></th>
<th><%= t.global.firstname %></th>
<th><%= t.global.lastname %></th>
<th><%= t.global.username %></th>
<th><%= t.global.role %></th>
<th class="text-center"><%= t.global.status %></th>
<th><%= t.global.action %></th>
</tr>
</thead>
<tbody>
<% users.forEach(u => { %>
<tr class="<%= u.active ? '' : 'table-secondary' %>">
<!-- ✅ Update Form -->
<form method="POST" action="/admin/users/update/<%= u.id %>">
<td class="fw-semibold"><%= u.id %></td>
<td>
<input type="text" name="title" value="<%= u.title || '' %>" class="form-control form-control-sm" disabled />
</td>
<td>
<input type="text" name="first_name" value="<%= u.first_name %>" class="form-control form-control-sm" disabled />
</td>
<td>
<input type="text" name="last_name" value="<%= u.last_name %>" class="form-control form-control-sm" disabled />
</td>
<td>
<input type="text" name="username" value="<%= u.username %>" class="form-control form-control-sm" disabled />
</td>
<td>
<select name="role" class="form-select form-select-sm" disabled>
<option value="mitarbeiter" <%= u.role === "mitarbeiter" ? "selected" : "" %>>Mitarbeiter</option>
<option value="arzt" <%= u.role === "arzt" ? "selected" : "" %>>Arzt</option>
<option value="admin" <%= u.role === "admin" ? "selected" : "" %>>Admin</option>
</select>
</td>
<td class="text-center">
<% if (u.active === 0) { %>
<span class="badge bg-secondary"><%= t.global.inactive %></span>
<% } else if (u.lock_until && new Date(u.lock_until) > new Date()) { %>
<span class="badge bg-danger"><%= t.global.closed %></span>
<% } else { %>
<span class="badge bg-success"><%= t.global.active %></span>
<% } %>
</td>
<td class="d-flex gap-2 align-items-center">
<!-- Save -->
<button class="btn btn-outline-success btn-sm save-btn" disabled>
<i class="bi bi-save"></i>
</button>
<!-- Edit -->
<button type="button" class="btn btn-outline-warning btn-sm lock-btn">
<i class="bi bi-pencil-square"></i>
</button>
</form>
<!-- Aktiv/Deaktiv -->
<% if (u.id !== currentUser.id) { %>
<form method="POST" action="/admin/users/<%= u.active ? 'deactivate' : 'activate' %>/<%= u.id %>">
<button class="btn btn-sm <%= u.active ? 'btn-outline-danger' : 'btn-outline-success' %>">
<i class="bi <%= u.active ? 'bi-person-x' : 'bi-person-check' %>"></i>
</button>
</form>
<% } else { %>
<span class="badge bg-light text-dark border">👤 <%= t.global.you %></span>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,213 +1,43 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Praxis System</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/bootstrap.min.css" />
<link rel="stylesheet" href="/bootstrap-icons/bootstrap-icons.min.css" />
<style>
body {
margin: 0;
background: #f4f6f9;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Ubuntu;
}
.layout {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 240px;
background: #111827;
color: white;
padding: 20px;
display: flex;
flex-direction: column;
}
.logo {
font-size: 18px;
font-weight: 700;
margin-bottom: 30px;
display: flex;
align-items: center;
gap: 10px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 15px;
border-radius: 8px;
color: #cbd5e1;
text-decoration: none;
margin-bottom: 6px;
font-size: 14px;
}
.nav-item:hover {
background: #1f2937;
color: white;
}
.nav-item.active {
background: #2563eb;
color: white;
}
.sidebar .spacer {
flex: 1;
}
/* Main */
.main {
flex: 1;
padding: 25px 30px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.topbar h3 {
margin: 0;
}
.main {
flex: 1;
padding: 24px;
background: #f4f6f9;
overflow: hidden;
display: flex;
flex-direction: column;
}
.waiting-monitor {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 10px;
}
.waiting-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 80px;
gap: 12px;
width: 100%;
}
.waiting-slot {
border: 2px dashed #cbd5e1;
border-radius: 10px;
background: #f8fafc;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
text-decoration: none;
color: inherit;
}
.waiting-slot.occupied {
border-style: solid;
background: #eefdf5;
}
.patient-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.waiting-slot.clickable {
cursor: pointer;
transition: 0.15s ease;
}
.waiting-slot.clickable:hover {
transform: scale(1.03);
box-shadow: 0 0 0 2px #2563eb;
}
.nav-item.locked {
opacity: 0.5;
cursor: not-allowed;
}
.nav-item.locked:hover {
background: transparent;
color: #cbd5e1;
}
</style>
</head>
<body>
<div class="layout">
<!-- ✅ SIDEBAR ausgelagert -->
<%- include("partials/sidebar", { user, active: "patients" }) %>
<!-- MAIN CONTENT -->
<div class="main">
<div class="topbar">
<h3>Willkommen, <%= user.username %></h3>
</div>
<!-- Flash Messages -->
<%- include("partials/flash") %>
<!-- =========================
WARTEZIMMER MONITOR
========================= -->
<div class="waiting-monitor">
<h5 class="mb-3">🪑 Wartezimmer-Monitor</h5>
<div class="waiting-grid">
<% if (waitingPatients && waitingPatients.length > 0) { %>
<% waitingPatients.forEach(p => { %>
<% if (user.role === 'arzt') { %>
<a href="/patients/<%= p.id %>/overview" class="waiting-slot occupied clickable">
<div class="patient-text">
<div class="name"><%= p.firstname %> <%= p.lastname %></div>
<div class="birthdate">
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
</div>
</div>
</a>
<% } else { %>
<div class="waiting-slot occupied">
<div class="patient-text">
<div class="name"><%= p.firstname %> <%= p.lastname %></div>
<div class="birthdate">
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
</div>
</div>
</div>
<% } %>
<% }) %>
<% } else { %>
<div class="text-muted">Keine Patienten im Wartezimmer.</div>
<% } %>
</div>
</div>
</div>
</div>
</body>
</html>
<!-- KEIN layout, KEINE sidebar, KEIN main -->
<%- include("partials/page-header", {
user,
title: t.dashboard.title,
subtitle: "",
showUserName: true,
hideDashboardButton: true
}) %>
<div class="content p-4">
<%- include("partials/flash") %>
<div class="waiting-monitor">
<h5 class="mb-3">🪑 <%= t.dashboard.waitingRoom %></h5>
<div class="waiting-grid">
<% if (waitingPatients && waitingPatients.length > 0) { %>
<% waitingPatients.forEach(p => { %>
<% if (user.role === "arzt") { %>
<form method="POST" action="/patients/<%= p.id %>/call">
<button class="waiting-slot occupied clickable">
<div><%= p.firstname %> <%= p.lastname %></div>
</button>
</form>
<% } else { %>
<div class="waiting-slot occupied">
<div><%= p.firstname %> <%= p.lastname %></div>
</div>
<% } %>
<% }) %>
<% } else { %>
<div class="text-muted">
<%= t.dashboard.noWaitingPatients %>
</div>
<% } %>
</div>
</div>
</div>

View File

@ -1,31 +1,31 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Rechnung anzeigen</title>
<link rel="stylesheet" href="/css/bootstrap.min.css">
</head>
<body>
<div class="container-fluid mt-3">
<!-- ACTION BAR -->
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">🧾 Rechnung</h5>
<a href="/services/open" class="btn btn-primary">
⬅️ Zurück zu offenen Leistungen
</a>
</div>
<!-- PDF VIEW -->
<iframe
src="<%= pdfUrl %>"
style="width:100%; height:92vh; border:none;"
title="Rechnung PDF">
</iframe>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Rechnung anzeigen</title>
<link rel="stylesheet" href="/css/bootstrap.min.css">
</head>
<body>
<div class="container-fluid mt-3">
<!-- ACTION BAR -->
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">🧾 Rechnung</h5>
<a href="/services/open" class="btn btn-primary">
⬅️ Zurück zu offenen Leistungen
</a>
</div>
<!-- PDF VIEW -->
<iframe
src="<%= pdfUrl %>"
style="width:100%; height:92vh; border:none;"
title="Rechnung PDF">
</iframe>
</div>
</body>
</html>

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