Compare commits

...

11 Commits

135 changed files with 14200 additions and 10544 deletions

425
app.js
View File

@ -3,18 +3,19 @@ require("dotenv").config();
const express = require("express"); const express = require("express");
const session = require("express-session"); const session = require("express-session");
const helmet = require("helmet"); const helmet = require("helmet");
const mysql = require("mysql2/promise");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const expressLayouts = require("express-ejs-layouts");
// ✅ Verschlüsselte Config // ✅ DB + Session Store
const { configExists, saveConfig } = require("./config-manager");
// ✅ Reset-Funktionen (Soft-Restart)
const db = require("./db"); const db = require("./db");
const { getSessionStore, resetSessionStore } = require("./config/session"); const { getSessionStore } = require("./config/session");
// ✅ Deine Routes (unverändert) // ✅ Setup Middleware + Setup Routes
const requireSetup = require("./middleware/requireSetup");
const setupRoutes = require("./routes/setup.routes");
// ✅ Routes (deine)
const adminRoutes = require("./routes/admin.routes"); const adminRoutes = require("./routes/admin.routes");
const dashboardRoutes = require("./routes/dashboard.routes"); const dashboardRoutes = require("./routes/dashboard.routes");
const patientRoutes = require("./routes/patient.routes"); const patientRoutes = require("./routes/patient.routes");
@ -27,58 +28,41 @@ const invoiceRoutes = require("./routes/invoice.routes");
const patientFileRoutes = require("./routes/patientFile.routes"); const patientFileRoutes = require("./routes/patientFile.routes");
const companySettingsRoutes = require("./routes/companySettings.routes"); const companySettingsRoutes = require("./routes/companySettings.routes");
const authRoutes = require("./routes/auth.routes"); const authRoutes = require("./routes/auth.routes");
const reportRoutes = require("./routes/report.routes");
const app = express(); const app = express();
/* =============================== /* ===============================
SETUP HTML Seriennummer / Trial Konfiguration
================================ */ ================================ */
function setupHtml(error = "") { const TRIAL_DAYS = 30;
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> Seriennummer Helper Funktionen
<input name="host" placeholder="85.215.63.122" required /> ================================ */
function normalizeSerial(input) {
return (input || "")
.toUpperCase()
.replace(/[^A-Z0-9-]/g, "")
.trim();
}
<label>DB Benutzer</label> // Format: AAAAA-AAAAA-AAAAA-AAAAA
<input name="user" placeholder="praxisuser" required /> function isValidSerialFormat(serial) {
return /^[A-Z0-9]{5}(-[A-Z0-9]{5}){3}$/.test(serial);
}
<label>DB Passwort</label> // Modulo-3 Check (Summe aller Zeichenwerte % 3 === 0)
<input name="password" type="password" required /> function passesModulo3(serial) {
const raw = serial.replace(/-/g, "");
let sum = 0;
<label>DB Name</label> for (const ch of raw) {
<input name="name" placeholder="praxissoftware" required /> if (/[0-9]/.test(ch)) sum += parseInt(ch, 10);
else sum += ch.charCodeAt(0) - 55; // A=10
}
<button type="submit"> Speichern</button> return sum % 3 === 0;
</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>
`;
} }
/* =============================== /* ===============================
@ -86,30 +70,50 @@ function setupHtml(error = "") {
================================ */ ================================ */
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.json()); app.use(express.json());
app.use(helmet());
// ✅ SessionStore dynamisch (Setup: MemoryStore, normal: MySQLStore) app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
},
},
}),
);
app.use( app.use(
session({ session({
name: "praxis.sid", name: "praxis.sid",
secret: process.env.SESSION_SECRET, secret: process.env.SESSION_SECRET || "dev-secret",
store: getSessionStore(), store: getSessionStore(),
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
}), }),
); );
// ✅ i18n Middleware // ✅ i18n Middleware (SAFE)
app.use((req, res, next) => { app.use((req, res, next) => {
const lang = req.session.lang || "de"; // Standard DE try {
const lang = req.session.lang || "de";
const filePath = path.join(__dirname, "locales", `${lang}.json`);
const filePath = path.join(__dirname, "locales", `${lang}.json`); let data = {};
const raw = fs.readFileSync(filePath, "utf-8"); if (fs.existsSync(filePath)) {
data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
res.locals.t = JSON.parse(raw); // t = translations res.locals.t = data;
res.locals.lang = lang; res.locals.lang = lang;
next();
next(); } catch (err) {
console.error("❌ i18n Fehler:", err.message);
res.locals.t = {};
res.locals.lang = "de";
next();
}
}); });
const flashMiddleware = require("./middleware/flash.middleware"); const flashMiddleware = require("./middleware/flash.middleware");
@ -117,90 +121,93 @@ app.use(flashMiddleware);
app.use(express.static("public")); app.use(express.static("public"));
app.use("/uploads", express.static("uploads")); app.use("/uploads", express.static("uploads"));
app.set("view engine", "ejs"); app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
app.use(expressLayouts);
app.set("layout", "layout");
app.use((req, res, next) => { app.use((req, res, next) => {
res.locals.user = req.session.user || null; res.locals.user = req.session.user || null;
next(); next();
}); });
/* =============================== /* ===============================
SETUP ROUTES SETUP ROUTES + SETUP GATE
WICHTIG: /setup zuerst mounten, danach requireSetup
================================ */ ================================ */
app.use("/setup", setupRoutes);
app.use(requireSetup);
// Setup-Seite /* ===============================
app.get("/setup", (req, res) => { LICENSE/TRIAL GATE
if (configExists()) return res.redirect("/"); ================================ */
return res.status(200).send(setupHtml()); app.use(async (req, res, next) => {
});
// Setup speichern + DB testen + Soft-Restart + Login redirect
app.post("/setup", async (req, res) => {
try { try {
const { host, user, password, name } = req.body; // Setup muss erreichbar bleiben
if (req.path.startsWith("/setup")) return next();
if (!host || !user || !password || !name) { // Login muss erreichbar bleiben
return res.status(400).send(setupHtml("Bitte alle Felder ausfüllen.")); 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();
} }
// ✅ DB Verbindung testen // Wenn noch immer kein trial start: nicht blockieren
const conn = await mysql.createConnection({ if (!settings?.trial_started_at) return next();
host,
user,
password,
database: name,
});
await conn.query("SELECT 1"); const trialStart = new Date(settings.trial_started_at);
await conn.end(); const now = new Date();
const diffDays = Math.floor((now - trialStart) / (1000 * 60 * 60 * 24));
// ✅ verschlüsselt speichern // ✅ Trial läuft noch
saveConfig({ if (diffDays < TRIAL_DAYS) return next();
db: { host, user, password, name },
});
// ✅ Soft-Restart (DB Pool + SessionStore neu laden) // ❌ Trial abgelaufen
if (typeof db.resetPool === "function") { if (req.session.user.role === "admin") {
db.resetPool(); return res.redirect("/admin/serial-number");
} }
resetSessionStore();
// ✅ automatisch zurück zur Loginseite return res.redirect("/serial-number");
return res.redirect("/");
} catch (err) { } catch (err) {
return res console.error("❌ LicenseGate Fehler:", err.message);
.status(500) return next();
.send(setupHtml("DB Verbindung fehlgeschlagen: " + err.message));
} }
}); });
// Wenn keine config.enc → alles außer /setup auf Setup umleiten /* ===============================
app.use((req, res, next) => { Sprache ändern
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) => { app.get("/lang/:lang", (req, res) => {
const newLang = req.params.lang; const newLang = req.params.lang;
@ -210,18 +217,180 @@ app.get("/lang/:lang", (req, res) => {
req.session.lang = newLang; req.session.lang = newLang;
// ✅ WICHTIG: Session speichern bevor redirect
req.session.save((err) => { req.session.save((err) => {
if (err) console.error("❌ Session save error:", err); if (err) console.error("❌ Session save error:", err);
return res.redirect(req.get("Referrer") || "/dashboard"); return res.redirect(req.get("Referrer") || "/dashboard");
}); });
}); });
/* =============================== /* ===============================
DEINE LOGIK (unverändert) 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(companySettingsRoutes);
app.use("/", authRoutes); app.use("/", authRoutes);
app.use("/dashboard", dashboardRoutes); app.use("/dashboard", dashboardRoutes);
@ -238,7 +407,9 @@ app.use("/services", serviceRoutes);
app.use("/", patientFileRoutes); app.use("/", patientFileRoutes);
app.use("/", waitingRoomRoutes); app.use("/", waitingRoomRoutes);
app.use("/", invoiceRoutes); app.use("/invoices", invoiceRoutes);
app.use("/reportview", reportRoutes);
app.get("/logout", (req, res) => { app.get("/logout", (req, res) => {
req.session.destroy(() => res.redirect("/")); req.session.destroy(() => res.redirect("/"));
@ -256,7 +427,7 @@ app.use((err, req, res, next) => {
SERVER SERVER
================================ */ ================================ */
const PORT = process.env.PORT || 51777; const PORT = process.env.PORT || 51777;
const HOST = "127.0.0.1"; const HOST = process.env.HOST || "0.0.0.0";
app.listen(PORT, HOST, () => { app.listen(PORT, HOST, () => {
console.log(`Server läuft auf http://${HOST}:${PORT}`); console.log(`Server läuft auf http://${HOST}:${PORT}`);

File diff suppressed because one or more lines are too long

View File

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

View File

@ -19,6 +19,13 @@ async function listUsers(req, res) {
} }
res.render("admin_users", { res.render("admin_users", {
title: "Benutzer",
sidebarPartial: "partials/admin-sidebar",
active: "users",
user: req.session.user,
lang: req.session.lang || "de",
users, users,
currentUser: req.session.user, currentUser: req.session.user,
query: { q }, query: { q },
@ -88,7 +95,7 @@ async function postCreateUser(req, res) {
password, password,
role, role,
fachrichtung, fachrichtung,
arztnummer arztnummer,
); );
req.session.flash = { req.session.flash = {
@ -159,7 +166,7 @@ async function resetUserPassword(req, res) {
}; };
} }
res.redirect("/admin/users"); res.redirect("/admin/users");
} },
); );
} }
@ -254,11 +261,17 @@ async function showInvoiceOverview(req, res) {
GROUP BY p.id GROUP BY p.id
ORDER BY total DESC ORDER BY total DESC
`, `,
[`%${search}%`] [`%${search}%`],
); );
res.render("admin/admin_invoice_overview", { res.render("admin/admin_invoice_overview", {
title: "Rechnungsübersicht",
sidebarPartial: "partials/admin-sidebar", // ✅ keine Sidebar
active: "invoices",
user: req.session.user, user: req.session.user,
lang: req.session.lang || "de",
yearly, yearly,
quarterly, quarterly,
monthly, monthly,

View File

@ -7,16 +7,46 @@ async function postLogin(req, res) {
const { username, password } = req.body; const { username, password } = req.body;
try { try {
const user = await loginUser( const user = await loginUser(db, username, password, LOCK_TIME_MINUTES);
db,
username, /* req.session.user = user;
password, res.redirect("/dashboard"); */
LOCK_TIME_MINUTES
);
req.session.user = user; req.session.user = user;
res.redirect("/dashboard");
// ✅ 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) { } catch (error) {
res.render("login", { error }); res.render("login", { error });
} }
@ -28,5 +58,5 @@ function getLogin(req, res) {
module.exports = { module.exports = {
getLogin, getLogin,
postLogin postLogin,
}; };

View File

@ -13,14 +13,27 @@ const safe = (v) => {
* GET: Firmendaten anzeigen * GET: Firmendaten anzeigen
*/ */
async function getCompanySettings(req, res) { async function getCompanySettings(req, res) {
const [[company]] = await db.promise().query( try {
"SELECT * FROM company_settings LIMIT 1" const [[company]] = await db
); .promise()
.query("SELECT * FROM company_settings LIMIT 1");
res.render("admin/company-settings", { res.render("admin/company-settings", {
user: req.user, layout: "layout", // 🔥 wichtig
company: company || {} title: "Firmendaten", // 🔥 DAS FEHLTE
}); active: "companySettings", // 🔥 Sidebar aktiv
sidebarPartial: "partials/admin-sidebar",
company: company || {},
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");
}
} }
/** /**

View File

@ -8,8 +8,15 @@ async function showDashboard(req, res) {
const waitingPatients = await getWaitingPatients(db); const waitingPatients = await getWaitingPatients(db);
res.render("dashboard", { res.render("dashboard", {
layout: "layout", // 🔥 DAS FEHLTE
title: "Dashboard",
active: "dashboard",
sidebarPartial: "partials/sidebar",
waitingPatients,
user: req.session.user, user: req.session.user,
waitingPatients lang: req.session.lang || "de"
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);

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

@ -43,9 +43,16 @@ function listMedications(req, res, next) {
if (err) return next(err); if (err) return next(err);
res.render("medications", { res.render("medications", {
title: "Medikamentenübersicht",
// ✅ IMMER patient-sidebar verwenden
sidebarPartial: "partials/sidebar-empty",
active: "medications",
rows, rows,
query: { q, onlyActive }, query: { q, onlyActive },
user: req.session.user, user: req.session.user,
lang: req.session.lang || "de",
}); });
}); });
} }
@ -80,7 +87,7 @@ function toggleMedication(req, res, next) {
(err) => { (err) => {
if (err) return next(err); if (err) return next(err);
res.redirect("/medications"); res.redirect("/medications");
} },
); );
} }
@ -122,9 +129,9 @@ function createMedication(req, res) {
if (err) return res.send("Fehler Variante"); if (err) return res.send("Fehler Variante");
res.redirect("/medications"); res.redirect("/medications");
} },
); );
} },
); );
} }

View File

@ -1,7 +1,13 @@
const db = require("../db"); const db = require("../db");
function showCreatePatient(req, res) { function showCreatePatient(req, res) {
res.render("patient_create"); res.render("patient_create", {
title: "Patient anlegen",
sidebarPartial: "partials/sidebar",
active: "patients",
user: req.session.user,
lang: req.session.lang || "de",
});
} }
function createPatient(req, res) { function createPatient(req, res) {
@ -16,24 +22,26 @@ function createPatient(req, res) {
return res.send("Datenbankfehler"); return res.send("Datenbankfehler");
} }
res.redirect("/dashboard"); res.redirect("/dashboard");
} },
); );
} }
function listPatients(req, res) { async function listPatients(req, res) {
const { firstname, lastname, birthdate } = req.query; const { firstname, lastname, birthdate } = req.query;
let sql = "SELECT * FROM patients WHERE 1=1"; let sql = "SELECT * FROM patients WHERE 1=1";
const params = []; const params = [];
if (firstname) { if (firstname) {
sql += " AND firstname LIKE ?"; sql += " AND LOWER(firstname) LIKE LOWER(?)";
params.push(`%${firstname}%`); params.push(`%${firstname}%`);
} }
if (lastname) { if (lastname) {
sql += " AND lastname LIKE ?"; sql += " AND LOWER(lastname) LIKE LOWER(?)";
params.push(`%${lastname}%`); params.push(`%${lastname}%`);
} }
if (birthdate) { if (birthdate) {
sql += " AND birthdate = ?"; sql += " AND birthdate = ?";
params.push(birthdate); params.push(birthdate);
@ -41,14 +49,59 @@ function listPatients(req, res) {
sql += " ORDER BY lastname, firstname"; sql += " ORDER BY lastname, firstname";
db.query(sql, params, (err, patients) => { try {
if (err) return res.send("Datenbankfehler"); // ✅ alle Patienten laden
res.render("patients", { const [patients] = await db.promise().query(sql, params);
// ✅ ausgewählten Patienten aus Session laden (falls vorhanden)
const selectedPatientId = req.session.selectedPatientId || null;
let selectedPatient = null;
if (selectedPatientId) {
const [rows] = await db
.promise()
.query("SELECT * FROM patients WHERE id = ?", [selectedPatientId]);
selectedPatient = rows?.[0] || null;
// ✅ falls Patient nicht mehr existiert → Auswahl löschen
if (!selectedPatient) {
req.session.selectedPatientId = null;
}
}
// ✅ Sidebar IMMER patient-sidebar (sofort beim Laden)
const backUrl = "/dashboard";
return res.render("patients", {
title: "Patientenübersicht",
// ✅ Sidebar dynamisch
sidebarPartial: selectedPatient
? "partials/patient_sidebar"
: "partials/sidebar",
// ✅ Active dynamisch
active: selectedPatient ? "patient_dashboard" : "patients",
patients, patients,
// ✅ wichtig: für patient-sidebar
patient: selectedPatient,
selectedPatientId: selectedPatient?.id || null,
query: req.query, query: req.query,
user: req.session.user, user: req.session.user,
lang: req.session.lang || "de",
// ✅ wichtig: zurück Button
backUrl,
}); });
}); } catch (err) {
console.error(err);
return res.send("Datenbankfehler");
}
} }
function showEditPatient(req, res) { function showEditPatient(req, res) {
@ -58,13 +111,19 @@ function showEditPatient(req, res) {
(err, results) => { (err, results) => {
if (err || results.length === 0) if (err || results.length === 0)
return res.send("Patient nicht gefunden"); return res.send("Patient nicht gefunden");
res.render("patient_edit", { res.render("patient_edit", {
title: "Patient bearbeiten",
sidebarPartial: "partials/patient_sidebar",
active: "patient_edit",
patient: results[0], patient: results[0],
error: null, error: null,
user: req.session.user, user: req.session.user,
lang: req.session.lang || "de",
returnTo: req.query.returnTo || null, returnTo: req.query.returnTo || null,
}); });
} },
); );
} }
@ -139,7 +198,7 @@ function updatePatient(req, res) {
} }
res.redirect("/patients"); res.redirect("/patients");
} },
); );
} }
@ -192,10 +251,15 @@ function showPatientMedications(req, res) {
return res.send("Aktuelle Medikation konnte nicht geladen werden"); return res.send("Aktuelle Medikation konnte nicht geladen werden");
res.render("patient_medications", { res.render("patient_medications", {
title: "Medikamente",
sidebarPartial: "partials/patient-doctor-sidebar",
active: "patient_medications",
patient: patients[0], patient: patients[0],
meds, meds,
currentMeds, currentMeds,
user: req.session.user, user: req.session.user,
lang: req.session.lang || "de",
returnTo, returnTo,
}); });
}); });
@ -217,8 +281,8 @@ function moveToWaitingRoom(req, res) {
[id], [id],
(err) => { (err) => {
if (err) return res.send("Fehler beim Verschieben ins Wartezimmer"); if (err) return res.send("Fehler beim Verschieben ins Wartezimmer");
return res.redirect("/dashboard"); // optional: direkt Dashboard return res.redirect("/dashboard");
} },
); );
} }
@ -229,10 +293,15 @@ function showWaitingRoom(req, res) {
if (err) return res.send("Datenbankfehler"); if (err) return res.send("Datenbankfehler");
res.render("waiting_room", { res.render("waiting_room", {
title: "Wartezimmer",
sidebarPartial: "partials/sidebar",
active: "patients",
patients, patients,
user: req.session.user, user: req.session.user,
lang: req.session.lang || "de",
}); });
} },
); );
} }
@ -277,7 +346,6 @@ function showPatientOverview(req, res) {
const patient = patients[0]; const patient = patients[0];
// 🇪🇸 / 🇩🇪 Sprache für Leistungen
const serviceNameField = const serviceNameField =
patient.country === "ES" patient.country === "ES"
? "COALESCE(NULLIF(name_es, ''), name_de)" ? "COALESCE(NULLIF(name_es, ''), name_de)"
@ -322,12 +390,17 @@ function showPatientOverview(req, res) {
if (err) return res.send("Fehler Medikamente"); if (err) return res.send("Fehler Medikamente");
res.render("patient_overview", { res.render("patient_overview", {
title: "Patient Übersicht",
sidebarPartial: "partials/patient-doctor-sidebar",
active: "patient_overview",
patient, patient,
notes, notes,
services, services,
todayServices, todayServices,
medicationVariants, medicationVariants,
user: req.session.user, user: req.session.user,
lang: req.session.lang || "de",
}); });
}); });
}); });
@ -374,7 +447,7 @@ function assignMedicationToPatient(req, res) {
}; };
res.redirect(`/patients/${patientId}/overview`); res.redirect(`/patients/${patientId}/overview`);
} },
); );
} }
@ -393,7 +466,7 @@ function addPatientNote(req, res) {
(err) => { (err) => {
if (err) return res.send("Fehler beim Speichern der Notiz"); if (err) return res.send("Fehler beim Speichern der Notiz");
res.redirect(`/patients/${patientId}/overview`); res.redirect(`/patients/${patientId}/overview`);
} },
); );
} }
@ -406,7 +479,7 @@ function callFromWaitingRoom(req, res) {
(err) => { (err) => {
if (err) return res.send("Fehler beim Entfernen aus dem Wartezimmer"); if (err) return res.send("Fehler beim Entfernen aus dem Wartezimmer");
res.redirect(`/patients/${patientId}/overview`); res.redirect(`/patients/${patientId}/overview`);
} },
); );
} }
@ -429,7 +502,7 @@ function dischargePatient(req, res) {
} }
return res.redirect("/dashboard"); return res.redirect("/dashboard");
} },
); );
} }
@ -464,8 +537,14 @@ function showMedicationPlan(req, res) {
if (err) return res.send("Medikationsplan konnte nicht geladen werden"); if (err) return res.send("Medikationsplan konnte nicht geladen werden");
res.render("patient_plan", { res.render("patient_plan", {
title: "Medikationsplan",
sidebarPartial: "partials/patient_sidebar",
active: "patient_plan",
patient: patients[0], patient: patients[0],
meds, meds,
user: req.session.user,
lang: req.session.lang || "de",
}); });
}); });
}); });
@ -500,7 +579,7 @@ function movePatientToWaitingRoom(req, res) {
}; };
return res.redirect("/dashboard"); return res.redirect("/dashboard");
} },
); );
} }
@ -552,7 +631,6 @@ async function showPatientOverviewDashborad(req, res) {
const patientId = req.params.id; const patientId = req.params.id;
try { try {
// 👤 Patient
const [[patient]] = await db const [[patient]] = await db
.promise() .promise()
.query("SELECT * FROM patients WHERE id = ?", [patientId]); .query("SELECT * FROM patients WHERE id = ?", [patientId]);
@ -561,27 +639,25 @@ async function showPatientOverviewDashborad(req, res) {
return res.redirect("/patients"); return res.redirect("/patients");
} }
// 💊 AKTUELLE MEDIKAMENTE (end_date IS NULL)
const [medications] = await db.promise().query( const [medications] = await db.promise().query(
` `
SELECT SELECT
m.name AS medication_name, m.name AS medication_name,
mv.dosage AS variant_dosage, mv.dosage AS variant_dosage,
pm.dosage_instruction, pm.dosage_instruction,
pm.start_date pm.start_date
FROM patient_medications pm FROM patient_medications pm
JOIN medication_variants mv JOIN medication_variants mv
ON pm.medication_variant_id = mv.id ON pm.medication_variant_id = mv.id
JOIN medications m JOIN medications m
ON mv.medication_id = m.id ON mv.medication_id = m.id
WHERE pm.patient_id = ? WHERE pm.patient_id = ?
AND pm.end_date IS NULL AND pm.end_date IS NULL
ORDER BY pm.start_date DESC ORDER BY pm.start_date DESC
`, `,
[patientId] [patientId],
); );
// 🧾 RECHNUNGEN
const [invoices] = await db.promise().query( const [invoices] = await db.promise().query(
` `
SELECT SELECT
@ -594,14 +670,19 @@ async function showPatientOverviewDashborad(req, res) {
WHERE patient_id = ? WHERE patient_id = ?
ORDER BY invoice_date DESC ORDER BY invoice_date DESC
`, `,
[patientId] [patientId],
); );
res.render("patient_overview_dashboard", { res.render("patient_overview_dashboard", {
title: "Patient Dashboard",
sidebarPartial: "partials/patient_sidebar",
active: "patient_dashboard",
patient, patient,
medications, medications,
invoices, invoices,
user: req.session.user, user: req.session.user,
lang: req.session.lang || "de",
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);

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

@ -35,9 +35,14 @@ function listServices(req, res) {
if (err) return res.send("Datenbankfehler"); if (err) return res.send("Datenbankfehler");
res.render("services", { res.render("services", {
title: "Leistungen",
sidebarPartial: "partials/sidebar-empty",
active: "services",
services, services,
user: req.session.user, user: req.session.user,
query: { q, onlyActive, patientId } lang: req.session.lang || "de",
query: { q, onlyActive, patientId },
}); });
}); });
}; };
@ -52,7 +57,7 @@ function listServices(req, res) {
serviceNameField = "name_es"; serviceNameField = "name_es";
} }
loadServices(); loadServices();
} },
); );
} else { } else {
// 🔹 Kein Patient → Deutsch // 🔹 Kein Patient → Deutsch
@ -98,17 +103,27 @@ function listServicesAdmin(req, res) {
if (err) return res.send("Datenbankfehler"); if (err) return res.send("Datenbankfehler");
res.render("services", { res.render("services", {
title: "Leistungen (Admin)",
sidebarPartial: "partials/admin-sidebar",
active: "services",
services, services,
user: req.session.user, user: req.session.user,
query: { q, onlyActive } lang: req.session.lang || "de",
query: { q, onlyActive },
}); });
}); });
} }
function showCreateService(req, res) { function showCreateService(req, res) {
res.render("service_create", { res.render("service_create", {
title: "Leistung anlegen",
sidebarPartial: "partials/sidebar-empty",
active: "services",
user: req.session.user, user: req.session.user,
error: null lang: req.session.lang || "de",
error: null,
}); });
} }
@ -118,8 +133,13 @@ function createService(req, res) {
if (!name_de || !price) { if (!name_de || !price) {
return res.render("service_create", { return res.render("service_create", {
title: "Leistung anlegen",
sidebarPartial: "partials/sidebar-empty",
active: "services",
user: req.session.user, user: req.session.user,
error: "Bezeichnung (DE) und Preis sind Pflichtfelder" lang: req.session.lang || "de",
error: "Bezeichnung (DE) und Preis sind Pflichtfelder",
}); });
} }
@ -139,11 +159,11 @@ function createService(req, res) {
(service_id, user_id, action, new_value) (service_id, user_id, action, new_value)
VALUES (?, ?, 'CREATE', ?) VALUES (?, ?, 'CREATE', ?)
`, `,
[result.insertId, userId, JSON.stringify(req.body)] [result.insertId, userId, JSON.stringify(req.body)],
); );
res.redirect("/services"); res.redirect("/services");
} },
); );
} }
@ -156,14 +176,15 @@ function updateServicePrice(req, res) {
"SELECT price, price_c70 FROM services WHERE id = ?", "SELECT price, price_c70 FROM services WHERE id = ?",
[serviceId], [serviceId],
(err, oldRows) => { (err, oldRows) => {
if (err || oldRows.length === 0) return res.send("Service nicht gefunden"); if (err || oldRows.length === 0)
return res.send("Service nicht gefunden");
const oldData = oldRows[0]; const oldData = oldRows[0];
db.query( db.query(
"UPDATE services SET price = ?, price_c70 = ? WHERE id = ?", "UPDATE services SET price = ?, price_c70 = ? WHERE id = ?",
[price, price_c70, serviceId], [price, price_c70, serviceId],
err => { (err) => {
if (err) return res.send("Update fehlgeschlagen"); if (err) return res.send("Update fehlgeschlagen");
db.query( db.query(
@ -176,14 +197,14 @@ function updateServicePrice(req, res) {
serviceId, serviceId,
userId, userId,
JSON.stringify(oldData), JSON.stringify(oldData),
JSON.stringify({ price, price_c70 }) JSON.stringify({ price, price_c70 }),
] ],
); );
res.redirect("/services"); res.redirect("/services");
} },
); );
} },
); );
} }
@ -203,22 +224,22 @@ function toggleService(req, res) {
db.query( db.query(
"UPDATE services SET active = ? WHERE id = ?", "UPDATE services SET active = ? WHERE id = ?",
[newActive, serviceId], [newActive, serviceId],
err => { (err) => {
if (err) return res.send("Update fehlgeschlagen"); if (err) return res.send("Update fehlgeschlagen");
db.query( db.query(
` `
INSERT INTO service_logs INSERT INTO service_logs
(service_id, user_id, action, old_value, new_value) (service_id, user_id, action, old_value, new_value)
VALUES (?, ?, 'TOGGLE_ACTIVE', ?, ?) VALUES (?, ?, 'TOGGLE_ACTIVE', ?, ?)
`, `,
[serviceId, userId, oldActive, newActive] [serviceId, userId, oldActive, newActive],
); );
res.redirect("/services"); res.redirect("/services");
} },
); );
} },
); );
} }
@ -251,17 +272,13 @@ async function listOpenServices(req, res, next) {
let connection; let connection;
try { try {
// 🔌 EXAKT EINE Connection holen
connection = await db.promise().getConnection(); connection = await db.promise().getConnection();
// 🔒 Isolation Level für DIESE Connection
await connection.query( await connection.query(
"SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED" "SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED",
); );
const [[cid]] = await connection.query( const [[cid]] = await connection.query("SELECT CONNECTION_ID() AS cid");
"SELECT CONNECTION_ID() AS cid"
);
console.log("🔌 OPEN SERVICES CID:", cid.cid); console.log("🔌 OPEN SERVICES CID:", cid.cid);
const [rows] = await connection.query(sql); const [rows] = await connection.query(sql);
@ -269,10 +286,14 @@ async function listOpenServices(req, res, next) {
console.log("🧾 OPEN SERVICES ROWS:", rows.length); console.log("🧾 OPEN SERVICES ROWS:", rows.length);
res.render("open_services", { res.render("open_services", {
rows, title: "Offene Leistungen",
user: req.session.user sidebarPartial: "partials/sidebar-invoices",
}); active: "services",
rows,
user: req.session.user,
lang: req.session.lang || "de",
});
} catch (err) { } catch (err) {
next(err); next(err);
} finally { } finally {
@ -280,8 +301,6 @@ async function listOpenServices(req, res, next) {
} }
} }
function showServiceLogs(req, res) { function showServiceLogs(req, res) {
db.query( db.query(
` `
@ -299,14 +318,18 @@ function showServiceLogs(req, res) {
if (err) return res.send("Datenbankfehler"); if (err) return res.send("Datenbankfehler");
res.render("admin_service_logs", { res.render("admin_service_logs", {
title: "Service Logs",
sidebarPartial: "partials/admin-sidebar",
active: "services",
logs, logs,
user: req.session.user user: req.session.user,
lang: req.session.lang || "de",
}); });
} },
); );
} }
module.exports = { module.exports = {
listServices, listServices,
showCreateService, showCreateService,
@ -315,5 +338,5 @@ module.exports = {
toggleService, toggleService,
listOpenServices, listOpenServices,
showServiceLogs, showServiceLogs,
listServicesAdmin listServicesAdmin,
}; };

1
db.js
View File

@ -11,6 +11,7 @@ function initPool() {
return mysql.createPool({ return mysql.createPool({
host: config.db.host, host: config.db.host,
port: config.db.port || 3306,
user: config.db.user, user: config.db.user,
password: config.db.password, password: config.db.password,
database: config.db.name, database: config.db.name,

View File

@ -4,23 +4,129 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"search": "Suchen", "search": "Suchen",
"reset": "Reset", "reset": "Reset",
"dashboard": "Dashboard" "dashboard": "Dashboard",
"logout": "Logout",
"title": "Titel",
"firstname": "Vorname",
"lastname": "Nachname",
"username": "Username",
"role": "Rolle",
"action": "Aktionen",
"status": "Status",
"you": "Du Selbst",
"newuser": "Neuer benutzer",
"inactive": "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": { "sidebar": {
"patients": "Patienten", "patients": "Patienten",
"medications": "Medikamente", "medications": "Medikamente",
"servicesOpen": "Offene Leistungen", "servicesOpen": "Patienten Rechnungen",
"billing": "Abrechnung", "billing": "Abrechnung",
"admin": "Verwaltung", "admin": "Verwaltung",
"logout": "Logout" "logout": "Logout"
}, },
"dashboard": { "dashboard": {
"welcome": "Willkommen", "welcome": "Willkommen",
"waitingRoom": "Wartezimmer-Monitor", "waitingRoom": "Wartezimmer-Monitor",
"noWaitingPatients": "Keine Patienten im Wartezimmer." "noWaitingPatients": "Keine Patienten im Wartezimmer.",
"title": "Dashboard"
}, },
"adminSidebar": { "adminSidebar": {
"users": "Userverwaltung", "users": "Userverwaltung",
"database": "Datenbankverwaltung" "database": "Datenbankverwaltung",
"user": "Benutzer",
"invocieoverview": "Rechnungsübersicht",
"seriennumber": "Seriennummer",
"databasetable": "Datenbank",
"companysettings": "Firmendaten"
},
"adminuseroverview": {
"useroverview": "Benutzerübersicht",
"usermanagement": "Benutzer Verwaltung",
"user": "Benutzer",
"invocieoverview": "Rechnungsübersicht",
"seriennumber": "Seriennummer",
"databasetable": "Datenbank"
},
"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

@ -4,8 +4,60 @@
"cancel": "Cancelar", "cancel": "Cancelar",
"search": "Buscar", "search": "Buscar",
"reset": "Resetear", "reset": "Resetear",
"dashboard": "Panel" "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": { "sidebar": {
"patients": "Pacientes", "patients": "Pacientes",
"medications": "Medicamentos", "medications": "Medicamentos",
@ -14,14 +66,67 @@
"admin": "Administración", "admin": "Administración",
"logout": "Cerrar sesión" "logout": "Cerrar sesión"
}, },
"dashboard": { "dashboard": {
"welcome": "Bienvenido", "welcome": "Bienvenido",
"waitingRoom": "Monitor sala de espera", "waitingRoom": "Monitor sala de espera",
"noWaitingPatients": "No hay pacientes en la sala de espera." "noWaitingPatients": "No hay pacientes en la sala de espera.",
"title": "Dashboard"
}, },
"adminSidebar": { "adminSidebar": {
"users": "Administración de usuarios", "users": "Administración de usuarios",
"database": "Administración de base de datos" "database": "Administración de base de datos",
"user": "usuario",
"invocieoverview": "Resumen de facturas",
"seriennumber": "número de serie",
"databasetable": "base de datos",
"companysettings": "Datos de la empresa"
},
"adminuseroverview": {
"useroverview": "Resumen de usuarios",
"usermanagement": "Administración de usuarios",
"user": "usuario",
"invocieoverview": "Resumen de facturas",
"seriennumber": "número de serie",
"databasetable": "base de datos"
},
"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

@ -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();
};

Binary file not shown.

199
package-lock.json generated
View File

@ -11,10 +11,12 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"docxtemplater": "^3.67.6", "docxtemplater": "^3.67.6",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.19.2", "express": "^4.19.2",
"express-ejs-layouts": "^2.5.1",
"express-mysql-session": "^3.0.3", "express-mysql-session": "^3.0.3",
"express-session": "^1.18.2", "express-session": "^1.18.2",
"fs-extra": "^11.3.3", "fs-extra": "^11.3.3",
@ -22,6 +24,8 @@
"html-pdf-node": "^1.0.8", "html-pdf-node": "^1.0.8",
"multer": "^2.0.2", "multer": "^2.0.2",
"mysql2": "^3.16.0", "mysql2": "^3.16.0",
"node-ssh": "^13.2.1",
"pdf-lib": "^1.17.1",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
@ -1036,6 +1040,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -1072,6 +1082,24 @@
"@noble/hashes": "^1.1.5" "@noble/hashes": "^1.1.5"
} }
}, },
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -1647,6 +1675,14 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/async": { "node_modules/async": {
"version": "3.2.6", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -1834,6 +1870,14 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -2061,6 +2105,15 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/busboy": { "node_modules/busboy": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -2214,6 +2267,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/cheerio": { "node_modules/cheerio": {
"version": "0.22.0", "version": "0.22.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz",
@ -2513,6 +2578,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/crc-32": { "node_modules/crc-32": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@ -3045,6 +3124,11 @@
"url": "https://opencollective.com/express" "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": { "node_modules/express-mysql-session": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/express-mysql-session/-/express-mysql-session-3.0.3.tgz", "resolved": "https://registry.npmjs.org/express-mysql-session/-/express-mysql-session-3.0.3.tgz",
@ -4105,7 +4189,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -5293,6 +5376,12 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"optional": true
},
"node_modules/napi-postinstall": { "node_modules/napi-postinstall": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@ -5374,6 +5463,44 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-ssh": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/node-ssh/-/node-ssh-13.2.1.tgz",
"integrity": "sha512-rfl4GWMygQfzlExPkQ2LWyya5n2jOBm5vhEnup+4mdw7tQhNpJWbP5ldr09Jfj93k5SfY5lxcn8od5qrQ/6mBg==",
"dependencies": {
"is-stream": "^2.0.0",
"make-dir": "^3.1.0",
"sb-promise-queue": "^2.1.0",
"sb-scandir": "^3.1.0",
"shell-escape": "^0.2.0",
"ssh2": "^1.14.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/node-ssh/node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/node-ssh/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.11", "version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -5585,6 +5712,12 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-json": { "node_modules/parse-json": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -5671,6 +5804,24 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pend": { "node_modules/pend": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@ -6030,6 +6181,25 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sb-promise-queue": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/sb-promise-queue/-/sb-promise-queue-2.1.1.tgz",
"integrity": "sha512-qXfdcJQMxMljxmPprn4Q4hl3pJmoljSCzUvvEBa9Kscewnv56n0KqrO6yWSrGLOL9E021wcGdPa39CHGKA6G0w==",
"engines": {
"node": ">= 8"
}
},
"node_modules/sb-scandir": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/sb-scandir/-/sb-scandir-3.1.1.tgz",
"integrity": "sha512-Q5xiQMtoragW9z8YsVYTAZcew+cRzdVBefPbb9theaIKw6cBo34WonP9qOCTKgyAmn/Ch5gmtAxT/krUgMILpA==",
"dependencies": {
"sb-promise-queue": "^2.1.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -6131,6 +6301,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/shell-escape": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz",
"integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw=="
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -6305,6 +6480,23 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/stack-utils": { "node_modules/stack-utils": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@ -6720,6 +6912,11 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
},
"node_modules/type-detect": { "node_modules/type-detect": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",

View File

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

View File

@ -62,6 +62,25 @@
opacity: 0.4; 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 { .auto-hide-flash {
animation: flashFadeOut 3s forwards; animation: flashFadeOut 3s forwards;
} }
@ -78,3 +97,214 @@
visibility: hidden; 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.

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

@ -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

@ -5,6 +5,9 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const { exec } = require("child_process"); const { exec } = require("child_process");
const multer = require("multer"); const multer = require("multer");
const { NodeSSH } = require("node-ssh");
const uploadLogo = require("../middleware/uploadLogo");
// ✅ Upload Ordner für Restore Dumps // ✅ Upload Ordner für Restore Dumps
const upload = multer({ dest: path.join(__dirname, "..", "uploads_tmp") }); const upload = multer({ dest: path.join(__dirname, "..", "uploads_tmp") });
@ -29,6 +32,13 @@ const { loadConfig, saveConfig } = require("../config-manager");
// ✅ DB (für resetPool) // ✅ DB (für resetPool)
const db = require("../db"); const db = require("../db");
// ✅ Firmendaten
const {
getCompanySettings,
saveCompanySettings
} = require("../controllers/companySettings.controller");
/* ========================== /* ==========================
VERWALTUNG (NUR ADMIN) VERWALTUNG (NUR ADMIN)
========================== */ ========================== */
@ -71,6 +81,7 @@ router.get("/database", requireAdmin, async (req, res) => {
if (cfg?.db) { if (cfg?.db) {
const conn = await mysql.createConnection({ const conn = await mysql.createConnection({
host: cfg.db.host, host: cfg.db.host,
port: Number(cfg.db.port || 3306), // ✅ WICHTIG: Port nutzen
user: cfg.db.user, user: cfg.db.user,
password: cfg.db.password, password: cfg.db.password,
database: cfg.db.name, database: cfg.db.name,
@ -131,26 +142,46 @@ router.get("/database", requireAdmin, async (req, res) => {
dbConfig: cfg?.db || null, dbConfig: cfg?.db || null,
testResult: null, testResult: null,
backupFiles, backupFiles,
systemInfo, // ✅ DAS HAT GEFEHLT systemInfo,
}); });
}); });
// ✅ Nur testen (ohne speichern) // ✅ Nur testen (ohne speichern)
router.post("/database/test", requireAdmin, async (req, res) => { router.post("/database/test", requireAdmin, async (req, res) => {
try { const backupDir = path.join(__dirname, "..", "backups");
const { host, user, password, name } = req.body;
if (!host || !user || !password || !name) { 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(); const cfg = loadConfig();
return res.render("admin/database", { return res.render("admin/database", {
user: req.session.user, user: req.session.user,
dbConfig: cfg?.db || null, dbConfig: cfg?.db || null,
testResult: { ok: false, message: "❌ Bitte alle Felder ausfüllen." }, testResult: { ok: false, message: "❌ Bitte alle Felder ausfüllen." },
backupFiles: getBackupFiles(),
systemInfo: null,
}); });
} }
const conn = await mysql.createConnection({ const conn = await mysql.createConnection({
host, host,
port: Number(port),
user, user,
password, password,
database: name, database: name,
@ -161,8 +192,10 @@ router.post("/database/test", requireAdmin, async (req, res) => {
return res.render("admin/database", { return res.render("admin/database", {
user: req.session.user, user: req.session.user,
dbConfig: { host, user, password, name }, dbConfig: { host, port: Number(port), user, password, name }, // ✅ PORT bleibt drin!
testResult: { ok: true, message: "✅ Verbindung erfolgreich!" }, testResult: { ok: true, message: "✅ Verbindung erfolgreich!" },
backupFiles: getBackupFiles(),
systemInfo: null,
}); });
} catch (err) { } catch (err) {
console.error("❌ DB TEST ERROR:", err); console.error("❌ DB TEST ERROR:", err);
@ -174,22 +207,59 @@ router.post("/database/test", requireAdmin, async (req, res) => {
ok: false, ok: false,
message: "❌ Verbindung fehlgeschlagen: " + err.message, message: "❌ Verbindung fehlgeschlagen: " + err.message,
}, },
backupFiles: getBackupFiles(),
systemInfo: null,
}); });
} }
}); });
// ✅ DB Settings speichern + Verbindung testen // ✅ DB Settings speichern + Verbindung testen
router.post("/database", requireAdmin, async (req, res) => { router.post("/database", requireAdmin, async (req, res) => {
try { function flashSafe(type, msg) {
const { host, user, password, name } = req.body; if (typeof req.flash === "function") {
req.flash(type, msg);
return;
}
req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg });
}
if (!host || !user || !password || !name) { const backupDir = path.join(__dirname, "..", "backups");
req.flash("error", "❌ Bitte alle Felder ausfüllen.");
return res.redirect("/admin/database"); // ✅ 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({ const conn = await mysql.createConnection({
host, host,
port: Number(port),
user, user,
password, password,
database: name, database: name,
@ -198,58 +268,88 @@ router.post("/database", requireAdmin, async (req, res) => {
await conn.query("SELECT 1"); await conn.query("SELECT 1");
await conn.end(); await conn.end();
// ✅ Speichern in config.enc // ✅ Speichern inkl. Port
const current = loadConfig() || {}; const current = loadConfig() || {};
current.db = { host, user, password, name }; current.db = {
host,
port: Number(port),
user,
password,
name,
};
saveConfig(current); saveConfig(current);
// ✅ DB Pool resetten (falls vorhanden) // ✅ Pool reset
if (typeof db.resetPool === "function") { if (typeof db.resetPool === "function") {
db.resetPool(); db.resetPool();
} }
req.flash( flashSafe("success", "✅ DB Einstellungen gespeichert!");
"success",
"✅ DB Einstellungen gespeichert + Verbindung erfolgreich getestet.", // ✅ DIREKT NEU LADEN aus config.enc (damit wirklich die gespeicherten Werte drin stehen)
); const freshCfg = loadConfig();
return res.redirect("/admin/database");
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) { } catch (err) {
console.error("❌ DB UPDATE ERROR:", err); console.error("❌ DB UPDATE ERROR:", err);
req.flash("error", "❌ Verbindung fehlgeschlagen: " + err.message); flashSafe("danger", "❌ Verbindung fehlgeschlagen: " + err.message);
return res.redirect("/admin/database");
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) BACKUP (NUR ADMIN)
========================== */ ========================== */
router.post("/database/backup", requireAdmin, (req, res) => { router.post("/database/backup", requireAdmin, async (req, res) => {
// ✅ Flash Safe (funktioniert auch ohne req.flash)
function flashSafe(type, msg) { function flashSafe(type, msg) {
if (typeof req.flash === "function") { if (typeof req.flash === "function") return req.flash(type, msg);
req.flash(type, msg);
return;
}
req.session.flash = req.session.flash || []; req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg }); req.session.flash.push({ type, message: msg });
console.log(`[FLASH-${type}]`, msg); console.log(`[FLASH-${type}]`, msg);
} }
try { try {
const cfg = loadConfig(); const cfg = loadConfig();
if (!cfg?.db) { if (!cfg?.db) {
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt)."); flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} }
const { host, user, password, name } = cfg.db; const { host, port, user, password, name } = cfg.db;
// ✅ Programmserver Backup Dir
const backupDir = path.join(__dirname, "..", "backups"); const backupDir = path.join(__dirname, "..", "backups");
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir); if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir);
// ✅ SSH Ziel (DB-Server)
const sshHost = process.env.DBSERVER_HOST;
const sshUser = process.env.DBSERVER_USER;
const sshPort = Number(process.env.DBSERVER_PORT || 22);
if (!sshHost || !sshUser) {
flashSafe("danger", "❌ SSH Config fehlt (DBSERVER_HOST / DBSERVER_USER).");
return res.redirect("/admin/database");
}
const stamp = new Date() const stamp = new Date()
.toISOString() .toISOString()
.replace(/T/, "_") .replace(/T/, "_")
@ -257,126 +357,156 @@ router.post("/database/backup", requireAdmin, (req, res) => {
.split(".")[0]; .split(".")[0];
const fileName = `${name}_${stamp}.sql`; const fileName = `${name}_${stamp}.sql`;
const filePath = path.join(backupDir, fileName);
// ✅ mysqldump.exe im Root // ✅ Datei wird zuerst auf DB-Server erstellt (tmp)
const mysqldumpPath = path.join(__dirname, "..", "mysqldump.exe"); const remoteTmpPath = `/tmp/${fileName}`;
// ✅ plugin Ordner im Root (muss existieren) // ✅ Datei wird dann lokal (Programmserver) gespeichert
const pluginDir = path.join(__dirname, "..", "plugin"); const localPath = path.join(backupDir, fileName);
if (!fs.existsSync(mysqldumpPath)) { const ssh = new NodeSSH();
flashSafe("danger", "❌ mysqldump.exe nicht gefunden: " + mysqldumpPath); await ssh.connect({
return res.redirect("/admin/database"); host: sshHost,
} username: sshUser,
port: sshPort,
if (!fs.existsSync(pluginDir)) { privateKeyPath: "/home/cay/.ssh/id_ed25519",
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");
}); });
// ✅ 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) { } catch (err) {
console.error("❌ BACKUP ERROR:", err); console.error("❌ BACKUP SSH ERROR:", err);
flashSafe("danger", "❌ Backup fehlgeschlagen: " + err.message); flashSafe("danger", "❌ Backup fehlgeschlagen: " + err.message);
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} }
}); });
/* ========================== /* ==========================
RESTORE (NUR ADMIN) RESTORE (NUR ADMIN)
========================== */ ========================== */
router.post("/database/restore", requireAdmin, (req, res) => { router.post("/database/restore", requireAdmin, async (req, res) => {
function flashSafe(type, msg) { function flashSafe(type, msg) {
if (typeof req.flash === "function") { if (typeof req.flash === "function") return req.flash(type, msg);
req.flash(type, msg);
return;
}
req.session.flash = req.session.flash || []; req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg }); req.session.flash.push({ type, message: msg });
console.log(`[FLASH-${type}]`, msg); console.log(`[FLASH-${type}]`, msg);
} }
const ssh = new NodeSSH();
try { try {
const cfg = loadConfig(); const cfg = loadConfig();
if (!cfg?.db) { if (!cfg?.db) {
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt)."); flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} }
const { host, user, password, name } = cfg.db; 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 backupDir = path.join(__dirname, "..", "backups");
const selectedFile = req.body.backupFile; const localPath = path.join(backupDir, backupFile);
if (!selectedFile) { if (!fs.existsSync(localPath)) {
flashSafe("danger", "❌ Bitte ein Backup auswählen."); flashSafe("danger", "❌ Datei nicht gefunden (Programmserver): " + backupFile);
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} }
const fullPath = path.join(backupDir, selectedFile); const sshHost = process.env.DBSERVER_HOST;
const sshUser = process.env.DBSERVER_USER;
const sshPort = Number(process.env.DBSERVER_PORT || 22);
if (!fs.existsSync(fullPath)) { if (!sshHost || !sshUser) {
flashSafe("danger", "❌ Backup Datei nicht gefunden: " + selectedFile); flashSafe("danger", "❌ SSH Config fehlt (DBSERVER_HOST / DBSERVER_USER).");
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} }
// ✅ mysql.exe im Root const remoteTmpPath = `/tmp/${backupFile}`;
const mysqlPath = path.join(__dirname, "..", "mysql.exe");
const pluginDir = path.join(__dirname, "..", "plugin");
if (!fs.existsSync(mysqlPath)) { await ssh.connect({
flashSafe("danger", "❌ mysql.exe nicht gefunden im Root: " + mysqlPath); host: sshHost,
return res.redirect("/admin/database"); username: sshUser,
} port: sshPort,
privateKeyPath: "/home/cay/.ssh/id_ed25519",
const cmd = `"${mysqlPath}" --plugin-dir="${pluginDir}" -h ${host} -u ${user} -p${password} ${name} < "${fullPath}"`;
exec(cmd, (error, stdout, stderr) => {
if (error) {
console.error("❌ RESTORE ERROR:", error);
console.error("STDERR:", stderr);
flashSafe(
"danger",
"❌ Restore fehlgeschlagen: " + (stderr || error.message),
);
return res.redirect("/admin/database");
}
flashSafe(
"success",
"✅ Restore erfolgreich abgeschlossen: " + selectedFile,
);
return res.redirect("/admin/database");
}); });
await ssh.putFile(localPath, remoteTmpPath);
const restoreCmd =
`mysql -h "${host}" -P "${port || 3306}" -u "${user}" -p'${password}' "${name}" < "${remoteTmpPath}"`;
const restoreRes = await ssh.execCommand(restoreCmd);
await ssh.execCommand(`rm -f "${remoteTmpPath}"`);
if (restoreRes.code !== 0) {
flashSafe("danger", "❌ Restore fehlgeschlagen: " + (restoreRes.stderr || "mysql Fehler"));
return res.redirect("/admin/database");
}
flashSafe("success", `✅ Restore erfolgreich: ${backupFile}`);
return res.redirect("/admin/database");
} catch (err) { } catch (err) {
console.error("❌ RESTORE ERROR:", err); console.error("❌ RESTORE SSH ERROR:", err);
flashSafe("danger", "❌ Restore fehlgeschlagen: " + err.message); flashSafe("danger", "❌ Restore fehlgeschlagen: " + err.message);
return res.redirect("/admin/database"); return res.redirect("/admin/database");
} finally {
try {
ssh.dispose();
} catch (e) {}
} }
}); });
/* ========================== /* ==========================
ABRECHNUNG (NUR ARZT) ABRECHNUNG (NUR ARZT)
========================== */ ========================== */
router.get("/invoices", requireArzt, showInvoiceOverview); router.get("/invoices", requireAdmin, showInvoiceOverview);
/* ==========================
Firmendaten
========================== */
router.get(
"/company-settings",
requireAdmin,
getCompanySettings
);
router.post(
"/company-settings",
requireAdmin,
uploadLogo.single("logo"),
saveCompanySettings
);
module.exports = router; module.exports = router;

View File

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

View File

@ -1,8 +1,40 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { requireArzt } = require("../middleware/auth.middleware"); const { requireArzt } = require("../middleware/auth.middleware");
const { createInvoicePdf } = require("../controllers/invoicePdf.controller"); 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); 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; module.exports = router;

View File

@ -1,8 +1,6 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { requireLogin, requireArzt } = require("../middleware/auth.middleware");
const { const {
listPatients, listPatients,
showCreatePatient, showCreatePatient,
@ -11,32 +9,81 @@ const {
updatePatient, updatePatient,
showPatientMedications, showPatientMedications,
moveToWaitingRoom, moveToWaitingRoom,
showWaitingRoom,
showPatientOverview, showPatientOverview,
addPatientNote, addPatientNote,
callFromWaitingRoom, callFromWaitingRoom,
dischargePatient, dischargePatient,
showMedicationPlan, showMedicationPlan,
movePatientToWaitingRoom,
deactivatePatient, deactivatePatient,
activatePatient, activatePatient,
showPatientOverviewDashborad, showPatientOverviewDashborad,
assignMedicationToPatient, assignMedicationToPatient,
} = require("../controllers/patient.controller"); } = 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("/", requireLogin, listPatients);
router.get("/create", requireLogin, showCreatePatient); router.get("/create", requireLogin, showCreatePatient);
router.post("/create", requireLogin, createPatient); router.post("/create", requireLogin, createPatient);
router.get("/edit/:id", requireLogin, showEditPatient);
router.post("/edit/:id", requireLogin, updatePatient); router.get("/waiting-room", requireLogin, showWaitingRoom);
router.get("/:id/medications", requireLogin, showPatientMedications);
router.post("/waiting-room/:id", requireLogin, moveToWaitingRoom); 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.get("/:id/overview", requireLogin, showPatientOverview);
router.post("/:id/notes", requireLogin, addPatientNote); 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.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("/deactivate/:id", requireLogin, deactivatePatient);
router.post("/activate/:id", requireLogin, activatePatient); router.post("/activate/:id", requireLogin, activatePatient);
// ✅ Patient Dashboard
router.get("/:id", requireLogin, showPatientOverviewDashborad); router.get("/:id", requireLogin, showPatientOverviewDashborad);
router.post("/:id/medications/assign", requireLogin, assignMedicationToPatient);
module.exports = router; 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;

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

@ -40,7 +40,10 @@ async function loginUser(db, username, password, lockTimeMinutes) {
resolve({ resolve({
id: user.id, id: user.id,
username: user.username, username: user.username,
role: user.role role: user.role,
title: user.title,
firstname: user.first_name,
lastname: user.last_name
}); });
} }
); );

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

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;
};

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

View File

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

View File

@ -1,406 +1,259 @@
<!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"> <div class="layout">
<!-- ✅ ADMIN SIDEBAR --> <!-- ✅ SIDEBAR (wie Dashboard, aber Admin Sidebar) -->
<%- include("../partials/admin-sidebar", { user, active: "database" }) %> <%- include("../partials/admin-sidebar", { user, active: "database", lang }) %>
<!-- ✅ MAIN CONTENT --> <!-- ✅ MAIN -->
<div class="main"> <div class="main">
<nav class="navbar navbar-dark bg-dark position-relative px-3 rounded mb-4"> <!-- ✅ HEADER (wie Dashboard) -->
<div class="position-absolute top-50 start-50 translate-middle d-flex align-items-center gap-2 text-white"> <%- include("../partials/page-header", {
<i class="bi bi-hdd-stack fs-4"></i> user,
<span class="fw-semibold fs-5">Datenbankverwaltung</span> title: t.adminSidebar.database,
</div> subtitle: "",
showUserName: true,
hideDashboardButton: true
}) %>
<div class="ms-auto"> <div class="content p-4">
<a href="/dashboard" class="btn btn-outline-light btn-sm">⬅️ Dashboard</a>
</div>
</nav>
<div class="container-fluid"> <!-- Flash Messages -->
<!-- ✅ Flash Messages -->
<%- include("../partials/flash") %> <%- include("../partials/flash") %>
<!-- ✅ Statusanzeige (Verbindung OK / Fehler) --> <div class="container-fluid p-0">
<% if (testResult) { %> <div class="row g-3">
<div class="alert <%= testResult.ok ? 'alert-success' : 'alert-danger' %>">
<%= testResult.message %>
</div>
<% } %>
<div class="card shadow"> <!-- ✅ DB Konfiguration -->
<div class="card-body"> <div class="col-12">
<div class="card shadow mb-3">
<div class="card-body">
<h4 class="mb-3">Datenbank Tools</h4> <h4 class="mb-3">
<i class="bi bi-sliders"></i> <%= t.databaseoverview.title%>
</h4>
<div class="alert alert-warning"> <p class="text-muted mb-4">
<b>Hinweis:</b> Diese Funktionen sind nur für <b>Admins</b> sichtbar und sollten mit Vorsicht benutzt werden. <%= t.databaseoverview.tittexte%>
</div> </p>
<!-- ✅ DB Einstellungen --> <!-- ✅ TEST + SPEICHERN -->
<div class="card border mb-4"> <form method="POST" action="/admin/database/test" class="row g-3 mb-3" autocomplete="off">
<div class="card-body">
<div class="mb-3"> <div class="col-md-6">
<h5 class="card-title m-0">🔧 Datenbankverbindung ändern</h5> <label class="form-label"><%= t.databaseoverview.host%> / IP</label>
</div> <input
type="text"
<% if (!dbConfig) { %> name="host"
<div class="alert alert-danger"> class="form-control"
❌ Keine Datenbank-Konfiguration gefunden (config.enc fehlt oder ungültig). value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.host) ? dbConfig.host : '' %>"
</div> autocomplete="off"
<% } %> required
<!-- ✅ 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) => { %> </div>
<label
class="d-flex align-items-center gap-2 p-2 rounded" <div class="col-md-3">
style="cursor:pointer;" <label class="form-label"><%= t.databaseoverview.port%></label>
> <input
<input type="number"
type="radio" name="port"
name="backupFile" class="form-control"
value="<%= f %>" value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.port) ? dbConfig.port : 3306 %>"
<%= index === 0 ? "checked" : "" %> autocomplete="off"
<%= (!backupFiles || backupFiles.length === 0) ? "disabled" : "" %> required
/> >
<span style="font-size: 14px;"><%= f %></span> </div>
</label>
<% }) %> <div class="col-md-3">
</div> <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 <button
class="btn btn-outline-danger" type="submit"
onclick="return confirm('⚠️ Achtung! Restore überschreibt Datenbankdaten. Wirklich fortfahren?');" class="btn btn-success"
<%= (!backupFiles || backupFiles.length === 0) ? "disabled" : "" %> formaction="/admin/database"
> >
Restore starten <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> </button>
</form> </form>
<div class="text-muted small mt-2"> <!-- ✅ Restore auswählen -->
Es werden die neuesten Backups zuerst angezeigt. Wenn mehr vorhanden sind, kannst du scrollen. <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>
<!-- ✅ 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>
@ -408,59 +261,3 @@
</div> </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>

View File

@ -50,9 +50,10 @@
</tr> </tr>
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
<br>
</div> </div>
</body> </body>
</html> </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"> <div class="layout">
<!-- ✅ ADMIN SIDEBAR --> <div class="main">
<%- include("partials/admin-sidebar", { active: "users" }) %>
<div class="main"> <!-- ✅ HEADER -->
<%- include("partials/page-header", {
user,
title: t.adminuseroverview.usermanagement,
subtitle: "",
showUserName: true
}) %>
<!-- ✅ TOP HEADER --> <div class="content">
<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") %> <%- include("partials/flash") %>
<div class="card shadow border-0 rounded-3"> <div class="container-fluid">
<div class="card-body">
<h4 class="mb-3">Benutzerübersicht</h4> <div class="card shadow-sm">
<div class="card-body">
<!-- ✅ Suche + Tabelle zusammen breit --> <div class="d-flex align-items-center justify-content-between mb-3">
<div class="table-wrapper"> <h4 class="mb-0"><%= t.adminuseroverview.useroverview %></h4>
<!-- ✅ Toolbar: Suche links, Button rechts --> <a href="/admin/create-user" class="btn btn-primary">
<div class="toolbar"> <i class="bi bi-plus-circle"></i>
<form method="GET" action="/admin/users" class="searchbar"> <%= t.global.newuser %>
<input </a>
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> </div>
<!-- ✅ Tabelle --> <!-- ✅ Tabelle -->
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-bordered table-hover table-sm align-middle mb-0 table-auto"> <table class="table table-bordered table-hover table-sm align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th style="width: 60px;">ID</th> <th>ID</th>
<th>Titel</th> <th><%= t.global.title %></th>
<th>Vorname</th> <th><%= t.global.firstname %></th>
<th>Nachname</th> <th><%= t.global.lastname %></th>
<th>Username</th> <th><%= t.global.username %></th>
<th style="width: 180px;">Rolle</th> <th><%= t.global.role %></th>
<th style="width: 110px;" class="text-center">Status</th> <th class="text-center"><%= t.global.status %></th>
<th style="width: 200px;">Aktionen</th> <th><%= t.global.action %></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% users.forEach(u => { %> <% users.forEach(u => { %>
<tr class="<%= u.active ? '' : 'table-secondary' %>">
<tr class="<%= u.active ? '' : 'table-secondary' %>"> <!-- ✅ Update Form -->
<form method="POST" action="/admin/users/update/<%= u.id %>">
<!-- ✅ Update Form --> <td class="fw-semibold"><%= u.id %></td>
<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> <td>
<input <input type="text" name="first_name" value="<%= u.first_name %>" class="form-control form-control-sm" disabled />
type="text" </td>
name="title"
value="<%= u.title || '' %>"
class="form-control form-control-sm"
disabled
>
</td>
<td> <td>
<input <input type="text" name="last_name" value="<%= u.last_name %>" class="form-control form-control-sm" disabled />
type="text" </td>
name="first_name"
value="<%= u.first_name %>"
class="form-control form-control-sm"
disabled
>
</td>
<td> <td>
<input <input type="text" name="username" value="<%= u.username %>" class="form-control form-control-sm" disabled />
type="text" </td>
name="last_name"
value="<%= u.last_name %>"
class="form-control form-control-sm"
disabled
>
</td>
<td> <td>
<input <select name="role" class="form-select form-select-sm" disabled>
type="text" <option value="mitarbeiter" <%= u.role === "mitarbeiter" ? "selected" : "" %>>Mitarbeiter</option>
name="username" <option value="arzt" <%= u.role === "arzt" ? "selected" : "" %>>Arzt</option>
value="<%= u.username %>" <option value="admin" <%= u.role === "admin" ? "selected" : "" %>>Admin</option>
class="form-control form-control-sm" </select>
disabled </td>
>
</td>
<td> <td class="text-center">
<select name="role" class="form-select form-select-sm" disabled> <% if (u.active === 0) { %>
<option value="mitarbeiter" <%= u.role === "mitarbeiter" ? "selected" : "" %>> <span class="badge bg-secondary"><%= t.global.inactive %></span>
Mitarbeiter <% } else if (u.lock_until && new Date(u.lock_until) > new Date()) { %>
</option> <span class="badge bg-danger"><%= t.global.closed %></span>
<option value="arzt" <%= u.role === "arzt" ? "selected" : "" %>> <% } else { %>
Arzt <span class="badge bg-success"><%= t.global.active %></span>
</option> <% } %>
<option value="admin" <%= u.role === "admin" ? "selected" : "" %>> </td>
Admin
</option>
</select>
</td>
<td class="text-center"> <td class="d-flex gap-2 align-items-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 btn-sm save-btn" disabled>
<i class="bi bi-save"></i>
</button>
<!-- ✅ Save --> <!-- Edit -->
<button <button type="button" class="btn btn-outline-warning btn-sm lock-btn">
class="btn btn-outline-success icon-btn save-btn" <i class="bi bi-pencil-square"></i>
disabled </button>
title="Speichern"
>
<i class="bi bi-save"></i>
</button>
<!-- ✅ Unlock --> </form>
<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 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>
<% } %>
<!-- ✅ Aktiv / Deaktiv --> </td>
<% if (u.id !== currentUser.id) { %> </tr>
<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> </tbody>
</table> </table>
</div> </div>
</div><!-- /table-wrapper --> </div>
</div> </div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</body>
</html>

View File

@ -1,213 +1,43 @@
<!DOCTYPE html> <!-- KEIN layout, KEINE sidebar, KEIN main -->
<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" /> <%- include("partials/page-header", {
<link rel="stylesheet" href="/bootstrap-icons/bootstrap-icons.min.css" /> user,
title: t.dashboard.title,
subtitle: "",
showUserName: true,
hideDashboardButton: true
}) %>
<style> <div class="content p-4">
body {
margin: 0;
background: #f4f6f9;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Ubuntu;
}
.layout { <%- include("partials/flash") %>
display: flex;
min-height: 100vh;
}
/* Sidebar */ <div class="waiting-monitor">
.sidebar { <h5 class="mb-3">🪑 <%= t.dashboard.waitingRoom %></h5>
width: 240px;
background: #111827;
color: white;
padding: 20px;
display: flex;
flex-direction: column;
}
.logo { <div class="waiting-grid">
font-size: 18px; <% if (waitingPatients && waitingPatients.length > 0) { %>
font-weight: 700; <% waitingPatients.forEach(p => { %>
margin-bottom: 30px;
display: flex;
align-items: center;
gap: 10px;
}
.nav-item { <% if (user.role === "arzt") { %>
display: flex; <form method="POST" action="/patients/<%= p.id %>/call">
align-items: center; <button class="waiting-slot occupied clickable">
gap: 12px; <div><%= p.firstname %> <%= p.lastname %></div>
padding: 12px 15px; </button>
border-radius: 8px; </form>
color: #cbd5e1; <% } else { %>
text-decoration: none; <div class="waiting-slot occupied">
margin-bottom: 6px; <div><%= p.firstname %> <%= p.lastname %></div>
font-size: 14px; </div>
} <% } %>
.nav-item:hover { <% }) %>
background: #1f2937; <% } else { %>
color: white; <div class="text-muted">
} <%= t.dashboard.noWaitingPatients %>
.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> </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> </div>
</body> </div>
</html>
</div>

View File

@ -0,0 +1,57 @@
<%- include("../partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
subtitle: "",
showUserName: true
}) %>
<!-- CONTENT -->
<div class="container mt-4">
<%- include("../partials/flash") %>
<h4>Stornierte Rechnungen</h4>
<!-- ✅ Jahresfilter -->
<form method="GET" action="/invoices/cancelled" style="margin-bottom:20px;">
<label>Jahr:</label>
<select
name="year"
onchange="this.form.submit()"
class="form-select"
style="width:150px; display:inline-block;"
>
<% years.forEach(y => { %>
<option value="<%= y %>" <%= y == selectedYear ? "selected" : "" %>>
<%= y %>
</option>
<% }) %>
</select>
</form>
<% if (invoices.length === 0) { %>
<p>Keine stornierten Rechnungen für dieses Jahr.</p>
<% } else { %>
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Patient</th>
<th>Datum</th>
<th>Betrag</th>
</tr>
</thead>
<tbody>
<% invoices.forEach(inv => { %>
<tr>
<td><%= inv.id %></td>
<td><%= inv.firstname %> <%= inv.lastname %></td>
<td><%= inv.invoice_date_formatted %></td>
<td><%= inv.total_amount_formatted %> €</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>

View File

@ -0,0 +1,110 @@
<%- include("../partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
subtitle: "",
showUserName: true
}) %>
<!-- CONTENT -->
<div class="container mt-4">
<%- include("../partials/flash") %>
<h4>Gutschrift Übersicht</h4>
<!-- Filter -->
<form method="GET" action="/invoices/credits" style="margin-bottom:20px">
<label>Jahr:</label>
<select
name="year"
class="form-select"
style="width:150px; display:inline-block"
onchange="this.form.submit()"
>
<option value="0">Alle</option>
<% years.forEach(y => { %>
<option
value="<%= y %>"
<%= y == selectedYear ? "selected" : "" %>
>
<%= y %>
</option>
<% }) %>
</select>
</form>
<table class="table table-striped">
<thead>
<tr>
<th>Rechnung</th>
<th>Datum</th>
<th>PDF</th>
<th>Gutschrift</th>
<th>Datum</th>
<th>PDF</th>
<th>Patient</th>
<th>Betrag</th>
</tr>
</thead>
<tbody>
<% items.forEach(i => { %>
<tr>
<!-- Rechnung -->
<td>#<%= i.invoice_id %></td>
<td><%= i.invoice_date_fmt %></td>
<td>
<% if (i.invoice_file) { %>
<a
href="<%= i.invoice_file %>"
target="_blank"
class="btn btn-sm btn-outline-primary"
>
📄 Öffnen
</a>
<% } %>
</td>
<!-- Gutschrift -->
<td>#<%= i.credit_id %></td>
<td><%= i.credit_date_fmt %></td>
<td>
<% if (i.credit_file) { %>
<a
href="<%= i.credit_file %>"
target="_blank"
class="btn btn-sm btn-outline-danger"
>
📄 Öffnen
</a>
<% } %>
</td>
<!-- Patient -->
<td><%= i.firstname %> <%= i.lastname %></td>
<!-- Betrag -->
<td>
<%= i.invoice_amount_fmt %> € /
<%= i.credit_amount_fmt %> €
</td>
</tr>
<% }) %>
</tbody>
</table>

View File

@ -0,0 +1,75 @@
<%- include("../partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
subtitle: "",
showUserName: true
}) %>
<!-- CONTENT -->
<div class="container mt-4">
<%- include("../partials/flash") %>
<h4>Leistungen</h4>
<% if (invoices.length === 0) { %>
<p>Keine offenen Rechnungen 🎉</p>
<% } else { %>
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Patient</th>
<th>Datum</th>
<th>Betrag</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<% invoices.forEach(inv => { %>
<tr>
<td><%= inv.id %></td>
<td><%= inv.firstname %> <%= inv.lastname %></td>
<td><%= inv.invoice_date_formatted %></td>
<td><%= inv.total_amount_formatted %> €</td>
<td>offen</td>
<!-- ✅ AKTIONEN -->
<td style="text-align:right; white-space:nowrap;">
<!-- BEZAHLT -->
<form
action="/invoices/<%= inv.id %>/pay"
method="POST"
style="display:inline;"
onsubmit="return confirm('Rechnung wirklich als bezahlt markieren?');"
>
<button
type="submit"
class="btn btn-sm btn-success"
>
BEZAHLT
</button>
</form>
<!-- STORNO -->
<form
action="/invoices/<%= inv.id %>/cancel"
method="POST"
style="display:inline;"
onsubmit="return confirm('Rechnung wirklich stornieren?');"
>
<button
type="submit"
class="btn btn-sm btn-danger"
style="margin-left:6px;"
>
STORNO
</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div>

View File

@ -0,0 +1,102 @@
<%- include("../partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
subtitle: "",
showUserName: true
}) %>
<% if (query?.error === "already_credited") { %>
<div class="alert alert-warning">
⚠️ Für diese Rechnung existiert bereits eine Gutschrift.
</div>
<% } %>
<!-- CONTENT -->
<div class="container mt-4">
<%- include("../partials/flash") %>
<h4>Bezahlte Rechnungen</h4>
<!-- FILTER -->
<form
method="GET"
action="/invoices/paid"
style="margin-bottom:20px; display:flex; gap:15px;"
>
<!-- Jahr -->
<div>
<label>Jahr</label>
<select name="year" class="form-select" onchange="this.form.submit()">
<% years.forEach(y => { %>
<option value="<%= y %>" <%= y==selectedYear?"selected":"" %>>
<%= y %>
</option>
<% }) %>
</select>
</div>
<!-- Quartal -->
<div>
<label>Quartal</label>
<select name="quarter" class="form-select" onchange="this.form.submit()">
<option value="0">Alle</option>
<option value="1" <%= selectedQuarter==1?"selected":"" %>>Q1</option>
<option value="2" <%= selectedQuarter==2?"selected":"" %>>Q2</option>
<option value="3" <%= selectedQuarter==3?"selected":"" %>>Q3</option>
<option value="4" <%= selectedQuarter==4?"selected":"" %>>Q4</option>
</select>
</div>
</form>
<!-- GUTSCHRIFT BUTTON -->
<form
id="creditForm"
method="POST"
action=""
style="margin-bottom:15px;"
>
<button
id="creditBtn"
type="submit"
class="btn btn-warning"
disabled
>
Gutschrift erstellen
</button>
</form>
<!-- TABELLE -->
<table class="table table-hover">
<thead>
<tr>
<th>#</th>
<th>Patient</th>
<th>Datum</th>
<th>Betrag</th>
</tr>
</thead>
<tbody>
<% invoices.forEach(inv => { %>
<tr
class="invoice-row"
data-id="<%= inv.id %>"
style="cursor:pointer;"
>
<td><%= inv.id %></td>
<td><%= inv.firstname %> <%= inv.lastname %></td>
<td><%= inv.invoice_date_formatted %></td>
<td><%= inv.total_amount_formatted %> €</td>
</tr>
<% }) %>
</tbody>
</table>
<script src="/js/paid-invoices.js"></script>

47
views/layout.ejs Normal file
View File

@ -0,0 +1,47 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>
<%= typeof title !== "undefined" ? title : "Privatarzt Software" %>
</title>
<!-- ✅ Bootstrap -->
<link rel="stylesheet" href="/css/bootstrap.min.css" />
<!-- ✅ Icons -->
<link rel="stylesheet" href="/bootstrap-icons/bootstrap-icons.min.css" />
<!-- ✅ Dein CSS -->
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<div class="layout">
<!-- ✅ Sidebar dynamisch -->
<% if (typeof sidebarPartial !== "undefined" && sidebarPartial) { %>
<%- include(sidebarPartial, {
user,
active,
lang,
t,
patient: (typeof patient !== "undefined" ? patient : null),
backUrl: (typeof backUrl !== "undefined" ? backUrl : null)
}) %>
<% } %>
<!-- ✅ Main -->
<div class="main">
<%- body %>
</div>
</div>
<!-- ✅ externes JS (CSP safe) -->
<script src="/js/datetime.js"></script>
<script src="/js/patient-select.js" defer></script>
<!-- <script src="/js/patient_sidebar.js" defer></script> -->
</body>
</html>

View File

@ -1,165 +1,140 @@
<!DOCTYPE html> <%- include("partials/page-header", {
<html lang="de"> user,
<head> title: t.patienteoverview.patienttitle,
<meta charset="UTF-8" /> subtitle: "",
<title>Medikamentenübersicht</title> showUserName: true
<link rel="stylesheet" href="/css/bootstrap.min.css" /> }) %>
<script src="/js/services-lock.js"></script>
<style> <div class="content p-4">
input.form-control { box-shadow: none !important; }
input.form-control:disabled { <%- include("partials/flash") %>
background-color: #fff !important;
color: #212529 !important;
opacity: 1 !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
}
input.form-control:disabled:focus { <div class="container-fluid p-0">
box-shadow: none !important;
outline: none !important;
}
/* Inaktive Medikamente ROT */
tr.table-secondary > td {
background-color: #f8d7da !important;
}
</style>
</head>
<body class="bg-light"> <div class="card shadow">
<div class="card-body">
<nav class="navbar navbar-dark bg-dark position-relative px-3"> <!-- 🔍 Suche -->
<div class="position-absolute top-50 start-50 translate-middle d-flex align-items-center gap-2 text-white"> <form method="GET" action="/medications" class="row g-2 mb-3">
<span style="font-size:1.3rem">💊</span>
<span class="fw-semibold fs-5">Medikamentenübersicht</span>
</div>
<div class="ms-auto">
<a href="/dashboard" class="btn btn-outline-light btn-sm">⬅️ Dashboard</a>
</div>
</nav>
<div class="container mt-4"> <div class="col-md-6">
<%- include("partials/flash") %> <input
type="text"
name="q"
class="form-control"
placeholder="🔍 Suche nach Medikament, Form, Dosierung"
value="<%= query?.q || '' %>"
>
</div>
<div class="card shadow"> <div class="col-md-3 d-flex gap-2">
<div class="card-body"> <button class="btn btn-primary w-100">Suchen</button>
<a href="/medications" class="btn btn-secondary w-100">Reset</a>
</div>
<!-- 🔍 Suche --> <div class="col-md-3 d-flex align-items-center">
<form method="GET" action="/medications" class="row g-2 mb-3"> <div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="onlyActive"
value="1"
<%= query?.onlyActive === "1" ? "checked" : "" %>
>
<label class="form-check-label">
Nur aktive Medikamente
</label>
</div>
</div>
<div class="col-md-6"> </form>
<input type="text"
name="q"
class="form-control"
placeholder="🔍 Suche nach Medikament, Form, Dosierung"
value="<%= query?.q || '' %>">
</div>
<div class="col-md-3 d-flex gap-2"> <!-- Neu -->
<button class="btn btn-primary w-100">Suchen</button> <a href="/medications/create" class="btn btn-success mb-3">
<a href="/medications" class="btn btn-secondary w-100">Reset</a> Neues Medikament
</div> </a>
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm align-middle">
<thead class="table-dark">
<tr>
<th>Medikament</th>
<th>Darreichungsform</th>
<th>Dosierung</th>
<th>Packung</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<% rows.forEach(r => { %>
<tr class="<%= r.active ? '' : 'table-secondary' %>">
<!-- UPDATE-FORM -->
<form method="POST" action="/medications/update/<%= r.id %>">
<td><%= r.medication %></td>
<td><%= r.form %></td>
<td>
<input
type="text"
name="dosage"
value="<%= r.dosage %>"
class="form-control form-control-sm"
disabled
>
</td>
<td>
<input
type="text"
name="package"
value="<%= r.package %>"
class="form-control form-control-sm"
disabled
>
</td>
<td class="text-center">
<%= r.active ? "Aktiv" : "Inaktiv" %>
</td>
<td class="d-flex gap-2">
<button class="btn btn-sm btn-outline-success save-btn" disabled>
💾
</button>
<button type="button" class="btn btn-sm btn-outline-warning lock-btn">
🔓
</button>
</form>
<!-- TOGGLE-FORM -->
<form method="POST" action="/medications/toggle/<%= r.medication_id %>">
<button class="btn btn-sm <%= r.active ? 'btn-outline-danger' : 'btn-outline-success' %>">
<%= r.active ? "⛔" : "✅" %>
</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="col-md-3 d-flex align-items-center">
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="onlyActive"
value="1"
<%= query?.onlyActive === "1" ? "checked" : "" %>>
<label class="form-check-label">
Nur aktive Medikamente
</label>
</div> </div>
</div> </div>
</form>
<!-- Neu -->
<a href="/medications/create" class="btn btn-success mb-3">
Neues Medikament
</a>
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm align-middle">
<thead class="table-dark">
<tr>
<th>Medikament</th>
<th>Darreichungsform</th>
<th>Dosierung</th>
<th>Packung</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<% rows.forEach(r => { %>
<tr class="<%= r.active ? '' : 'table-secondary' %>">
<!-- UPDATE-FORM -->
<form method="POST" action="/medications/update/<%= r.id %>">
<td><%= r.medication %></td>
<td><%= r.form %></td>
<td>
<input type="text"
name="dosage"
value="<%= r.dosage %>"
class="form-control form-control-sm"
disabled>
</td>
<td>
<input type="text"
name="package"
value="<%= r.package %>"
class="form-control form-control-sm"
disabled>
</td>
<td class="text-center">
<%= r.active ? "Aktiv" : "Inaktiv" %>
</td>
<td class="d-flex gap-2">
<button class="btn btn-sm btn-outline-success save-btn" disabled>
💾
</button>
<button type="button"
class="btn btn-sm btn-outline-warning lock-btn">
🔓
</button>
</form>
<!-- TOGGLE-FORM (separat!) -->
<form method="POST" action="/medications/toggle/<%= r.medication_id %>">
<button class="btn btn-sm <%= r.active ? 'btn-outline-danger' : 'btn-outline-success' %>">
<%= r.active ? "⛔" : "✅" %>
</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div> </div>
</div> </div>
</div> <!-- ✅ Externes JS (Helmet/CSP safe) -->
</div> <script src="/js/services-lock.js"></script>
</body>
</html>

View File

@ -1,56 +1,49 @@
<!DOCTYPE html> <%- include("partials/page-header", {
<html lang="de"> user,
<head> title: "Offene Leistungen",
<meta charset="UTF-8" /> subtitle: "Offene Rechnungen",
<title>Offene Leistungen</title> showUserName: true
<link rel="stylesheet" href="/css/bootstrap.min.css" /> }) %>
</head>
<body>
<div class="container mt-4">
<!-- HEADER -->
<div class="position-relative mb-3">
<div
class="position-absolute top-50 start-50 translate-middle d-flex align-items-center gap-2"
>
<span style="font-size: 1.4rem">📄</span>
<h3 class="mb-0">Offene Rechnungen</h3>
</div>
<div class="text-end"> <div class="content p-4">
<a href="/dashboard" class="btn btn-outline-primary btn-sm">
⬅️ Dashboard
</a>
</div>
</div>
<% let currentPatient = null; %> <% if (!rows.length) { %> <div class="container-fluid p-0">
<% let currentPatient = null; %>
<% if (!rows.length) { %>
<div class="alert alert-success"> <div class="alert alert-success">
✅ Keine offenen Leistungen vorhanden ✅ Keine offenen Leistungen vorhanden
</div> </div>
<% } %> <% rows.forEach(r => { %> <% if (!currentPatient || currentPatient <% } %>
!== r.patient_id) { %> <% currentPatient = r.patient_id; %>
<hr /> <% rows.forEach(r => { %>
<h5 class="clearfix"> <% if (!currentPatient || currentPatient !== r.patient_id) { %>
👤 <%= r.firstname %> <%= r.lastname %> <% currentPatient = r.patient_id; %>
<hr />
<h5 class="clearfix">
👤 <%= r.firstname %> <%= r.lastname %>
<!-- 🧾 RECHNUNG ERSTELLEN -->
<form
method="POST"
action="/invoices/patients/<%= r.patient_id %>/create-invoice"
class="invoice-form d-inline float-end ms-2"
>
<button class="btn btn-sm btn-success">
🧾 Rechnung erstellen
</button>
</form>
</h5>
<!-- 🧾 RECHNUNG ERSTELLEN -->
<form
method="POST"
action="/patients/<%= r.patient_id %>/create-invoice"
class="invoice-form d-inline float-end ms-2"
>
<button class="btn btn-sm btn-success">🧾 Rechnung erstellen</button>
</form>
</h5>
<% } %> <% } %>
<!-- LEISTUNG --> <!-- LEISTUNG -->
<div <div class="border rounded p-2 mb-2 d-flex align-items-center gap-2 flex-wrap">
class="border rounded p-2 mb-2 d-flex align-items-center gap-2 flex-wrap" <strong class="flex-grow-1"><%= r.name %></strong>
>
<strong class="flex-grow-1"> <%= r.name %> </strong>
<!-- 🔢 MENGE --> <!-- 🔢 MENGE -->
<form <form
@ -65,7 +58,7 @@
step="1" step="1"
value="<%= r.quantity %>" value="<%= r.quantity %>"
class="form-control form-control-sm" class="form-control form-control-sm"
style="width: 70px" style="width:70px"
/> />
<button class="btn btn-sm btn-outline-primary">💾</button> <button class="btn btn-sm btn-outline-primary">💾</button>
</form> </form>
@ -82,7 +75,7 @@
name="price" name="price"
value="<%= Number(r.price).toFixed(2) %>" value="<%= Number(r.price).toFixed(2) %>"
class="form-control form-control-sm" class="form-control form-control-sm"
style="width: 100px" style="width:100px"
/> />
<button class="btn btn-sm btn-outline-primary">💾</button> <button class="btn btn-sm btn-outline-primary">💾</button>
</form> </form>
@ -97,10 +90,11 @@
</form> </form>
</div> </div>
<% }) %> <% }) %>
</div>
<!-- Externes JS --> </div>
<script src="/js/open-services.js"></script>
</body> </div>
</html>
<!-- ✅ Externes JS (Helmet safe) -->
<script src="/js/open-services.js"></script>

View File

@ -1,80 +1,96 @@
<%
const role = user?.role || "";
const isAdmin = role === "admin";
function lockClass(allowed) {
return allowed ? "" : "locked";
}
function hrefIfAllowed(allowed, url) {
return allowed ? url : "#";
}
%>
<div class="sidebar"> <div class="sidebar">
<!-- ✅ Logo + Sprachbuttons --> <div class="sidebar-title">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:30px;"> <h2>Admin</h2>
</div>
<!-- ✅ Logo -->
<div style="padding:20px; text-align:center;">
<div class="logo" style="margin:0;"> <div class="logo" style="margin:0;">
🔐 Admin Bereich 🩺 Praxis System
</div>
<!-- ✅ Sprache oben rechts -->
<div style="display:flex; gap:6px;">
<a
href="/lang/de"
class="btn btn-sm btn-outline-light <%= lang === 'de' ? 'active' : '' %>"
style="padding:2px 8px; font-size:12px;"
title="Deutsch"
>
DE
</a>
<a
href="/lang/es"
class="btn btn-sm btn-outline-light <%= lang === 'es' ? 'active' : '' %>"
style="padding:2px 8px; font-size:12px;"
title="Español"
>
ES
</a>
</div> </div>
</div> </div>
<% <div class="sidebar-menu">
const role = user?.role || null;
const isAdmin = role === "admin";
function hrefIfAllowed(allowed, href) { <!-- ✅ Firmendaten Verwaltung -->
return allowed ? href : "#"; <a
} href="<%= hrefIfAllowed(isAdmin, '/admin/company-settings') %>"
class="nav-item <%= active === 'companySettings' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>"
>
<i class="bi bi-people"></i> <%= t.adminSidebar.companysettings %>
<% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
function lockClass(allowed) { <!-- ✅ User Verwaltung -->
return allowed ? "" : "locked"; <a
} href="<%= hrefIfAllowed(isAdmin, '/admin/users') %>"
class="nav-item <%= active === 'users' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>"
>
<i class="bi bi-people"></i> <%= t.adminSidebar.user %>
<% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
function lockClick(allowed) { <!-- ✅ Rechnungsübersicht -->
return allowed ? "" : 'onclick="return false;"'; <a
} href="<%= hrefIfAllowed(isAdmin, '/admin/invoices') %>"
%> class="nav-item <%= active === 'invoice_overview' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>"
>
<i class="bi bi-calculator"></i> <%= t.adminSidebar.invocieoverview %>
<% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<!-- ✅ Userverwaltung -->
<a <!-- ✅ Seriennummer -->
href="<%= hrefIfAllowed(isAdmin, '/admin/users') %>" <a
class="nav-item <%= active === 'users' ? 'active' : '' %> <%= lockClass(isAdmin) %>" href="<%= hrefIfAllowed(isAdmin, '/admin/serial-number') %>"
<%- lockClick(isAdmin) %> class="nav-item <%= active === 'serialnumber' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>" title="<%= isAdmin ? '' : 'Nur Admin' %>"
> >
<i class="bi bi-people"></i> <%= t.adminSidebar.users %> <i class="bi bi-key"></i> <%= t.adminSidebar.seriennumber %>
<% if (!isAdmin) { %> <% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span> <span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %> <% } %>
</a>
<!-- ✅ Datenbank -->
<a
href="<%= hrefIfAllowed(isAdmin, '/admin/database') %>"
class="nav-item <%= active === 'database' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>"
>
<i class="bi bi-hdd-stack"></i> <%= t.adminSidebar.databasetable %>
<% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<!-- ✅ Logout -->
<a href="/logout" class="nav-item">
<i class="bi bi-box-arrow-right"></i> <%= t.sidebar.logout %>
</a> </a>
<!-- ✅ Datenbankverwaltung --> </div>
<a
href="<%= hrefIfAllowed(isAdmin, '/admin/database') %>"
class="nav-item <%= active === 'database' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
<%- lockClick(isAdmin) %>
title="<%= isAdmin ? '' : 'Nur Admin' %>"
>
<i class="bi bi-hdd-stack"></i> <%= t.adminSidebar.database %>
<% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<div class="spacer"></div>
<!-- ✅ Zurück zum Dashboard -->
<a href="/dashboard" class="nav-item">
<i class="bi bi-arrow-left"></i> Dashboard
</a>
</div> </div>

View File

@ -0,0 +1,34 @@
<%
const titleText = typeof title !== "undefined" ? title : "";
const subtitleText = typeof subtitle !== "undefined" ? subtitle : "";
const showUser = typeof showUserName !== "undefined" ? showUserName : true;
%>
<div class="page-header">
<!-- links -->
<div class="page-header-left"></div>
<!-- center -->
<div class="page-header-center">
<% if (showUser && user?.username) { %>
<div class="page-header-username">
<%=t.global.welcome%>, <%= user.title + " " + user.firstname + " " + user.lastname %>
</div>
<% } %>
<% if (titleText) { %>
<div class="page-header-title">
<%= titleText %>
<% if (subtitleText) { %>
<span class="page-header-subtitle"> - <%= subtitleText %></span>
<% } %>
</div>
<% } %>
</div>
<!-- rechts -->
<div class="page-header-right">
<span id="datetime" class="page-header-datetime"></span>
</div>
</div>

View File

@ -0,0 +1,67 @@
<%
const pid = patient?.id || null;
// ✅ Wenn wir in der Medikamentenseite sind → nur Zurück anzeigen
const onlyBack = active === "patient_medications";
%>
<div class="sidebar">
<!-- ✅ Logo -->
<div style="margin-bottom: 30px; display: flex; flex-direction: column; gap: 10px;">
<div style="padding: 20px; text-align: center">
<div class="logo" style="margin: 0">🩺 Praxis System</div>
</div>
</div>
<!-- ✅ Zurück (immer sichtbar) -->
<a href="<%= pid ? '/patients/' + pid + '/overview' : '/dashboard' %>" class="nav-item">
<i class="bi bi-arrow-left-circle"></i> Zurück
</a>
<% if (!onlyBack && pid) { %>
<div style="margin: 10px 0; border-top: 1px solid rgba(255, 255, 255, 0.12)"></div>
<!-- ✅ Medikamentenverwaltung -->
<a
href="/patients/<%= pid %>/medications?returnTo=overview"
class="nav-item <%= active === 'patient_medications' ? 'active' : '' %>"
>
<i class="bi bi-capsule"></i> Medikamentenverwaltung
</a>
<!-- ✅ Patient bearbeiten -->
<a
href="/patients/edit/<%= pid %>?returnTo=overview"
class="nav-item <%= active === 'patient_edit' ? 'active' : '' %>"
>
<i class="bi bi-pencil-square"></i> Patient bearbeiten
</a>
<!-- ✅ Ins Wartezimmer -->
<form method="POST" action="/patients/<%= pid %>/back-to-waiting-room">
<button
type="submit"
class="nav-item"
style="width:100%; border:none; background:transparent; text-align:left;"
>
<i class="bi bi-door-open"></i> Ins Wartezimmer
</button>
</form>
<!-- ✅ Entlassen -->
<form method="POST" action="/patients/<%= pid %>/discharge">
<button
type="submit"
class="nav-item"
style="width:100%; border:none; background:transparent; text-align:left;"
onclick="return confirm('Patient wirklich entlassen?')"
>
<i class="bi bi-check2-circle"></i> Entlassen
</button>
</form>
<% } %>
</div>

View File

@ -0,0 +1,140 @@
<%
// =========================
// BASISDATEN
// =========================
const role = user?.role || null;
// Arzt + Mitarbeiter dürfen Patienten bedienen
const canPatientArea = role === "arzt" || role === "mitarbeiter";
const pid = patient && patient.id ? patient.id : null;
const isActive = patient && patient.active ? true : false;
const isWaiting = patient && patient.waiting_room ? true : false;
const canUsePatient = canPatientArea && !!pid;
function lockClass(allowed) {
return allowed ? "" : "locked";
}
function hrefIfAllowed(allowed, href) {
return allowed ? href : "#";
}
%>
<div class="sidebar">
<!-- ✅ Logo -->
<div style="margin-bottom:30px; display:flex; flex-direction:column; gap:10px;">
<div style="padding:20px; text-align:center;">
<div class="logo" style="margin:0;">🩺 Praxis System</div>
</div>
</div>
<!-- ✅ Zurück -->
<a href="<%= backUrl || '/patients' %>" class="nav-item">
<i class="bi bi-arrow-left-circle"></i> Zurück
</a>
<div style="margin:10px 0; border-top:1px solid rgba(255,255,255,0.12);"></div>
<!-- ✅ Kein Patient gewählt -->
<% if (!pid) { %>
<div class="nav-item locked" style="opacity:0.7;">
<i class="bi bi-info-circle"></i> Bitte Patient auswählen
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
</div>
<% } %>
<!-- =========================
WARTEZIMMER
========================= -->
<% if (pid && canPatientArea) { %>
<% if (isWaiting) { %>
<div class="nav-item locked" style="opacity:0.75;">
<i class="bi bi-hourglass-split"></i> Wartet bereits
<span style="margin-left:auto;"><i class="bi bi-check-circle-fill"></i></span>
</div>
<% } else { %>
<form method="POST" action="/patients/waiting-room/<%= pid %>">
<button
type="submit"
class="nav-item"
style="width:100%; border:none; background:transparent; text-align:left;"
title="Patient ins Wartezimmer setzen"
>
<i class="bi bi-door-open"></i> Ins Wartezimmer
</button>
</form>
<% } %>
<% } else { %>
<div class="nav-item locked" style="opacity:0.7;">
<i class="bi bi-door-open"></i> Ins Wartezimmer
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
</div>
<% } %>
<!-- =========================
BEARBEITEN
========================= -->
<a
href="<%= hrefIfAllowed(canUsePatient, '/patients/edit/' + pid) %>"
class="nav-item <%= active === 'patient_edit' ? 'active' : '' %> <%= lockClass(canUsePatient) %>"
title="<%= canUsePatient ? '' : 'Bitte zuerst einen Patienten auswählen' %>"
>
<i class="bi bi-pencil-square"></i> Bearbeiten
<% if (!canUsePatient) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<!-- =========================
ÜBERSICHT (Dashboard)
========================= -->
<a
href="<%= hrefIfAllowed(canUsePatient, '/patients/' + pid) %>"
class="nav-item <%= active === 'patient_dashboard' ? 'active' : '' %> <%= lockClass(canUsePatient) %>"
title="<%= canUsePatient ? '' : 'Bitte zuerst einen Patienten auswählen' %>"
>
<i class="bi bi-clipboard2-heart"></i> Übersicht
<% if (!canUsePatient) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<!-- =========================
STATUS TOGGLE
========================= -->
<form
method="POST"
action="<%= canUsePatient ? (isActive ? '/patients/deactivate/' + pid : '/patients/activate/' + pid) : '#' %>"
>
<button
type="submit"
class="nav-item <%= lockClass(canUsePatient) %>"
style="width:100%; border:none; background:transparent; text-align:left;"
<%= canUsePatient ? '' : 'disabled' %>
title="<%= canUsePatient ? 'Status wechseln' : 'Bitte zuerst einen Patienten auswählen' %>"
>
<% if (isActive) { %>
<i class="bi bi-x-circle"></i> Patient sperren (Inaktiv)
<% } else { %>
<i class="bi bi-check-circle"></i> Patient aktivieren
<% } %>
<% if (!canUsePatient) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</button>
</form>
<div class="spacer"></div>
<!-- ✅ Logout -->
<a href="/logout" class="nav-item">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</div>

View File

@ -0,0 +1,101 @@
<div class="sidebar">
<div class="logo">
<i class="bi bi-person-lines-fill"></i>
Patient
</div>
<!-- ✅ Patient Badge -->
<% if (patient) { %>
<div class="patient-badge">
<div class="patient-name">
<strong><%= patient.firstname %> <%= patient.lastname %></strong>
</div>
</div>
<% } else { %>
<div class="patient-badge">
<div class="patient-name">
<strong>Kein Patient gewählt</strong>
</div>
<div class="patient-meta">
Bitte auswählen
</div>
</div>
<% } %>
</div>
<style>
.sidebar {
width: 240px;
background: #111827;
color: white;
padding: 20px;
display: flex;
flex-direction: column;
}
.logo {
font-size: 18px;
font-weight: 700;
margin-bottom: 18px;
display: flex;
align-items: center;
gap: 10px;
}
.patient-badge {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 12px;
margin-bottom: 15px;
}
.patient-name {
font-size: 14px;
margin-bottom: 4px;
}
.patient-meta {
font-size: 12px;
opacity: 0.85;
}
.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;
border: 0;
background: transparent;
width: 100%;
}
.nav-item:hover {
background: #1f2937;
color: white;
}
.nav-item.active {
background: #2563eb;
color: white;
}
.nav-item.disabled {
opacity: 0.45;
pointer-events: none;
}
.nav-btn {
cursor: pointer;
text-align: left;
}
.spacer {
flex: 1;
}
</style>

View File

@ -0,0 +1,177 @@
<%
// =========================
// BASISDATEN
// =========================
const role = user?.role || null;
// Arzt + Mitarbeiter dürfen Patienten bedienen
const canPatientArea = role === "arzt" || role === "mitarbeiter";
const pid = patient && patient.id ? patient.id : null;
const isActive = patient && patient.active ? true : false;
const isWaiting = patient && patient.waiting_room ? true : false;
const canUsePatient = canPatientArea && !!pid;
function lockClass(allowed) {
return allowed ? "" : "locked";
}
function hrefIfAllowed(allowed, href) {
return allowed ? href : "#";
}
%>
<div class="sidebar">
<!-- ✅ Logo -->
<div style="margin-bottom:30px; display:flex; flex-direction:column; gap:10px;">
<div style="padding:20px; text-align:center;">
<div class="logo" style="margin:0;">🩺 Praxis System</div>
</div>
</div>
<!-- ✅ Zurück -->
<a href="<%= backUrl || '/patients' %>" class="nav-item">
<i class="bi bi-arrow-left-circle"></i> <%= t.global.return %>
</a>
<div style="margin:10px 0; border-top:1px solid rgba(255,255,255,0.12);"></div>
<!-- ✅ Kein Patient gewählt -->
<% if (!pid) { %>
<div class="nav-item locked" style="opacity:0.7;">
<i class="bi bi-info-circle"></i> Bitte Patient auswählen
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
</div>
<% } %>
<!-- =========================
WARTEZIMMER
========================= -->
<% if (pid && canPatientArea) { %>
<% if (isWaiting) { %>
<div class="nav-item locked" style="opacity:0.75;">
<i class="bi bi-hourglass-split"></i> <%= t.global.waiting %>
<span style="margin-left:auto;"><i class="bi bi-check-circle-fill"></i></span>
</div>
<% } else { %>
<form method="POST" action="/patients/waiting-room/<%= pid %>">
<button
type="submit"
class="nav-item"
style="width:100%; border:none; background:transparent; text-align:left;"
title="Patient ins Wartezimmer setzen"
>
<i class="bi bi-door-open"></i><%= t.global.towaitingroom %>
</button>
</form>
<% } %>
<% } else { %>
<div class="nav-item locked" style="opacity:0.7;">
<i class="bi bi-door-open"></i> <%= t.global.towaitingroom %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
</div>
<% } %>
<!-- =========================
BEARBEITEN
========================= -->
<a
href="<%= hrefIfAllowed(canUsePatient, '/patients/edit/' + pid) %>"
class="nav-item <%= active === 'patient_edit' ? 'active' : '' %> <%= lockClass(canUsePatient) %>"
title="<%= canUsePatient ? '' : 'Bitte zuerst einen Patienten auswählen' %>"
>
<i class="bi bi-pencil-square"></i> <%= t.global.edit %>
<% if (!canUsePatient) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<!-- =========================
ÜBERSICHT (Dashboard)
========================= -->
<a
href="<%= hrefIfAllowed(canUsePatient, '/patients/' + pid) %>"
class="nav-item <%= active === 'patient_dashboard' ? 'active' : '' %> <%= lockClass(canUsePatient) %>"
title="<%= canUsePatient ? '' : 'Bitte zuerst einen Patienten auswählen' %>"
>
<i class="bi bi-clipboard2-heart"></i> <%= t.global.overview %>
<% if (!canUsePatient) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<!-- =========================
STATUS TOGGLE
========================= -->
<form
method="POST"
action="<%= canUsePatient ? (isActive ? '/patients/deactivate/' + pid : '/patients/activate/' + pid) : '#' %>"
>
<button
type="submit"
class="nav-item <%= lockClass(canUsePatient) %>"
style="width:100%; border:none; background:transparent; text-align:left;"
<%= canUsePatient ? '' : 'disabled' %>
title="<%= canUsePatient ? 'Status wechseln' : 'Bitte zuerst einen Patienten auswählen' %>"
>
<% if (isActive) { %>
<i class="bi bi-x-circle"></i> <%= t.patienteoverview.closepatient %>
<% } else { %>
<i class="bi bi-check-circle"></i> <%= t.patienteoverview.openpatient %>
<% } %>
<% if (!canUsePatient) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</button>
</form>
<!-- ✅ Upload -->
<div class="sidebar-upload <%= lockClass(canUsePatient) %>">
<div style="font-weight: 600; margin: 10px 0 6px 0; color: #e5e7eb">
<i class="bi bi-paperclip"></i> <%= t.global.fileupload %>
</div>
<% if (canUsePatient) { %>
<form
action="/patients/<%= pid %>/files"
method="POST"
enctype="multipart/form-data"
>
<% } %>
<input
id="sbUploadInput"
type="file"
name="file"
class="form-control form-control-sm mb-2"
<%= canUsePatient ? "" : "disabled" %>
required
/>
<button
id="sbUploadBtn"
type="submit"
class="btn btn-sm btn-outline-light w-100"
<%= canUsePatient ? "" : "disabled" %>
>
📎 <%= t.global.upload %>
<% if (!canUsePatient) { %>
<span class="ms-2"><i class="bi bi-lock-fill"></i></span>
<% } %>
</button>
<% if (canUsePatient) { %>
</form>
<% } %>
</div>
<div class="spacer"></div>
</div>

View File

@ -0,0 +1,20 @@
<div class="sidebar-empty">
<!-- ✅ Logo -->
<div
style="
margin-bottom: 30px;
display: flex;
flex-direction: column;
gap: 10px;
"
>
<div style="padding: 20px; text-align: center">
<div class="logo" style="margin: 0">🩺 Praxis System</div>
</div>
</div>
<!-- ✅ Zurück -->
<a href="<%= backUrl || '/patients' %>" class="nav-item">
<i class="bi bi-arrow-left-circle"></i> Zurück
</a>
</div>

View File

@ -0,0 +1,109 @@
<%
// =========================
// BASISDATEN
// =========================
const role = user?.role || null;
// ✅ Bereich 1: Arzt + Mitarbeiter
const canDoctorAndStaff = role === "arzt" || role === "mitarbeiter";
// Arzt + Mitarbeiter dürfen Patienten bedienen
const canPatientArea = role === "arzt" || role === "mitarbeiter";
const pid = patient && patient.id ? patient.id : null;
const isActive = patient && patient.active ? true : false;
const isWaiting = patient && patient.waiting_room ? true : false;
const canUsePatient = canPatientArea && !!pid;
function lockClass(allowed) {
return allowed ? "" : "locked";
}
function hrefIfAllowed(allowed, href) {
return allowed ? href : "#";
}
%>
<div class="sidebar">
<!-- ✅ Logo -->
<div style="margin-bottom:30px; display:flex; flex-direction:column; gap:10px;">
<div style="padding:20px; text-align:center;">
<div class="logo" style="margin:0;">🩺 Praxis System</div>
</div>
</div>
<!-- ✅ Zurück -->
<a href="<%= backUrl || '/patients' %>" class="nav-item">
<i class="bi bi-arrow-left-circle"></i> Zurück
</a>
<div style="margin:10px 0; border-top:1px solid rgba(255,255,255,0.12);"></div>
<!-- =========================
Rechnungen
========================= -->
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/open') %>"
class="nav-item <%= active === 'open_invoices' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-receipt"></i> <%= t.openinvoices.openinvoices %>
<% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/cancelled') %>"
class="nav-item <%= active === 'cancelled_invoices' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.openinvoices.canceledinvoices %>
<% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/reportview') %>"
class="nav-item <%= active === 'reportview' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.openinvoices.report %>
<% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/paid') %>"
class="nav-item <%= active === 'paid' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.openinvoices.payedinvoices %>
<% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/credit-overview') %>"
class="nav-item <%= active === 'credit-overview' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.openinvoices.creditoverview %>
<% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<div class="spacer"></div>
<!-- ✅ Logout -->
<a href="/logout" class="nav-item">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</div>

View File

@ -1,13 +1,17 @@
<div class="sidebar"> <div class="sidebar">
<!-- ✅ Logo + Sprachbuttons --> <!-- ✅ Logo + Sprachbuttons -->
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:30px;"> <div style="margin-bottom:30px; display:flex; flex-direction:column; gap:10px;">
<div class="logo" style="margin:0;">
🩺 Praxis System <!-- ✅ Zeile 1: Logo -->
<div style="padding:20px; text-align:center;">
<div class="logo" style="margin:0;">
🩺 Praxis System
</div>
</div> </div>
<!-- ✅ Sprache oben rechts --> <!-- ✅ Zeile 2: Sprache -->
<div style="display:flex; gap:6px;"> <div style="display:flex; gap:8px;">
<a <a
href="/lang/de" href="/lang/de"
class="btn btn-sm btn-outline-light <%= lang === 'de' ? 'active' : '' %>" class="btn btn-sm btn-outline-light <%= lang === 'de' ? 'active' : '' %>"
@ -26,17 +30,18 @@
ES ES
</a> </a>
</div> </div>
</div> </div>
<% <%
const role = user?.role || null; const role = user?.role || null;
// ✅ Regeln: // ✅ Regeln:
// Arztbereich: NUR arzt // ✅ Bereich 1: Arzt + Mitarbeiter
const canDoctorArea = role === "arzt"; const canDoctorAndStaff = role === "arzt" || role === "mitarbeiter";
// Verwaltung: NUR admin // ✅ Bereich 2: NUR Admin
const canAdminArea = role === "admin"; const canOnlyAdmin = role === "admin";
function hrefIfAllowed(allowed, href) { function hrefIfAllowed(allowed, href) {
return allowed ? href : "#"; return allowed ? href : "#";
@ -45,80 +50,73 @@
function lockClass(allowed) { function lockClass(allowed) {
return allowed ? "" : "locked"; return allowed ? "" : "locked";
} }
function lockClick(allowed) {
return allowed ? "" : 'onclick="return false;"';
}
%> %>
<!-- Patienten --> <!-- ✅ Patienten (Arzt + Mitarbeiter) -->
<a <a
href="<%= hrefIfAllowed(canDoctorArea, '/patients') %>" href="<%= hrefIfAllowed(canDoctorAndStaff, '/patients') %>"
class="nav-item <%= active === 'patients' ? 'active' : '' %> <%= lockClass(canDoctorArea) %>" class="nav-item <%= active === 'patients' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
<%- lockClick(canDoctorArea) %> title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
title="<%= canDoctorArea ? '' : 'Nur Arzt' %>"
> >
<i class="bi bi-people"></i> <%= t.sidebar.patients %> <i class="bi bi-people"></i> <%= t.sidebar.patients %>
<% if (!canDoctorArea) { %> <% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span> <span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %> <% } %>
</a> </a>
<!-- Medikamente --> <!-- Medikamente (Arzt + Mitarbeiter) -->
<a <a
href="<%= hrefIfAllowed(canDoctorArea, '/medications') %>" href="<%= hrefIfAllowed(canDoctorAndStaff, '/medications') %>"
class="nav-item <%= active === 'medications' ? 'active' : '' %> <%= lockClass(canDoctorArea) %>" class="nav-item <%= active === 'medications' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
<%- lockClick(canDoctorArea) %> title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
title="<%= canDoctorArea ? '' : 'Nur Arzt' %>"
> >
<i class="bi bi-capsule"></i> <%= t.sidebar.medications %> <i class="bi bi-capsule"></i> <%= t.sidebar.medications %>
<% if (!canDoctorArea) { %> <% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span> <span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %> <% } %>
</a> </a>
<!-- Offene Leistungen --> <!-- Offene Leistungen (Arzt + Mitarbeiter) -->
<a <a
href="<%= hrefIfAllowed(canDoctorArea, '/services/open') %>" href="<%= hrefIfAllowed(canDoctorAndStaff, '/services/open') %>"
class="nav-item <%= active === 'services' ? 'active' : '' %> <%= lockClass(canDoctorArea) %>" class="nav-item <%= active === 'services' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
<%- lockClick(canDoctorArea) %> title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
title="<%= canDoctorArea ? '' : 'Nur Arzt' %>"
> >
<i class="bi bi-receipt"></i> <%= t.sidebar.servicesOpen %> <i class="bi bi-receipt"></i> <%= t.sidebar.servicesOpen %>
<% if (!canDoctorArea) { %> <% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span> <span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %> <% } %>
</a> </a>
<!-- Abrechnung --> <!-- Abrechnung (Arzt + Mitarbeiter) -->
<a <a
href="<%= hrefIfAllowed(canDoctorArea, '/admin/invoices') %>" href="<%= hrefIfAllowed(canDoctorAndStaff, '/admin/invoices') %>"
class="nav-item <%= active === 'billing' ? 'active' : '' %> <%= lockClass(canDoctorArea) %>" class="nav-item <%= active === 'billing' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
<%- lockClick(canDoctorArea) %> title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
title="<%= canDoctorArea ? '' : 'Nur Arzt' %>"
> >
<i class="bi bi-cash-coin"></i> <%= t.sidebar.billing %> <i class="bi bi-cash-coin"></i> <%= t.sidebar.billing %>
<% if (!canDoctorArea) { %> <% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span> <span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %> <% } %>
</a> </a>
<!-- Verwaltung (nur Admin) --> <!-- Verwaltung (nur Admin) -->
<a <a
href="<%= hrefIfAllowed(canAdminArea, '/admin/users') %>" href="<%= hrefIfAllowed(canOnlyAdmin, '/admin/users') %>"
class="nav-item <%= active === 'admin' ? 'active' : '' %> <%= lockClass(canAdminArea) %>" class="nav-item <%= active === 'admin' ? 'active' : '' %> <%= lockClass(canOnlyAdmin) %>"
<%- lockClick(canAdminArea) %> title="<%= canOnlyAdmin ? '' : 'Nur Admin' %>"
title="<%= canAdminArea ? '' : 'Nur Admin' %>"
> >
<i class="bi bi-gear"></i> <%= t.sidebar.admin %> <i class="bi bi-gear"></i> <%= t.sidebar.admin %>
<% if (!canAdminArea) { %> <% if (!canOnlyAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span> <span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %> <% } %>
</a> </a>
<div class="spacer"></div> <div class="spacer"></div>
<!-- ✅ Logout -->
<a href="/logout" class="nav-item"> <a href="/logout" class="nav-item">
<i class="bi bi-box-arrow-right"></i> Logout <i class="bi bi-box-arrow-right"></i> <%= t.sidebar.logout %>
</a> </a>
</div> </div>

View File

@ -1,96 +1,108 @@
<!DOCTYPE html> <div class="layout">
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Patient bearbeiten</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/css/bootstrap.min.css">
</head>
<body class="bg-light">
<nav class="navbar navbar-dark bg-dark px-3"> <!-- ✅ Sidebar dynamisch über layout.ejs -->
<span class="navbar-brand">Patient bearbeiten</span> <!-- wird automatisch geladen -->
<a href="<%= returnTo === 'overview'
? `/patients/${patient.id}/overview`
: '/patients' %>" class="btn btn-outline-light btn-sm">
Zurück
</a>
</nav>
<div class="container mt-4"> <div class="main">
<%- include("partials/flash") %>
<div class="card shadow mx-auto" style="max-width: 700px;">
<div class="card-body">
<h4 class="mb-3"> <!-- ✅ Neuer Header -->
<%= patient.firstname %> <%= patient.lastname %> <%- include("partials/page-header", {
</h4> user,
title: "Patient bearbeiten",
subtitle: patient.firstname + " " + patient.lastname,
showUserName: true,
hideDashboardButton: false
}) %>
<div class="content">
<%- include("partials/flash") %>
<div class="container-fluid">
<div class="card shadow mx-auto" style="max-width: 700px;">
<div class="card-body">
<% if (error) { %> <% if (error) { %>
<div class="alert alert-danger"><%= error %></div> <div class="alert alert-danger"><%= error %></div>
<% } %> <% } %>
<form method="POST" action="/patients/edit/<%= patient.id %>?returnTo=<%= returnTo || '' %>"> <!-- ✅ POST geht auf /patients/update/:id -->
<form method="POST" action="/patients/update/<%= patient.id %>">
<div class="row"> <!-- ✅ returnTo per POST mitschicken -->
<div class="col-md-6 mb-2"> <input type="hidden" name="returnTo" value="<%= returnTo || '' %>">
<input class="form-control"
name="firstname"
value="<%= patient.firstname %>"
placeholder="Vorname"
required>
</div>
<div class="col-md-6 mb-2"> <div class="row">
<input class="form-control" <div class="col-md-6 mb-2">
name="lastname" <input
value="<%= patient.lastname %>" class="form-control"
placeholder="Nachname" name="firstname"
required> value="<%= patient.firstname %>"
</div> placeholder="Vorname"
required
/>
</div> </div>
<div class="row"> <div class="col-md-6 mb-2">
<div class="col-md-4 mb-2"> <input
<select class="form-select" name="gender"> class="form-control"
<option value="">Geschlecht</option> name="lastname"
<option value="m" <%= patient.gender === 'm' ? 'selected' : '' %>>Männlich</option> value="<%= patient.lastname %>"
<option value="w" <%= patient.gender === 'w' ? 'selected' : '' %>>Weiblich</option> placeholder="Nachname"
<option value="d" <%= patient.gender === 'd' ? 'selected' : '' %>>Divers</option> required
</select> />
</div> </div>
</div>
<div class="col-md-8 mb-2"> <div class="row">
<input class="form-control" <div class="col-md-4 mb-2">
type="date" <select class="form-select" name="gender">
name="birthdate" <option value="">Geschlecht</option>
value="<%= patient.birthdate ? new Date(patient.birthdate).toISOString().split('T')[0] : '' %>" <option value="m" <%= patient.gender === "m" ? "selected" : "" %>>Männlich</option>
required> <option value="w" <%= patient.gender === "w" ? "selected" : "" %>>Weiblich</option>
</div> <option value="d" <%= patient.gender === "d" ? "selected" : "" %>>Divers</option>
</select>
</div> </div>
<input class="form-control mb-2" name="email" value="<%= patient.email || '' %>" placeholder="E-Mail"> <div class="col-md-8 mb-2">
<input class="form-control mb-2" name="phone" value="<%= patient.phone || '' %>" placeholder="Telefon"> <input
class="form-control"
type="date"
name="birthdate"
value="<%= patient.birthdate ? new Date(patient.birthdate).toISOString().split('T')[0] : '' %>"
required
/>
</div>
</div>
<input class="form-control mb-2" name="street" value="<%= patient.street || '' %>" placeholder="Straße"> <input class="form-control mb-2" name="email" value="<%= patient.email || '' %>" placeholder="E-Mail" />
<input class="form-control mb-2" name="house_number" value="<%= patient.house_number || '' %>" placeholder="Hausnummer"> <input class="form-control mb-2" name="phone" value="<%= patient.phone || '' %>" placeholder="Telefon" />
<input class="form-control mb-2" name="postal_code" value="<%= patient.postal_code || '' %>" placeholder="PLZ">
<input class="form-control mb-2" name="city" value="<%= patient.city || '' %>" placeholder="Ort">
<input class="form-control mb-2" name="country" value="<%= patient.country || '' %>" placeholder="Land">
<textarea class="form-control mb-3" <input class="form-control mb-2" name="street" value="<%= patient.street || '' %>" placeholder="Straße" />
name="notes" <input class="form-control mb-2" name="house_number" value="<%= patient.house_number || '' %>" placeholder="Hausnummer" />
rows="4" <input class="form-control mb-2" name="postal_code" value="<%= patient.postal_code || '' %>" placeholder="PLZ" />
placeholder="Notizen"><%= patient.notes || '' %></textarea> <input class="form-control mb-2" name="city" value="<%= patient.city || '' %>" placeholder="Ort" />
<input class="form-control mb-2" name="country" value="<%= patient.country || '' %>" placeholder="Land" />
<textarea
class="form-control mb-3"
name="notes"
rows="4"
placeholder="Notizen"
><%= patient.notes || '' %></textarea>
<button class="btn btn-primary w-100">
Änderungen speichern
</button>
<button class="btn btn-primary w-100">
Änderungen speichern
</button>
</form> </form>
</div>
</div> </div>
</div>
</div>
</body> </div>
</html>
</div>
</div>
</div>

View File

@ -1,124 +1,148 @@
<!DOCTYPE html> <%- include("partials/page-header", {
<html lang="de"> user,
<head> title: "💊 Medikation",
<meta charset="UTF-8"> subtitle: patient.firstname + " " + patient.lastname,
<title>Medikation <%= patient.firstname %> <%= patient.lastname %></title> showUserName: true,
<meta name="viewport" content="width=device-width, initial-scale=1"> showDashboardButton: false
<link rel="stylesheet" href="/css/bootstrap.min.css"> }) %>
</head>
<body class="bg-light">
<% <div class="content">
/* =========================
HILFSFUNKTION
========================== */
function formatDate(d) {
return d ? new Date(d).toLocaleDateString("de-DE") : "-";
}
%>
<nav class="navbar navbar-dark bg-dark px-3"> <%- include("partials/flash") %>
<span class="navbar-brand">
💊 Medikation <%= patient.firstname %> <%= patient.lastname %>
</span>
<a href="<%= returnTo === 'overview' <div class="container-fluid">
? `/patients/${patient.id}/overview`
: '/patients' %>"
class="btn btn-outline-light btn-sm">
Zurück
</a>
</nav>
<div class="container mt-4"> <!-- ✅ Patient Info -->
<%- include("partials/flash") %> <div class="card shadow-sm mb-3 patient-box">
<!-- ========================= <div class="card-body">
FORMULAR (NUR ADMIN) <h5 class="mb-1">
========================== --> <%= patient.firstname %> <%= patient.lastname %>
<% if (user && user.role === 'arzt') { %> </h5>
<div class="text-muted small">
<div class="card shadow mb-4"> Geboren am:
<%= new Date(patient.birthdate).toLocaleDateString("de-DE") %>
<% } else { %>
<div class="alert alert-info">
Nur Administratoren dürfen Medikamente eintragen.
</div> </div>
</div>
</div>
<% } %> <div class="row g-3">
<!-- ========================= <!-- ✅ Medikament hinzufügen -->
AKTUELLE MEDIKATION <div class="col-lg-6">
========================== --> <div class="card shadow-sm h-100">
<div class="card-header fw-semibold">
Medikament zuweisen
</div>
<h4>Aktuelle Medikation</h4> <div class="card-body">
<table class="table table-bordered table-sm mt-3"> <form method="POST" action="/patients/<%= patient.id %>/medications/assign">
<thead class="table-light">
<tr>
<th>Medikament</th>
<th>Dosierung</th>
<th>Packung</th>
<th>Anweisung</th>
<th>Zeitraum</th>
<% if (user && user.role === 'arzt') { %>
<th>Aktionen</th>
<% } %>
</tr>
</thead>
<tbody> <div class="mb-2">
<label class="form-label">Medikament auswählen</label>
<select name="medication_variant_id" class="form-select" required>
<option value="">-- auswählen --</option>
<% meds.forEach(m => { %>
<option value="<%= m.id %>">
<%= m.medication %> | <%= m.form %> | <%= m.dosage %>
<% if (m.package) { %>
| <%= m.package %>
<% } %>
</option>
<% }) %>
</select>
</div>
<% if (!currentMeds || currentMeds.length === 0) { %> <div class="mb-2">
<label class="form-label">Dosierungsanweisung</label>
<input
type="text"
class="form-control"
name="dosage_instruction"
placeholder="z.B. 1-0-1"
/>
</div>
<tr> <div class="row g-2 mb-2">
<td colspan="6" class="text-center text-muted"> <div class="col-md-6">
Keine Medikation vorhanden <label class="form-label">Startdatum</label>
</td> <input type="date" class="form-control" name="start_date" />
</tr> </div>
<% } else { %> <div class="col-md-6">
<label class="form-label">Enddatum</label>
<input type="date" class="form-control" name="end_date" />
</div>
</div>
<% currentMeds.forEach(m => { %> <button class="btn btn-primary">
<tr> ✅ Speichern
<td><%= m.medication %> (<%= m.form %>)</td> </button>
<td><%= m.dosage %></td>
<td><%= m.package %></td>
<td><%= m.dosage_instruction || "-" %></td>
<td>
<%= formatDate(m.start_date) %>
<%= m.end_date ? formatDate(m.end_date) : "laufend" %>
</td>
<% if (user && user.role === 'arzt') { %> <a href="/patients/<%= patient.id %>/overview" class="btn btn-outline-secondary">
<td class="d-flex gap-1"> ⬅️ Zur Übersicht
</a>
<form method="POST" </form>
action="/patient-medications/end/<%= m.id %>?returnTo=<%= returnTo || '' %>">
<button class="btn btn-sm btn-warning">
⏹ Beenden
</button>
</form>
<form method="POST" </div>
action="/patient-medications/delete/<%= m.id %>?returnTo=<%= returnTo || '' %>" </div>
onsubmit="return confirm('Medikation wirklich löschen?')"> </div>
<button class="btn btn-sm btn-danger">
🗑️ Löschen
</button>
</form>
<!-- ✅ Aktuelle Medikation -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold">
📋 Aktuelle Medikation
</div>
<div class="card-body">
<% if (!currentMeds || currentMeds.length === 0) { %>
<div class="text-muted">
Keine Medikation vorhanden.
</div>
<% } else { %>
<div class="table-responsive">
<table class="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Medikament</th>
<th>Form</th>
<th>Dosierung</th>
<th>Anweisung</th>
<th>Von</th>
<th>Bis</th>
</tr>
</thead>
<tbody>
<% currentMeds.forEach(cm => { %>
<tr>
<td><%= cm.medication %></td>
<td><%= cm.form %></td>
<td><%= cm.dosage %></td>
<td><%= cm.dosage_instruction || "-" %></td>
<td>
<%= cm.start_date ? new Date(cm.start_date).toLocaleDateString("de-DE") : "-" %>
</td> </td>
<% } %> <td>
</tr> <%= cm.end_date ? new Date(cm.end_date).toLocaleDateString("de-DE") : "-" %>
<% }) %> </td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %> <% } %>
</tbody> </div>
</table> </div>
</div>
</div>
</div>
</div> </div>
</body>
</html>

View File

@ -1,45 +1,27 @@
<!DOCTYPE html> <div class="layout">
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>
Patientenübersicht <%= patient.firstname %> <%= patient.lastname %>
</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/bootstrap.min.css" />
<script src="/js/service-search.js"></script>
</head>
<body class="bg-light"> <!-- ✅ Sidebar: Patient -->
<!-- NAVBAR --> <!-- kommt automatisch über layout.ejs, wenn sidebarPartial gesetzt ist -->
<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"
>
<span style="font-size: 1.4rem">👨‍⚕️</span>
<span class="fw-semibold fs-5">
Patient <%= patient.firstname %> <%= patient.lastname %>
</span>
</div>
<div class="ms-auto"> <div class="main">
<form
method="POST" <!-- ✅ Neuer Header -->
action="/patients/<%= patient.id %>/waiting-room" <%- include("partials/page-header", {
onsubmit="return confirm('Patient ins Wartezimmer zurücksetzen?')" user,
> title: "Patient",
<button class="btn btn-warning btn-sm">🪑 Ins Wartezimmer</button> subtitle: patient.firstname + " " + patient.lastname,
</form> showUserName: true
</div> }) %>
</nav>
<div class="content p-4">
<div class="container mt-4">
<%- include("partials/flash") %> <%- include("partials/flash") %>
<!-- PATIENTENDATEN --> <!-- PATIENTENDATEN -->
<div class="card shadow mb-4"> <div class="card shadow-sm mb-3 patient-data-box">
<div class="card-body"> <div class="card-body">
<h4>Patientendaten</h4> <h4>Patientendaten</h4>
<table class="table table-sm"> <table class="table table-sm">
<tr> <tr>
<th>Vorname</th> <th>Vorname</th>
@ -52,8 +34,7 @@
<tr> <tr>
<th>Geburtsdatum</th> <th>Geburtsdatum</th>
<td> <td>
<%= patient.birthdate ? new <%= patient.birthdate ? new Date(patient.birthdate).toLocaleDateString("de-DE") : "-" %>
Date(patient.birthdate).toLocaleDateString("de-DE") : "-" %>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -68,53 +49,16 @@
</div> </div>
</div> </div>
<!-- AKTIONEN --> <!-- ✅ UNTERER BEREICH -->
<div class="d-flex gap-2 mb-4"> <div class="row g-3">
<a
href="/patients/<%= patient.id %>/medications?returnTo=overview"
class="btn btn-primary"
>
💊 Medikation verwalten
</a>
<a
href="/patients/edit/<%= patient.id %>?returnTo=overview"
class="btn btn-outline-info"
>
✏️ Patient bearbeiten
</a>
<form method="POST" action="/patients/<%= patient.id %>/discharge">
<button
class="btn btn-danger btn-sm"
onclick="return confirm('Patient wirklich entlassen?')"
>
✅ Entlassen
</button>
</form>
</div>
<!-- UNTERER BEREICH -->
<div
class="row g-3"
style="
height: calc(100vh - 520px);
min-height: 320px;
padding-bottom: 3rem;
overflow: hidden;
"
>
<!-- 📝 NOTIZEN --> <!-- 📝 NOTIZEN -->
<div class="col-lg-5 col-md-12 h-100"> <div class="col-lg-5 col-md-12">
<div class="card shadow h-100"> <div class="card shadow h-100">
<div class="card-body d-flex flex-column h-100"> <div class="card-body d-flex flex-column">
<h5>📝 Notizen</h5> <h5>📝 Notizen</h5>
<form <form method="POST" action="/patients/<%= patient.id %>/notes">
method="POST"
action="/patients/<%= patient.id %>/notes"
style="flex-shrink: 0"
>
<textarea <textarea
class="form-control mb-2" class="form-control mb-2"
name="note" name="note"
@ -122,59 +66,49 @@
style="resize: none" style="resize: none"
placeholder="Neue Notiz hinzufügen…" placeholder="Neue Notiz hinzufügen…"
></textarea> ></textarea>
<button class="btn btn-sm btn-primary"> <button class="btn btn-sm btn-primary">
Notiz speichern Notiz speichern
</button> </button>
</form> </form>
<hr class="my-2" style="flex-shrink: 0" /> <hr class="my-2" />
<div <div style="max-height: 320px; overflow-y: auto;">
style="
flex: 1 1 auto;
overflow-y: auto;
min-height: 0;
padding-bottom: 2rem;
"
>
<% if (!notes || notes.length === 0) { %> <% if (!notes || notes.length === 0) { %>
<p class="text-muted">Keine Notizen vorhanden</p> <p class="text-muted">Keine Notizen vorhanden</p>
<% } else { %> <% notes.forEach(n => { %> <% } else { %>
<div class="mb-3 p-2 border rounded bg-light"> <% notes.forEach(n => { %>
<div class="small text-muted"> <div class="mb-3 p-2 border rounded bg-light">
<%= new Date(n.created_at).toLocaleString("de-DE") %> <% if <div class="small text-muted">
(n.first_name && n.last_name) { %> <%= (n.title ? n.title <%= new Date(n.created_at).toLocaleString("de-DE") %>
+ " " : "") %><%= n.first_name %> <%= n.last_name %> <% } %> <% if (n.first_name && n.last_name) { %>
</div> <%= (n.title ? n.title + " " : "") %><%= n.first_name %> <%= n.last_name %>
<div><%= n.note %></div> <% } %>
</div> </div>
<% }) %> <% } %> <div><%= n.note %></div>
</div>
<% }) %>
<% } %>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 💊 MEDIKAMENT --> <!-- 💊 MEDIKAMENT -->
<div class="col-lg-3 col-md-6 h-100"> <div class="col-lg-3 col-md-6">
<div class="card shadow h-100"> <div class="card shadow h-100">
<div class="card-body"> <div class="card-body">
<h5>💊 Rezept erstellen</h5> <h5>💊 Rezept erstellen</h5>
<form <form method="POST" action="/patients/<%= patient.id %>/medications">
method="POST" <select name="medication_variant_id" class="form-select mb-2" required>
action="/patients/<%= patient.id %>/medications/assign"
>
<select
name="medication_variant_id"
class="form-select mb-2"
required
>
<option value="">Bitte auswählen…</option> <option value="">Bitte auswählen…</option>
<% medicationVariants.forEach(mv => { %> <% medicationVariants.forEach(mv => { %>
<option value="<%= mv.variant_id %>"> <option value="<%= mv.variant_id %>">
<%= mv.medication_name %> <%= mv.form_name %> <%= <%= mv.medication_name %> <%= mv.form_name %> <%= mv.dosage %>
mv.dosage %> </option>
</option>
<% }) %> <% }) %>
</select> </select>
@ -203,16 +137,12 @@
</div> </div>
<!-- 🧾 HEUTIGE LEISTUNGEN --> <!-- 🧾 HEUTIGE LEISTUNGEN -->
<div class="col-lg-4 col-md-6 h-100"> <div class="col-lg-4 col-md-6">
<div class="card shadow h-100"> <div class="card shadow h-100">
<div class="card-body d-flex flex-column h-100"> <div class="card-body d-flex flex-column">
<h5>🧾 Heutige Leistungen</h5> <h5>🧾 Heutige Leistungen</h5>
<form <form method="POST" action="/patients/<%= patient.id %>/services">
method="POST"
action="/patients/<%= patient.id %>/services"
style="flex-shrink: 0"
>
<input <input
type="text" type="text"
id="serviceSearch" id="serviceSearch"
@ -228,9 +158,9 @@
required required
> >
<% services.forEach(s => { %> <% services.forEach(s => { %>
<option value="<%= s.id %>"> <option value="<%= s.id %>">
<%= s.name %> <%= Number(s.price || 0).toFixed(2) %> € <%= s.name %> <%= Number(s.price || 0).toFixed(2) %> €
</option> </option>
<% }) %> <% }) %>
</select> </select>
@ -247,30 +177,28 @@
</button> </button>
</form> </form>
<hr class="my-2" style="flex-shrink: 0" /> <hr class="my-2" />
<div <div style="max-height: 320px; overflow-y: auto;">
style="
flex: 1 1 auto;
overflow-y: auto;
min-height: 0;
padding-bottom: 2rem;
"
>
<% if (!todayServices || todayServices.length === 0) { %> <% if (!todayServices || todayServices.length === 0) { %>
<p class="text-muted">Noch keine Leistungen für heute.</p> <p class="text-muted">Noch keine Leistungen für heute.</p>
<% } else { %> <% todayServices.forEach(ls => { %> <% } else { %>
<div class="border rounded p-2 mb-2 bg-light"> <% todayServices.forEach(ls => { %>
<strong><%= ls.name %></strong><br /> <div class="border rounded p-2 mb-2 bg-light">
Menge: <%= ls.quantity %><br /> <strong><%= ls.name %></strong><br />
Preis: <%= Number(ls.price).toFixed(2) %> € Menge: <%= ls.quantity %><br />
</div> Preis: <%= Number(ls.price).toFixed(2) %> €
<% }) %> <% } %> </div>
<% }) %>
<% } %>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</body> </div>
</html> </div>

View File

@ -1,163 +1,162 @@
<!DOCTYPE html> <div class="layout">
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Patientenübersicht</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/bootstrap.min.css" />
</head>
<body class="bg-light"> <div class="main">
<!-- 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"
>
<span style="font-size: 1.4rem">👥</span>
<span class="fw-semibold fs-5">Patientenübersicht</span>
</div>
<div class="ms-auto"> <!-- ✅ Neuer globaler Header -->
<a href="/dashboard" class="btn btn-outline-primary btn-sm"> <%- include("partials/page-header", {
⬅️ Dashboard user,
</a> title: "Patientenübersicht",
</div> subtitle: patient.firstname + " " + patient.lastname,
</nav> showUserName: true,
hideDashboardButton: false
}) %>
<div class="container mt-4"> <div class="content">
<!-- PATIENT INFO -->
<div class="card shadow mb-4">
<div class="card-body">
<h4>👤 <%= patient.firstname %> <%= patient.lastname %></h4>
<p class="text-muted mb-3"> <%- include("partials/flash") %>
Geboren am <%= new
Date(patient.birthdate).toLocaleDateString("de-DE") %>
</p>
<ul class="list-group"> <div class="container-fluid mt-3">
<li class="list-group-item">
<strong>E-Mail:</strong> <%= patient.email || "-" %>
</li>
<li class="list-group-item">
<strong>Telefon:</strong> <%= patient.phone || "-" %>
</li>
<li class="list-group-item">
<strong>Adresse:</strong>
<%= patient.street || "" %> <%= patient.house_number || "" %>, <%=
patient.postal_code || "" %> <%= patient.city || "" %>
</li>
</ul>
</div>
</div>
<!-- ========================= <!-- =========================
MEDIKAMENTE & RECHNUNGEN PATIENT INFO
========================== --> ========================== -->
<div <div class="card shadow mb-4">
class="row g-3" <div class="card-body">
style=" <h4 class="mb-1">👤 <%= patient.firstname %> <%= patient.lastname %></h4>
height: calc(100vh - 420px);
min-height: 300px;
padding-bottom: 3rem;
overflow: hidden;
"
>
<!-- 💊 MEDIKAMENTE -->
<div class="col-lg-6 h-100">
<div class="card shadow h-100">
<div class="card-body d-flex flex-column h-100">
<h5>💊 Aktuelle Medikamente</h5>
<div <p class="text-muted mb-3">
style=" Geboren am <%= new Date(patient.birthdate).toLocaleDateString("de-DE") %>
flex: 1 1 auto; </p>
overflow-y: auto;
min-height: 0; <ul class="list-group">
padding-bottom: 1.5rem; <li class="list-group-item">
" <strong>E-Mail:</strong> <%= patient.email || "-" %>
> </li>
<% if (medications.length === 0) { %> <li class="list-group-item">
<p class="text-muted">Keine aktiven Medikamente</p> <strong>Telefon:</strong> <%= patient.phone || "-" %>
<% } else { %> </li>
<table class="table table-sm table-bordered mt-2"> <li class="list-group-item">
<thead class="table-light"> <strong>Adresse:</strong>
<tr> <%= patient.street || "" %> <%= patient.house_number || "" %>,
<th>Medikament</th> <%= patient.postal_code || "" %> <%= patient.city || "" %>
<th>Variante</th> </li>
<th>Anweisung</th> </ul>
</tr>
</thead>
<tbody>
<% medications.forEach(m => { %>
<tr>
<td><%= m.medication_name %></td>
<td><%= m.variant_dosage %></td>
<td><%= m.dosage_instruction || "-" %></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div>
</div>
</div> </div>
</div> </div>
<!-- 🧾 RECHNUNGEN --> <!-- =========================
<div class="col-lg-6 h-100"> MEDIKAMENTE & RECHNUNGEN
<div class="card shadow h-100"> ========================== -->
<div class="card-body d-flex flex-column h-100"> <div
<h5>🧾 Rechnungen</h5> class="row g-3"
style="
height: calc(100vh - 420px);
min-height: 300px;
padding-bottom: 3rem;
overflow: hidden;
"
>
<!-- 💊 MEDIKAMENTE -->
<div class="col-lg-6 h-100">
<div class="card shadow h-100">
<div class="card-body d-flex flex-column h-100">
<h5>💊 Aktuelle Medikamente</h5>
<div
style="
flex: 1 1 auto;
overflow-y: auto;
min-height: 0;
padding-bottom: 1.5rem;
"
>
<% if (medications.length === 0) { %>
<p class="text-muted">Keine aktiven Medikamente</p>
<% } else { %>
<table class="table table-sm table-bordered mt-2">
<thead class="table-light">
<tr>
<th>Medikament</th>
<th>Variante</th>
<th>Anweisung</th>
</tr>
</thead>
<tbody>
<% medications.forEach(m => { %>
<tr>
<td><%= m.medication_name %></td>
<td><%= m.variant_dosage %></td>
<td><%= m.dosage_instruction || "-" %></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div>
<div
style="
flex: 1 1 auto;
overflow-y: auto;
min-height: 0;
padding-bottom: 1.5rem;
"
>
<% if (invoices.length === 0) { %>
<p class="text-muted">Keine Rechnungen vorhanden</p>
<% } else { %>
<table class="table table-sm table-bordered mt-2">
<thead class="table-light">
<tr>
<th>Datum</th>
<th>Betrag</th>
<th>PDF</th>
</tr>
</thead>
<tbody>
<% invoices.forEach(i => { %>
<tr>
<td>
<%= new Date(i.invoice_date).toLocaleDateString("de-DE")
%>
</td>
<td><%= Number(i.total_amount).toFixed(2) %> €</td>
<td>
<% if (i.file_path) { %>
<a
href="<%= i.file_path %>"
target="_blank"
class="btn btn-sm btn-outline-primary"
>
📄 Öffnen
</a>
<% } else { %> - <% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div> </div>
</div> </div>
</div> </div>
<!-- 🧾 RECHNUNGEN -->
<div class="col-lg-6 h-100">
<div class="card shadow h-100">
<div class="card-body d-flex flex-column h-100">
<h5>🧾 Rechnungen</h5>
<div
style="
flex: 1 1 auto;
overflow-y: auto;
min-height: 0;
padding-bottom: 1.5rem;
"
>
<% if (invoices.length === 0) { %>
<p class="text-muted">Keine Rechnungen vorhanden</p>
<% } else { %>
<table class="table table-sm table-bordered mt-2">
<thead class="table-light">
<tr>
<th>Datum</th>
<th>Betrag</th>
<th>PDF</th>
</tr>
</thead>
<tbody>
<% invoices.forEach(i => { %>
<tr>
<td><%= new Date(i.invoice_date).toLocaleDateString("de-DE") %></td>
<td><%= Number(i.total_amount).toFixed(2) %> €</td>
<td>
<% if (i.file_path) { %>
<a
href="<%= i.file_path %>"
target="_blank"
class="btn btn-sm btn-outline-primary"
>
📄 Öffnen
</a>
<% } else { %>
-
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</body> </div>
</html> </div>

View File

@ -1,130 +1,134 @@
<!DOCTYPE html> <%- include("partials/page-header", {
<html lang="de"> user,
<head> title: t.patienteoverview.patienttitle,
<meta charset="UTF-8" /> subtitle: "",
<title>Patientenübersicht</title> showUserName: true
<meta name="viewport" content="width=device-width, initial-scale=1" /> }) %>
<link rel="stylesheet" href="/css/bootstrap.min.css" />
</head>
<body class="bg-light">
<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.4rem">👥</span>
<span class="fw-semibold fs-5">Patientenübersicht</span>
</div>
<!-- 🔵 RECHTS: DASHBOARD --> <div class="content p-4">
<div class="ms-auto">
<a href="/dashboard" class="btn btn-outline-primary btn-sm">
⬅️ Dashboard
</a>
</div>
</nav>
<div class="container-fluid mt-4"> <%- include("partials/flash") %>
<%- include("partials/flash") %>
<!-- Aktionen oben --> <!-- Aktionen oben -->
<div class="d-flex gap-2 mb-3"> <div class="d-flex gap-2 mb-3">
<a href="/patients/create" class="btn btn-success"> + Neuer Patient </a> <a href="/patients/create" class="btn btn-success">
</div> + <%= t.patienteoverview.newpatient %>
</a>
</div>
<div class="card shadow"> <div class="card shadow">
<div class="card-body"> <div class="card-body">
<!-- Suchformular -->
<form method="GET" action="/patients" class="row g-2 mb-4">
<div class="col-md-3">
<input
type="text"
name="firstname"
class="form-control"
placeholder="Vorname"
value="<%= query?.firstname || '' %>"
/>
</div>
<div class="col-md-3"> <!-- Suchformular -->
<input <form method="GET" action="/patients" class="row g-2 mb-4">
type="text" <div class="col-md-3">
name="lastname" <input
class="form-control" type="text"
placeholder="Nachname" name="firstname"
value="<%= query?.lastname || '' %>" class="form-control"
/> placeholder="<%= t.global.firstname %>"
</div> value="<%= query?.firstname || '' %>"
/>
</div>
<div class="col-md-3"> <div class="col-md-3">
<input <input
type="date" type="text"
name="birthdate" name="lastname"
class="form-control" class="form-control"
value="<%= query?.birthdate || '' %>" placeholder="<%= t.global.lastname %>"
/> value="<%= query?.lastname || '' %>"
</div> />
</div>
<div class="col-md-3 d-flex gap-2"> <div class="col-md-3">
<button class="btn btn-primary w-100">Suchen</button> <input
<a href="/patients" class="btn btn-secondary w-100"> type="date"
Zurücksetzen name="birthdate"
</a> class="form-control"
</div> value="<%= query?.birthdate || '' %>"
</form> />
</div>
<!-- Tabelle --> <div class="col-md-3 d-flex gap-2">
<div class="table-responsive"> <button class="btn btn-primary w-100"><%= t.global.search %></button>
<table <a href="/patients" class="btn btn-secondary w-100">
class="table table-bordered table-hover align-middle table-sm" <%= t.global.reset2 %>
> </a>
<thead class="table-dark"> </div>
</form>
<!-- ✅ EINE Form für ALLE Radiobuttons -->
<form method="GET" action="/patients">
<!-- Filter beibehalten -->
<input type="hidden" name="firstname" value="<%= query?.firstname || '' %>">
<input type="hidden" name="lastname" value="<%= query?.lastname || '' %>">
<input type="hidden" name="birthdate" value="<%= query?.birthdate || '' %>">
<div class="table-responsive">
<table class="table table-bordered table-hover align-middle table-sm">
<thead class="table-dark">
<tr>
<th style="width:40px;"></th>
<th>ID</th>
<th><%= t.global.name %></th>
<th>DNI</th>
<th><%= t.global.gender %></th>
<th><%= t.global.birthday %></th>
<th><%= t.global.email %></th>
<th><%= t.global.phone %></th>
<th><%= t.global.address %></th>
<th><%= t.global.country %></th>
<th><%= t.global.status %></th>
<th><%= t.global.notice %></th>
<th><%= t.global.create %></th>
<th><%= t.global.change %></th>
</tr>
</thead>
<tbody>
<% if (patients.length === 0) { %>
<tr> <tr>
<th>ID</th> <td colspan="15" class="text-center text-muted">
<th>Name</th> <%= t.patientoverview.nopatientfound %>
<th>N.I.E. / DNI</th>
<th>Geschlecht</th>
<th>Geburtstag</th>
<th>E-Mail</th>
<th>Telefon</th>
<th>Adresse</th>
<th>Land</th>
<th>Status</th>
<th>Notizen</th>
<th>Erstellt</th>
<th>Geändert</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<% if (patients.length === 0) { %>
<tr>
<td colspan="13" class="text-center text-muted">
Keine Patienten gefunden
</td> </td>
</tr> </tr>
<% } %> <% patients.forEach(p => { %> <% } %>
<% patients.forEach(p => { %>
<tr> <tr>
<!-- ✅ EIN Radiobutton korrekt gruppiert -->
<td class="text-center">
<input
class="patient-radio"
type="radio"
name="selectedPatientId"
value="<%= p.id %>"
<%= selectedPatientId === p.id ? "checked" : "" %>
onchange="this.form.submit()"
/>
</td>
<td><%= p.id %></td> <td><%= p.id %></td>
<td><strong><%= p.firstname %> <%= p.lastname %></strong></td> <td><strong><%= p.firstname %> <%= p.lastname %></strong></td>
<td><%= p.dni || "-" %></td> <td><%= p.dni || "-" %></td>
<td> <td>
<% if (p.gender === 'm') { %>m <% } else if (p.gender === <%= p.gender === 'm' ? 'm' :
'w') { %>w <% } else if (p.gender === 'd') { %>d <% } else { p.gender === 'w' ? 'w' :
%>-<% } %> p.gender === 'd' ? 'd' : '-' %>
</td>
<td>
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
</td> </td>
<td><%= new Date(p.birthdate).toLocaleDateString("de-DE") %></td>
<td><%= p.email || "-" %></td> <td><%= p.email || "-" %></td>
<td><%= p.phone || "-" %></td> <td><%= p.phone || "-" %></td>
<td> <td>
<%= p.street || "" %> <%= p.house_number || "" %><br /> <%= p.street || "" %> <%= p.house_number || "" %><br>
<%= p.postal_code || "" %> <%= p.city || "" %> <%= p.postal_code || "" %> <%= p.city || "" %>
</td> </td>
@ -132,139 +136,23 @@
<td> <td>
<% if (p.active) { %> <% if (p.active) { %>
<span class="badge bg-success">Aktiv</span> <span class="badge bg-success">Aktiv</span>
<% } else { %> <% } else { %>
<span class="badge bg-secondary">Inaktiv</span> <span class="badge bg-secondary">Inaktiv</span>
<% } %> <% } %>
</td> </td>
<td style="max-width: 200px"> <td><%= p.notes ? p.notes.substring(0, 80) : "-" %></td>
<%= p.notes ? p.notes.substring(0, 80) : "-" %>
</td>
<td><%= new Date(p.created_at).toLocaleString("de-DE") %></td> <td><%= new Date(p.created_at).toLocaleString("de-DE") %></td>
<td><%= new Date(p.updated_at).toLocaleString("de-DE") %></td> <td><%= new Date(p.updated_at).toLocaleString("de-DE") %></td>
<!-- AKTIONEN -->
<td class="text-nowrap">
<div class="dropdown">
<button
class="btn btn-sm btn-outline-secondary"
data-bs-toggle="dropdown"
>
Auswahl ▾
</button>
<ul
class="dropdown-menu dropdown-menu-end position-fixed"
>
<!-- ✏️ BEARBEITEN -->
<li>
<a
class="dropdown-item"
href="/patients/edit/<%= p.id %>"
>
✏️ Bearbeiten
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<!-- 🪑 WARTEZIMMER -->
<% if (p.waiting_room) { %>
<li>
<span class="dropdown-item text-muted">
🪑 Wartet bereits
</span>
</li>
<% } else { %>
<li>
<form
method="POST"
action="/patients/waiting-room/<%= p.id %>"
>
<button class="dropdown-item">
🪑 Ins Wartezimmer
</button>
</form>
</li>
<% } %>
<li><hr class="dropdown-divider" /></li>
<!-- 💊 MEDIKAMENTE -->
<li>
<a
class="dropdown-item"
href="/patients/<%= p.id %>/medications"
>
💊 Medikamente
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<!-- 🔒 STATUS -->
<li>
<% if (p.active) { %>
<form
method="POST"
action="/patients/deactivate/<%= p.id %>"
>
<button class="dropdown-item text-warning">
🔒 Sperren
</button>
</form>
<% } else { %>
<form
method="POST"
action="/patients/activate/<%= p.id %>"
>
<button class="dropdown-item text-success">
🔓 Entsperren
</button>
</form>
<% } %>
</li>
<!-- 📋 ÜBERSICHT -->
<li>
<a class="dropdown-item" href="/patients/<%= p.id %>">
📋 Übersicht
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<!-- 📎 DATEI-UPLOAD -->
<li class="px-3 py-2">
<form
method="POST"
action="/patients/<%= p.id %>/files"
enctype="multipart/form-data"
>
<input
type="file"
name="file"
class="form-control form-control-sm mb-2"
required
/>
<button class="btn btn-sm btn-secondary w-100">
📎 Hochladen
</button>
</form>
</li>
</ul>
</div>
</td>
</tr> </tr>
<% }) %> <% }) %>
</tbody> </tbody>
</table>
</div> </table>
</div> </div>
</div>
</form>
</div> </div>
<script src="/js/bootstrap.bundle.min.js"></script> </div>
</body> </div>
</html>

69
views/reportview.ejs Normal file
View File

@ -0,0 +1,69 @@
<%- include("partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
subtitle: "",
showUserName: true
}) %>
<!-- CONTENT -->
<div class="container mt-4">
<%- include("partials/flash") %>
<h4>Abrechungsreport</h4>
<form
method="GET"
action="/reports"
style="margin-bottom:25px; display:flex; gap:15px; align-items:end;"
>
<!-- Jahr -->
<div>
<label>Jahr</label>
<select
name="year"
class="form-select"
onchange="this.form.submit()"
>
<% years.forEach(y => { %>
<option
value="<%= y %>"
<%= y == selectedYear ? "selected" : "" %>
>
<%= y %>
</option>
<% }) %>
</select>
</div>
<!-- Quartal -->
<div>
<label>Quartal</label>
<select
name="quarter"
class="form-select"
onchange="this.form.submit()"
>
<option value="0">Alle</option>
<option value="1" <%= selectedQuarter == 1 ? "selected" : "" %>>Q1</option>
<option value="2" <%= selectedQuarter == 2 ? "selected" : "" %>>Q2</option>
<option value="3" <%= selectedQuarter == 3 ? "selected" : "" %>>Q3</option>
<option value="4" <%= selectedQuarter == 4 ? "selected" : "" %>>Q4</option>
</select>
</div>
</form>
<div style="max-width: 400px; margin: auto">
<canvas id="statusChart"></canvas>
<div id="custom-legend" class="chart-legend"></div>
</div>
<!-- ✅ JSON-Daten sicher speichern -->
<script id="stats-data" type="application/json">
<%- JSON.stringify(stats) %>
</script>
<!-- Externe Scripts -->
<script src="/js/chart.js"></script>
<script src="/js/reports.js"></script>

View File

@ -0,0 +1,56 @@
<div class="layout">
<!-- ✅ Admin Sidebar -->
<%- include("partials/admin-sidebar", { user, active: "serialnumber", lang }) %>
<div class="main">
<!-- ✅ Header -->
<%- include("partials/page-header", {
user,
title: "Seriennummer",
subtitle: "Lizenz aktivieren",
showUserName: true
}) %>
<div class="content" style="max-width:650px; margin:30px auto;">
<h2>🔑 <%= t.seriennumber.seriennumbertitle %></h2>
<p style="color:#777;">
<%= t.seriennumber.seriennumbertext %>
</p>
<% if (error) { %>
<div class="alert alert-danger"><%= error %></div>
<% } %>
<% if (success) { %>
<div class="alert alert-success"><%= success %></div>
<% } %>
<form method="POST" action="/admin/serial-number" style="max-width: 500px;">
<div class="form-group">
<label><%= t.seriennumber.seriennumbershort %></label>
<input
type="text"
name="serial_number"
value="<%= currentSerial || '' %>"
class="form-control"
placeholder="ABCDE-12345-ABCDE-12345"
maxlength="23"
required
/>
<small style="color:#777; display:block; margin-top:6px;">
<%= t.seriennumber.seriennumberdeclaration %>
</small>
</div>
<button class="btn btn-primary" style="margin-top: 15px;">
<%= t.seriennumber.saveseriennumber %>
</button>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,108 @@
<div class="layout">
<div class="main">
<!-- ✅ Header -->
<%- include("partials/page-header", {
user,
title: "Testphase",
subtitle: "Trial Version",
showUserName: true
}) %>
<div class="content" style="max-width:1100px; margin:30px auto;">
<div
style="
display:grid;
grid-template-columns: repeat(3, 1fr);
gap:16px;
"
>
<!-- ✅ Deutsch -->
<div
style="
border:1px solid #ddd;
border-radius:14px;
padding:18px;
background:#fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
display:flex;
flex-direction:column;
"
>
<h4 style="margin:0 0 10px 0;">🇩🇪 Deutsch</h4>
<p style="margin:0; color:#444; line-height:1.5;">
Vielen Dank, dass Sie unsere Software testen.<br />
Ihre Testphase ist aktiv und läuft noch <b><%= daysLeft %> Tage</b>.<br /><br />
Nach Ablauf der Testphase muss der Administrator eine gültige Seriennummer hinterlegen.
</p>
<div style="margin-top:auto; padding-top:16px;">
<a href="/dashboard" class="btn btn-primary w-100">
Zum Dashboard
</a>
</div>
</div>
<!-- ✅ English -->
<div
style="
border:1px solid #ddd;
border-radius:14px;
padding:18px;
background:#fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
display:flex;
flex-direction:column;
"
>
<h4 style="margin:0 0 10px 0;">🇬🇧 English</h4>
<p style="margin:0; color:#444; line-height:1.5;">
Thank you for testing our software.<br />
Your trial period is active and will run for <b><%= daysLeft %> more days</b>.<br /><br />
After the trial expires, the administrator must enter a valid serial number.
</p>
<div style="margin-top:auto; padding-top:16px;">
<a href="/dashboard" class="btn btn-primary w-100">
Go to Dashboard
</a>
</div>
</div>
<!-- ✅ Español -->
<div
style="
border:1px solid #ddd;
border-radius:14px;
padding:18px;
background:#fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
display:flex;
flex-direction:column;
"
>
<h4 style="margin:0 0 10px 0;">🇪🇸 Español</h4>
<p style="margin:0; color:#444; line-height:1.5;">
Gracias por probar nuestro software.<br />
Su período de prueba está activo y durará <b><%= daysLeft %> días más</b>.<br /><br />
Después de que finalice la prueba, el administrador debe introducir un número de serie válido.
</p>
<div style="margin-top:auto; padding-top:16px;">
<a href="/dashboard" class="btn btn-primary w-100">
Ir al Dashboard
</a>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,35 +1,13 @@
<!DOCTYPE html> <%- include("partials/page-header", {
<html lang="de"> user,
<head> title: t.patienteoverview.patienttitle,
<meta charset="UTF-8"> subtitle: "",
<title>Leistungen</title> showUserName: true
<link rel="stylesheet" href="/css/bootstrap.min.css"> }) %>
<script src="/js/services-lock.js"></script> ✔ erlaubt
</head>
<body>
<!-- NAVBAR -->
<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">Leistungen</span>
</div>
<!-- DASHBOARD -->
<div class="ms-auto">
<a href="/dashboard" class="btn btn-outline-light btn-sm">
⬅️ Dashboard
</a>
</div>
</nav>
<!-- CONTENT --> <!-- CONTENT -->
<div class="container mt-4"> <div class="container mt-4">
<%- include("partials/flash") %>
<h4>Leistungen</h4> <h4>Leistungen</h4>
<!-- SUCHFORMULAR --> <!-- SUCHFORMULAR -->

84
views/setup/index.ejs Normal file
View File

@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title><%= title %></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: Arial, sans-serif; background:#f5f5f5; padding:20px; }
.card { max-width: 560px; margin: 0 auto; background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
label { display:block; margin-top: 12px; font-weight: 600; }
input { width: 100%; padding: 10px; margin-top: 6px; border-radius: 8px; border: 1px solid #ddd; }
.row { display:flex; gap: 12px; }
.row > div { flex: 1; }
button { margin-top: 16px; padding: 10px 14px; border: 0; border-radius: 10px; cursor:pointer; }
.btn-primary { background:#2563eb; color:white; }
.btn-secondary { background:#111827; color:white; }
.msg { margin-top: 10px; padding:10px; border-radius: 10px; display:none; }
.msg.ok { background:#dcfce7; color:#166534; }
.msg.bad { background:#fee2e2; color:#991b1b; }
</style>
</head>
<body>
<div class="card">
<h2>🛠️ Erstinstallation</h2>
<p>Bitte DB Daten eingeben. Danach wird <code>config.enc</code> gespeichert.</p>
<form method="POST" action="/setup">
<label>DB Host</label>
<input name="host" placeholder="192.168.0.86" required />
<label>DB Port</label>
<input name="port" placeholder="3306" value="3306" 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 />
<label>Passwort</label>
<input name="password" type="password" value="<%= defaults.password %>" />
<button type="button" class="btn-secondary" onclick="testConnection()">🔍 Verbindung testen</button>
<button type="submit" class="btn-primary">✅ Speichern & Setup abschließen</button>
<div id="msg" class="msg"></div>
</form>
</div>
<script>
async function testConnection() {
const form = document.querySelector("form");
const data = new FormData(form);
const body = Object.fromEntries(data.entries());
const msg = document.getElementById("msg");
msg.style.display = "block";
msg.className = "msg";
msg.textContent = "Teste Verbindung...";
try {
const res = await fetch("/setup/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const json = await res.json();
msg.textContent = json.message;
if (json.ok) msg.classList.add("ok");
else msg.classList.add("bad");
} catch (e) {
msg.textContent = "❌ Fehler: " + e.message;
msg.classList.add("bad");
}
}
</script>
</body>
</html>

33
views/trial_expired.ejs Normal file
View File

@ -0,0 +1,33 @@
<div class="layout">
<!-- ✅ Normale Sidebar -->
<%- include("partials/sidebar", { user, active: "" }) %>
<div class="main">
<!-- ✅ Header -->
<%- include("partials/page-header", {
user,
title: "Testphase abgelaufen",
subtitle: "",
showUserName: true
}) %>
<div class="content" style="max-width:700px; margin:30px auto; text-align:center;">
<h2 style="color:#b00020;">❌ Testphase abgelaufen</h2>
<p style="font-size:18px; margin-top:15px;">
Die Testphase ist beendet.<br />
Bitte wende dich an den Administrator.<br />
Nur ein Admin kann die Seriennummer hinterlegen.
</p>
<a href="/logout" class="btn btn-outline-danger" style="margin-top:20px;">
Abmelden
</a>
</div>
</div>
</div>