Compare commits

..

6 Commits

146 changed files with 8747 additions and 13552 deletions

12
.env Normal file
View File

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

190
app.js
View File

@ -3,17 +3,17 @@ require("dotenv").config();
const express = require("express");
const session = require("express-session");
const helmet = require("helmet");
const mysql = require("mysql2/promise");
const fs = require("fs");
const path = require("path");
const expressLayouts = require("express-ejs-layouts");
// ✅ DB + Session Store
const db = require("./db");
const { getSessionStore } = require("./config/session");
// ✅ Verschlüsselte Config
const { configExists, saveConfig } = require("./config-manager");
// ✅ Setup Middleware + Setup Routes
const requireSetup = require("./middleware/requireSetup");
const setupRoutes = require("./routes/setup.routes");
// ✅ DB + Session Reset
const db = require("./db");
const { getSessionStore, resetSessionStore } = require("./config/session");
// ✅ Routes (deine)
const adminRoutes = require("./routes/admin.routes");
@ -28,8 +28,6 @@ const invoiceRoutes = require("./routes/invoice.routes");
const patientFileRoutes = require("./routes/patientFile.routes");
const companySettingsRoutes = require("./routes/companySettings.routes");
const authRoutes = require("./routes/auth.routes");
const reportRoutes = require("./routes/report.routes");
const calendarRoutes = require("./routes/calendar.routes");
const app = express();
@ -66,55 +64,85 @@ function passesModulo3(serial) {
return sum % 3 === 0;
}
/* ===============================
SETUP HTML
================================ */
function setupHtml(error = "") {
return `
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Praxissoftware Setup</title>
<style>
body{font-family:Arial;background:#f4f4f4;padding:30px}
.card{max-width:520px;margin:auto;background:#fff;padding:22px;border-radius:14px}
input{width:100%;padding:10px;margin:6px 0;border:1px solid #ccc;border-radius:8px}
button{padding:10px 14px;border:0;border-radius:8px;background:#111;color:#fff;cursor:pointer}
.err{color:#b00020;margin:10px 0}
.hint{color:#666;font-size:13px;margin-top:12px}
</style>
</head>
<body>
<div class="card">
<h2>🔧 Datenbank Einrichtung</h2>
${error ? `<div class="err">❌ ${error}</div>` : ""}
<form method="POST" action="/setup">
<label>DB Host</label>
<input name="host" placeholder="85.215.63.122" required />
<label>DB Benutzer</label>
<input name="user" placeholder="praxisuser" required />
<label>DB Passwort</label>
<input name="password" type="password" required />
<label>DB Name</label>
<input name="name" placeholder="praxissoftware" required />
<button type="submit"> Speichern</button>
</form>
<div class="hint">
Die Daten werden verschlüsselt gespeichert (<b>config.enc</b>).<br/>
Danach wirst du automatisch auf die Loginseite weitergeleitet.
</div>
</div>
</body>
</html>
`;
}
/* ===============================
MIDDLEWARE
================================ */
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
},
},
}),
);
app.use(helmet());
app.use(
session({
name: "praxis.sid",
secret: process.env.SESSION_SECRET || "dev-secret",
secret: process.env.SESSION_SECRET,
store: getSessionStore(),
resave: false,
saveUninitialized: false,
}),
);
// ✅ i18n Middleware (SAFE)
// ✅ i18n Middleware 1 (setzt res.locals.t + lang)
app.use((req, res, next) => {
try {
const lang = req.session.lang || "de";
const filePath = path.join(__dirname, "locales", `${lang}.json`);
const raw = fs.readFileSync(filePath, "utf-8");
let data = {};
if (fs.existsSync(filePath)) {
data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
res.locals.t = data;
res.locals.t = JSON.parse(raw);
res.locals.lang = lang;
next();
} catch (err) {
console.error("❌ i18n Fehler:", err.message);
res.locals.t = {};
res.locals.lang = "de";
next();
}
});
const flashMiddleware = require("./middleware/flash.middleware");
@ -124,24 +152,20 @@ app.use(express.static("public"));
app.use("/uploads", express.static("uploads"));
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
app.use(expressLayouts);
app.set("layout", "layout");
app.set("layout", "layout"); // verwendet views/layout.ejs
app.use((req, res, next) => {
res.locals.user = req.session.user || null;
next();
});
/* ===============================
SETUP ROUTES + SETUP GATE
WICHTIG: /setup zuerst mounten, danach requireSetup
================================ */
app.use("/setup", setupRoutes);
app.use(requireSetup);
/* ===============================
LICENSE/TRIAL GATE
- Trial startet automatisch, wenn noch NULL
- Wenn abgelaufen:
Admin -> /admin/serial-number
Arzt/Member -> /serial-number
================================ */
app.use(async (req, res, next) => {
try {
@ -206,6 +230,57 @@ app.use(async (req, res, next) => {
}
});
/* ===============================
SETUP ROUTES
================================ */
app.get("/setup", (req, res) => {
if (configExists()) return res.redirect("/");
return res.status(200).send(setupHtml());
});
app.post("/setup", async (req, res) => {
try {
const { host, user, password, name } = req.body;
if (!host || !user || !password || !name) {
return res.status(400).send(setupHtml("Bitte alle Felder ausfüllen."));
}
const conn = await mysql.createConnection({
host,
user,
password,
database: name,
});
await conn.query("SELECT 1");
await conn.end();
saveConfig({
db: { host, user, password, name },
});
if (typeof db.resetPool === "function") {
db.resetPool();
}
resetSessionStore();
return res.redirect("/");
} catch (err) {
return res
.status(500)
.send(setupHtml("DB Verbindung fehlgeschlagen: " + err.message));
}
});
// Wenn keine config.enc → alles außer /setup auf Setup umleiten
app.use((req, res, next) => {
if (!configExists() && req.path !== "/setup") {
return res.redirect("/setup");
}
next();
});
/* ===============================
Sprache ändern
================================ */
@ -227,6 +302,14 @@ app.get("/lang/:lang", (req, res) => {
/* ===============================
SERIAL PAGES
================================ */
/**
* /serial-number
* - Trial aktiv: zeigt Resttage + Button Dashboard
* - Trial abgelaufen:
* Admin -> redirect /admin/serial-number
* Arzt/Member -> trial_expired.ejs
*/
app.get("/serial-number", async (req, res) => {
try {
if (!req.session?.user) return res.redirect("/");
@ -288,6 +371,9 @@ app.get("/serial-number", async (req, res) => {
}
});
/**
* Admin Seite: Seriennummer eingeben
*/
app.get("/admin/serial-number", async (req, res) => {
try {
if (!req.session?.user) return res.redirect("/");
@ -316,6 +402,9 @@ app.get("/admin/serial-number", async (req, res) => {
}
});
/**
* Admin Seite: Seriennummer speichern
*/
app.post("/admin/serial-number", async (req, res) => {
try {
if (!req.session?.user) return res.redirect("/");
@ -408,11 +497,7 @@ app.use("/services", serviceRoutes);
app.use("/", patientFileRoutes);
app.use("/", waitingRoomRoutes);
app.use("/invoices", invoiceRoutes);
app.use("/reportview", reportRoutes);
app.use("/calendar", calendarRoutes);
app.use("/", invoiceRoutes);
app.get("/logout", (req, res) => {
req.session.destroy(() => res.redirect("/"));
@ -430,10 +515,7 @@ app.use((err, req, res, next) => {
SERVER
================================ */
const PORT = process.env.PORT || 51777;
const HOST = process.env.HOST || "0.0.0.0";
console.log("DB HOST:", process.env.DBSERVER_HOST);
console.log("DB PORT:", process.env.DBSERVER_PORT);
const HOST = "127.0.0.1";
app.listen(PORT, HOST, () => {
console.log(`Server läuft auf http://${HOST}:${PORT}`);

File diff suppressed because one or more lines are too long

View File

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

View File

@ -266,8 +266,8 @@ async function showInvoiceOverview(req, res) {
res.render("admin/admin_invoice_overview", {
title: "Rechnungsübersicht",
sidebarPartial: "partials/admin-sidebar", // ✅ keine Sidebar
active: "invoices",
sidebarPartial: "partials/sidebar-empty", // ✅ keine Sidebar
active: "",
user: req.session.user,
lang: req.session.lang || "de",

View File

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

View File

@ -13,27 +13,14 @@ const safe = (v) => {
* GET: Firmendaten anzeigen
*/
async function getCompanySettings(req, res) {
try {
const [[company]] = await db
.promise()
.query("SELECT * FROM company_settings LIMIT 1");
const [[company]] = await db.promise().query(
"SELECT * FROM company_settings LIMIT 1"
);
res.render("admin/company-settings", {
layout: "layout", // 🔥 wichtig
title: "Firmendaten", // 🔥 DAS FEHLTE
active: "companySettings", // 🔥 Sidebar aktiv
sidebarPartial: "partials/admin-sidebar",
company: company || {},
user: req.session.user, // 🔥 konsistent
lang: req.session.lang || "de"
// t kommt aus res.locals
user: req.user,
company: company || {}
});
} catch (err) {
console.error(err);
res.status(500).send("Datenbankfehler");
}
}
/**

View File

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

View File

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

View File

@ -44,10 +44,7 @@ function listMedications(req, res, next) {
res.render("medications", {
title: "Medikamentenübersicht",
// ✅ IMMER patient-sidebar verwenden
sidebarPartial: "partials/sidebar-empty",
backUrl: "/dashboard",
sidebarPartial: "partials/sidebar-empty", // ✅ schwarzer Balken links
active: "medications",
rows,

View File

@ -33,12 +33,12 @@ async function listPatients(req, res) {
const params = [];
if (firstname) {
sql += " AND LOWER(firstname) LIKE LOWER(?)";
sql += " AND firstname LIKE ?";
params.push(`%${firstname}%`);
}
if (lastname) {
sql += " AND LOWER(lastname) LIKE LOWER(?)";
sql += " AND lastname LIKE ?";
params.push(`%${lastname}%`);
}
@ -79,7 +79,7 @@ async function listPatients(req, res) {
// ✅ Sidebar dynamisch
sidebarPartial: selectedPatient
? "partials/patient_sidebar"
? "partials/patient-sidebar"
: "partials/sidebar",
// ✅ Active dynamisch
@ -114,7 +114,7 @@ function showEditPatient(req, res) {
res.render("patient_edit", {
title: "Patient bearbeiten",
sidebarPartial: "partials/patient_sidebar",
sidebarPartial: "partials/patient-sidebar",
active: "patient_edit",
patient: results[0],
@ -538,7 +538,7 @@ function showMedicationPlan(req, res) {
res.render("patient_plan", {
title: "Medikationsplan",
sidebarPartial: "partials/patient_sidebar",
sidebarPartial: "partials/patient-sidebar",
active: "patient_plan",
patient: patients[0],
@ -675,7 +675,7 @@ async function showPatientOverviewDashborad(req, res) {
res.render("patient_overview_dashboard", {
title: "Patient Dashboard",
sidebarPartial: "partials/patient_sidebar",
sidebarPartial: "partials/patient-sidebar",
active: "patient_dashboard",
patient,

View File

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

View File

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

View File

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

View File

@ -4,405 +4,23 @@
"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"
"dashboard": "Dashboard"
},
"sidebar": {
"patients": "Patienten",
"medications": "Medikamente",
"servicesOpen": "Patienten Rechnungen",
"servicesOpen": "Offene Leistungen",
"billing": "Abrechnung",
"admin": "Verwaltung",
"logout": "Logout"
},
"dashboard": {
"welcome": "Willkommen",
"waitingRoom": "Wartezimmer-Monitor",
"noWaitingPatients": "Keine Patienten im Wartezimmer.",
"title": "Dashboard"
"noWaitingPatients": "Keine Patienten im Wartezimmer."
},
"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"
"database": "Datenbankverwaltung"
}
}

View File

@ -4,405 +4,24 @@
"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"
"dashboard": "Panel"
},
"sidebar": {
"patients": "Pacientes",
"medications": "Medicamentos",
"servicesOpen": "Facturas de pacientes",
"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": "Panel"
"noWaitingPatients": "No hay pacientes en la sala de espera."
},
"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."
"database": "Administración de base de datos"
}
}

View File

@ -7,49 +7,40 @@ function requireLogin(req, res, next) {
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
// ✅ NEU: Arzt-only (das war früher dein requireAdmin)
function requireArzt(req, res, next) {
if (!req.session.user) return res.redirect("/");
console.log("ARZT CHECK:", req.session.user);
if (!req.session.user) {
return res.redirect("/");
}
if (req.session.user.role !== "arzt") {
return denyAccess(req, res, "⛔ Kein Zugriff diese Seite ist nur für Ärzte.");
return res
.status(403)
.send(
"⛔ Kein Zugriff (Arzt erforderlich). Rolle: " + req.session.user.role,
);
}
req.user = req.session.user;
next();
}
// ✅ Admin-only
// ✅ NEU: Admin-only
function requireAdmin(req, res, next) {
if (!req.session.user) return res.redirect("/");
console.log("ADMIN CHECK:", req.session.user);
if (!req.session.user) {
return res.redirect("/");
}
if (req.session.user.role !== "admin") {
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.");
return res
.status(403)
.send(
"⛔ Kein Zugriff (Admin erforderlich). Rolle: " + req.session.user.role,
);
}
req.user = req.session.user;
@ -60,5 +51,4 @@ module.exports = {
requireLogin,
requireArzt,
requireAdmin,
requireArztOrMitarbeiter,
};

View File

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

Binary file not shown.

343
package-lock.json generated
View File

@ -11,8 +11,6 @@
"bcrypt": "^6.0.0",
"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",
@ -25,8 +23,6 @@
"html-pdf-node": "^1.0.8",
"multer": "^2.0.2",
"mysql2": "^3.16.0",
"node-ssh": "^13.2.1",
"pdf-lib": "^1.17.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
@ -1041,12 +1037,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -1083,24 +1073,6 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -1676,23 +1648,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/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",
@ -1880,14 +1835,6 @@
"node": ">= 18"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -2115,15 +2062,6 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -2144,18 +2082,6 @@
"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",
@ -2289,18 +2215,6 @@
"node": ">=10"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/cheerio": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz",
@ -2600,20 +2514,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@ -2677,91 +2577,6 @@
"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",
@ -2798,6 +2613,7 @@
"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"
@ -4295,6 +4111,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -4437,12 +4254,6 @@
"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",
@ -5151,12 +4962,6 @@
"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",
@ -5438,27 +5243,6 @@
"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",
@ -5515,12 +5299,6 @@
"node": ">=8.0.0"
}
},
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"optional": true
},
"node_modules/napi-postinstall": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@ -5602,44 +5380,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-ssh": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/node-ssh/-/node-ssh-13.2.1.tgz",
"integrity": "sha512-rfl4GWMygQfzlExPkQ2LWyya5n2jOBm5vhEnup+4mdw7tQhNpJWbP5ldr09Jfj93k5SfY5lxcn8od5qrQ/6mBg==",
"dependencies": {
"is-stream": "^2.0.0",
"make-dir": "^3.1.0",
"sb-promise-queue": "^2.1.0",
"sb-scandir": "^3.1.0",
"shell-escape": "^0.2.0",
"ssh2": "^1.14.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/node-ssh/node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/node-ssh/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -5851,12 +5591,6 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -5943,24 +5677,6 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@ -6017,15 +5733,6 @@
"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",
@ -6329,25 +6036,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sb-promise-queue": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/sb-promise-queue/-/sb-promise-queue-2.1.1.tgz",
"integrity": "sha512-qXfdcJQMxMljxmPprn4Q4hl3pJmoljSCzUvvEBa9Kscewnv56n0KqrO6yWSrGLOL9E021wcGdPa39CHGKA6G0w==",
"engines": {
"node": ">= 8"
}
},
"node_modules/sb-scandir": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/sb-scandir/-/sb-scandir-3.1.1.tgz",
"integrity": "sha512-Q5xiQMtoragW9z8YsVYTAZcew+cRzdVBefPbb9theaIKw6cBo34WonP9qOCTKgyAmn/Ch5gmtAxT/krUgMILpA==",
"dependencies": {
"sb-promise-queue": "^2.1.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -6449,11 +6137,6 @@
"node": ">=8"
}
},
"node_modules/shell-escape": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz",
"integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -6628,23 +6311,6 @@
"node": ">=0.8"
}
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@ -7060,11 +6726,6 @@
"license": "0BSD",
"optional": true
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
},
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",

View File

@ -15,8 +15,6 @@
"bcrypt": "^6.0.0",
"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",
@ -29,8 +27,6 @@
"html-pdf-node": "^1.0.8",
"multer": "^2.0.2",
"mysql2": "^3.16.0",
"node-ssh": "^13.2.1",
"pdf-lib": "^1.17.1",
"xlsx": "^0.18.5"
},
"devDependencies": {

View File

@ -172,7 +172,7 @@ a.waiting-slot {
/* ✅ Uhrzeit (oben rechts unter dem Button) */
.page-header-datetime {
font-size: 24px;
font-size: 14px;
opacity: 0.85;
}
@ -285,26 +285,3 @@ a.waiting-slot {
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.

View File

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

File diff suppressed because one or more lines are too long

View File

@ -2,18 +2,7 @@
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}`;
el.textContent = new Date().toLocaleString("de-DE");
}
updateDateTime();

View File

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

View File

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

View File

@ -1,14 +1,24 @@
/**
* public/js/patient-select.js
*
* Ersetzt den inline onchange="this.form.submit()" Handler
* an den Patienten-Radiobuttons (CSP-sicher).
*/
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.patient-radio').forEach(function (radio) {
radio.addEventListener('change', function () {
var form = this.closest('form');
if (form) form.submit();
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,8 @@
<div class="layout">
<%- include("../partials/admin-sidebar", { user, active: "database", lang }) %>
<div class="main">
<%- include("../partials/page-header", {
user,
title: t.adminSidebar.database,
title: "Datenbankverwaltung",
subtitle: "",
showUserName: true,
hideDashboardButton: true
showUserName: true
}) %>
<div class="content p-4">
@ -19,64 +12,104 @@
<div class="container-fluid p-0">
<div class="row g-3">
<!-- DB Konfiguration -->
<div class="col-12">
<!-- ✅ Sidebar -->
<div class="col-md-3 col-lg-2 p-0">
<%- include("../partials/admin-sidebar", { user, active: "database" }) %>
</div>
<!-- ✅ Content -->
<div class="col-md-9 col-lg-10">
<!-- ✅ DB Konfiguration -->
<div class="card shadow mb-3">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-sliders"></i> <%= t.databaseoverview.title %>
<i class="bi bi-sliders"></i> Datenbank Konfiguration
</h4>
<p class="text-muted mb-4"><%= t.databaseoverview.text %></p>
<form method="POST" action="/admin/database/test"
class="row g-3 mb-3" autocomplete="off">
<p class="text-muted mb-4">
Hier kannst du die DB-Verbindung testen und speichern.
</p>
<!-- ✅ TEST (ohne speichern) + SPEICHERN -->
<form method="POST" action="/admin/database/test" class="row g-3 mb-3" autocomplete="off">
<div class="col-md-6">
<label class="form-label"><%= t.databaseoverview.host %> / IP</label>
<input type="text" name="host" class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.host) ? dbConfig.host : '' %>"
autocomplete="off" required>
<label class="form-label">Host / IP</label>
<input
type="text"
name="host"
class="form-control"
value="<%= dbConfig?.host || '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-3">
<label class="form-label"><%= t.databaseoverview.port %></label>
<input type="number" name="port" class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.port) ? dbConfig.port : 3306 %>"
autocomplete="off" required>
<label class="form-label">Port</label>
<input
type="number"
name="port"
class="form-control"
value="<%= dbConfig?.port || 3306 %>"
autocomplete="off"
required
>
</div>
<div class="col-md-3">
<label class="form-label"><%= t.databaseoverview.database %></label>
<input type="text" name="name" class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.name) ? dbConfig.name : '' %>"
autocomplete="off" required>
<label class="form-label">Datenbank</label>
<input
type="text"
name="name"
class="form-control"
value="<%= dbConfig?.name || '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-6">
<label class="form-label"><%= t.global.username %></label>
<input type="text" name="user" class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.user) ? dbConfig.user : '' %>"
autocomplete="off" required>
<label class="form-label">Benutzer</label>
<input
type="text"
name="user"
class="form-control"
value="<%= 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>
<label class="form-label">Passwort</label>
<input
type="password"
name="password"
class="form-control"
value="<%= dbConfig?.password || '' %>"
autocomplete="off"
required
>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-plug"></i> <%= t.databaseoverview.connectiontest %>
</button>
<button type="submit" class="btn btn-success" formaction="/admin/database">
<i class="bi bi-save"></i> <%= t.global.save %>
</button>
</div>
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-plug"></i> Verbindung testen
</button>
<!-- ✅ Speichern + Testen -->
<button
type="submit"
class="btn btn-success"
formaction="/admin/database"
>
<i class="bi bi-save"></i> Speichern
</button>
</div>
</form>
<% if (typeof testResult !== "undefined" && testResult) { %>
@ -87,20 +120,19 @@
</div>
</div>
</div>
<!-- System Info -->
<div class="col-12">
<!-- ✅ System Info -->
<div class="card shadow mb-3">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-info-circle"></i> <%= t.global.systeminfo %>
<i class="bi bi-info-circle"></i> Systeminformationen
</h4>
<% if (typeof systemInfo !== "undefined" && systemInfo && systemInfo.error) { %>
<% if (typeof systemInfo !== "undefined" && systemInfo?.error) { %>
<div class="alert alert-danger mb-0">
❌ <%= t.global.errordatabase %>
❌ Fehler beim Auslesen der Datenbankinfos:
<div class="mt-2"><code><%= systemInfo.error %></code></div>
</div>
@ -109,19 +141,21 @@
<div class="row g-3">
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small"><%= t.databaseoverview.mysqlversion %></div>
<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="text-muted small">Anzahl Tabellen</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="text-muted small">Datenbankgröße</div>
<div class="fw-bold"><%= systemInfo.dbSizeMB %> MB</div>
</div>
</div>
@ -129,22 +163,25 @@
<% if (systemInfo.tables && systemInfo.tables.length > 0) { %>
<hr>
<h6 class="mb-2"><%= t.databaseoverview.tableoverview %></h6>
<h6 class="mb-2">Tabellenübersicht</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>
<th>Tabelle</th>
<th class="text-end">Zeilen</th>
<th class="text-end">Größe (MB)</th>
</tr>
</thead>
<tbody>
<% systemInfo.tables.forEach(tbl => { %>
<% systemInfo.tables.forEach(t => { %>
<tr>
<td><%= tbl.name %></td>
<td class="text-end"><%= tbl.row_count %></td>
<td class="text-end"><%= tbl.size_mb %></td>
<td><%= t.name %></td>
<td class="text-end"><%= t.row_count %></td>
<td class="text-end"><%= t.size_mb %></td>
</tr>
<% }) %>
</tbody>
@ -153,17 +190,17 @@
<% } %>
<% } else { %>
<div class="alert alert-warning mb-0">
⚠️ <%= t.databaseoverview.nodbinfo %>
⚠️ Keine Systeminfos verfügbar (DB ist evtl. nicht konfiguriert oder Verbindung fehlgeschlagen).
</div>
<% } %>
</div>
</div>
</div>
<!-- Backup & Restore -->
<div class="col-12">
<!-- ✅ Backup & Restore -->
<div class="card shadow">
<div class="card-body">
@ -173,20 +210,25 @@
<div class="d-flex flex-wrap gap-3">
<!-- ✅ Backup erstellen -->
<form action="/admin/database/backup" method="POST">
<button type="submit" class="btn btn-primary">
<i class="bi bi-download"></i> Backup erstellen
</button>
</form>
<!-- ✅ Restore auswählen -->
<form action="/admin/database/restore" method="POST">
<div class="input-group">
<select name="backupFile" class="form-select" required>
<option value="">Backup auswählen...</option>
<% (typeof backupFiles !== "undefined" && backupFiles ? backupFiles : []).forEach(file => { %>
<option value="<%= file %>"><%= file %></option>
<% }) %>
</select>
<button type="submit" class="btn btn-warning">
<i class="bi bi-upload"></i> Restore starten
</button>
@ -203,11 +245,8 @@
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

@ -1,9 +1,11 @@
<div class="layout">
<div class="main">
<!-- ✅ HEADER -->
<%- include("partials/page-header", {
user,
title: t.adminuseroverview.usermanagement,
title: "User Verwaltung",
subtitle: "",
showUserName: true
}) %>
@ -13,28 +15,32 @@
<%- include("partials/flash") %>
<div class="container-fluid">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0"><%= t.adminuseroverview.useroverview %></h4>
<h4 class="mb-0">Benutzerübersicht</h4>
<a href="/admin/create-user" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> <%= t.global.newuser %>
<i class="bi bi-plus-circle"></i>
Neuer Benutzer
</a>
</div>
<!-- ✅ Tabelle -->
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm align-middle mb-0">
<thead>
<tr>
<th>ID</th>
<th><%= t.global.title %></th>
<th><%= t.global.firstname %></th>
<th><%= t.global.lastname %></th>
<th><%= t.global.username %></th>
<th><%= t.global.role %></th>
<th class="text-center"><%= t.global.status %></th>
<th><%= t.global.action %></th>
<th>Titel</th>
<th>Vorname</th>
<th>Nachname</th>
<th>Username</th>
<th>Rolle</th>
<th class="text-center">Status</th>
<th>Aktionen</th>
</tr>
</thead>
@ -42,25 +48,25 @@
<% users.forEach(u => { %>
<tr class="<%= u.active ? '' : 'table-secondary' %>">
<!-- ✅ Update Form -->
<form method="POST" action="/admin/users/update/<%= u.id %>">
<td class="fw-semibold"><%= u.id %></td>
<td>
<input type="text" name="title" value="<%= u.title || '' %>"
class="form-control form-control-sm" disabled />
<input type="text" name="title" value="<%= u.title || '' %>" class="form-control form-control-sm" disabled />
</td>
<td>
<input type="text" name="first_name" value="<%= u.first_name %>"
class="form-control form-control-sm" disabled />
<input type="text" name="first_name" value="<%= u.first_name %>" class="form-control form-control-sm" disabled />
</td>
<td>
<input type="text" name="last_name" value="<%= u.last_name %>"
class="form-control form-control-sm" disabled />
<input type="text" name="last_name" value="<%= u.last_name %>" class="form-control form-control-sm" disabled />
</td>
<td>
<input type="text" name="username" value="<%= u.username %>"
class="form-control form-control-sm" disabled />
<input type="text" name="username" value="<%= u.username %>" class="form-control form-control-sm" disabled />
</td>
<td>
@ -73,34 +79,37 @@
<td class="text-center">
<% if (u.active === 0) { %>
<span class="badge bg-secondary"><%= t.global.inactive %></span>
<span class="badge bg-secondary">Inaktiv</span>
<% } else if (u.lock_until && new Date(u.lock_until) > new Date()) { %>
<span class="badge bg-danger"><%= t.global.closed %></span>
<span class="badge bg-danger">Gesperrt</span>
<% } else { %>
<span class="badge bg-success"><%= t.global.active %></span>
<span class="badge bg-success">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>
<!-- Edit -->
<button type="button" class="btn btn-outline-warning btn-sm lock-btn">
<i class="bi bi-pencil-square"></i>
</button>
</form>
<!-- Aktiv/Deaktiv -->
<% if (u.id !== currentUser.id) { %>
<form method="POST"
action="/admin/users/<%= u.active ? 'deactivate' : 'activate' %>/<%= u.id %>">
<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>
<span class="badge bg-light text-dark border">👤 Du selbst</span>
<% } %>
</td>
@ -113,8 +122,15 @@
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// ⚠️ Inline Script wird von CSP blockiert!
// Wenn du diese Buttons brauchst, sag Bescheid,
// dann verlagern wir das sauber in /public/js/admin-users.js (CSP safe).
</script>

View File

@ -1,285 +0,0 @@
<%# views/calendar/index.ejs %>
<%# Eingebettet in das bestehende layout.ejs via express-ejs-layouts %>
<style>
/* ── Kalender-Variablen ──────────────────────────────────────────────── */
:root {
--cal-slot-h: 40px;
--cal-time-w: 60px;
--cal-min-col: 160px;
--cal-border: #dee2e6;
--cal-hover: #e8f0fe;
--cal-now: #dc3545;
--cal-holiday: #fff3cd;
--cal-weekend: #f8f9fa;
}
/* ── Layout ─────────────────────────────────────────────────────────── */
#calendarPage { display:flex; flex-direction:column; height:calc(100vh - 70px); overflow:hidden; }
#calToolbar { flex-shrink:0; }
#calHolidayBanner { flex-shrink:0; display:none; }
#calBody { flex:1; display:flex; overflow:hidden; }
/* ── Sidebar ─────────────────────────────────────────────────────────── */
#calSidebar {
width: 220px;
flex-shrink: 0;
border-right: 1px solid var(--cal-border);
overflow-y: auto;
background: #fff;
padding: 12px;
}
.mini-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
text-align: center;
font-size: 12px;
}
.mini-wd { color:#6c757d; font-weight:600; padding-bottom:3px; }
.mini-day { padding:3px 1px; border-radius:4px; cursor:pointer; line-height:1.6; }
.mini-day:hover { background:#e9ecef; }
.mini-day.today { background:#cfe2ff; color:#0d6efd; font-weight:600; }
.mini-day.selected { background:#0d6efd; color:#fff; font-weight:600; }
.mini-day.holiday { color:#dc3545; }
.mini-day.other-month { color:#ced4da; }
.doc-item {
display:flex; align-items:center; gap:8px;
padding:6px 4px; border-radius:6px; cursor:pointer;
font-size:13px; transition:background .12s;
user-select:none;
}
.doc-item:hover { background:#f8f9fa; }
.doc-dot { width:10px; height:10px; border-radius:50%; flex-shrink:0; }
.doc-check {
width:16px; height:16px; border-radius:3px; flex-shrink:0;
border:1.5px solid #ced4da; margin-left:auto;
display:flex; align-items:center; justify-content:center;
}
.doc-item.active .doc-check { background:#0d6efd; border-color:#0d6efd; }
/* ── Kalender-Bereich ────────────────────────────────────────────────── */
#calMain { flex:1; display:flex; flex-direction:column; overflow:hidden; }
#calColHeaders { display:flex; background:#fff; border-bottom:2px solid var(--cal-border); flex-shrink:0; z-index:10; }
#calScroll { flex:1; overflow-y:auto; overflow-x:hidden; }
#calGrid { display:flex; }
/* Zeitachse */
.cal-time-axis { width:var(--cal-time-w); flex-shrink:0; background:#f8f9fa; }
.cal-time-label {
height:var(--cal-slot-h); display:flex; align-items:flex-start; justify-content:flex-end;
padding:0 6px; font-size:11px; color:#6c757d; transform:translateY(-6px);
font-variant-numeric:tabular-nums;
}
.cal-time-label.hour { font-weight:600; color:#495057; }
/* Spalten */
#calColHeadersInner { display:flex; flex:1; overflow:hidden; }
#calColumnsInner { display:flex; flex:1; }
.col-header {
flex:1; min-width:var(--cal-min-col);
padding:8px 10px; border-left:1px solid var(--cal-border);
display:flex; align-items:center; gap:6px;
}
.col-header-name { font-weight:600; font-size:13px; }
.col-header-spec { font-size:11px; color:#6c757d; }
.col-header-count {
margin-left:auto; font-size:11px; background:#e9ecef;
padding:1px 7px; border-radius:20px; color:#6c757d; white-space:nowrap;
}
.col-header-color {
width:32px; height:20px; border-radius:4px; border:none;
cursor:pointer; padding:0 2px;
}
.doc-col { flex:1; min-width:var(--cal-min-col); border-left:1px solid var(--cal-border); position:relative; }
.slot-row {
height:var(--cal-slot-h); border-bottom:1px solid #f0f0f0;
cursor:pointer; position:relative; transition:background .08s;
}
.slot-row.hour-start { border-bottom-color:#dee2e6; }
.slot-row.weekend { background:var(--cal-weekend); }
.slot-row:hover { background:var(--cal-hover); }
/* Terminblock */
.appt-block {
position:absolute; left:3px; right:3px;
border-radius:5px; padding:3px 6px;
cursor:pointer; z-index:3; overflow:hidden;
border-left:3px solid; font-size:12px;
transition:filter .12s;
}
.appt-block:hover { filter:brightness(.9); }
.appt-block.cancelled { opacity:.45; text-decoration:line-through; }
.appt-block.completed { opacity:.65; }
.appt-patient { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.appt-time { font-size:10px; opacity:.75; font-variant-numeric:tabular-nums; }
/* Jetzt-Linie */
.now-line { position:absolute; left:0; right:0; height:2px; background:var(--cal-now); z-index:4; pointer-events:none; }
.now-dot { position:absolute; left:-4px; top:-4px; width:10px; height:10px; border-radius:50%; background:var(--cal-now); }
/* Scrollbar */
#calScroll::-webkit-scrollbar { width:5px; }
#calScroll::-webkit-scrollbar-thumb { background:#ced4da; border-radius:3px; }
</style>
<div id="calendarPage">
<%# ── Toolbar ── %>
<div id="calToolbar" class="d-flex align-items-center gap-2 p-2 border-bottom bg-white">
<i class="bi bi-calendar3 text-primary fs-5"></i>
<strong class="me-2">Kalender</strong>
<button class="btn btn-sm btn-outline-secondary" id="btnPrev">
<i class="bi bi-chevron-left"></i>
</button>
<button class="btn btn-sm btn-outline-secondary fw-semibold" id="btnDateDisplay" style="min-width:220px">
Lädt …
</button>
<button class="btn btn-sm btn-outline-secondary" id="btnNext">
<i class="bi bi-chevron-right"></i>
</button>
<button class="btn btn-sm btn-outline-primary" id="btnToday">Heute</button>
<div class="ms-auto d-flex gap-2">
<button class="btn btn-sm btn-primary" id="btnNewAppt">
<i class="bi bi-plus-lg me-1"></i>Neuer Termin
</button>
</div>
</div>
<%# ── Feiertagsbanner ── %>
<div id="calHolidayBanner" class="alert alert-warning d-flex align-items-center gap-2 mb-0 rounded-0 py-2 px-3">
<i class="bi bi-star-fill text-warning"></i>
<span id="calHolidayText"></span>
</div>
<%# ── Haupt-Body ── %>
<div id="calBody">
<%# ── Sidebar ── %>
<div id="calSidebar">
<%# Mini-Kalender %>
<div class="mb-3">
<div id="miniCal"></div>
</div>
<hr class="my-2">
<div class="text-uppercase fw-bold" style="font-size:11px;color:#6c757d;letter-spacing:.06em;">Ärzte</div>
<div id="docList" class="mt-2"></div>
</div>
<%# ── Kalender-Spalten ── %>
<div id="calMain">
<div id="calColHeaders">
<div style="width:var(--cal-time-w);flex-shrink:0;"></div>
<div id="calColHeadersInner"></div>
</div>
<div id="calScroll">
<div id="calGrid">
<div class="cal-time-axis" id="calTimeAxis"></div>
<div id="calColumnsInner"></div>
</div>
</div>
</div>
</div><%# /calBody %>
</div><%# /calendarPage %>
<%# ── Modal: Termin ── %>
<div class="modal fade" id="apptModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="apptModalTitle">Neuer Termin</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-2">
<div class="col-7">
<label class="form-label small fw-semibold">Arzt</label>
<select class="form-select form-select-sm" id="fDoctor"></select>
</div>
<div class="col-5">
<label class="form-label small fw-semibold">Status</label>
<select class="form-select form-select-sm" id="fStatus">
<option value="scheduled">Geplant</option>
<option value="completed">Abgeschlossen</option>
<option value="cancelled">Abgesagt</option>
</select>
</div>
<div class="col-12">
<label class="form-label small fw-semibold">Patient</label>
<div class="position-relative">
<input type="text"
class="form-control form-control-sm"
id="fPatient"
placeholder="Name eingeben …"
autocomplete="off">
<input type="hidden" id="fPatientId">
<div id="patientDropdown"
class="position-absolute w-100 bg-white border rounded shadow-sm"
style="display:none; z-index:1060; top:100%; max-height:200px; overflow-y:auto;">
</div>
</div>
</div>
<div class="col-4">
<label class="form-label small fw-semibold">Datum</label>
<input type="date" class="form-control form-control-sm" id="fDate">
</div>
<div class="col-4">
<label class="form-label small fw-semibold">Uhrzeit</label>
<select class="form-select form-select-sm" id="fTime"></select>
</div>
<div class="col-4">
<label class="form-label small fw-semibold">Dauer</label>
<select class="form-select form-select-sm" id="fDuration">
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="45">45 min</option>
<option value="60">60 min</option>
<option value="90">90 min</option>
</select>
</div>
<div class="col-12">
<label class="form-label small fw-semibold">Notizen</label>
<textarea class="form-control form-control-sm" id="fNotes" rows="2" placeholder="Optional …"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm btn-outline-danger me-auto" id="btnApptDelete" style="display:none">
<i class="bi bi-trash me-1"></i>Löschen
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-sm btn-primary" id="btnApptSave">
<i class="bi bi-check-lg me-1"></i>Speichern
</button>
</div>
</div>
</div>
</div>
<%# ── Toast ── %>
<div class="position-fixed bottom-0 start-50 translate-middle-x p-3" style="z-index:9999">
<div id="calToast" class="toast align-items-center text-bg-dark border-0" role="alert">
<div class="d-flex">
<div class="toast-body" id="calToastMsg"></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<%# ── Ärzte-Daten CSP-sicher übergeben (type="application/json" wird NICHT geblockt) ── %>
<script type="application/json" id="calDoctorsData"><%- JSON.stringify(doctors) %></script>
<%# ── Externes Script (script-src 'self' erlaubt dies) ── %>
<script src="/js/calendar.js"></script>

View File

@ -1,8 +1,15 @@
<!-- KEIN layout, KEINE sidebar, KEIN main -->
<div class="layout">
<!-- ✅ SIDEBAR -->
<%- include("partials/sidebar", { user, active: "patients", lang }) %>
<!-- ✅ MAIN -->
<div class="main">
<!-- ✅ HEADER (inkl. Uhrzeit) -->
<%- include("partials/page-header", {
user,
title: t.dashboard.title,
title: "Dashboard",
subtitle: "",
showUserName: true,
hideDashboardButton: true
@ -10,34 +17,50 @@
<div class="content p-4">
<!-- Flash Messages -->
<%- include("partials/flash") %>
<!-- =========================
WARTEZIMMER MONITOR
========================= -->
<div class="waiting-monitor">
<h5 class="mb-3">🪑 <%= t.dashboard.waitingRoom %></h5>
<h5 class="mb-3">🪑 Wartezimmer-Monitor</h5>
<div class="waiting-grid">
<% if (waitingPatients && waitingPatients.length > 0) { %>
<% waitingPatients.forEach(p => { %>
<% if (user.role === "arzt") { %>
<form method="POST" action="/patients/<%= p.id %>/call">
<button class="waiting-slot occupied clickable">
<div><%= p.firstname %> <%= p.lastname %></div>
<% if (user.role === 'arzt') { %>
<form method="POST" action="/patients/<%= p.id %>/call" style="width:100%; margin:0;">
<button type="submit" class="waiting-slot occupied clickable waiting-btn">
<div class="patient-text">
<div class="name"><%= p.firstname %> <%= p.lastname %></div>
<div class="birthdate">
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
</div>
</div>
</button>
</form>
<% } else { %>
<div class="waiting-slot occupied">
<div><%= p.firstname %> <%= p.lastname %></div>
<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">
<%= t.dashboard.noWaitingPatients %>
</div>
<div class="text-muted">Keine Patienten im Wartezimmer.</div>
<% } %>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,110 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Dashboard</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" />
</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"
>
<i class="bi bi-speedometer2 fs-4"></i>
<span class="fw-semibold fs-5">Dashboard</span>
</div>
<!-- 🔴 RECHTS: LOGOUT -->
<div class="ms-auto">
<a href="/logout" class="btn btn-outline-light btn-sm"> Logout </a>
</div>
</nav>
<div class="container-fluid mt-4">
<!-- Flash Messages -->
<%- include("partials/flash") %>
<!-- =========================
OBERER BEREICH
========================== -->
<div class="mb-4">
<h3>Willkommen, <%= user.username %></h3>
<div class="d-flex flex-wrap gap-2 mt-3">
<a href="/waiting-room" class="btn btn-outline-primary">
🪑 Wartezimmer
</a>
<% if (user.role === 'arzt') { %>
<a href="/admin/users" class="btn btn-outline-primary">
👥 Userverwaltung
</a>
<% } %>
<a href="/patients" class="btn btn-primary"> Patientenübersicht </a>
<a href="/medications" class="btn btn-secondary">
Medikamentenübersicht
</a>
<% if (user.role === 'arzt') { %>
<a href="/services" class="btn btn-secondary"> 🧾 Leistungen </a>
<% } %>
<a href="/services/open" class="btn btn-warning">
🧾 Offene Leistungen
</a>
<% if (user.role === 'arzt') { %>
<a href="/services/logs" class="btn btn-outline-secondary">
📜 Änderungsprotokoll (Services)
</a>
<% } %> <% if (user.role === 'arzt') { %>
<a href="/admin/company-settings" class="btn btn-outline-dark">
🏢 Firmendaten
</a>
<% } %> <% if (user.role === 'arzt') { %>
<a href="/admin/invoices" class="btn btn-outline-success">
💶 Abrechnung
</a>
<% } %>
</div>
</div>
<!-- =========================
UNTERE HÄLFTE MONITOR
========================== -->
<div class="waiting-monitor">
<h5 class="mb-3">🪑 Wartezimmer-Monitor</h5>
<div class="waiting-grid">
<% const maxSlots = 21; for (let i = 0; i < maxSlots; i++) { const p =
waitingPatients && waitingPatients[i]; %>
<div class="waiting-slot <%= p ? 'occupied' : 'empty' %>">
<% if (p) { %>
<div class="name"><%= p.firstname %> <%= p.lastname %></div>
<div class="birthdate">
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
</div>
<% } else { %>
<div class="placeholder">
<img
src="/images/stuhl.jpg"
alt="Freier Platz"
class="chair-icon"
/>
</div>
<% } %>
</div>
<% } %>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,12 +0,0 @@
/**
* public/js/invoice-confirm.js
* Ersetzt onsubmit="return confirm(...)" in offenen Rechnungen (CSP-sicher)
*/
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.js-confirm-pay, .js-confirm-cancel').forEach(function (form) {
form.addEventListener('submit', function (e) {
const msg = form.dataset.msg || 'Wirklich fortfahren?';
if (!confirm(msg)) e.preventDefault();
});
});
});

View File

@ -1,16 +0,0 @@
/**
* public/js/invoice-select.js
* Ersetzt onchange="this.form.submit()" in Rechnungs-Filtern (CSP-sicher)
*/
document.addEventListener('DOMContentLoaded', function () {
const ids = ['cancelledYear', 'creditYear', 'paidYear', 'paidQuarter'];
ids.forEach(function (id) {
const el = document.getElementById(id);
if (el) {
el.addEventListener('change', function () {
const form = this.closest('form');
if (form) form.submit();
});
}
});
});

View File

@ -1,48 +0,0 @@
<%- include("../partials/page-header", {
user,
title: t.cancelledInvoices.title,
subtitle: "",
showUserName: true
}) %>
<div class="container mt-4">
<%- include("../partials/flash") %>
<h4><%= t.cancelledInvoices.title %></h4>
<form method="GET" action="/invoices/cancelled" style="margin-bottom:20px;">
<label><%= t.cancelledInvoices.year %></label>
<select name="year" class="form-select" id="cancelledYear"
style="width:150px; display:inline-block;">
<% years.forEach(y => { %>
<option value="<%= y %>" <%= y == selectedYear ? "selected" : "" %>><%= y %></option>
<% }) %>
</select>
</form>
<% if (invoices.length === 0) { %>
<p><%= t.cancelledInvoices.noinvoices %></p>
<% } else { %>
<table class="table">
<thead>
<tr>
<th>#</th>
<th><%= t.cancelledInvoices.patient %></th>
<th><%= t.cancelledInvoices.date %></th>
<th><%= t.cancelledInvoices.amount %></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>
<% } %>
</div>
<script src="/js/invoice-select.js" defer></script>

View File

@ -1,67 +0,0 @@
<%- include("../partials/page-header", {
user,
title: t.creditOverview.title,
subtitle: "",
showUserName: true
}) %>
<div class="container mt-4">
<%- include("../partials/flash") %>
<h4><%= t.creditOverview.title %></h4>
<form method="GET" action="/invoices/credits" style="margin-bottom:20px">
<label><%= t.creditOverview.year %></label>
<select name="year" class="form-select" id="creditYear"
style="width:150px; display:inline-block">
<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><%= t.creditOverview.invoice %></th>
<th><%= t.creditOverview.date %></th>
<th><%= t.creditOverview.pdf %></th>
<th><%= t.creditOverview.creditnote %></th>
<th><%= t.creditOverview.date %></th>
<th><%= t.creditOverview.pdf %></th>
<th><%= t.creditOverview.patient %></th>
<th><%= t.creditOverview.amount %></th>
</tr>
</thead>
<tbody>
<% items.forEach(i => { %>
<tr>
<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">
📄 <%= t.creditOverview.open %>
</a>
<% } %>
</td>
<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">
📄 <%= t.creditOverview.open %>
</a>
<% } %>
</td>
<td><%= i.firstname %> <%= i.lastname %></td>
<td><%= i.invoice_amount_fmt %> € / <%= i.credit_amount_fmt %> €</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<script src="/js/invoice-select.js" defer></script>

View File

@ -1,65 +0,0 @@
<%- include("../partials/page-header", {
user,
title: t.openInvoices.title,
subtitle: "",
showUserName: true
}) %>
<div class="container mt-4">
<%- include("../partials/flash") %>
<h4><%= t.openInvoices.title %></h4>
<% if (invoices.length === 0) { %>
<p><%= t.openInvoices.noinvoices %></p>
<% } else { %>
<table class="table">
<thead>
<tr>
<th>#</th>
<th><%= t.openInvoices.patient %></th>
<th><%= t.openInvoices.date %></th>
<th><%= t.openInvoices.amount %></th>
<th><%= t.openInvoices.status %></th>
<th></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><%= t.openInvoices.open %></td>
<td style="text-align:right; white-space:nowrap;">
<!-- BEZAHLT -->
<form action="/invoices/<%= inv.id %>/pay" method="POST"
style="display:inline;"
class="js-confirm-pay"
data-msg="<%= t.global.save %>?">
<button type="submit" class="btn btn-sm btn-success">
<%= t.global.save %>
</button>
</form>
<!-- STORNO -->
<form action="/invoices/<%= inv.id %>/cancel" method="POST"
style="display:inline;"
class="js-confirm-cancel"
data-msg="Storno?">
<button type="submit" class="btn btn-sm btn-danger" style="margin-left:6px;">
STORNO
</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div>
<script src="/js/invoice-confirm.js" defer></script>

View File

@ -1,72 +0,0 @@
<%- include("../partials/page-header", {
user,
title: t.paidInvoices.title,
subtitle: "",
showUserName: true
}) %>
<% if (query?.error === "already_credited") { %>
<div class="alert alert-warning">
⚠️ <%= t.global.nodata %>
</div>
<% } %>
<div class="container mt-4">
<%- include("../partials/flash") %>
<h4><%= t.paidInvoices.title %></h4>
<form method="GET" action="/invoices/paid"
style="margin-bottom:20px; display:flex; gap:15px;">
<div>
<label><%= t.paidInvoices.year %></label>
<select name="year" class="form-select" id="paidYear">
<% years.forEach(y => { %>
<option value="<%= y %>" <%= y==selectedYear?"selected":"" %>><%= y %></option>
<% }) %>
</select>
</div>
<div>
<label><%= t.paidInvoices.quarter %></label>
<select name="quarter" class="form-select" id="paidQuarter">
<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>
<form id="creditForm" method="POST" action="" style="margin-bottom:15px;">
<button id="creditBtn" type="submit" class="btn btn-warning" disabled>
<%= t.creditOverview.creditnote %>
</button>
</form>
<table class="table table-hover">
<thead>
<tr>
<th>#</th>
<th><%= t.paidInvoices.patient %></th>
<th><%= t.paidInvoices.date %></th>
<th><%= t.paidInvoices.amount %></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>
<script src="/js/invoice-select.js" defer></script>
</div>

View File

@ -20,6 +20,7 @@
<body>
<div class="layout">
<!-- ✅ Sidebar dynamisch -->
<% if (typeof sidebarPartial !== "undefined" && sidebarPartial) { %>
<%- include(sidebarPartial, {
@ -39,15 +40,8 @@
</div>
<!-- ✅ Bootstrap JS (Pflicht für Modals, Toasts usw.) -->
<script src="/js/bootstrap.bundle.min.js"></script>
<!-- ✅ 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> -->
<!-- ✅ Sidebar: gesperrte Menüpunkte abfangen -->
<script src="/js/sidebar-lock.js" defer></script>
</body>
</html>

View File

@ -1,12 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<title><%= t.medicationCreate.title %></title>
<title>Neues Medikament</title>
<link rel="stylesheet" href="/css/bootstrap.min.css" />
</head>
<body class="bg-light">
<div class="container mt-4">
<h4> <%= t.medicationCreate.title %></h4>
<h4> Neues Medikament</h4>
<% if (error) { %>
<div class="alert alert-danger"><%= error %></div>
@ -14,12 +14,12 @@
<form method="POST" action="/medications/create">
<div class="mb-3">
<label class="form-label"><%= t.medicationCreate.medication %></label>
<label class="form-label">Medikament</label>
<input name="name" class="form-control" required />
</div>
<div class="mb-3">
<label class="form-label"><%= t.medicationCreate.form %></label>
<label class="form-label">Darreichungsform</label>
<select name="form_id" class="form-control" required>
<% forms.forEach(f => { %>
<option value="<%= f.id %>"><%= f.name %></option>
@ -28,17 +28,17 @@
</div>
<div class="mb-3">
<label class="form-label"><%= t.medicationCreate.dosage %></label>
<label class="form-label">Dosierung</label>
<input name="dosage" class="form-control" required />
</div>
<div class="mb-3">
<label class="form-label"><%= t.medicationCreate.package %></label>
<label class="form-label">Packung</label>
<input name="package" class="form-control" />
</div>
<button class="btn btn-success"><%= t.medicationCreate.save %></button>
<a href="/medications" class="btn btn-secondary"><%= t.medicationCreate.cancel %></a>
<button class="btn btn-success">Speichern</button>
<a href="/medications" class="btn btn-secondary">Abbrechen</a>
</form>
</div>
</body>

View File

@ -1,6 +1,6 @@
<%- include("partials/page-header", {
user,
title: t.medications.title,
title: "Medikamentenübersicht",
subtitle: "",
showUserName: true
}) %>
@ -14,7 +14,7 @@
<div class="card shadow">
<div class="card-body">
<!-- Suche -->
<!-- 🔍 Suche -->
<form method="GET" action="/medications" class="row g-2 mb-3">
<div class="col-md-6">
@ -22,14 +22,14 @@
type="text"
name="q"
class="form-control"
placeholder="🔍 <%= t.medications.searchplaceholder %>"
placeholder="🔍 Suche nach Medikament, Form, Dosierung"
value="<%= query?.q || '' %>"
>
</div>
<div class="col-md-3 d-flex gap-2">
<button class="btn btn-primary w-100"><%= t.medications.search %></button>
<a href="/medications" class="btn btn-secondary w-100"><%= t.medications.reset %></a>
<button class="btn btn-primary w-100">Suchen</button>
<a href="/medications" class="btn btn-secondary w-100">Reset</a>
</div>
<div class="col-md-3 d-flex align-items-center">
@ -42,16 +42,16 @@
<%= query?.onlyActive === "1" ? "checked" : "" %>
>
<label class="form-check-label">
<%= t.global.active %>
Nur aktive Medikamente
</label>
</div>
</div>
</form>
<!-- Neu -->
<!-- Neu -->
<a href="/medications/create" class="btn btn-success mb-3">
<%= t.medications.newmedication %>
Neues Medikament
</a>
<div class="table-responsive">
@ -59,12 +59,12 @@
<thead class="table-dark">
<tr>
<th><%= t.medications.medication %></th>
<th><%= t.medications.form %></th>
<th><%= t.medications.dosage %></th>
<th><%= t.medications.package %></th>
<th><%= t.medications.status %></th>
<th><%= t.medications.actions %></th>
<th>Medikament</th>
<th>Darreichungsform</th>
<th>Dosierung</th>
<th>Packung</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
@ -73,31 +73,49 @@
<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>
<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>
<input
type="text"
name="package"
value="<%= r.package %>"
class="form-control form-control-sm"
disabled
>
</td>
<td class="text-center">
<%= r.active ? t.global.active : t.global.inactive %>
<%= 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>
<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 ? "⛔" : "✅" %>
@ -118,4 +136,6 @@
</div>
</div>
<!-- ✅ Externes JS (Helmet/CSP safe) -->
<script src="/js/services-lock.js"></script>

View File

@ -1,18 +1,19 @@
<%- include("partials/page-header", {
user,
title: t.openServices.title,
subtitle: "",
title: "Offene Leistungen",
subtitle: "Offene Rechnungen",
showUserName: true
}) %>
<div class="content p-4">
<div class="container-fluid p-0">
<% let currentPatient = null; %>
<% if (!rows.length) { %>
<div class="alert alert-success">
<%= t.openServices.noopenservices %>
Keine offenen Leistungen vorhanden
</div>
<% } %>
@ -20,41 +21,71 @@
<% if (!currentPatient || currentPatient !== r.patient_id) { %>
<% currentPatient = r.patient_id; %>
<hr />
<h5 class="clearfix">
👤 <%= r.firstname %> <%= r.lastname %>
<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">🧾 <%= t.global.create %></button>
<!-- 🧾 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 -->
<div class="border rounded p-2 mb-2 d-flex align-items-center gap-2 flex-wrap">
<strong class="flex-grow-1"><%= r.name %></strong>
<form method="POST"
<!-- 🔢 MENGE -->
<form
method="POST"
action="/patients/services/update-quantity/<%= r.patient_service_id %>"
class="d-flex gap-1 me-2">
<input type="number" name="quantity" min="1" step="1"
class="d-flex gap-1 me-2"
>
<input
type="number"
name="quantity"
min="1"
step="1"
value="<%= r.quantity %>"
class="form-control form-control-sm" style="width:70px" />
class="form-control form-control-sm"
style="width:70px"
/>
<button class="btn btn-sm btn-outline-primary">💾</button>
</form>
<form method="POST"
<!-- 💰 PREIS -->
<form
method="POST"
action="/patients/services/update-price/<%= r.patient_service_id %>"
class="d-flex gap-1 me-2">
<input type="number" step="0.01" name="price"
class="d-flex gap-1 me-2"
>
<input
type="number"
step="0.01"
name="price"
value="<%= Number(r.price).toFixed(2) %>"
class="form-control form-control-sm" style="width:100px" />
class="form-control form-control-sm"
style="width:100px"
/>
<button class="btn btn-sm btn-outline-primary">💾</button>
</form>
<form method="POST"
<!-- ❌ LÖSCHEN -->
<form
method="POST"
action="/patients/services/delete/<%= r.patient_service_id %>"
class="js-confirm-delete">
class="js-confirm-delete"
>
<button class="btn btn-sm btn-outline-danger">❌</button>
</form>
</div>
@ -62,6 +93,8 @@
<% }) %>
</div>
</div>
<!-- ✅ Externes JS (Helmet safe) -->
<script src="/js/open-services.js"></script>

View File

@ -26,27 +26,13 @@
<div class="sidebar-menu">
<!-- ✅ Firmendaten Verwaltung -->
<a
href="<%= hrefIfAllowed(isAdmin, '/admin/company-settings') %>"
class="nav-item <%= active === 'companySettings' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>"
<% if (!isAdmin) { %>data-locked="Kein Zugriff nur für Administratoren"<% } %>
>
<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>
<!-- ✅ User Verwaltung -->
<a
href="<%= hrefIfAllowed(isAdmin, '/admin/users') %>"
class="nav-item <%= active === 'users' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>"
<% if (!isAdmin) { %>data-locked="Kein Zugriff nur für Administratoren"<% } %>
>
<i class="bi bi-people"></i> <%= t.adminSidebar.user %>
<i class="bi bi-people"></i> Benutzer
<% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
@ -57,9 +43,8 @@
href="<%= hrefIfAllowed(isAdmin, '/admin/invoices') %>"
class="nav-item <%= active === 'invoice_overview' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>"
<% if (!isAdmin) { %>data-locked="Kein Zugriff nur für Administratoren"<% } %>
>
<i class="bi bi-calculator"></i> <%= t.adminSidebar.invocieoverview %>
<i class="bi bi-calculator"></i> Rechnungsübersicht
<% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
@ -71,9 +56,8 @@
href="<%= hrefIfAllowed(isAdmin, '/admin/serial-number') %>"
class="nav-item <%= active === 'serialnumber' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>"
<% if (!isAdmin) { %>data-locked="Kein Zugriff nur für Administratoren"<% } %>
>
<i class="bi bi-key"></i> <%= t.adminSidebar.seriennumber %>
<i class="bi bi-key"></i> Seriennummer
<% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
@ -84,31 +68,12 @@
href="<%= hrefIfAllowed(isAdmin, '/admin/database') %>"
class="nav-item <%= active === 'database' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>"
<% if (!isAdmin) { %>data-locked="Kein Zugriff nur für Administratoren"<% } %>
>
<i class="bi bi-hdd-stack"></i> <%= t.adminSidebar.databasetable %>
<i class="bi bi-hdd-stack"></i> Datenbank
<% 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>
</div>
</div>
<!-- ✅ Kein-Zugriff Toast (wird von /js/sidebar-lock.js gesteuert) -->
<div class="position-fixed top-0 start-50 translate-middle-x p-3" style="z-index:9999; margin-top:16px;">
<div id="lockToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive">
<div class="d-flex">
<div class="toast-body d-flex align-items-center gap-2">
<i class="bi bi-lock-fill"></i>
<span id="lockToastMsg">Kein Zugriff</span>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>

View File

@ -2,6 +2,11 @@
const titleText = typeof title !== "undefined" ? title : "";
const subtitleText = typeof subtitle !== "undefined" ? subtitle : "";
const showUser = typeof showUserName !== "undefined" ? showUserName : true;
// ✅ Standard: Button anzeigen
const hideDashboard = typeof hideDashboardButton !== "undefined"
? hideDashboardButton
: false;
%>
<div class="page-header">
@ -13,7 +18,7 @@
<div class="page-header-center">
<% if (showUser && user?.username) { %>
<div class="page-header-username">
<%=t.global.welcome%>, <%= user.title + " " + user.firstname + " " + user.lastname %>
Willkommen, <%= user.username %>
</div>
<% } %>

View File

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

@ -1,192 +0,0 @@
<%
// =========================
// 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' %>"
<% if (!canUsePatient) { %>data-locked="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' %>"
<% if (!canUsePatient) { %>data-locked="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>
<!-- ✅ Kein-Zugriff Toast (wird von /js/sidebar-lock.js gesteuert) -->
<div class="position-fixed top-0 start-50 translate-middle-x p-3" style="z-index:9999; margin-top:16px;">
<div id="lockToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive">
<div class="d-flex">
<div class="toast-body d-flex align-items-center gap-2">
<i class="bi bi-lock-fill"></i>
<span id="lockToastMsg">Kein Zugriff</span>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>

View File

@ -1,20 +1,5 @@
<div class="sidebar-empty">
<!-- ✅ Logo -->
<div
style="
margin-bottom: 30px;
display: flex;
flex-direction: column;
gap: 10px;
"
>
<div class="sidebar sidebar-empty">
<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

@ -1,127 +0,0 @@
<%
// =========================
// 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' %>"
<% if (!canDoctorAndStaff) { %>data-locked="Kein Zugriff nur für Ärzte und 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' %>"
<% if (!canDoctorAndStaff) { %>data-locked="Kein Zugriff nur für Ärzte und 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' %>"
<% if (!canDoctorAndStaff) { %>data-locked="Kein Zugriff nur für Ärzte und 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' %>"
<% if (!canDoctorAndStaff) { %>data-locked="Kein Zugriff nur für Ärzte und 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' %>"
<% if (!canDoctorAndStaff) { %>data-locked="Kein Zugriff nur für Ärzte und 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>
<!-- ✅ Kein-Zugriff Toast (wird von /js/sidebar-lock.js gesteuert) -->
<div class="position-fixed top-0 start-50 translate-middle-x p-3" style="z-index:9999; margin-top:16px;">
<div id="lockToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive">
<div class="d-flex">
<div class="toast-body d-flex align-items-center gap-2">
<i class="bi bi-lock-fill"></i>
<span id="lockToastMsg">Kein Zugriff</span>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>

View File

@ -36,7 +36,11 @@
<%
const role = user?.role || null;
// ✅ Regeln:
// ✅ Bereich 1: Arzt + Mitarbeiter
const canDoctorAndStaff = role === "arzt" || role === "mitarbeiter";
// ✅ Bereich 2: NUR Admin
const canOnlyAdmin = role === "admin";
function hrefIfAllowed(allowed, href) {
@ -46,20 +50,13 @@
function lockClass(allowed) {
return allowed ? "" : "locked";
}
// Nachricht je Berechtigungsgruppe
function lockMsg(allowed, requiredRole) {
if (allowed) return "";
if (requiredRole === "admin") return "Kein Zugriff nur für Administratoren";
return "Kein Zugriff nur für Ärzte und Mitarbeiter";
}
%>
<!-- ✅ Patienten (Arzt + Mitarbeiter) -->
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/patients') %>"
class="nav-item <%= active === 'patients' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
<% if (!canDoctorAndStaff) { %>data-locked="<%= lockMsg(canDoctorAndStaff, 'arzt') %>"<% } %>
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.sidebar.patients %>
<% if (!canDoctorAndStaff) { %>
@ -67,23 +64,11 @@
<% } %>
</a>
<!-- ✅ Kalender (Arzt + Mitarbeiter) -->
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/calendar') %>"
class="nav-item <%= active === 'calendar' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
<% if (!canDoctorAndStaff) { %>data-locked="<%= lockMsg(canDoctorAndStaff, 'arzt') %>"<% } %>
>
<i class="bi bi-calendar3"></i> Kalender
<% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<!-- ✅ Medikamente (Arzt + Mitarbeiter) -->
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/medications') %>"
class="nav-item <%= active === 'medications' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
<% if (!canDoctorAndStaff) { %>data-locked="<%= lockMsg(canDoctorAndStaff, 'arzt') %>"<% } %>
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-capsule"></i> <%= t.sidebar.medications %>
<% if (!canDoctorAndStaff) { %>
@ -95,7 +80,7 @@
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/services/open') %>"
class="nav-item <%= active === 'services' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
<% if (!canDoctorAndStaff) { %>data-locked="<%= lockMsg(canDoctorAndStaff, 'arzt') %>"<% } %>
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-receipt"></i> <%= t.sidebar.servicesOpen %>
<% if (!canDoctorAndStaff) { %>
@ -107,7 +92,7 @@
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/admin/invoices') %>"
class="nav-item <%= active === 'billing' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
<% if (!canDoctorAndStaff) { %>data-locked="<%= lockMsg(canDoctorAndStaff, 'arzt') %>"<% } %>
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-cash-coin"></i> <%= t.sidebar.billing %>
<% if (!canDoctorAndStaff) { %>
@ -119,7 +104,7 @@
<a
href="<%= hrefIfAllowed(canOnlyAdmin, '/admin/users') %>"
class="nav-item <%= active === 'admin' ? 'active' : '' %> <%= lockClass(canOnlyAdmin) %>"
<% if (!canOnlyAdmin) { %>data-locked="<%= lockMsg(canOnlyAdmin, 'admin') %>"<% } %>
title="<%= canOnlyAdmin ? '' : 'Nur Admin' %>"
>
<i class="bi bi-gear"></i> <%= t.sidebar.admin %>
<% if (!canOnlyAdmin) { %>
@ -131,20 +116,7 @@
<!-- ✅ Logout -->
<a href="/logout" class="nav-item">
<i class="bi bi-box-arrow-right"></i> <%= t.sidebar.logout %>
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</div>
<!-- ✅ Kein-Zugriff Toast (CSP-sicher, kein Inline-Script) -->
<div class="position-fixed top-0 start-50 translate-middle-x p-3" style="z-index:9999; margin-top:16px;">
<div id="lockToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive">
<div class="d-flex">
<div class="toast-body d-flex align-items-center gap-2">
<i class="bi bi-lock-fill"></i>
<span id="lockToastMsg">Kein Zugriff</span>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>

View File

@ -1,8 +1,8 @@
<!DOCTYPE html>
<html lang="<%= lang %>">
<html lang="de">
<head>
<meta charset="UTF-8">
<title><%= t.patientCreate.title %></title>
<title>Patient anlegen</title>
<link rel="stylesheet" href="/css/bootstrap.min.css">
</head>
<body class="bg-light">
@ -12,7 +12,7 @@
<div class="card shadow mx-auto" style="max-width: 600px;">
<div class="card-body">
<h3 class="mb-3"><%= t.patientCreate.title %></h3>
<h3 class="mb-3">Neuer Patient</h3>
<% if (error) { %>
<div class="alert alert-danger"><%= error %></div>
@ -20,41 +20,32 @@
<form method="POST" action="/patients/create">
<input class="form-control mb-2" name="firstname"
placeholder="<%= t.patientCreate.firstname %>" required>
<input class="form-control mb-2" name="lastname"
placeholder="<%= t.patientCreate.lastname %>" required>
<input class="form-control mb-2" name="dni"
placeholder="<%= t.patientCreate.dni %>" required>
<input class="form-control mb-2" name="firstname" placeholder="Vorname" required>
<input class="form-control mb-2" name="lastname" placeholder="Nachname" required>
<input class="form-control mb-2" name="dni" placeholder="N.I.E. / DNI" required>
<select class="form-select mb-2" name="gender">
<option value=""><%= t.global.gender %></option>
<option value="">Geschlecht</option>
<option value="m">Männlich</option>
<option value="w">Weiblich</option>
<option value="d">Divers</option>
</select>
<input class="form-control mb-2" type="date" name="birthdate" required>
<input class="form-control mb-2" name="email"
placeholder="<%= t.patientCreate.email %>">
<input class="form-control mb-2" name="phone"
placeholder="<%= t.patientCreate.phone %>">
<input class="form-control mb-2" name="street"
placeholder="<%= t.patientCreate.street %>">
<input class="form-control mb-2" name="house_number"
placeholder="<%= t.patientCreate.housenumber %>">
<input class="form-control mb-2" name="postal_code"
placeholder="<%= t.patientCreate.zip %>">
<input class="form-control mb-2" name="city"
placeholder="<%= t.patientCreate.city %>">
<input class="form-control mb-2" name="country"
placeholder="<%= t.patientCreate.country %>" value="Deutschland">
<input class="form-control mb-2" name="email" placeholder="E-Mail">
<input class="form-control mb-2" name="phone" placeholder="Telefon">
<textarea class="form-control mb-3" name="notes"
placeholder="<%= t.patientCreate.notes %>"></textarea>
<input class="form-control mb-2" name="street" placeholder="Straße">
<input class="form-control mb-2" name="house_number" placeholder="Hausnummer">
<input class="form-control mb-2" name="postal_code" placeholder="PLZ">
<input class="form-control mb-2" name="city" placeholder="Ort">
<input class="form-control mb-2" name="country" placeholder="Land" value="Deutschland">
<textarea class="form-control mb-3"
name="notes"
placeholder="Notizen"></textarea>
<button class="btn btn-primary w-100">
<%= t.global.save %>
Patient speichern
</button>
</form>

View File

@ -1,10 +1,14 @@
<div class="layout">
<!-- ✅ Sidebar dynamisch über layout.ejs -->
<!-- wird automatisch geladen -->
<div class="main">
<!-- ✅ Neuer Header -->
<%- include("partials/page-header", {
user,
title: t.global.edit,
title: "Patient bearbeiten",
subtitle: patient.firstname + " " + patient.lastname,
showUserName: true,
hideDashboardButton: false
@ -23,66 +27,73 @@
<div class="alert alert-danger"><%= error %></div>
<% } %>
<!-- ✅ POST geht auf /patients/update/:id -->
<form method="POST" action="/patients/update/<%= patient.id %>">
<!-- ✅ returnTo per POST mitschicken -->
<input type="hidden" name="returnTo" value="<%= returnTo || '' %>">
<div class="row">
<div class="col-md-6 mb-2">
<input class="form-control" name="firstname"
<input
class="form-control"
name="firstname"
value="<%= patient.firstname %>"
placeholder="<%= t.patientEdit.firstname %>" required />
placeholder="Vorname"
required
/>
</div>
<div class="col-md-6 mb-2">
<input class="form-control" name="lastname"
<input
class="form-control"
name="lastname"
value="<%= patient.lastname %>"
placeholder="<%= t.patientEdit.lastname %>" required />
placeholder="Nachname"
required
/>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-2">
<select class="form-select" name="gender">
<option value=""><%= t.global.gender %></option>
<option value="">Geschlecht</option>
<option value="m" <%= patient.gender === "m" ? "selected" : "" %>>Männlich</option>
<option value="w" <%= patient.gender === "w" ? "selected" : "" %>>Weiblich</option>
<option value="d" <%= patient.gender === "d" ? "selected" : "" %>>Divers</option>
</select>
</div>
<div class="col-md-8 mb-2">
<input class="form-control" type="date" name="birthdate"
<input
class="form-control"
type="date"
name="birthdate"
value="<%= patient.birthdate ? new Date(patient.birthdate).toISOString().split('T')[0] : '' %>"
required />
required
/>
</div>
</div>
<input class="form-control mb-2" name="email"
value="<%= patient.email || '' %>"
placeholder="<%= t.patientEdit.email %>" />
<input class="form-control mb-2" name="phone"
value="<%= patient.phone || '' %>"
placeholder="<%= t.patientEdit.phone %>" />
<input class="form-control mb-2" name="street"
value="<%= patient.street || '' %>"
placeholder="<%= t.patientEdit.street %>" />
<input class="form-control mb-2" name="house_number"
value="<%= patient.house_number || '' %>"
placeholder="<%= t.patientEdit.housenumber %>" />
<input class="form-control mb-2" name="postal_code"
value="<%= patient.postal_code || '' %>"
placeholder="<%= t.patientEdit.zip %>" />
<input class="form-control mb-2" name="city"
value="<%= patient.city || '' %>"
placeholder="<%= t.patientEdit.city %>" />
<input class="form-control mb-2" name="country"
value="<%= patient.country || '' %>"
placeholder="<%= t.patientEdit.country %>" />
<input class="form-control mb-2" name="email" value="<%= patient.email || '' %>" placeholder="E-Mail" />
<input class="form-control mb-2" name="phone" value="<%= patient.phone || '' %>" placeholder="Telefon" />
<textarea class="form-control mb-3" name="notes" rows="4"
placeholder="<%= t.patientEdit.notes %>"><%= patient.notes || '' %></textarea>
<input class="form-control mb-2" name="street" value="<%= patient.street || '' %>" placeholder="Straße" />
<input class="form-control mb-2" name="house_number" value="<%= patient.house_number || '' %>" placeholder="Hausnummer" />
<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"
name="notes"
rows="4"
placeholder="Notizen"
><%= patient.notes || '' %></textarea>
<button class="btn btn-primary w-100">
<%= t.patientEdit.save %>
Änderungen speichern
</button>
</form>

View File

@ -1,6 +1,6 @@
<%- include("partials/page-header", {
user,
title: "💊 " + t.patientMedications.selectmedication,
title: "💊 Medikation",
subtitle: patient.firstname + " " + patient.lastname,
showUserName: true,
showDashboardButton: false
@ -12,11 +12,14 @@
<div class="container-fluid">
<!-- ✅ Patient Info -->
<div class="card shadow-sm mb-3 patient-box">
<div class="card-body">
<h5 class="mb-1"><%= patient.firstname %> <%= patient.lastname %></h5>
<h5 class="mb-1">
<%= patient.firstname %> <%= patient.lastname %>
</h5>
<div class="text-muted small">
<%= t.global.birthdate %>:
Geboren am:
<%= new Date(patient.birthdate).toLocaleDateString("de-DE") %>
</div>
</div>
@ -24,52 +27,60 @@
<div class="row g-3">
<!-- Medikament hinzufügen -->
<!-- Medikament hinzufügen -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold">
<%= t.patientMedications.selectmedication %>
Medikament zuweisen
</div>
<div class="card-body">
<form method="POST" action="/patients/<%= patient.id %>/medications/assign">
<div class="mb-2">
<label class="form-label"><%= t.patientMedications.selectmedication %></label>
<label class="form-label">Medikament auswählen</label>
<select name="medication_variant_id" class="form-select" required>
<option value="">-- <%= t.global.selection %> --</option>
<option value="">-- auswählen --</option>
<% meds.forEach(m => { %>
<option value="<%= m.id %>">
<%= m.medication %> | <%= m.form %> | <%= m.dosage %>
<% if (m.package) { %> | <%= m.package %><% } %>
<% if (m.package) { %>
| <%= m.package %>
<% } %>
</option>
<% }) %>
</select>
</div>
<div class="mb-2">
<label class="form-label"><%= t.patientMedications.dosageinstructions %></label>
<input type="text" class="form-control" name="dosage_instruction"
placeholder="<%= t.patientMedications.example %>" />
<label class="form-label">Dosierungsanweisung</label>
<input
type="text"
class="form-control"
name="dosage_instruction"
placeholder="z.B. 1-0-1"
/>
</div>
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label"><%= t.patientMedications.startdate %></label>
<label class="form-label">Startdatum</label>
<input type="date" class="form-control" name="start_date" />
</div>
<div class="col-md-6">
<label class="form-label"><%= t.patientMedications.enddate %></label>
<label class="form-label">Enddatum</label>
<input type="date" class="form-control" name="end_date" />
</div>
</div>
<button class="btn btn-primary">
<%= t.patientMedications.save %>
Speichern
</button>
<a href="/patients/<%= patient.id %>/overview" class="btn btn-outline-secondary">
⬅️ <%= t.patientMedications.backoverview %>
⬅️ Zur Übersicht
</a>
</form>
@ -78,30 +89,34 @@
</div>
</div>
<!-- Aktuelle Medikation -->
<!-- Aktuelle Medikation -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold">
📋 <%= t.patientMedications.selectmedication %>
📋 Aktuelle Medikation
</div>
<div class="card-body">
<% if (!currentMeds || currentMeds.length === 0) { %>
<div class="text-muted"><%= t.patientMedications.nomedication %></div>
<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><%= t.patientMedications.medication %></th>
<th><%= t.patientMedications.form %></th>
<th><%= t.patientMedications.dosage %></th>
<th><%= t.patientMedications.instruction %></th>
<th><%= t.patientMedications.from %></th>
<th><%= t.patientMedications.to %></th>
<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>
@ -109,8 +124,12 @@
<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><%= cm.end_date ? new Date(cm.end_date).toLocaleDateString("de-DE") : "-" %></td>
<td>
<%= cm.start_date ? new Date(cm.start_date).toLocaleDateString("de-DE") : "-" %>
</td>
<td>
<%= cm.end_date ? new Date(cm.end_date).toLocaleDateString("de-DE") : "-" %>
</td>
</tr>
<% }) %>
</tbody>

View File

@ -1,10 +1,14 @@
<div class="layout">
<!-- ✅ Sidebar: Patient -->
<!-- kommt automatisch über layout.ejs, wenn sidebarPartial gesetzt ist -->
<div class="main">
<!-- ✅ Neuer Header -->
<%- include("partials/page-header", {
user,
title: t.global.patient,
title: "Patient",
subtitle: patient.firstname + " " + patient.lastname,
showUserName: true
}) %>
@ -13,50 +17,58 @@
<%- include("partials/flash") %>
<!-- Patientendaten -->
<!-- ✅ PATIENTENDATEN -->
<div class="card shadow-sm mb-3 patient-data-box">
<div class="card-body">
<h4><%= t.patientOverview.patientdata %></h4>
<h4>Patientendaten</h4>
<table class="table table-sm">
<tr>
<th><%= t.patientOverview.firstname %></th>
<th>Vorname</th>
<td><%= patient.firstname %></td>
</tr>
<tr>
<th><%= t.patientOverview.lastname %></th>
<th>Nachname</th>
<td><%= patient.lastname %></td>
</tr>
<tr>
<th><%= t.patientOverview.birthdate %></th>
<td><%= patient.birthdate ? new Date(patient.birthdate).toLocaleDateString("de-DE") : "-" %></td>
<th>Geburtsdatum</th>
<td>
<%= patient.birthdate ? new Date(patient.birthdate).toLocaleDateString("de-DE") : "-" %>
</td>
</tr>
<tr>
<th><%= t.patientOverview.email %></th>
<th>E-Mail</th>
<td><%= patient.email || "-" %></td>
</tr>
<tr>
<th><%= t.patientOverview.phone %></th>
<th>Telefon</th>
<td><%= patient.phone || "-" %></td>
</tr>
</table>
</div>
</div>
<!-- ✅ UNTERER BEREICH -->
<div class="row g-3">
<!-- Notizen -->
<!-- 📝 NOTIZEN -->
<div class="col-lg-5 col-md-12">
<div class="card shadow h-100">
<div class="card-body d-flex flex-column">
<h5>📝 <%= t.patientOverview.notes %></h5>
<h5>📝 Notizen</h5>
<form method="POST" action="/patients/<%= patient.id %>/notes">
<textarea class="form-control mb-2" name="note" rows="3"
<textarea
class="form-control mb-2"
name="note"
rows="3"
style="resize: none"
placeholder="<%= t.patientOverview.newnote %>"></textarea>
placeholder="Neue Notiz hinzufügen…"
></textarea>
<button class="btn btn-sm btn-primary">
<%= t.global.save %>
Notiz speichern
</button>
</form>
@ -64,7 +76,7 @@
<div style="max-height: 320px; overflow-y: auto;">
<% if (!notes || notes.length === 0) { %>
<p class="text-muted"><%= t.patientOverview.nonotes %></p>
<p class="text-muted">Keine Notizen vorhanden</p>
<% } else { %>
<% notes.forEach(n => { %>
<div class="mb-3 p-2 border rounded bg-light">
@ -84,15 +96,15 @@
</div>
</div>
<!-- Rezept -->
<!-- 💊 MEDIKAMENT -->
<div class="col-lg-3 col-md-6">
<div class="card shadow h-100">
<div class="card-body">
<h5>💊 <%= t.patientOverview.createrecipe %></h5>
<h5>💊 Rezept erstellen</h5>
<form method="POST" action="/patients/<%= patient.id %>/medications">
<select name="medication_variant_id" class="form-select mb-2" required>
<option value=""><%=t.global.selection %>…</option>
<option value="">Bitte auswählen…</option>
<% medicationVariants.forEach(mv => { %>
<option value="<%= mv.variant_id %>">
<%= mv.medication_name %> <%= mv.form_name %> <%= mv.dosage %>
@ -100,33 +112,51 @@
<% }) %>
</select>
<input type="text" name="dosage_instruction" class="form-control mb-2"
placeholder="<%= t.patientMedications.example %>" />
<input
type="text"
name="dosage_instruction"
class="form-control mb-2"
placeholder="z. B. 101"
/>
<input type="date" name="start_date" class="form-control mb-2"
value="<%= new Date().toISOString().split('T')[0] %>" />
<input
type="date"
name="start_date"
class="form-control mb-2"
value="<%= new Date().toISOString().split('T')[0] %>"
/>
<input type="date" name="end_date" class="form-control mb-3" />
<button class="btn btn-sm btn-success w-100">
<%= t.global.save %>
Verordnen
</button>
</form>
</div>
</div>
</div>
<!-- Heutige Leistungen -->
<!-- 🧾 HEUTIGE LEISTUNGEN -->
<div class="col-lg-4 col-md-6">
<div class="card shadow h-100">
<div class="card-body d-flex flex-column">
<h5>🧾 <%= t.patientOverview.searchservice %></h5>
<h5>🧾 Heutige Leistungen</h5>
<form method="POST" action="/patients/<%= patient.id %>/services">
<input type="text" id="serviceSearch" class="form-control mb-2"
placeholder="<%= t.patientOverview.searchservice %>" />
<input
type="text"
id="serviceSearch"
class="form-control mb-2"
placeholder="Leistung suchen…"
/>
<select name="service_id" id="serviceSelect" class="form-select mb-2" size="5" required>
<select
name="service_id"
id="serviceSelect"
class="form-select mb-2"
size="5"
required
>
<% services.forEach(s => { %>
<option value="<%= s.id %>">
<%= s.name %> <%= Number(s.price || 0).toFixed(2) %> €
@ -134,10 +164,16 @@
<% }) %>
</select>
<input type="number" name="quantity" class="form-control mb-2" value="1" min="1" />
<input
type="number"
name="quantity"
class="form-control mb-2"
value="1"
min="1"
/>
<button class="btn btn-sm btn-success w-100">
<%= t.patientOverview.addservice %>
Leistung hinzufügen
</button>
</form>
@ -145,13 +181,13 @@
<div style="max-height: 320px; overflow-y: auto;">
<% if (!todayServices || todayServices.length === 0) { %>
<p class="text-muted"><%= t.patientOverview.noservices %></p>
<p class="text-muted">Noch keine Leistungen für heute.</p>
<% } else { %>
<% todayServices.forEach(ls => { %>
<div class="border rounded p-2 mb-2 bg-light">
<strong><%= ls.name %></strong><br />
<%= t.global.quantity %>: <%= ls.quantity %><br />
<%= t.global.price %>: <%= Number(ls.price).toFixed(2) %> €
Menge: <%= ls.quantity %><br />
Preis: <%= Number(ls.price).toFixed(2) %> €
</div>
<% }) %>
<% } %>

View File

@ -2,9 +2,10 @@
<div class="main">
<!-- ✅ Neuer globaler Header -->
<%- include("partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
title: "Patientenübersicht",
subtitle: patient.firstname + " " + patient.lastname,
showUserName: true,
hideDashboardButton: false
@ -16,22 +17,26 @@
<div class="container-fluid mt-3">
<!-- Patient Info -->
<!-- =========================
PATIENT INFO
========================== -->
<div class="card shadow mb-4">
<div class="card-body">
<h4 class="mb-1">👤 <%= patient.firstname %> <%= patient.lastname %></h4>
<p class="text-muted mb-3">
<%= t.global.birthday %> <%= new Date(patient.birthdate).toLocaleDateString("de-DE") %>
Geboren am <%= new Date(patient.birthdate).toLocaleDateString("de-DE") %>
</p>
<ul class="list-group">
<li class="list-group-item">
<strong><%= t.patientDashboard.email %></strong> <%= patient.email || "-" %>
<strong>E-Mail:</strong> <%= patient.email || "-" %>
</li>
<li class="list-group-item">
<strong><%= t.patientDashboard.phone %></strong> <%= patient.phone || "-" %>
<strong>Telefon:</strong> <%= patient.phone || "-" %>
</li>
<li class="list-group-item">
<strong><%= t.patientDashboard.address %></strong>
<strong>Adresse:</strong>
<%= patient.street || "" %> <%= patient.house_number || "" %>,
<%= patient.postal_code || "" %> <%= patient.city || "" %>
</li>
@ -39,23 +44,42 @@
</div>
</div>
<div class="row g-3" style="height: calc(100vh - 420px); min-height: 300px; padding-bottom: 3rem; overflow: hidden;">
<!-- =========================
MEDIKAMENTE & RECHNUNGEN
========================== -->
<div
class="row g-3"
style="
height: calc(100vh - 420px);
min-height: 300px;
padding-bottom: 3rem;
overflow: hidden;
"
>
<!-- Medikamente -->
<!-- 💊 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>💊 <%= t.patientDashboard.medications %></h5>
<div style="flex: 1 1 auto; overflow-y: auto; min-height: 0; padding-bottom: 1.5rem;">
<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"><%= t.patientDashboard.nomedications %></p>
<p class="text-muted">Keine aktiven Medikamente</p>
<% } else { %>
<table class="table table-sm table-bordered mt-2">
<thead class="table-light">
<tr>
<th><%= t.patientDashboard.medication %></th>
<th><%= t.patientDashboard.variant %></th>
<th><%= t.patientDashboard.instruction %></th>
<th>Medikament</th>
<th>Variante</th>
<th>Anweisung</th>
</tr>
</thead>
<tbody>
@ -70,25 +94,34 @@
</table>
<% } %>
</div>
</div>
</div>
</div>
<!-- Rechnungen -->
<!-- 🧾 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>🧾 <%= t.patientDashboard.invoices %></h5>
<div style="flex: 1 1 auto; overflow-y: auto; min-height: 0; padding-bottom: 1.5rem;">
<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"><%= t.patientDashboard.noinvoices %></p>
<p class="text-muted">Keine Rechnungen vorhanden</p>
<% } else { %>
<table class="table table-sm table-bordered mt-2">
<thead class="table-light">
<tr>
<th><%= t.patientDashboard.date %></th>
<th><%= t.patientDashboard.amount %></th>
<th><%= t.patientDashboard.pdf %></th>
<th>Datum</th>
<th>Betrag</th>
<th>PDF</th>
</tr>
</thead>
<tbody>
@ -98,9 +131,12 @@
<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">
📄 <%= t.patientDashboard.open %>
<a
href="<%= i.file_path %>"
target="_blank"
class="btn btn-sm btn-outline-primary"
>
📄 Öffnen
</a>
<% } else { %>
-
@ -112,6 +148,7 @@
</table>
<% } %>
</div>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
<%- include("partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
title: "Patientenübersicht",
subtitle: "",
showUserName: true
}) %>
@ -9,9 +9,10 @@
<%- include("partials/flash") %>
<!-- Aktionen oben -->
<div class="d-flex gap-2 mb-3">
<a href="/patients/create" class="btn btn-success">
+ <%= t.patienteoverview.newpatient %>
+ Neuer Patient
</a>
</div>
@ -21,51 +22,62 @@
<!-- 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="<%= t.global.firstname %>"
value="<%= query?.firstname || '' %>" />
<input
type="text"
name="firstname"
class="form-control"
placeholder="Vorname"
value="<%= query?.firstname || '' %>"
/>
</div>
<div class="col-md-3">
<input type="text" name="lastname" class="form-control"
placeholder="<%= t.global.lastname %>"
value="<%= query?.lastname || '' %>" />
<input
type="text"
name="lastname"
class="form-control"
placeholder="Nachname"
value="<%= query?.lastname || '' %>"
/>
</div>
<div class="col-md-3">
<input type="date" name="birthdate" class="form-control"
value="<%= query?.birthdate || '' %>" />
<input
type="date"
name="birthdate"
class="form-control"
value="<%= query?.birthdate || '' %>"
/>
</div>
<div class="col-md-3 d-flex gap-2">
<button class="btn btn-primary w-100"><%= t.global.search %></button>
<a href="/patients" class="btn btn-secondary w-100"><%= t.global.reset2 %></a>
<button class="btn btn-primary w-100">Suchen</button>
<a href="/patients" class="btn btn-secondary w-100">
Zurücksetzen
</a>
</div>
</form>
<!-- Patienten-Tabelle -->
<form method="GET" action="/patients">
<input type="hidden" name="firstname" value="<%= query?.firstname || '' %>">
<input type="hidden" name="lastname" value="<%= query?.lastname || '' %>">
<input type="hidden" name="birthdate" value="<%= query?.birthdate || '' %>">
<!-- Tabelle -->
<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><%= t.patienteoverview.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>
<th>Name</th>
<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>
@ -73,14 +85,22 @@
<% if (patients.length === 0) { %>
<tr>
<td colspan="15" class="text-center text-muted">
<%= t.patientoverview.nopatientfound %>
Keine Patienten gefunden
</td>
</tr>
<% } %>
<% patients.forEach(p => { %>
<tr>
<!-- ✅ RADIOBUTTON ganz vorne -->
<td class="text-center">
<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 || '' %>">
<input
class="patient-radio"
type="radio"
@ -88,24 +108,36 @@
value="<%= p.id %>"
<%= selectedPatientId === p.id ? "checked" : "" %>
/>
</form>
</td>
<td><%= p.id %></td>
<td><strong><%= p.firstname %> <%= p.lastname %></strong></td>
<td><%= p.dni || "-" %></td>
<td>
<%= p.gender === 'm' ? 'm' :
p.gender === 'w' ? 'w' :
p.gender === 'd' ? 'd' : '-' %>
<% if (p.gender === 'm') { %>
m
<% } else if (p.gender === 'w') { %>
w
<% } else if (p.gender === 'd') { %>
d
<% } else { %>
-
<% } %>
</td>
<td>
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
</td>
<td><%= new Date(p.birthdate).toLocaleDateString("de-DE") %></td>
<td><%= p.email || "-" %></td>
<td><%= p.phone || "-" %></td>
<td>
<%= p.street || "" %> <%= p.house_number || "" %><br>
<%= p.street || "" %> <%= p.house_number || "" %><br />
<%= p.postal_code || "" %> <%= p.city || "" %>
</td>
@ -113,15 +145,90 @@
<td>
<% if (p.active) { %>
<span class="badge bg-success"><%= t.patienteoverview.active %></span>
<span class="badge bg-success">Aktiv</span>
<% } else { %>
<span class="badge bg-secondary"><%= t.patienteoverview.inactive %></span>
<span class="badge bg-secondary">Inaktiv</span>
<% } %>
</td>
<td><%= p.notes ? p.notes.substring(0, 80) : "-" %></td>
<td style="max-width: 200px">
<%= p.notes ? p.notes.substring(0, 80) : "-" %>
</td>
<td><%= new Date(p.created_at).toLocaleString("de-DE") %></td>
<td><%= new Date(p.updated_at).toLocaleString("de-DE") %></td>
<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">
<li>
<a class="dropdown-item" href="/patients/edit/<%= p.id %>">
✏️ Bearbeiten
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<% 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>
<li>
<a class="dropdown-item" href="/patients/<%= p.id %>/medications">
💊 Medikamente
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<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>
<li>
<a class="dropdown-item" href="/patients/<%= p.id %>">
📋 Übersicht
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<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>
<% }) %>
</tbody>
@ -129,7 +236,7 @@
</table>
</div>
</form>
</div>
</div>
</div>

View File

@ -1,15 +0,0 @@
/**
* public/js/reportview-select.js
* Ersetzt onchange="this.form.submit()" im Report-Filter (CSP-sicher)
*/
document.addEventListener('DOMContentLoaded', function () {
['reportYear', 'reportQuarter'].forEach(function (id) {
const el = document.getElementById(id);
if (el) {
el.addEventListener('change', function () {
const form = this.closest('form');
if (form) form.submit();
});
}
});
});

View File

@ -1,49 +0,0 @@
<%- include("partials/page-header", {
user,
title: t.reportview.title,
subtitle: "",
showUserName: true
}) %>
<div class="container mt-4">
<%- include("partials/flash") %>
<h4><%= t.reportview.title %></h4>
<form method="GET" action="/reports"
style="margin-bottom:25px; display:flex; gap:15px; align-items:end;">
<div>
<label><%= t.reportview.year %></label>
<select name="year" class="form-select" id="reportYear">
<% years.forEach(y => { %>
<option value="<%= y %>" <%= y == selectedYear ? "selected" : "" %>><%= y %></option>
<% }) %>
</select>
</div>
<div>
<label><%= t.reportview.quarter %></label>
<select name="quarter" class="form-select" id="reportQuarter">
<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>
<script id="stats-data" type="application/json">
<%- JSON.stringify(stats) %>
</script>
<script src="/js/chart.js"></script>
<script src="/js/reports.js"></script>
<script src="/js/reportview-select.js" defer></script>
</div>

View File

@ -15,10 +15,10 @@
<div class="content" style="max-width:650px; margin:30px auto;">
<h2>🔑 <%= t.seriennumber.seriennumbertitle %></h2>
<h2>🔑 Seriennummer eingeben</h2>
<p style="color:#777;">
<%= t.seriennumber.seriennumbertext %>
Bitte gib deine Lizenz-Seriennummer ein um die Software dauerhaft freizuschalten.
</p>
<% if (error) { %>
@ -31,7 +31,7 @@
<form method="POST" action="/admin/serial-number" style="max-width: 500px;">
<div class="form-group">
<label><%= t.seriennumber.seriennumbershort %></label>
<label>Seriennummer (AAAAA-AAAAA-AAAAA-AAAAA)</label>
<input
type="text"
name="serial_number"
@ -42,12 +42,12 @@
required
/>
<small style="color:#777; display:block; margin-top:6px;">
<%= t.seriennumber.seriennumberdeclaration %>
Nur Buchstaben + Zahlen. Format: 4×5 Zeichen, getrennt mit „-“.
</small>
</div>
<button class="btn btn-primary" style="margin-top: 15px;">
<%= t.seriennumber.saveseriennumber %>
Seriennummer speichern
</button>
</form>

View File

@ -1,22 +1,23 @@
<!DOCTYPE html>
<html lang="<%= lang %>">
<html lang="de">
<head>
<meta charset="UTF-8">
<title><%= t.serviceCreate.title %></title>
<title>Neue Leistung</title>
<link rel="stylesheet" href="/css/bootstrap.min.css">
</head>
<body>
<nav class="navbar navbar-dark bg-dark px-3">
<span class="navbar-brand"> <%= t.serviceCreate.title %></span>
<a href="/services" class="btn btn-outline-light btn-sm"><%= t.serviceCreate.back %></a>
<span class="navbar-brand"> Neue Leistung</span>
<a href="/services" class="btn btn-outline-light btn-sm">Zurück</a>
</nav>
<div class="container mt-4">
<div class="card shadow mx-auto" style="max-width: 600px;">
<div class="card-body">
<h4 class="mb-3"><%= t.serviceCreate.newservice %></h4>
<h4 class="mb-3">Neue Leistung anlegen</h4>
<% if (error) { %>
<div class="alert alert-danger"><%= error %></div>
@ -25,39 +26,47 @@
<form method="POST">
<div class="mb-2">
<label class="form-label"><%= t.serviceCreate.namede %></label>
<label class="form-label">Bezeichnung (Deutsch) *</label>
<input name="name_de" class="form-control" required>
</div>
<div class="mb-2">
<label class="form-label"><%= t.serviceCreate.namees %></label>
<label class="form-label">Bezeichnung (Spanisch)</label>
<input name="name_es" class="form-control">
</div>
<div class="mb-2">
<label class="form-label"><%= t.serviceCreate.category %></label>
<label class="form-label">Kategorie</label>
<input name="category" class="form-control">
</div>
<div class="row">
<div class="col">
<label class="form-label"><%= t.serviceCreate.price %></label>
<input name="price" type="number" step="0.01" class="form-control" required>
<label class="form-label">Preis (€) *</label>
<input name="price"
type="number"
step="0.01"
class="form-control"
required>
</div>
<div class="col">
<label class="form-label"><%= t.serviceCreate.pricec70 %></label>
<input name="price_c70" type="number" step="0.01" class="form-control">
<label class="form-label">Preis C70 (€)</label>
<input name="price_c70"
type="number"
step="0.01"
class="form-control">
</div>
</div>
<button class="btn btn-success w-100 mt-3">
💾 <%= t.global.save %>
💾 Leistung speichern
</button>
</form>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,38 +1,81 @@
<%- include("partials/page-header", {
user,
title: t.services.title,
subtitle: "",
showUserName: true
}) %>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Leistungen</title>
<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 -->
<div class="container mt-4">
<%- include("partials/flash") %>
<h4><%= t.services.title %></h4>
<h4>Leistungen</h4>
<!-- SUCHFORMULAR -->
<form method="GET" action="/services" class="row g-2 mb-3">
<div class="col-md-6">
<input type="text" name="q" class="form-control"
placeholder="🔍 <%= t.services.searchplaceholder %>"
<input type="text"
name="q"
class="form-control"
placeholder="🔍 Suche nach Name oder Kategorie"
value="<%= query?.q || '' %>">
</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"
<input class="form-check-input"
type="checkbox"
name="onlyActive"
value="1"
<%= query?.onlyActive === "1" ? "checked" : "" %>>
<label class="form-check-label"><%= t.global.active %></label>
<label class="form-check-label">
Nur aktive Leistungen
</label>
</div>
</div>
<div class="col-md-3 d-flex gap-2">
<button class="btn btn-primary w-100"><%= t.global.search %></button>
<a href="/services" class="btn btn-secondary w-100"><%= t.global.reset %></a>
<button class="btn btn-primary w-100">
Suchen
</button>
<a href="/services" class="btn btn-secondary w-100">
Reset
</a>
</div>
</form>
<!-- NEUE LEISTUNG -->
<a href="/services/create" class="btn btn-success mb-3">
<%= t.services.newservice %>
Neue Leistung
</a>
<!-- TABELLE -->
<table class="table table-bordered table-sm align-middle">
<!-- FIXE SPALTENBREITEN -->
<colgroup>
<col style="width:35%">
<col style="width:25%">
@ -41,43 +84,81 @@
<col style="width:8%">
<col style="width:12%">
</colgroup>
<thead class="table-light">
<tr>
<th><%= t.services.namede %></th>
<th><%= t.services.namees %></th>
<th><%= t.services.price %></th>
<th><%= t.services.pricec70 %></th>
<th><%= t.services.status %></th>
<th><%= t.services.actions %></th>
<th>Bezeichnung (DE)</th>
<th>Bezeichnung (ES)</th>
<th>Preis</th>
<th>Preis C70</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<% services.forEach(s => { %>
<tr class="<%= s.active ? '' : 'table-secondary' %>">
<!-- DE -->
<td><%= s.name_de %></td>
<!-- ES -->
<td><%= s.name_es || "-" %></td>
<!-- FORM BEGINNT -->
<form method="POST" action="/services/<%= s.id %>/update-price">
<!-- PREIS -->
<td>
<input name="price" value="<%= s.price %>"
class="form-control form-control-sm text-end w-100" disabled>
<input name="price"
value="<%= s.price %>"
class="form-control form-control-sm text-end w-100"
disabled>
</td>
<!-- PREIS C70 -->
<td>
<input name="price_c70" value="<%= s.price_c70 %>"
class="form-control form-control-sm text-end w-100" disabled>
<input name="price_c70"
value="<%= s.price_c70 %>"
class="form-control form-control-sm text-end w-100"
disabled>
</td>
<!-- STATUS -->
<td class="text-center">
<%= s.active ? t.global.active : t.global.inactive %>
<%= s.active ? 'Aktiv' : 'Inaktiv' %>
</td>
<!-- AKTIONEN -->
<td class="d-flex justify-content-center gap-2">
<button type="submit" class="btn btn-sm btn-primary save-btn" disabled>💾</button>
<button type="button" class="btn btn-sm btn-outline-warning lock-btn"
title="<%= t.services.editunlock %>">🔓</button>
<!-- SPEICHERN -->
<button type="submit"
class="btn btn-sm btn-primary save-btn"
disabled>
💾
</button>
<!-- SPERREN / ENTSPERREN -->
<button type="button"
class="btn btn-sm btn-outline-warning lock-btn"
title="Bearbeiten freigeben">
🔓
</button>
</td>
</form>
</tr>
<% }) %>
</tbody>
</table>
</div>
</body>
</html>

View File

@ -1,218 +0,0 @@
<!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>
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: Arial, sans-serif;
background: #f0f2f5;
padding: 40px 20px;
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
}
.card {
width: 100%;
max-width: 580px;
background: white;
padding: 32px;
border-radius: 14px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.10);
}
.card h2 {
margin: 0 0 6px 0;
font-size: 22px;
color: #111827;
}
.card p {
margin: 0 0 24px 0;
color: #6b7280;
font-size: 14px;
}
.badge-update {
display: inline-block;
background: #fef3c7;
color: #92400e;
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
margin-bottom: 16px;
border: 1px solid #fcd34d;
}
label {
display: block;
margin-top: 16px;
margin-bottom: 5px;
font-weight: 600;
font-size: 13px;
color: #374151;
}
input {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #d1d5db;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37,99,235,0.12);
}
.row {
display: flex;
gap: 12px;
}
.row > div { flex: 1; }
.btn-row {
display: flex;
gap: 10px;
margin-top: 24px;
flex-wrap: wrap;
}
button {
padding: 10px 18px;
border: 0;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: opacity 0.2s;
}
button:hover { opacity: 0.88; }
.btn-primary { background: #2563eb; color: white; flex: 1; }
.btn-secondary { background: #111827; color: white; }
.msg {
margin-top: 14px;
padding: 12px 14px;
border-radius: 10px;
font-size: 14px;
display: none;
}
.msg.ok { background: #dcfce7; color: #166534; border: 1px solid #86efac; }
.msg.bad { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
.msg.pending { background: #eff6ff; color: #1d4ed8; border: 1px solid #93c5fd; }
.divider {
border: none;
border-top: 1px solid #e5e7eb;
margin: 24px 0;
}
</style>
</head>
<body>
<div class="card">
<h2>🛠️ <%= title %></h2>
<% if (typeof isUpdate !== 'undefined' && isUpdate) { %>
<span class="badge-update">⚠️ Bestehende Konfiguration wird überschrieben</span>
<% } %>
<p>DB-Verbindungsdaten eingeben. Die Verbindung wird vor dem Speichern geprüft.</p>
<form method="POST" action="/setup" id="setupForm">
<div class="row">
<div>
<label for="host">DB Host</label>
<input
id="host"
name="host"
placeholder="85.215.63.122"
value="<%= defaults.host %>"
required
/>
</div>
<div style="max-width:110px">
<label for="port">Port</label>
<input
id="port"
name="port"
placeholder="3306"
value="<%= defaults.port %>"
required
/>
</div>
</div>
<label for="user">DB Benutzer</label>
<input
id="user"
name="user"
placeholder="praxisuser"
value="<%= defaults.user %>"
required
/>
<label for="password">DB Passwort</label>
<input
id="password"
name="password"
type="password"
placeholder="<%= typeof isUpdate !== 'undefined' && isUpdate ? '(unverändert lassen = leer lassen)' : '' %>"
/>
<label for="name">DB Name</label>
<input
id="name"
name="name"
placeholder="praxissoftware"
value="<%= defaults.name %>"
required
/>
<hr class="divider" />
<div class="btn-row">
<button type="button" class="btn-secondary" onclick="testConnection()">
🔍 Verbindung testen
</button>
<button type="submit" class="btn-primary">
✅ Speichern & abschließen
</button>
</div>
<div id="msg" class="msg"></div>
</form>
</div>
<script>
async function testConnection() {
const msg = document.getElementById("msg");
msg.className = "msg pending";
msg.style.display = "block";
msg.textContent = "⏳ Teste Verbindung...";
// Felder einzeln auslesen (sicherer als FormData bei doppelten Namen)
const body = {
host: document.getElementById("host").value.trim(),
port: document.getElementById("port").value.trim(),
user: document.getElementById("user").value.trim(),
password: document.getElementById("password").value,
name: document.getElementById("name").value.trim(),
};
try {
const res = await fetch("/setup/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
msg.className = "msg " + (json.ok ? "ok" : "bad");
msg.textContent = json.message;
} catch (e) {
msg.className = "msg bad";
msg.textContent = "❌ Netzwerkfehler: " + e.message;
}
}
</script>
</body>
</html>