diff --git a/app.js b/app.js
index 751bf53..aef037b 100644
--- a/app.js
+++ b/app.js
@@ -29,6 +29,7 @@ const patientFileRoutes = require("./routes/patientFile.routes");
const companySettingsRoutes = require("./routes/companySettings.routes");
const authRoutes = require("./routes/auth.routes");
const reportRoutes = require("./routes/report.routes");
+const calendarRoutes = require("./routes/calendar.routes");
const app = express();
@@ -411,6 +412,8 @@ app.use("/invoices", invoiceRoutes);
app.use("/reportview", reportRoutes);
+app.use("/calendar", calendarRoutes);
+
app.get("/logout", (req, res) => {
req.session.destroy(() => res.redirect("/"));
});
diff --git a/controllers/calendar.controller.js b/controllers/calendar.controller.js
new file mode 100644
index 0000000..c9c6913
--- /dev/null
+++ b/controllers/calendar.controller.js
@@ -0,0 +1,257 @@
+/**
+ * controllers/calendar.controller.js
+ */
+
+const db = require("../db");
+const Holidays = require("date-holidays");
+
+// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
+
+function pad(n) {
+ return String(n).padStart(2, "0");
+}
+
+function toISO(d) {
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
+// ── Hauptseite (EJS rendern) ─────────────────────────────────────────────────
+
+exports.index = async (req, res) => {
+ try {
+ // Alle aktiven Ärzte (users mit role = 'arzt')
+ const [doctors] = await db.promise().query(`
+ SELECT id, username AS name, doctor_color AS color
+ FROM users
+ WHERE role = 'arzt' AND active = 1
+ ORDER BY username
+ `);
+
+ const today = toISO(new Date());
+
+ return res.render("calendar/index", {
+ active: "calendar",
+ doctors,
+ today,
+ user: req.session.user,
+ });
+ } catch (err) {
+ console.error("❌ calendar.index:", err.message);
+ return res.status(500).send("Interner Serverfehler");
+ }
+};
+
+// ── API: Termine eines Tages ─────────────────────────────────────────────────
+
+exports.getAppointments = async (req, res) => {
+ try {
+ const { date } = req.params; // YYYY-MM-DD
+
+ const [rows] = await db.promise().query(
+ `SELECT
+ a.id, a.doctor_id, a.date,
+ TIME_FORMAT(a.time, '%H:%i') AS time,
+ a.duration, a.patient_name, a.notes, a.status,
+ u.username AS doctor_name,
+ u.doctor_color AS doctor_color
+ FROM appointments a
+ JOIN users u ON u.id = a.doctor_id
+ WHERE a.date = ?
+ ORDER BY a.time, u.username`,
+ [date]
+ );
+
+ return res.json(rows);
+ } catch (err) {
+ console.error("❌ getAppointments:", err.message);
+ return res.status(500).json({ error: "Datenbankfehler" });
+ }
+};
+
+// ── API: Termin erstellen ────────────────────────────────────────────────────
+
+exports.createAppointment = async (req, res) => {
+ try {
+ const { doctor_id, date, time, duration = 15, patient_name, notes = "" } =
+ req.body;
+
+ if (!doctor_id || !date || !time || !patient_name?.trim()) {
+ return res
+ .status(400)
+ .json({ error: "doctor_id, date, time und patient_name sind Pflicht" });
+ }
+
+ // Kollisionsprüfung
+ const [conflict] = await db.promise().query(
+ `SELECT id FROM appointments
+ WHERE doctor_id = ? AND date = ? AND time = ? AND status != 'cancelled'`,
+ [doctor_id, date, time]
+ );
+
+ if (conflict.length > 0) {
+ return res.status(409).json({ error: "Dieser Zeitslot ist bereits belegt" });
+ }
+
+ const [result] = await db.promise().query(
+ `INSERT INTO appointments (doctor_id, date, time, duration, patient_name, notes)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [doctor_id, date, time, duration, patient_name.trim(), notes]
+ );
+
+ return res.status(201).json({ id: result.insertId });
+ } catch (err) {
+ console.error("❌ createAppointment:", err.message);
+ return res.status(500).json({ error: "Datenbankfehler" });
+ }
+};
+
+// ── API: Termin aktualisieren ────────────────────────────────────────────────
+
+exports.updateAppointment = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { doctor_id, date, time, duration, patient_name, notes, status } =
+ req.body;
+
+ await db.promise().query(
+ `UPDATE appointments
+ SET doctor_id = ?, date = ?, time = ?, duration = ?,
+ patient_name = ?, notes = ?, status = ?
+ WHERE id = ?`,
+ [doctor_id, date, time, duration, patient_name, notes, status, id]
+ );
+
+ return res.json({ success: true });
+ } catch (err) {
+ console.error("❌ updateAppointment:", err.message);
+ return res.status(500).json({ error: "Datenbankfehler" });
+ }
+};
+
+// ── API: Termin löschen ──────────────────────────────────────────────────────
+
+exports.deleteAppointment = async (req, res) => {
+ try {
+ await db.promise().query("DELETE FROM appointments WHERE id = ?", [
+ req.params.id,
+ ]);
+ return res.json({ success: true });
+ } catch (err) {
+ console.error("❌ deleteAppointment:", err.message);
+ return res.status(500).json({ error: "Datenbankfehler" });
+ }
+};
+
+// ── API: Status ändern ───────────────────────────────────────────────────────
+
+exports.patchStatus = async (req, res) => {
+ try {
+ const allowed = ["scheduled", "completed", "cancelled"];
+ const { status } = req.body;
+
+ if (!allowed.includes(status)) {
+ return res.status(400).json({ error: "Ungültiger Status" });
+ }
+
+ await db
+ .promise()
+ .query("UPDATE appointments SET status = ? WHERE id = ?", [
+ status,
+ req.params.id,
+ ]);
+
+ return res.json({ success: true });
+ } catch (err) {
+ console.error("❌ patchStatus:", err.message);
+ return res.status(500).json({ error: "Datenbankfehler" });
+ }
+};
+
+// ── API: Feiertage eines Jahres ──────────────────────────────────────────────
+
+exports.getHolidays = (req, res) => {
+ try {
+ const year = parseInt(req.params.year);
+ const country = (req.query.country || process.env.HOLIDAY_COUNTRY || "DE").toUpperCase();
+ const state = (req.query.state || process.env.HOLIDAY_STATE || "").toUpperCase();
+
+ if (isNaN(year) || year < 1900 || year > 2100) {
+ return res.status(400).json({ error: "Ungültiges Jahr" });
+ }
+
+ const hd = new Holidays();
+ const inited = state ? hd.init(country, state) : hd.init(country);
+
+ if (!inited) {
+ return res.status(400).json({ error: `Unbekanntes Land/Bundesland: ${country}/${state}` });
+ }
+
+ const holidays = hd
+ .getHolidays(year)
+ .filter((h) => ["public", "bank"].includes(h.type))
+ .map((h) => ({
+ date: h.date.substring(0, 10),
+ name: h.name,
+ type: h.type,
+ }));
+
+ return res.json({ country, state, year, holidays });
+ } catch (err) {
+ console.error("❌ getHolidays:", err.message);
+ return res.status(500).json({ error: "Fehler beim Laden der Feiertage" });
+ }
+};
+
+// ── API: Patienten-Suche (Autocomplete) ─────────────────────────────────────
+
+exports.searchPatients = async (req, res) => {
+ try {
+ const q = (req.query.q || "").trim();
+
+ if (q.length < 1) return res.json([]);
+
+ const like = `%${q}%`;
+
+ const [rows] = await db.promise().query(
+ `SELECT
+ id,
+ firstname,
+ lastname,
+ birthdate,
+ CONCAT(firstname, ' ', lastname) AS full_name
+ FROM patients
+ WHERE active = 1
+ AND (
+ firstname LIKE ? OR
+ lastname LIKE ? OR
+ CONCAT(firstname, ' ', lastname) LIKE ?
+ )
+ ORDER BY lastname, firstname
+ LIMIT 10`,
+ [like, like, like]
+ );
+
+ return res.json(rows);
+ } catch (err) {
+ console.error("❌ searchPatients:", err.message);
+ return res.status(500).json({ error: "Datenbankfehler" });
+ }
+};
+
+// ── API: Arzt-Farbe speichern ────────────────────────────────────────────────
+
+exports.updateDoctorColor = async (req, res) => {
+ try {
+ const { color } = req.body;
+ await db
+ .promise()
+ .query("UPDATE users SET doctor_color = ? WHERE id = ?", [
+ color,
+ req.params.id,
+ ]);
+ return res.json({ success: true });
+ } catch (err) {
+ console.error("❌ updateDoctorColor:", err.message);
+ return res.status(500).json({ error: "Datenbankfehler" });
+ }
+};
diff --git a/controllers/medication.controller.js b/controllers/medication.controller.js
index 4901efa..cf2a6d6 100644
--- a/controllers/medication.controller.js
+++ b/controllers/medication.controller.js
@@ -1,144 +1,145 @@
-const db = require("../db");
-
-// 📋 LISTE
-function listMedications(req, res, next) {
- const { q, onlyActive } = req.query;
-
- let sql = `
- SELECT
- v.id,
- m.id AS medication_id,
- m.name AS medication,
- m.active,
- f.name AS form,
- v.dosage,
- v.package
- FROM medication_variants v
- JOIN medications m ON v.medication_id = m.id
- JOIN medication_forms f ON v.form_id = f.id
- WHERE 1=1
- `;
-
- const params = [];
-
- if (q) {
- sql += `
- AND (
- m.name LIKE ?
- OR f.name LIKE ?
- OR v.dosage LIKE ?
- OR v.package LIKE ?
- )
- `;
- params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
- }
-
- if (onlyActive === "1") {
- sql += " AND m.active = 1";
- }
-
- sql += " ORDER BY m.name, v.dosage";
-
- db.query(sql, params, (err, rows) => {
- if (err) return next(err);
-
- res.render("medications", {
- title: "Medikamentenübersicht",
-
- // ✅ IMMER patient-sidebar verwenden
- sidebarPartial: "partials/sidebar-empty",
- active: "medications",
-
- rows,
- query: { q, onlyActive },
- user: req.session.user,
- lang: req.session.lang || "de",
- });
- });
-}
-
-// 💾 UPDATE
-function updateMedication(req, res, next) {
- const { medication, form, dosage, package: pkg } = req.body;
- const id = req.params.id;
-
- const sql = `
- UPDATE medication_variants
- SET
- dosage = ?,
- package = ?
- WHERE id = ?
- `;
-
- db.query(sql, [dosage, pkg, id], (err) => {
- if (err) return next(err);
-
- req.session.flash = { type: "success", message: "Medikament gespeichert" };
- res.redirect("/medications");
- });
-}
-
-function toggleMedication(req, res, next) {
- const id = req.params.id;
-
- db.query(
- "UPDATE medications SET active = NOT active WHERE id = ?",
- [id],
- (err) => {
- if (err) return next(err);
- res.redirect("/medications");
- },
- );
-}
-
-function showCreateMedication(req, res) {
- const sql = "SELECT id, name FROM medication_forms ORDER BY name";
-
- db.query(sql, (err, forms) => {
- if (err) return res.send("DB Fehler");
-
- res.render("medication_create", {
- forms,
- user: req.session.user,
- error: null,
- });
- });
-}
-
-function createMedication(req, res) {
- const { name, form_id, dosage, package: pkg } = req.body;
-
- if (!name || !form_id || !dosage) {
- return res.send("Pflichtfelder fehlen");
- }
-
- db.query(
- "INSERT INTO medications (name, active) VALUES (?, 1)",
- [name],
- (err, result) => {
- if (err) return res.send("Fehler Medikament");
-
- const medicationId = result.insertId;
-
- db.query(
- `INSERT INTO medication_variants
- (medication_id, form_id, dosage, package)
- VALUES (?, ?, ?, ?)`,
- [medicationId, form_id, dosage, pkg || null],
- (err) => {
- if (err) return res.send("Fehler Variante");
-
- res.redirect("/medications");
- },
- );
- },
- );
-}
-
-module.exports = {
- listMedications,
- updateMedication,
- toggleMedication,
- showCreateMedication,
- createMedication,
-};
+const db = require("../db");
+
+// 📋 LISTE
+function listMedications(req, res, next) {
+ const { q, onlyActive } = req.query;
+
+ let sql = `
+ SELECT
+ v.id,
+ m.id AS medication_id,
+ m.name AS medication,
+ m.active,
+ f.name AS form,
+ v.dosage,
+ v.package
+ FROM medication_variants v
+ JOIN medications m ON v.medication_id = m.id
+ JOIN medication_forms f ON v.form_id = f.id
+ WHERE 1=1
+ `;
+
+ const params = [];
+
+ if (q) {
+ sql += `
+ AND (
+ m.name LIKE ?
+ OR f.name LIKE ?
+ OR v.dosage LIKE ?
+ OR v.package LIKE ?
+ )
+ `;
+ params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
+ }
+
+ if (onlyActive === "1") {
+ sql += " AND m.active = 1";
+ }
+
+ sql += " ORDER BY m.name, v.dosage";
+
+ db.query(sql, params, (err, rows) => {
+ if (err) return next(err);
+
+ res.render("medications", {
+ title: "Medikamentenübersicht",
+
+ // ✅ IMMER patient-sidebar verwenden
+ sidebarPartial: "partials/sidebar-empty",
+ backUrl: "/dashboard",
+ active: "medications",
+
+ rows,
+ query: { q, onlyActive },
+ user: req.session.user,
+ lang: req.session.lang || "de",
+ });
+ });
+}
+
+// 💾 UPDATE
+function updateMedication(req, res, next) {
+ const { medication, form, dosage, package: pkg } = req.body;
+ const id = req.params.id;
+
+ const sql = `
+ UPDATE medication_variants
+ SET
+ dosage = ?,
+ package = ?
+ WHERE id = ?
+ `;
+
+ db.query(sql, [dosage, pkg, id], (err) => {
+ if (err) return next(err);
+
+ req.session.flash = { type: "success", message: "Medikament gespeichert" };
+ res.redirect("/medications");
+ });
+}
+
+function toggleMedication(req, res, next) {
+ const id = req.params.id;
+
+ db.query(
+ "UPDATE medications SET active = NOT active WHERE id = ?",
+ [id],
+ (err) => {
+ if (err) return next(err);
+ res.redirect("/medications");
+ },
+ );
+}
+
+function showCreateMedication(req, res) {
+ const sql = "SELECT id, name FROM medication_forms ORDER BY name";
+
+ db.query(sql, (err, forms) => {
+ if (err) return res.send("DB Fehler");
+
+ res.render("medication_create", {
+ forms,
+ user: req.session.user,
+ error: null,
+ });
+ });
+}
+
+function createMedication(req, res) {
+ const { name, form_id, dosage, package: pkg } = req.body;
+
+ if (!name || !form_id || !dosage) {
+ return res.send("Pflichtfelder fehlen");
+ }
+
+ db.query(
+ "INSERT INTO medications (name, active) VALUES (?, 1)",
+ [name],
+ (err, result) => {
+ if (err) return res.send("Fehler Medikament");
+
+ const medicationId = result.insertId;
+
+ db.query(
+ `INSERT INTO medication_variants
+ (medication_id, form_id, dosage, package)
+ VALUES (?, ?, ?, ?)`,
+ [medicationId, form_id, dosage, pkg || null],
+ (err) => {
+ if (err) return res.send("Fehler Variante");
+
+ res.redirect("/medications");
+ },
+ );
+ },
+ );
+}
+
+module.exports = {
+ listMedications,
+ updateMedication,
+ toggleMedication,
+ showCreateMedication,
+ createMedication,
+};
diff --git a/controllers/service.controller.js b/controllers/service.controller.js
index 5f307ed..88629df 100644
--- a/controllers/service.controller.js
+++ b/controllers/service.controller.js
@@ -1,342 +1,347 @@
-const db = require("../db");
-
-function listServices(req, res) {
- const { q, onlyActive, patientId } = req.query;
-
- // 🔹 Standard: Deutsch
- let serviceNameField = "name_de";
-
- const loadServices = () => {
- let sql = `
- SELECT id, ${serviceNameField} AS name, category, price, active
- FROM services
- WHERE 1=1
- `;
- const params = [];
-
- if (q) {
- sql += `
- AND (
- name_de LIKE ?
- OR name_es LIKE ?
- OR category LIKE ?
- )
- `;
- params.push(`%${q}%`, `%${q}%`, `%${q}%`);
- }
-
- if (onlyActive === "1") {
- sql += " AND active = 1";
- }
-
- sql += ` ORDER BY ${serviceNameField}`;
-
- db.query(sql, params, (err, services) => {
- if (err) return res.send("Datenbankfehler");
-
- res.render("services", {
- title: "Leistungen",
- sidebarPartial: "partials/sidebar-empty",
- active: "services",
-
- services,
- user: req.session.user,
- lang: req.session.lang || "de",
- query: { q, onlyActive, patientId },
- });
- });
- };
-
- // 🔹 Wenn Patient angegeben → Country prüfen
- if (patientId) {
- db.query(
- "SELECT country FROM patients WHERE id = ?",
- [patientId],
- (err, rows) => {
- if (!err && rows.length && rows[0].country === "ES") {
- serviceNameField = "name_es";
- }
- loadServices();
- },
- );
- } else {
- // 🔹 Kein Patient → Deutsch
- loadServices();
- }
-}
-
-function listServicesAdmin(req, res) {
- const { q, onlyActive } = req.query;
-
- let sql = `
- SELECT
- id,
- name_de,
- name_es,
- category,
- price,
- price_c70,
- active
- FROM services
- WHERE 1=1
- `;
- const params = [];
-
- if (q) {
- sql += `
- AND (
- name_de LIKE ?
- OR name_es LIKE ?
- OR category LIKE ?
- )
- `;
- params.push(`%${q}%`, `%${q}%`, `%${q}%`);
- }
-
- if (onlyActive === "1") {
- sql += " AND active = 1";
- }
-
- sql += " ORDER BY name_de";
-
- db.query(sql, params, (err, services) => {
- if (err) return res.send("Datenbankfehler");
-
- res.render("services", {
- title: "Leistungen (Admin)",
- sidebarPartial: "partials/admin-sidebar",
- active: "services",
-
- services,
- user: req.session.user,
- lang: req.session.lang || "de",
- query: { q, onlyActive },
- });
- });
-}
-
-function showCreateService(req, res) {
- res.render("service_create", {
- title: "Leistung anlegen",
- sidebarPartial: "partials/sidebar-empty",
- active: "services",
-
- user: req.session.user,
- lang: req.session.lang || "de",
- error: null,
- });
-}
-
-function createService(req, res) {
- const { name_de, name_es, category, price, price_c70 } = req.body;
- const userId = req.session.user.id;
-
- if (!name_de || !price) {
- return res.render("service_create", {
- title: "Leistung anlegen",
- sidebarPartial: "partials/sidebar-empty",
- active: "services",
-
- user: req.session.user,
- lang: req.session.lang || "de",
- error: "Bezeichnung (DE) und Preis sind Pflichtfelder",
- });
- }
-
- db.query(
- `
- INSERT INTO services
- (name_de, name_es, category, price, price_c70, active)
- VALUES (?, ?, ?, ?, ?, 1)
- `,
- [name_de, name_es || "--", category || "--", price, price_c70 || 0],
- (err, result) => {
- if (err) return res.send("Fehler beim Anlegen der Leistung");
-
- db.query(
- `
- INSERT INTO service_logs
- (service_id, user_id, action, new_value)
- VALUES (?, ?, 'CREATE', ?)
- `,
- [result.insertId, userId, JSON.stringify(req.body)],
- );
-
- res.redirect("/services");
- },
- );
-}
-
-function updateServicePrice(req, res) {
- const serviceId = req.params.id;
- const { price, price_c70 } = req.body;
- const userId = req.session.user.id;
-
- db.query(
- "SELECT price, price_c70 FROM services WHERE id = ?",
- [serviceId],
- (err, oldRows) => {
- if (err || oldRows.length === 0)
- return res.send("Service nicht gefunden");
-
- const oldData = oldRows[0];
-
- db.query(
- "UPDATE services SET price = ?, price_c70 = ? WHERE id = ?",
- [price, price_c70, serviceId],
- (err) => {
- if (err) return res.send("Update fehlgeschlagen");
-
- db.query(
- `
- INSERT INTO service_logs
- (service_id, user_id, action, old_value, new_value)
- VALUES (?, ?, 'UPDATE_PRICE', ?, ?)
- `,
- [
- serviceId,
- userId,
- JSON.stringify(oldData),
- JSON.stringify({ price, price_c70 }),
- ],
- );
-
- res.redirect("/services");
- },
- );
- },
- );
-}
-
-function toggleService(req, res) {
- const serviceId = req.params.id;
- const userId = req.session.user.id;
-
- db.query(
- "SELECT active FROM services WHERE id = ?",
- [serviceId],
- (err, rows) => {
- if (err || rows.length === 0) return res.send("Service nicht gefunden");
-
- const oldActive = rows[0].active;
- const newActive = oldActive ? 0 : 1;
-
- db.query(
- "UPDATE services SET active = ? WHERE id = ?",
- [newActive, serviceId],
- (err) => {
- if (err) return res.send("Update fehlgeschlagen");
-
- db.query(
- `
- INSERT INTO service_logs
- (service_id, user_id, action, old_value, new_value)
- VALUES (?, ?, 'TOGGLE_ACTIVE', ?, ?)
- `,
- [serviceId, userId, oldActive, newActive],
- );
-
- res.redirect("/services");
- },
- );
- },
- );
-}
-
-async function listOpenServices(req, res, next) {
- res.set("Cache-Control", "no-store, no-cache, must-revalidate, private");
- res.set("Pragma", "no-cache");
- res.set("Expires", "0");
-
- const sql = `
- SELECT
- p.id AS patient_id,
- p.firstname,
- p.lastname,
- p.country,
- ps.id AS patient_service_id,
- ps.quantity,
- COALESCE(ps.price_override, s.price) AS price,
- CASE
- WHEN UPPER(TRIM(p.country)) = 'ES'
- THEN COALESCE(NULLIF(s.name_es, ''), s.name_de)
- ELSE s.name_de
- END AS name
- FROM patient_services ps
- JOIN patients p ON ps.patient_id = p.id
- JOIN services s ON ps.service_id = s.id
- WHERE ps.invoice_id IS NULL
- ORDER BY p.lastname, p.firstname, name
- `;
-
- let connection;
-
- try {
- connection = await db.promise().getConnection();
-
- await connection.query(
- "SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED",
- );
-
- const [[cid]] = await connection.query("SELECT CONNECTION_ID() AS cid");
- console.log("🔌 OPEN SERVICES CID:", cid.cid);
-
- const [rows] = await connection.query(sql);
-
- console.log("🧾 OPEN SERVICES ROWS:", rows.length);
-
- res.render("open_services", {
- title: "Offene Leistungen",
- sidebarPartial: "partials/sidebar-invoices",
- active: "services",
-
- rows,
- user: req.session.user,
- lang: req.session.lang || "de",
- });
- } catch (err) {
- next(err);
- } finally {
- if (connection) connection.release();
- }
-}
-
-function showServiceLogs(req, res) {
- db.query(
- `
- SELECT
- l.created_at,
- u.username,
- l.action,
- l.old_value,
- l.new_value
- FROM service_logs l
- JOIN users u ON l.user_id = u.id
- ORDER BY l.created_at DESC
- `,
- (err, logs) => {
- if (err) return res.send("Datenbankfehler");
-
- res.render("admin_service_logs", {
- title: "Service Logs",
- sidebarPartial: "partials/admin-sidebar",
- active: "services",
-
- logs,
- user: req.session.user,
- lang: req.session.lang || "de",
- });
- },
- );
-}
-
-module.exports = {
- listServices,
- showCreateService,
- createService,
- updateServicePrice,
- toggleService,
- listOpenServices,
- showServiceLogs,
- listServicesAdmin,
-};
+const db = require("../db");
+
+function listServices(req, res) {
+ const { q, onlyActive, patientId } = req.query;
+
+ // 🔹 Standard: Deutsch
+ let serviceNameField = "name_de";
+
+ const loadServices = () => {
+ let sql = `
+ SELECT id, ${serviceNameField} AS name, category, price, active
+ FROM services
+ WHERE 1=1
+ `;
+ const params = [];
+
+ if (q) {
+ sql += `
+ AND (
+ name_de LIKE ?
+ OR name_es LIKE ?
+ OR category LIKE ?
+ )
+ `;
+ params.push(`%${q}%`, `%${q}%`, `%${q}%`);
+ }
+
+ if (onlyActive === "1") {
+ sql += " AND active = 1";
+ }
+
+ sql += ` ORDER BY ${serviceNameField}`;
+
+ db.query(sql, params, (err, services) => {
+ if (err) return res.send("Datenbankfehler");
+
+ res.render("services", {
+ title: "Leistungen",
+ sidebarPartial: "partials/sidebar-empty",
+ backUrl: "/dashboard",
+ active: "services",
+
+ services,
+ user: req.session.user,
+ lang: req.session.lang || "de",
+ query: { q, onlyActive, patientId },
+ });
+ });
+ };
+
+ // 🔹 Wenn Patient angegeben → Country prüfen
+ if (patientId) {
+ db.query(
+ "SELECT country FROM patients WHERE id = ?",
+ [patientId],
+ (err, rows) => {
+ if (!err && rows.length && rows[0].country === "ES") {
+ serviceNameField = "name_es";
+ }
+ loadServices();
+ },
+ );
+ } else {
+ // 🔹 Kein Patient → Deutsch
+ loadServices();
+ }
+}
+
+function listServicesAdmin(req, res) {
+ const { q, onlyActive } = req.query;
+
+ let sql = `
+ SELECT
+ id,
+ name_de,
+ name_es,
+ category,
+ price,
+ price_c70,
+ active
+ FROM services
+ WHERE 1=1
+ `;
+ const params = [];
+
+ if (q) {
+ sql += `
+ AND (
+ name_de LIKE ?
+ OR name_es LIKE ?
+ OR category LIKE ?
+ )
+ `;
+ params.push(`%${q}%`, `%${q}%`, `%${q}%`);
+ }
+
+ if (onlyActive === "1") {
+ sql += " AND active = 1";
+ }
+
+ sql += " ORDER BY name_de";
+
+ db.query(sql, params, (err, services) => {
+ if (err) return res.send("Datenbankfehler");
+
+ res.render("services", {
+ title: "Leistungen (Admin)",
+ sidebarPartial: "partials/admin-sidebar",
+ backUrl: "/dashboard",
+ active: "services",
+
+ services,
+ user: req.session.user,
+ lang: req.session.lang || "de",
+ query: { q, onlyActive },
+ });
+ });
+}
+
+function showCreateService(req, res) {
+ res.render("service_create", {
+ title: "Leistung anlegen",
+ sidebarPartial: "partials/sidebar-empty",
+ backUrl: "/dashboard",
+ active: "services",
+
+ user: req.session.user,
+ lang: req.session.lang || "de",
+ error: null,
+ });
+}
+
+function createService(req, res) {
+ const { name_de, name_es, category, price, price_c70 } = req.body;
+ const userId = req.session.user.id;
+
+ if (!name_de || !price) {
+ return res.render("service_create", {
+ title: "Leistung anlegen",
+ sidebarPartial: "partials/sidebar-empty",
+ backUrl: "/dashboard",
+ active: "services",
+
+ user: req.session.user,
+ lang: req.session.lang || "de",
+ error: "Bezeichnung (DE) und Preis sind Pflichtfelder",
+ });
+ }
+
+ db.query(
+ `
+ INSERT INTO services
+ (name_de, name_es, category, price, price_c70, active)
+ VALUES (?, ?, ?, ?, ?, 1)
+ `,
+ [name_de, name_es || "--", category || "--", price, price_c70 || 0],
+ (err, result) => {
+ if (err) return res.send("Fehler beim Anlegen der Leistung");
+
+ db.query(
+ `
+ INSERT INTO service_logs
+ (service_id, user_id, action, new_value)
+ VALUES (?, ?, 'CREATE', ?)
+ `,
+ [result.insertId, userId, JSON.stringify(req.body)],
+ );
+
+ res.redirect("/services");
+ },
+ );
+}
+
+function updateServicePrice(req, res) {
+ const serviceId = req.params.id;
+ const { price, price_c70 } = req.body;
+ const userId = req.session.user.id;
+
+ db.query(
+ "SELECT price, price_c70 FROM services WHERE id = ?",
+ [serviceId],
+ (err, oldRows) => {
+ if (err || oldRows.length === 0)
+ return res.send("Service nicht gefunden");
+
+ const oldData = oldRows[0];
+
+ db.query(
+ "UPDATE services SET price = ?, price_c70 = ? WHERE id = ?",
+ [price, price_c70, serviceId],
+ (err) => {
+ if (err) return res.send("Update fehlgeschlagen");
+
+ db.query(
+ `
+ INSERT INTO service_logs
+ (service_id, user_id, action, old_value, new_value)
+ VALUES (?, ?, 'UPDATE_PRICE', ?, ?)
+ `,
+ [
+ serviceId,
+ userId,
+ JSON.stringify(oldData),
+ JSON.stringify({ price, price_c70 }),
+ ],
+ );
+
+ res.redirect("/services");
+ },
+ );
+ },
+ );
+}
+
+function toggleService(req, res) {
+ const serviceId = req.params.id;
+ const userId = req.session.user.id;
+
+ db.query(
+ "SELECT active FROM services WHERE id = ?",
+ [serviceId],
+ (err, rows) => {
+ if (err || rows.length === 0) return res.send("Service nicht gefunden");
+
+ const oldActive = rows[0].active;
+ const newActive = oldActive ? 0 : 1;
+
+ db.query(
+ "UPDATE services SET active = ? WHERE id = ?",
+ [newActive, serviceId],
+ (err) => {
+ if (err) return res.send("Update fehlgeschlagen");
+
+ db.query(
+ `
+ INSERT INTO service_logs
+ (service_id, user_id, action, old_value, new_value)
+ VALUES (?, ?, 'TOGGLE_ACTIVE', ?, ?)
+ `,
+ [serviceId, userId, oldActive, newActive],
+ );
+
+ res.redirect("/services");
+ },
+ );
+ },
+ );
+}
+
+async function listOpenServices(req, res, next) {
+ res.set("Cache-Control", "no-store, no-cache, must-revalidate, private");
+ res.set("Pragma", "no-cache");
+ res.set("Expires", "0");
+
+ const sql = `
+ SELECT
+ p.id AS patient_id,
+ p.firstname,
+ p.lastname,
+ p.country,
+ ps.id AS patient_service_id,
+ ps.quantity,
+ COALESCE(ps.price_override, s.price) AS price,
+ CASE
+ WHEN UPPER(TRIM(p.country)) = 'ES'
+ THEN COALESCE(NULLIF(s.name_es, ''), s.name_de)
+ ELSE s.name_de
+ END AS name
+ FROM patient_services ps
+ JOIN patients p ON ps.patient_id = p.id
+ JOIN services s ON ps.service_id = s.id
+ WHERE ps.invoice_id IS NULL
+ ORDER BY p.lastname, p.firstname, name
+ `;
+
+ let connection;
+
+ try {
+ connection = await db.promise().getConnection();
+
+ await connection.query(
+ "SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED",
+ );
+
+ const [[cid]] = await connection.query("SELECT CONNECTION_ID() AS cid");
+ console.log("🔌 OPEN SERVICES CID:", cid.cid);
+
+ const [rows] = await connection.query(sql);
+
+ console.log("🧾 OPEN SERVICES ROWS:", rows.length);
+
+ res.render("open_services", {
+ title: "Offene Leistungen",
+ sidebarPartial: "partials/sidebar-invoices",
+ backUrl: "/dashboard",
+ active: "services",
+
+ rows,
+ user: req.session.user,
+ lang: req.session.lang || "de",
+ });
+ } catch (err) {
+ next(err);
+ } finally {
+ if (connection) connection.release();
+ }
+}
+
+function showServiceLogs(req, res) {
+ db.query(
+ `
+ SELECT
+ l.created_at,
+ u.username,
+ l.action,
+ l.old_value,
+ l.new_value
+ FROM service_logs l
+ JOIN users u ON l.user_id = u.id
+ ORDER BY l.created_at DESC
+ `,
+ (err, logs) => {
+ if (err) return res.send("Datenbankfehler");
+
+ res.render("admin_service_logs", {
+ title: "Service Logs",
+ sidebarPartial: "partials/admin-sidebar",
+ active: "services",
+
+ logs,
+ user: req.session.user,
+ lang: req.session.lang || "de",
+ });
+ },
+ );
+}
+
+module.exports = {
+ listServices,
+ showCreateService,
+ createService,
+ updateServicePrice,
+ toggleService,
+ listOpenServices,
+ showServiceLogs,
+ listServicesAdmin,
+};
diff --git a/db/calendar_migrate.js b/db/calendar_migrate.js
new file mode 100644
index 0000000..f4b1422
--- /dev/null
+++ b/db/calendar_migrate.js
@@ -0,0 +1,65 @@
+/**
+ * calendar_migrate.js
+ * Führe einmalig aus: node db/calendar_migrate.js
+ *
+ * Erstellt die appointments-Tabelle für den Kalender.
+ * Ärzte werden aus der bestehenden `users`-Tabelle (role = 'arzt') gezogen.
+ */
+
+// ✅ MUSS als erstes stehen – lädt CONFIG_KEY bevor config-manager greift
+require("dotenv").config();
+
+const db = require("../db");
+
+async function migrate() {
+ const conn = db.promise();
+
+ console.log("→ Erstelle Kalender-Tabellen …");
+
+ // ── Termine ──────────────────────────────────────────────────────────────
+ await conn.query(`
+ CREATE TABLE IF NOT EXISTS appointments (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ doctor_id INT NOT NULL COMMENT 'Referenz auf users.id (role=arzt)',
+ date DATE NOT NULL,
+ time TIME NOT NULL,
+ duration INT NOT NULL DEFAULT 15 COMMENT 'Minuten',
+ patient_name VARCHAR(150) NOT NULL,
+ notes TEXT DEFAULT NULL,
+ status ENUM('scheduled','completed','cancelled') DEFAULT 'scheduled',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_date (date),
+ INDEX idx_doctor (doctor_id),
+ INDEX idx_date_doc (date, doctor_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+ `);
+
+ console.log("✓ Tabelle `appointments` bereit");
+
+ // ── Farben für Ärzte ─────────────────────────────────────────────────────
+ // Falls die users-Tabelle noch keine doctor_color-Spalte hat, fügen wir sie hinzu.
+ // Fehler = Spalte existiert schon → ignorieren.
+ try {
+ await conn.query(`
+ ALTER TABLE users
+ ADD COLUMN doctor_color VARCHAR(20) DEFAULT '#3B82F6'
+ AFTER role;
+ `);
+ console.log("✓ Spalte `users.doctor_color` hinzugefügt");
+ } catch (e) {
+ if (e.code === "ER_DUP_FIELDNAME") {
+ console.log("ℹ️ Spalte `users.doctor_color` existiert bereits – übersprungen");
+ } else {
+ throw e;
+ }
+ }
+
+ console.log("\n✅ Kalender-Migration abgeschlossen.\n");
+ process.exit(0);
+}
+
+migrate().catch((err) => {
+ console.error("❌ Migration fehlgeschlagen:", err.message);
+ process.exit(1);
+});
diff --git a/locales/de.json b/locales/de.json
index c93dfff..7d59594 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -1,132 +1,408 @@
-{
- "global": {
- "save": "Speichern",
- "cancel": "Abbrechen",
- "search": "Suchen",
- "reset": "Reset",
- "dashboard": "Dashboard",
- "logout": "Logout",
- "title": "Titel",
- "firstname": "Vorname",
- "lastname": "Nachname",
- "username": "Username",
- "role": "Rolle",
- "action": "Aktionen",
- "status": "Status",
- "you": "Du Selbst",
- "newuser": "Neuer benutzer",
- "inactive": "inaktive",
- "active": "aktive",
- "closed": "gesperrt",
- "filter": "Filtern",
- "yearcash": "Jahresumsatz",
- "monthcash": "Monatsumsatz",
- "quartalcash": "Quartalsumsatz",
- "year": "Jahr",
- "nodata": "keine Daten",
- "month": "Monat",
- "patientcash": "Umsatz pro Patient",
- "patient": "Patient",
- "systeminfo": "Systeminformationen",
- "table": "Tabelle",
- "lines": "Zeilen",
- "size": "Grösse",
- "errordatabase": "Fehler beim Auslesen der Datenbankinfos:",
- "welcome": "Willkommen",
- "waitingroomtext": "Wartezimmer-Monitor",
- "waitingroomtextnopatient": "Keine Patienten im Wartezimmer.",
- "gender": "Geschlecht",
- "birthday": "Geburtstag",
- "email": "E-Mail",
- "phone": "Telefon",
- "address": "Adresse",
- "country": "Land",
- "notice": "Notizen",
- "create": "Erstellt",
- "change": "Geändert",
- "reset2": "Zurücksetzen",
- "edit": "Bearbeiten",
- "selection": "Auswahl",
- "waiting": "Wartet bereits",
- "towaitingroom": "Ins Wartezimmer",
- "overview": "Übersicht",
- "upload": "Hochladen",
- "lock": "Sperren",
- "unlock": "Enrsperren",
- "name": "Name",
- "return": "Zurück",
- "fileupload": "Hochladen"
- },
-
- "sidebar": {
- "patients": "Patienten",
- "medications": "Medikamente",
- "servicesOpen": "Patienten Rechnungen",
- "billing": "Abrechnung",
- "admin": "Verwaltung",
- "logout": "Logout"
- },
-
- "dashboard": {
- "welcome": "Willkommen",
- "waitingRoom": "Wartezimmer-Monitor",
- "noWaitingPatients": "Keine Patienten im Wartezimmer.",
- "title": "Dashboard"
- },
-
- "adminSidebar": {
- "users": "Userverwaltung",
- "database": "Datenbankverwaltung",
- "user": "Benutzer",
- "invocieoverview": "Rechnungsübersicht",
- "seriennumber": "Seriennummer",
- "databasetable": "Datenbank",
- "companysettings": "Firmendaten"
- },
-
- "adminuseroverview": {
- "useroverview": "Benutzerübersicht",
- "usermanagement": "Benutzer Verwaltung",
- "user": "Benutzer",
- "invocieoverview": "Rechnungsübersicht",
- "seriennumber": "Seriennummer",
- "databasetable": "Datenbank"
- },
-
- "seriennumber": {
- "seriennumbertitle": "Seriennummer eingeben",
- "seriennumbertext": "Bitte gib deine Lizenz-Seriennummer ein um die Software dauerhaft freizuschalten.",
- "seriennumbershort": "Seriennummer (AAAAA-AAAAA-AAAAA-AAAAA)",
- "seriennumberdeclaration": "Nur Buchstaben + Zahlen. Format: 4×5 Zeichen, getrennt mit „-“. ",
- "saveseriennumber": "Seriennummer Speichern"
- },
-
- "databaseoverview": {
- "title": "Datenbank Konfiguration",
- "text": "Hier kannst du die DB-Verbindung testen und speichern. ",
- "host": "Host",
- "port": "Port",
- "database": "Datenbank",
- "password": "Password",
- "connectiontest": "Verbindung testen",
- "tablecount": "Anzahl Tabellen",
- "databasesize": "Datenbankgrösse",
- "tableoverview": "Tabellenübersicht"
- },
-
- "patienteoverview": {
- "patienttitle": "Patientenübersicht",
- "newpatient": "Neuer Patient",
- "nopatientfound": "Keine Patienten gefunden",
- "closepatient": "Patient sperren ( inaktiv)",
- "openpatient": "Patient entsperren (Aktiv)"
- },
-
- "openinvoices": {
- "openinvoices": "Offene Rechnungen",
- "canceledinvoices": "Stornierte Rechnungen",
- "report": "Umsatzreport",
- "payedinvoices": "Bezahlte Rechnungen",
- "creditoverview": "Gutschrift Übersicht"
- }
-}
+{
+ "global": {
+ "save": "Speichern",
+ "cancel": "Abbrechen",
+ "search": "Suchen",
+ "reset": "Reset",
+ "reset2": "Zurücksetzen",
+ "dashboard": "Dashboard",
+ "logout": "Logout",
+ "title": "Titel",
+ "firstname": "Vorname",
+ "lastname": "Nachname",
+ "username": "Username",
+ "role": "Rolle",
+ "action": "Aktionen",
+ "status": "Status",
+ "you": "Du Selbst",
+ "newuser": "Neuer Benutzer",
+ "inactive": "Inaktiv",
+ "active": "Aktiv",
+ "closed": "Gesperrt",
+ "filter": "Filtern",
+ "yearcash": "Jahresumsatz",
+ "monthcash": "Monatsumsatz",
+ "quartalcash": "Quartalsumsatz",
+ "year": "Jahr",
+ "nodata": "Keine Daten",
+ "month": "Monat",
+ "patientcash": "Umsatz pro Patient",
+ "patient": "Patient",
+ "systeminfo": "Systeminformationen",
+ "table": "Tabelle",
+ "lines": "Zeilen",
+ "size": "Größe",
+ "errordatabase": "Fehler beim Auslesen der Datenbankinfos:",
+ "welcome": "Willkommen",
+ "waitingroomtext": "Wartezimmer-Monitor",
+ "waitingroomtextnopatient": "Keine Patienten im Wartezimmer.",
+ "gender": "Geschlecht",
+ "birthday": "Geburtstag",
+ "birthdate": "Geburtsdatum",
+ "email": "E-Mail",
+ "phone": "Telefon",
+ "address": "Adresse",
+ "country": "Land",
+ "notice": "Notizen",
+ "notes": "Notizen",
+ "create": "Erstellt",
+ "change": "Geändert",
+ "edit": "Bearbeiten",
+ "selection": "Auswahl",
+ "waiting": "Wartet bereits",
+ "towaitingroom": "Ins Wartezimmer",
+ "overview": "Übersicht",
+ "upload": "Hochladen",
+ "fileupload": "Hochladen",
+ "lock": "Sperren",
+ "unlock": "Entsperren",
+ "name": "Name",
+ "return": "Zurück",
+ "back": "Zurück",
+ "date": "Datum",
+ "amount": "Betrag",
+ "quantity": "Menge",
+ "price": "Preis (€)",
+ "sum": "Summe (€)",
+ "pdf": "PDF",
+ "open": "Öffnen",
+ "from": "Von",
+ "to": "Bis",
+ "street": "Straße",
+ "housenumber": "Hausnummer",
+ "zip": "PLZ",
+ "city": "Ort",
+ "dni": "N.I.E. / DNI",
+ "dosage": "Dosierung",
+ "form": "Darreichungsform",
+ "package": "Packung",
+ "specialty": "Fachrichtung",
+ "doctornumber": "Arztnummer",
+ "category": "Kategorie"
+ },
+
+ "sidebar": {
+ "patients": "Patienten",
+ "medications": "Medikamente",
+ "servicesOpen": "Patienten Rechnungen",
+ "billing": "Abrechnung",
+ "admin": "Verwaltung",
+ "logout": "Logout"
+ },
+
+ "dashboard": {
+ "welcome": "Willkommen",
+ "waitingRoom": "Wartezimmer-Monitor",
+ "noWaitingPatients": "Keine Patienten im Wartezimmer.",
+ "title": "Dashboard"
+ },
+
+ "adminSidebar": {
+ "users": "Userverwaltung",
+ "database": "Datenbankverwaltung",
+ "user": "Benutzer",
+ "invocieoverview": "Rechnungsübersicht",
+ "seriennumber": "Seriennummer",
+ "databasetable": "Datenbank",
+ "companysettings": "Firmendaten"
+ },
+
+ "adminuseroverview": {
+ "useroverview": "Benutzerübersicht",
+ "usermanagement": "Benutzer Verwaltung",
+ "user": "Benutzer",
+ "invocieoverview": "Rechnungsübersicht",
+ "seriennumber": "Seriennummer",
+ "databasetable": "Datenbank"
+ },
+
+ "adminCreateUser": {
+ "title": "Benutzer anlegen",
+ "firstname": "Vorname",
+ "lastname": "Nachname",
+ "usertitle": "Titel (z.B. Dr., Prof.)",
+ "username": "Benutzername (Login)",
+ "password": "Passwort",
+ "specialty": "Fachrichtung",
+ "doctornumber": "Arztnummer",
+ "createuser": "Benutzer erstellen",
+ "back": "Zurück"
+ },
+
+ "adminServiceLogs": {
+ "title": "Service-Änderungsprotokoll",
+ "date": "Datum",
+ "user": "User",
+ "action": "Aktion",
+ "before": "Vorher",
+ "after": "Nachher"
+ },
+
+ "companySettings": {
+ "title": "Firmendaten",
+ "companyname": "Firmenname",
+ "legalform": "Rechtsform",
+ "owner": "Inhaber / Geschäftsführer",
+ "email": "E-Mail",
+ "street": "Straße",
+ "housenumber": "Hausnummer",
+ "zip": "PLZ",
+ "city": "Ort",
+ "country": "Land",
+ "taxid": "USt-ID / Steuernummer",
+ "bank": "Bank",
+ "iban": "IBAN",
+ "bic": "BIC",
+ "invoicefooter": "Rechnungs-Footer",
+ "companylogo": "Firmenlogo",
+ "currentlogo": "Aktuelles Logo:",
+ "back": "Zurück"
+ },
+
+ "databaseoverview": {
+ "title": "Datenbank Konfiguration",
+ "text": "Hier kannst du die DB-Verbindung testen und speichern.",
+ "host": "Host",
+ "port": "Port",
+ "database": "Datenbank",
+ "password": "Passwort",
+ "connectiontest": "Verbindung testen",
+ "tablecount": "Anzahl Tabellen",
+ "databasesize": "Datenbankgröße",
+ "tableoverview": "Tabellenübersicht",
+ "mysqlversion": "MySQL Version",
+ "nodbinfo": "Keine Systeminfos verfügbar (DB ist evtl. nicht konfiguriert oder Verbindung fehlgeschlagen)"
+ },
+
+ "invoiceAdmin": {
+ "fromyear": "Von Jahr",
+ "toyear": "Bis Jahr",
+ "searchpatient": "Patient suchen..."
+ },
+
+ "cancelledInvoices": {
+ "title": "Stornierte Rechnungen",
+ "year": "Jahr:",
+ "noinvoices": "Keine stornierten Rechnungen für dieses Jahr.",
+ "patient": "Patient",
+ "date": "Datum",
+ "amount": "Betrag"
+ },
+
+ "creditOverview": {
+ "title": "Gutschrift Übersicht",
+ "year": "Jahr:",
+ "invoice": "Rechnung",
+ "date": "Datum",
+ "pdf": "PDF",
+ "creditnote": "Gutschrift",
+ "patient": "Patient",
+ "amount": "Betrag",
+ "open": "Öffnen"
+ },
+
+ "invoice": {
+ "title": "RECHNUNG / FACTURA",
+ "invoicenumber": "Rechnungsnummer:",
+ "nie": "N.I.E / DNI:",
+ "birthdate": "Geburtsdatum:",
+ "patient": "Patient:",
+ "servicetext": "Für unsere Leistungen erlauben wir uns Ihnen folgendes in Rechnung zu stellen:",
+ "quantity": "Menge",
+ "treatment": "Behandlung",
+ "price": "Preis (€)",
+ "sum": "Summe (€)",
+ "doctor": "Behandelnder Arzt:",
+ "specialty": "Fachrichtung:",
+ "doctornumber": "Arztnummer:",
+ "legal": "Privatärztliche Rechnung"
+ },
+
+ "openInvoices": {
+ "title": "Offene Leistungen",
+ "noinvoices": "Keine offenen Rechnungen 🎉",
+ "patient": "Patient",
+ "date": "Datum",
+ "amount": "Betrag",
+ "status": "Status",
+ "open": "Offen"
+ },
+
+ "paidInvoices": {
+ "title": "Bezahlte Rechnungen",
+ "year": "Jahr",
+ "quarter": "Quartal",
+ "patient": "Patient",
+ "date": "Datum",
+ "amount": "Betrag"
+ },
+
+ "openinvoices": {
+ "openinvoices": "Offene Rechnungen",
+ "canceledinvoices": "Stornierte Rechnungen",
+ "report": "Umsatzreport",
+ "payedinvoices": "Bezahlte Rechnungen",
+ "creditoverview": "Gutschrift Übersicht"
+ },
+
+ "medications": {
+ "title": "Medikamentenübersicht",
+ "newmedication": "Neues Medikament",
+ "searchplaceholder": "Suche nach Medikament, Form, Dosierung",
+ "search": "Suchen",
+ "reset": "Reset",
+ "medication": "Medikament",
+ "form": "Darreichungsform",
+ "dosage": "Dosierung",
+ "package": "Packung",
+ "status": "Status",
+ "actions": "Aktionen"
+ },
+
+ "medicationCreate": {
+ "title": "Neues Medikament",
+ "medication": "Medikament",
+ "form": "Darreichungsform",
+ "dosage": "Dosierung",
+ "package": "Packung",
+ "save": "Speichern",
+ "cancel": "Abbrechen"
+ },
+
+ "openServices": {
+ "title": "Offene Leistungen",
+ "noopenservices": "Keine offenen Leistungen vorhanden"
+ },
+
+ "patienteoverview": {
+ "patienttitle": "Patientenübersicht",
+ "newpatient": "Neuer Patient",
+ "nopatientfound": "Keine Patienten gefunden",
+ "closepatient": "Patient sperren (inaktiv)",
+ "openpatient": "Patient entsperren (Aktiv)",
+ "active": "Aktiv",
+ "inactive": "Inaktiv",
+ "dni": "DNI"
+ },
+
+ "patientCreate": {
+ "title": "Neuer Patient",
+ "firstname": "Vorname",
+ "lastname": "Nachname",
+ "dni": "N.I.E. / DNI",
+ "email": "E-Mail",
+ "phone": "Telefon",
+ "street": "Straße",
+ "housenumber": "Hausnummer",
+ "zip": "PLZ",
+ "city": "Ort",
+ "country": "Land",
+ "notes": "Notizen"
+ },
+
+ "patientEdit": {
+ "firstname": "Vorname",
+ "lastname": "Nachname",
+ "email": "E-Mail",
+ "phone": "Telefon",
+ "street": "Straße",
+ "housenumber": "Hausnummer",
+ "zip": "PLZ",
+ "city": "Ort",
+ "country": "Land",
+ "notes": "Notizen",
+ "save": "Änderungen speichern"
+ },
+
+ "patientMedications": {
+ "selectmedication": "Medikament auswählen",
+ "dosageinstructions": "Dosierungsanweisung",
+ "example": "z.B. 1-0-1",
+ "startdate": "Startdatum",
+ "enddate": "Enddatum",
+ "save": "Speichern",
+ "backoverview": "Zur Übersicht",
+ "nomedication": "Keine Medikation vorhanden.",
+ "medication": "Medikament",
+ "form": "Form",
+ "dosage": "Dosierung",
+ "instruction": "Anweisung",
+ "from": "Von",
+ "to": "Bis"
+ },
+
+ "patientOverview": {
+ "patientdata": "Patientendaten",
+ "firstname": "Vorname",
+ "lastname": "Nachname",
+ "birthdate": "Geburtsdatum",
+ "email": "E-Mail",
+ "phone": "Telefon",
+ "notes": "Notizen",
+ "newnote": "Neue Notiz hinzufügen…",
+ "nonotes": "Keine Notizen vorhanden",
+ "createrecipe": "Rezept erstellen",
+ "searchservice": "Leistung suchen…",
+ "noservices": "Noch keine Leistungen für heute.",
+ "addservice": "Leistung hinzufügen"
+ },
+
+ "patientDashboard": {
+ "email": "E-Mail:",
+ "phone": "Telefon:",
+ "address": "Adresse:",
+ "medications": "Aktuelle Medikamente",
+ "nomedications": "Keine aktiven Medikamente",
+ "medication": "Medikament",
+ "variant": "Variante",
+ "instruction": "Anweisung",
+ "invoices": "Rechnungen",
+ "noinvoices": "Keine Rechnungen vorhanden",
+ "date": "Datum",
+ "amount": "Betrag",
+ "pdf": "PDF",
+ "open": "Öffnen"
+ },
+
+ "services": {
+ "title": "Leistungen",
+ "newservice": "Neue Leistung",
+ "searchplaceholder": "Suche nach Name oder Kategorie",
+ "namede": "Bezeichnung (DE)",
+ "namees": "Bezeichnung (ES)",
+ "price": "Preis",
+ "pricec70": "Preis C70",
+ "status": "Status",
+ "actions": "Aktionen",
+ "editunlock": "Bearbeiten freigeben"
+ },
+
+ "serviceCreate": {
+ "title": "Neue Leistung",
+ "back": "Zurück",
+ "newservice": "Neue Leistung anlegen",
+ "namede": "Bezeichnung (Deutsch) *",
+ "namees": "Bezeichnung (Spanisch)",
+ "category": "Kategorie",
+ "price": "Preis (€) *",
+ "pricec70": "Preis C70 (€)"
+ },
+
+ "reportview": {
+ "title": "Abrechnungsreport",
+ "year": "Jahr",
+ "quarter": "Quartal"
+ },
+
+ "seriennumber": {
+ "seriennumbertitle": "Seriennummer eingeben",
+ "seriennumbertext": "Bitte gib deine Lizenz-Seriennummer ein um die Software dauerhaft freizuschalten.",
+ "seriennumbershort": "Seriennummer (AAAAA-AAAAA-AAAAA-AAAAA)",
+ "seriennumberdeclaration": "Nur Buchstaben + Zahlen. Format: 4x5 Zeichen, getrennt mit Bindestrich.",
+ "saveseriennumber": "Seriennummer Speichern"
+ },
+
+ "patientoverview": {
+ "nopatientfound": "Keine Patienten gefunden"
+ }
+}
diff --git a/locales/es.json b/locales/es.json
index b66f952..d2cb6c4 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -1,132 +1,408 @@
-{
- "global": {
- "save": "Guardar",
- "cancel": "Cancelar",
- "search": "Buscar",
- "reset": "Resetear",
- "dashboard": "Panel",
- "logout": "cerrar sesión",
- "title": "Título",
- "firstname": "Nombre",
- "lastname": "apellido",
- "username": "Nombre de usuario",
- "role": "desempeñar",
- "action": "acción",
- "status": "Estado",
- "you": "su mismo",
- "newuser": "Nuevo usuario",
- "inactive": "inactivo",
- "active": "activo",
- "closed": "bloqueado",
- "filter": "Filtro",
- "yearcash": "volumen de negocios anual",
- "monthcash": "volumen de negocios mensual",
- "quartalcash": "volumen de negocios trimestral",
- "year": "ano",
- "nodata": "sin datos",
- "month": "mes",
- "patientcash": "Ingresos por paciente",
- "patient": "paciente",
- "systeminfo": "Información del sistema",
- "table": "tablas",
- "lines": "líneas",
- "size": "Tamaño",
- "errordatabase": "Error al leer la información de la base de datos:",
- "welcome": "Bienvenido",
- "waitingroomtext": "Monitor de sala de espera",
- "waitingroomtextnopatient": "No hay pacientes en la sala de espera.",
- "gender": "Sexo",
- "birthday": "Fecha de nacimiento",
- "email": "Correo electrónico",
- "phone": "Teléfono",
- "address": "Dirección",
- "country": "País",
- "notice": "Notas",
- "create": "Creado",
- "change": "Modificado",
- "reset2": "Restablecer",
- "edit": "Editar",
- "selection": "Selección",
- "waiting": "Ya está esperando",
- "towaitingroom": "A la sala de espera",
- "overview": "Resumen",
- "upload": "Subir archivo",
- "lock": "bloquear",
- "unlock": "desbloquear",
- "name": "Nombre",
- "return": "Atrás",
- "fileupload": "Cargar"
- },
-
- "sidebar": {
- "patients": "Pacientes",
- "medications": "Medicamentos",
- "servicesOpen": "Servicios abiertos",
- "billing": "Facturación",
- "admin": "Administración",
- "logout": "Cerrar sesión"
- },
-
- "dashboard": {
- "welcome": "Bienvenido",
- "waitingRoom": "Monitor sala de espera",
- "noWaitingPatients": "No hay pacientes en la sala de espera.",
- "title": "Dashboard"
- },
-
- "adminSidebar": {
- "users": "Administración de usuarios",
- "database": "Administración de base de datos",
- "user": "usuario",
- "invocieoverview": "Resumen de facturas",
- "seriennumber": "número de serie",
- "databasetable": "base de datos",
- "companysettings": "Datos de la empresa"
- },
-
- "adminuseroverview": {
- "useroverview": "Resumen de usuarios",
- "usermanagement": "Administración de usuarios",
- "user": "usuario",
- "invocieoverview": "Resumen de facturas",
- "seriennumber": "número de serie",
- "databasetable": "base de datos"
- },
-
- "seriennumber": {
- "seriennumbertitle": "Introduce el número de serie",
- "seriennumbertext": "Introduce el número de serie de tu licencia para activar el software de forma permanente.",
- "seriennumbershort": "Número de serie (AAAAA-AAAAA-AAAAA-AAAAA)",
- "seriennumberdeclaration": "Solo letras y números. Formato: 4×5 caracteres, separados por «-». ",
- "saveseriennumber": "Guardar número de serie"
- },
-
- "databaseoverview": {
- "title": "Configuración de la base de datos",
- "host": "Host",
- "port": "Puerto",
- "database": "Base de datos",
- "password": "Contraseña",
- "connectiontest": "Probar conexión",
- "text": "Aquí puedes probar y guardar la conexión a la base de datos. ",
- "tablecount": "Número de tablas",
- "databasesize": "Tamaño de la base de datos",
- "tableoverview": "Resumen de tablas"
- },
-
- "patienteoverview": {
- "patienttitle": "Resumen de pacientes",
- "newpatient": "Paciente nuevo",
- "nopatientfound": "No se han encontrado pacientes.",
- "closepatient": "Bloquear paciente (inactivo)",
- "openpatient": "Desbloquear paciente (activo)"
- },
-
- "openinvoices": {
- "openinvoices": "Facturas de pacientes",
- "canceledinvoices": "Facturas canceladas",
- "report": "Informe de ventas",
- "payedinvoices": "Facturas pagadas",
- "creditoverview": "Resumen de abonos"
- }
-}
+{
+ "global": {
+ "save": "Guardar",
+ "cancel": "Cancelar",
+ "search": "Buscar",
+ "reset": "Resetear",
+ "reset2": "Restablecer",
+ "dashboard": "Panel",
+ "logout": "Cerrar sesión",
+ "title": "Título",
+ "firstname": "Nombre",
+ "lastname": "Apellido",
+ "username": "Nombre de usuario",
+ "role": "Rol",
+ "action": "Acciones",
+ "status": "Estado",
+ "you": "Usted mismo",
+ "newuser": "Nuevo usuario",
+ "inactive": "Inactivo",
+ "active": "Activo",
+ "closed": "Bloqueado",
+ "filter": "Filtro",
+ "yearcash": "Facturación anual",
+ "monthcash": "Facturación mensual",
+ "quartalcash": "Facturación trimestral",
+ "year": "Año",
+ "nodata": "Sin datos",
+ "month": "Mes",
+ "patientcash": "Ingresos por paciente",
+ "patient": "Paciente",
+ "systeminfo": "Información del sistema",
+ "table": "Tabla",
+ "lines": "Líneas",
+ "size": "Tamaño",
+ "errordatabase": "Error al leer la información de la base de datos:",
+ "welcome": "Bienvenido",
+ "waitingroomtext": "Monitor de sala de espera",
+ "waitingroomtextnopatient": "No hay pacientes en la sala de espera.",
+ "gender": "Sexo",
+ "birthday": "Fecha de nacimiento",
+ "birthdate": "Fecha de nacimiento",
+ "email": "Correo electrónico",
+ "phone": "Teléfono",
+ "address": "Dirección",
+ "country": "País",
+ "notice": "Notas",
+ "notes": "Notas",
+ "create": "Creado",
+ "change": "Modificado",
+ "edit": "Editar",
+ "selection": "Selección",
+ "waiting": "Ya está esperando",
+ "towaitingroom": "A la sala de espera",
+ "overview": "Resumen",
+ "upload": "Subir archivo",
+ "fileupload": "Cargar",
+ "lock": "Bloquear",
+ "unlock": "Desbloquear",
+ "name": "Nombre",
+ "return": "Atrás",
+ "back": "Atrás",
+ "date": "Fecha",
+ "amount": "Importe",
+ "quantity": "Cantidad",
+ "price": "Precio (€)",
+ "sum": "Total (€)",
+ "pdf": "PDF",
+ "open": "Abrir",
+ "from": "Desde",
+ "to": "Hasta",
+ "street": "Calle",
+ "housenumber": "Número",
+ "zip": "Código postal",
+ "city": "Ciudad",
+ "dni": "N.I.E. / DNI",
+ "dosage": "Dosificación",
+ "form": "Forma farmacéutica",
+ "package": "Envase",
+ "specialty": "Especialidad",
+ "doctornumber": "Número de médico",
+ "category": "Categoría"
+ },
+
+ "sidebar": {
+ "patients": "Pacientes",
+ "medications": "Medicamentos",
+ "servicesOpen": "Facturas de pacientes",
+ "billing": "Facturación",
+ "admin": "Administración",
+ "logout": "Cerrar sesión"
+ },
+
+ "dashboard": {
+ "welcome": "Bienvenido",
+ "waitingRoom": "Monitor sala de espera",
+ "noWaitingPatients": "No hay pacientes en la sala de espera.",
+ "title": "Panel"
+ },
+
+ "adminSidebar": {
+ "users": "Administración de usuarios",
+ "database": "Administración de base de datos",
+ "user": "Usuario",
+ "invocieoverview": "Resumen de facturas",
+ "seriennumber": "Número de serie",
+ "databasetable": "Base de datos",
+ "companysettings": "Datos de la empresa"
+ },
+
+ "adminuseroverview": {
+ "useroverview": "Resumen de usuarios",
+ "usermanagement": "Administración de usuarios",
+ "user": "Usuario",
+ "invocieoverview": "Resumen de facturas",
+ "seriennumber": "Número de serie",
+ "databasetable": "Base de datos"
+ },
+
+ "adminCreateUser": {
+ "title": "Crear usuario",
+ "firstname": "Nombre",
+ "lastname": "Apellido",
+ "usertitle": "Título (p. ej. Dr., Prof.)",
+ "username": "Nombre de usuario (login)",
+ "password": "Contraseña",
+ "specialty": "Especialidad",
+ "doctornumber": "Número de médico",
+ "createuser": "Crear usuario",
+ "back": "Atrás"
+ },
+
+ "adminServiceLogs": {
+ "title": "Registro de cambios de servicios",
+ "date": "Fecha",
+ "user": "Usuario",
+ "action": "Acción",
+ "before": "Antes",
+ "after": "Después"
+ },
+
+ "companySettings": {
+ "title": "Datos de la empresa",
+ "companyname": "Nombre de la empresa",
+ "legalform": "Forma jurídica",
+ "owner": "Propietario / Director",
+ "email": "Correo electrónico",
+ "street": "Calle",
+ "housenumber": "Número",
+ "zip": "Código postal",
+ "city": "Ciudad",
+ "country": "País",
+ "taxid": "NIF / Número fiscal",
+ "bank": "Banco",
+ "iban": "IBAN",
+ "bic": "BIC",
+ "invoicefooter": "Pie de factura",
+ "companylogo": "Logotipo de la empresa",
+ "currentlogo": "Logotipo actual:",
+ "back": "Atrás"
+ },
+
+ "databaseoverview": {
+ "title": "Configuración de la base de datos",
+ "text": "Aquí puedes probar y guardar la conexión a la base de datos.",
+ "host": "Host",
+ "port": "Puerto",
+ "database": "Base de datos",
+ "password": "Contraseña",
+ "connectiontest": "Probar conexión",
+ "tablecount": "Número de tablas",
+ "databasesize": "Tamaño de la base de datos",
+ "tableoverview": "Resumen de tablas",
+ "mysqlversion": "Versión de MySQL",
+ "nodbinfo": "No hay información del sistema disponible (la BD puede no estar configurada o la conexión falló)"
+ },
+
+ "invoiceAdmin": {
+ "fromyear": "Año desde",
+ "toyear": "Año hasta",
+ "searchpatient": "Buscar paciente..."
+ },
+
+ "cancelledInvoices": {
+ "title": "Facturas canceladas",
+ "year": "Año:",
+ "noinvoices": "No hay facturas canceladas para este año.",
+ "patient": "Paciente",
+ "date": "Fecha",
+ "amount": "Importe"
+ },
+
+ "creditOverview": {
+ "title": "Resumen de abonos",
+ "year": "Año:",
+ "invoice": "Factura",
+ "date": "Fecha",
+ "pdf": "PDF",
+ "creditnote": "Abono",
+ "patient": "Paciente",
+ "amount": "Importe",
+ "open": "Abrir"
+ },
+
+ "invoice": {
+ "title": "RECHNUNG / FACTURA",
+ "invoicenumber": "Número de factura:",
+ "nie": "N.I.E / DNI:",
+ "birthdate": "Fecha de nacimiento:",
+ "patient": "Paciente:",
+ "servicetext": "Por nuestros servicios, nos permitimos facturarle lo siguiente:",
+ "quantity": "Cantidad",
+ "treatment": "Tratamiento",
+ "price": "Precio (€)",
+ "sum": "Total (€)",
+ "doctor": "Médico responsable:",
+ "specialty": "Especialidad:",
+ "doctornumber": "Número de médico:",
+ "legal": "Factura médica privada"
+ },
+
+ "openInvoices": {
+ "title": "Servicios abiertos",
+ "noinvoices": "No hay facturas abiertas 🎉",
+ "patient": "Paciente",
+ "date": "Fecha",
+ "amount": "Importe",
+ "status": "Estado",
+ "open": "Abierto"
+ },
+
+ "paidInvoices": {
+ "title": "Facturas pagadas",
+ "year": "Año",
+ "quarter": "Trimestre",
+ "patient": "Paciente",
+ "date": "Fecha",
+ "amount": "Importe"
+ },
+
+ "openinvoices": {
+ "openinvoices": "Facturas de pacientes",
+ "canceledinvoices": "Facturas canceladas",
+ "report": "Informe de ventas",
+ "payedinvoices": "Facturas pagadas",
+ "creditoverview": "Resumen de abonos"
+ },
+
+ "medications": {
+ "title": "Resumen de medicamentos",
+ "newmedication": "Nuevo medicamento",
+ "searchplaceholder": "Buscar medicamento, forma, dosificación",
+ "search": "Buscar",
+ "reset": "Restablecer",
+ "medication": "Medicamento",
+ "form": "Forma farmacéutica",
+ "dosage": "Dosificación",
+ "package": "Envase",
+ "status": "Estado",
+ "actions": "Acciones"
+ },
+
+ "medicationCreate": {
+ "title": "Nuevo medicamento",
+ "medication": "Medicamento",
+ "form": "Forma farmacéutica",
+ "dosage": "Dosificación",
+ "package": "Envase",
+ "save": "Guardar",
+ "cancel": "Cancelar"
+ },
+
+ "openServices": {
+ "title": "Servicios abiertos",
+ "noopenservices": "No hay servicios abiertos"
+ },
+
+ "patienteoverview": {
+ "patienttitle": "Resumen de pacientes",
+ "newpatient": "Paciente nuevo",
+ "nopatientfound": "No se han encontrado pacientes.",
+ "closepatient": "Bloquear paciente (inactivo)",
+ "openpatient": "Desbloquear paciente (activo)",
+ "active": "Activo",
+ "inactive": "Inactivo",
+ "dni": "DNI"
+ },
+
+ "patientCreate": {
+ "title": "Nuevo paciente",
+ "firstname": "Nombre",
+ "lastname": "Apellido",
+ "dni": "N.I.E. / DNI",
+ "email": "Correo electrónico",
+ "phone": "Teléfono",
+ "street": "Calle",
+ "housenumber": "Número",
+ "zip": "Código postal",
+ "city": "Ciudad",
+ "country": "País",
+ "notes": "Notas"
+ },
+
+ "patientEdit": {
+ "firstname": "Nombre",
+ "lastname": "Apellido",
+ "email": "Correo electrónico",
+ "phone": "Teléfono",
+ "street": "Calle",
+ "housenumber": "Número",
+ "zip": "Código postal",
+ "city": "Ciudad",
+ "country": "País",
+ "notes": "Notas",
+ "save": "Guardar cambios"
+ },
+
+ "patientMedications": {
+ "selectmedication": "Seleccionar medicamento",
+ "dosageinstructions": "Instrucciones de dosificación",
+ "example": "p.ej. 1-0-1",
+ "startdate": "Fecha de inicio",
+ "enddate": "Fecha de fin",
+ "save": "Guardar",
+ "backoverview": "Volver al resumen",
+ "nomedication": "No hay medicación registrada.",
+ "medication": "Medicamento",
+ "form": "Forma",
+ "dosage": "Dosificación",
+ "instruction": "Instrucción",
+ "from": "Desde",
+ "to": "Hasta"
+ },
+
+ "patientOverview": {
+ "patientdata": "Datos del paciente",
+ "firstname": "Nombre",
+ "lastname": "Apellido",
+ "birthdate": "Fecha de nacimiento",
+ "email": "Correo electrónico",
+ "phone": "Teléfono",
+ "notes": "Notas",
+ "newnote": "Añadir nueva nota…",
+ "nonotes": "No hay notas",
+ "createrecipe": "Crear receta",
+ "searchservice": "Buscar servicio…",
+ "noservices": "Todavía no hay servicios para hoy.",
+ "addservice": "Añadir servicio"
+ },
+
+ "patientDashboard": {
+ "email": "Correo electrónico:",
+ "phone": "Teléfono:",
+ "address": "Dirección:",
+ "medications": "Medicamentos actuales",
+ "nomedications": "Sin medicamentos activos",
+ "medication": "Medicamento",
+ "variant": "Variante",
+ "instruction": "Instrucción",
+ "invoices": "Facturas",
+ "noinvoices": "No hay facturas",
+ "date": "Fecha",
+ "amount": "Importe",
+ "pdf": "PDF",
+ "open": "Abrir"
+ },
+
+ "services": {
+ "title": "Servicios",
+ "newservice": "Nuevo servicio",
+ "searchplaceholder": "Buscar por nombre o categoría",
+ "namede": "Denominación (DE)",
+ "namees": "Denominación (ES)",
+ "price": "Precio",
+ "pricec70": "Precio C70",
+ "status": "Estado",
+ "actions": "Acciones",
+ "editunlock": "Desbloquear edición"
+ },
+
+ "serviceCreate": {
+ "title": "Nuevo servicio",
+ "back": "Atrás",
+ "newservice": "Crear nuevo servicio",
+ "namede": "Denominación (Alemán) *",
+ "namees": "Denominación (Español)",
+ "category": "Categoría",
+ "price": "Precio (€) *",
+ "pricec70": "Precio C70 (€)"
+ },
+
+ "reportview": {
+ "title": "Informe de facturación",
+ "year": "Año",
+ "quarter": "Trimestre"
+ },
+
+ "seriennumber": {
+ "seriennumbertitle": "Introduce el número de serie",
+ "seriennumbertext": "Introduce el número de serie de tu licencia para activar el software de forma permanente.",
+ "seriennumbershort": "Número de serie (AAAAA-AAAAA-AAAAA-AAAAA)",
+ "seriennumberdeclaration": "Solo letras y números. Formato: 4x5 caracteres, separados por guion.",
+ "saveseriennumber": "Guardar número de serie"
+ },
+
+ "patientoverview": {
+ "nopatientfound": "No se han encontrado pacientes."
+ }
+}
diff --git a/middleware/auth.middleware.js b/middleware/auth.middleware.js
index e46a537..db29ce8 100644
--- a/middleware/auth.middleware.js
+++ b/middleware/auth.middleware.js
@@ -1,54 +1,64 @@
-function requireLogin(req, res, next) {
- if (!req.session.user) {
- return res.redirect("/");
- }
-
- req.user = req.session.user;
- next();
-}
-
-// ✅ NEU: Arzt-only (das war früher dein requireAdmin)
-function requireArzt(req, res, next) {
- console.log("ARZT CHECK:", req.session.user);
-
- if (!req.session.user) {
- return res.redirect("/");
- }
-
- if (req.session.user.role !== "arzt") {
- return res
- .status(403)
- .send(
- "⛔ Kein Zugriff (Arzt erforderlich). Rolle: " + req.session.user.role,
- );
- }
-
- req.user = req.session.user;
- next();
-}
-
-// ✅ NEU: Admin-only
-function requireAdmin(req, res, next) {
- console.log("ADMIN CHECK:", req.session.user);
-
- if (!req.session.user) {
- return res.redirect("/");
- }
-
- if (req.session.user.role !== "admin") {
- return res
- .status(403)
- .send(
- "⛔ Kein Zugriff (Admin erforderlich). Rolle: " + req.session.user.role,
- );
- }
-
- req.user = req.session.user;
- next();
-}
-
-module.exports = {
- requireLogin,
- requireArzt,
- requireAdmin,
-};
+function requireLogin(req, res, next) {
+ if (!req.session.user) {
+ return res.redirect("/");
+ }
+
+ req.user = req.session.user;
+ next();
+}
+
+// ── Hilfsfunktion: Zugriff verweigern mit Flash + Redirect ────────────────────
+function denyAccess(req, res, message) {
+ // Zurück zur vorherigen Seite, oder zum Dashboard
+ const back = req.get("Referrer") || "/dashboard";
+
+ req.session.flash = req.session.flash || [];
+ req.session.flash.push({ type: "danger", message });
+
+ return res.redirect(back);
+}
+
+// ✅ Arzt-only
+function requireArzt(req, res, next) {
+ if (!req.session.user) return res.redirect("/");
+
+ if (req.session.user.role !== "arzt") {
+ return denyAccess(req, res, "⛔ Kein Zugriff – diese Seite ist nur für Ärzte.");
+ }
+
+ req.user = req.session.user;
+ next();
+}
+
+// ✅ Admin-only
+function requireAdmin(req, res, next) {
+ if (!req.session.user) return res.redirect("/");
+
+ if (req.session.user.role !== "admin") {
+ return denyAccess(req, res, "⛔ Kein Zugriff – diese Seite ist nur für Administratoren.");
+ }
+
+ req.user = req.session.user;
+ next();
+}
+
+// ✅ Arzt + Mitarbeiter
+function requireArztOrMitarbeiter(req, res, next) {
+ if (!req.session.user) return res.redirect("/");
+
+ const allowed = ["arzt", "mitarbeiter"];
+
+ if (!allowed.includes(req.session.user.role)) {
+ return denyAccess(req, res, "⛔ Kein Zugriff – diese Seite ist nur für Ärzte und Mitarbeiter.");
+ }
+
+ req.user = req.session.user;
+ next();
+}
+
+module.exports = {
+ requireLogin,
+ requireArzt,
+ requireAdmin,
+ requireArztOrMitarbeiter,
+};
diff --git a/package-lock.json b/package-lock.json
index eca3a61..b144b27 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
+ "date-holidays": "^3.26.11",
"docxtemplater": "^3.67.6",
"dotenv": "^17.2.3",
"ejs": "^3.1.10",
@@ -1683,6 +1684,15 @@
"safer-buffer": "~2.1.0"
}
},
+ "node_modules/astronomia": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/astronomia/-/astronomia-4.2.0.tgz",
+ "integrity": "sha512-mTvpBGyXB80aSsDhAAiuwza5VqAyqmj5yzhjBrFhRy17DcWDzJrb8Vdl4Sm+g276S+mY7bk/5hi6akZ5RQFeHg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -2134,6 +2144,18 @@
"node": ">= 0.8"
}
},
+ "node_modules/caldate": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/caldate/-/caldate-2.0.5.tgz",
+ "integrity": "sha512-JndhrUuDuE975KUhFqJaVR1OQkCHZqpOrJur/CFXEIEhWhBMjxO85cRSK8q4FW+B+yyPq6GYua2u4KvNzTcq0w==",
+ "license": "ISC",
+ "dependencies": {
+ "moment-timezone": "^0.5.43"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -2655,6 +2677,91 @@
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"license": "MIT"
},
+ "node_modules/date-bengali-revised": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/date-bengali-revised/-/date-bengali-revised-2.0.2.tgz",
+ "integrity": "sha512-q9iDru4+TSA9k4zfm0CFHJj6nBsxP7AYgWC/qodK/i7oOIlj5K2z5IcQDtESfs/Qwqt/xJYaP86tkazd/vRptg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/date-chinese": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/date-chinese/-/date-chinese-2.1.4.tgz",
+ "integrity": "sha512-WY+6+Qw92ZGWFvGtStmNQHEYpNa87b8IAQ5T8VKt4wqrn24lBXyyBnWI5jAIyy7h/KVwJZ06bD8l/b7yss82Ww==",
+ "license": "MIT",
+ "dependencies": {
+ "astronomia": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/date-easter": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/date-easter/-/date-easter-1.0.3.tgz",
+ "integrity": "sha512-aOViyIgpM4W0OWUiLqivznwTtuMlD/rdUWhc5IatYnplhPiWrLv75cnifaKYhmQwUBLAMWLNG4/9mlLIbXoGBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/date-holidays": {
+ "version": "3.26.11",
+ "resolved": "https://registry.npmjs.org/date-holidays/-/date-holidays-3.26.11.tgz",
+ "integrity": "sha512-A8997Xv4k6fhpfu1xg2hEMfhB5MvWk/7TWIt1YmRFM2QPMENgL2WiaSe4zpSRzfnHSpkozcea9+R+Y5IvGJimQ==",
+ "license": "(ISC AND CC-BY-3.0)",
+ "dependencies": {
+ "date-holidays-parser": "^3.4.7",
+ "js-yaml": "^4.1.1",
+ "lodash": "^4.17.23",
+ "prepin": "^1.0.3"
+ },
+ "bin": {
+ "holidays2json": "scripts/holidays2json.cjs"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/date-holidays-parser": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/date-holidays-parser/-/date-holidays-parser-3.4.7.tgz",
+ "integrity": "sha512-h09ZEtM6u5cYM6m1bX+1Ny9f+nLO9KVZUKNPEnH7lhbXYTfqZogaGTnhONswGeIJFF91UImIftS3CdM9HLW5oQ==",
+ "license": "ISC",
+ "dependencies": {
+ "astronomia": "^4.1.1",
+ "caldate": "^2.0.5",
+ "date-bengali-revised": "^2.0.2",
+ "date-chinese": "^2.1.4",
+ "date-easter": "^1.0.3",
+ "deepmerge": "^4.3.1",
+ "jalaali-js": "^1.2.7",
+ "moment-timezone": "^0.5.47"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/date-holidays/node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/date-holidays/node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2691,7 +2798,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4331,6 +4437,12 @@
"node": ">=10"
}
},
+ "node_modules/jalaali-js": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
+ "integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==",
+ "license": "MIT"
+ },
"node_modules/jest": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz",
@@ -5039,6 +5151,12 @@
"node": ">=8"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "license": "MIT"
+ },
"node_modules/lodash.assignin": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz",
@@ -5320,6 +5438,27 @@
"mkdirp": "bin/cmd.js"
}
},
+ "node_modules/moment": {
+ "version": "2.30.1",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
+ "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/moment-timezone": {
+ "version": "0.5.48",
+ "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
+ "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
+ "license": "MIT",
+ "dependencies": {
+ "moment": "^2.29.4"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5878,6 +6017,15 @@
"node": ">=8"
}
},
+ "node_modules/prepin": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/prepin/-/prepin-1.0.3.tgz",
+ "integrity": "sha512-0XL2hreherEEvUy0fiaGEfN/ioXFV+JpImqIzQjxk6iBg4jQ2ARKqvC4+BmRD8w/pnpD+lbxvh0Ub+z7yBEjvA==",
+ "license": "Unlicense",
+ "bin": {
+ "prepin": "bin/prepin.js"
+ }
+ },
"node_modules/pretty-format": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz",
diff --git a/package.json b/package.json
index 0d50c50..7833362 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
+ "date-holidays": "^3.26.11",
"docxtemplater": "^3.67.6",
"dotenv": "^17.2.3",
"ejs": "^3.1.10",
diff --git a/public/js/calendar.js b/public/js/calendar.js
new file mode 100644
index 0000000..6b38c22
--- /dev/null
+++ b/public/js/calendar.js
@@ -0,0 +1,506 @@
+(function () {
+ 'use strict';
+
+ /* ── Daten aus DOM (CSP-sicher via
-
-