Compare commits

..

6 Commits

136 changed files with 10964 additions and 14163 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

184
app.js
View File

@ -3,17 +3,17 @@ require("dotenv").config();
const express = require("express"); const express = require("express");
const session = require("express-session"); const session = require("express-session");
const helmet = require("helmet"); const helmet = require("helmet");
const mysql = require("mysql2/promise");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const expressLayouts = require("express-ejs-layouts"); const expressLayouts = require("express-ejs-layouts");
// ✅ DB + Session Store // ✅ Verschlüsselte Config
const db = require("./db"); const { configExists, saveConfig } = require("./config-manager");
const { getSessionStore } = require("./config/session");
// ✅ Setup Middleware + Setup Routes // ✅ DB + Session Reset
const requireSetup = require("./middleware/requireSetup"); const db = require("./db");
const setupRoutes = require("./routes/setup.routes"); const { getSessionStore, resetSessionStore } = require("./config/session");
// ✅ Routes (deine) // ✅ Routes (deine)
const adminRoutes = require("./routes/admin.routes"); const adminRoutes = require("./routes/admin.routes");
@ -28,7 +28,6 @@ const invoiceRoutes = require("./routes/invoice.routes");
const patientFileRoutes = require("./routes/patientFile.routes"); const patientFileRoutes = require("./routes/patientFile.routes");
const companySettingsRoutes = require("./routes/companySettings.routes"); const companySettingsRoutes = require("./routes/companySettings.routes");
const authRoutes = require("./routes/auth.routes"); const authRoutes = require("./routes/auth.routes");
const reportRoutes = require("./routes/report.routes");
const app = express(); const app = express();
@ -65,55 +64,85 @@ function passesModulo3(serial) {
return sum % 3 === 0; 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 MIDDLEWARE
================================ */ ================================ */
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.json()); app.use(express.json());
app.use(helmet());
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
},
},
}),
);
app.use( app.use(
session({ session({
name: "praxis.sid", name: "praxis.sid",
secret: process.env.SESSION_SECRET || "dev-secret", secret: process.env.SESSION_SECRET,
store: getSessionStore(), store: getSessionStore(),
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
}), }),
); );
// ✅ i18n Middleware (SAFE) // ✅ i18n Middleware 1 (setzt res.locals.t + lang)
app.use((req, res, next) => { app.use((req, res, next) => {
try {
const lang = req.session.lang || "de"; const lang = req.session.lang || "de";
const filePath = path.join(__dirname, "locales", `${lang}.json`); const filePath = path.join(__dirname, "locales", `${lang}.json`);
const raw = fs.readFileSync(filePath, "utf-8");
let data = {}; res.locals.t = JSON.parse(raw);
if (fs.existsSync(filePath)) {
data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
res.locals.t = data;
res.locals.lang = lang; res.locals.lang = lang;
next(); next();
} catch (err) {
console.error("❌ i18n Fehler:", err.message);
res.locals.t = {};
res.locals.lang = "de";
next();
}
}); });
const flashMiddleware = require("./middleware/flash.middleware"); const flashMiddleware = require("./middleware/flash.middleware");
@ -123,24 +152,20 @@ app.use(express.static("public"));
app.use("/uploads", express.static("uploads")); app.use("/uploads", express.static("uploads"));
app.set("view engine", "ejs"); app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
app.use(expressLayouts); app.use(expressLayouts);
app.set("layout", "layout"); app.set("layout", "layout"); // verwendet views/layout.ejs
app.use((req, res, next) => { app.use((req, res, next) => {
res.locals.user = req.session.user || null; res.locals.user = req.session.user || null;
next(); next();
}); });
/* ===============================
SETUP ROUTES + SETUP GATE
WICHTIG: /setup zuerst mounten, danach requireSetup
================================ */
app.use("/setup", setupRoutes);
app.use(requireSetup);
/* =============================== /* ===============================
LICENSE/TRIAL GATE 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) => { app.use(async (req, res, next) => {
try { try {
@ -205,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 Sprache ändern
================================ */ ================================ */
@ -226,6 +302,14 @@ app.get("/lang/:lang", (req, res) => {
/* =============================== /* ===============================
SERIAL PAGES 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) => { app.get("/serial-number", async (req, res) => {
try { try {
if (!req.session?.user) return res.redirect("/"); if (!req.session?.user) return res.redirect("/");
@ -287,6 +371,9 @@ app.get("/serial-number", async (req, res) => {
} }
}); });
/**
* Admin Seite: Seriennummer eingeben
*/
app.get("/admin/serial-number", async (req, res) => { app.get("/admin/serial-number", async (req, res) => {
try { try {
if (!req.session?.user) return res.redirect("/"); if (!req.session?.user) return res.redirect("/");
@ -315,6 +402,9 @@ app.get("/admin/serial-number", async (req, res) => {
} }
}); });
/**
* Admin Seite: Seriennummer speichern
*/
app.post("/admin/serial-number", async (req, res) => { app.post("/admin/serial-number", async (req, res) => {
try { try {
if (!req.session?.user) return res.redirect("/"); if (!req.session?.user) return res.redirect("/");
@ -407,9 +497,7 @@ app.use("/services", serviceRoutes);
app.use("/", patientFileRoutes); app.use("/", patientFileRoutes);
app.use("/", waitingRoomRoutes); app.use("/", waitingRoomRoutes);
app.use("/invoices", invoiceRoutes); app.use("/", invoiceRoutes);
app.use("/reportview", reportRoutes);
app.get("/logout", (req, res) => { app.get("/logout", (req, res) => {
req.session.destroy(() => res.redirect("/")); req.session.destroy(() => res.redirect("/"));
@ -427,7 +515,7 @@ app.use((err, req, res, next) => {
SERVER SERVER
================================ */ ================================ */
const PORT = process.env.PORT || 51777; const PORT = process.env.PORT || 51777;
const HOST = process.env.HOST || "0.0.0.0"; const HOST = "127.0.0.1";
app.listen(PORT, HOST, () => { app.listen(PORT, HOST, () => {
console.log(`Server läuft auf http://${HOST}:${PORT}`); console.log(`Server läuft auf http://${HOST}:${PORT}`);

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -8,15 +8,8 @@ async function showDashboard(req, res) {
const waitingPatients = await getWaitingPatients(db); const waitingPatients = await getWaitingPatients(db);
res.render("dashboard", { res.render("dashboard", {
layout: "layout", // 🔥 DAS FEHLTE
title: "Dashboard",
active: "dashboard",
sidebarPartial: "partials/sidebar",
waitingPatients,
user: req.session.user, user: req.session.user,
lang: req.session.lang || "de" waitingPatients
}); });
} catch (err) { } catch (err) {
console.error(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,9 +44,7 @@ function listMedications(req, res, next) {
res.render("medications", { res.render("medications", {
title: "Medikamentenübersicht", title: "Medikamentenübersicht",
sidebarPartial: "partials/sidebar-empty", // ✅ schwarzer Balken links
// ✅ IMMER patient-sidebar verwenden
sidebarPartial: "partials/sidebar-empty",
active: "medications", active: "medications",
rows, rows,

View File

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

@ -287,7 +287,7 @@ async function listOpenServices(req, res, next) {
res.render("open_services", { res.render("open_services", {
title: "Offene Leistungen", title: "Offene Leistungen",
sidebarPartial: "partials/sidebar-invoices", sidebarPartial: "partials/sidebar-empty",
active: "services", active: "services",
rows, rows,

View File

@ -4,129 +4,23 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"search": "Suchen", "search": "Suchen",
"reset": "Reset", "reset": "Reset",
"dashboard": "Dashboard", "dashboard": "Dashboard"
"logout": "Logout",
"title": "Titel",
"firstname": "Vorname",
"lastname": "Nachname",
"username": "Username",
"role": "Rolle",
"action": "Aktionen",
"status": "Status",
"you": "Du Selbst",
"newuser": "Neuer benutzer",
"inactive": "inaktive",
"active": "aktive",
"closed": "gesperrt",
"filter": "Filtern",
"yearcash": "Jahresumsatz",
"monthcash": "Monatsumsatz",
"quartalcash": "Quartalsumsatz",
"year": "Jahr",
"nodata": "keine Daten",
"month": "Monat",
"patientcash": "Umsatz pro Patient",
"patient": "Patient",
"systeminfo": "Systeminformationen",
"table": "Tabelle",
"lines": "Zeilen",
"size": "Grösse",
"errordatabase": "Fehler beim Auslesen der Datenbankinfos:",
"welcome": "Willkommen",
"waitingroomtext": "Wartezimmer-Monitor",
"waitingroomtextnopatient": "Keine Patienten im Wartezimmer.",
"gender": "Geschlecht",
"birthday": "Geburtstag",
"email": "E-Mail",
"phone": "Telefon",
"address": "Adresse",
"country": "Land",
"notice": "Notizen",
"create": "Erstellt",
"change": "Geändert",
"reset2": "Zurücksetzen",
"edit": "Bearbeiten",
"selection": "Auswahl",
"waiting": "Wartet bereits",
"towaitingroom": "Ins Wartezimmer",
"overview": "Übersicht",
"upload": "Hochladen",
"lock": "Sperren",
"unlock": "Enrsperren",
"name": "Name",
"return": "Zurück",
"fileupload": "Hochladen"
}, },
"sidebar": { "sidebar": {
"patients": "Patienten", "patients": "Patienten",
"medications": "Medikamente", "medications": "Medikamente",
"servicesOpen": "Patienten Rechnungen", "servicesOpen": "Offene Leistungen",
"billing": "Abrechnung", "billing": "Abrechnung",
"admin": "Verwaltung", "admin": "Verwaltung",
"logout": "Logout" "logout": "Logout"
}, },
"dashboard": { "dashboard": {
"welcome": "Willkommen", "welcome": "Willkommen",
"waitingRoom": "Wartezimmer-Monitor", "waitingRoom": "Wartezimmer-Monitor",
"noWaitingPatients": "Keine Patienten im Wartezimmer.", "noWaitingPatients": "Keine Patienten im Wartezimmer."
"title": "Dashboard"
}, },
"adminSidebar": { "adminSidebar": {
"users": "Userverwaltung", "users": "Userverwaltung",
"database": "Datenbankverwaltung", "database": "Datenbankverwaltung"
"user": "Benutzer",
"invocieoverview": "Rechnungsübersicht",
"seriennumber": "Seriennummer",
"databasetable": "Datenbank",
"companysettings": "Firmendaten"
},
"adminuseroverview": {
"useroverview": "Benutzerübersicht",
"usermanagement": "Benutzer Verwaltung",
"user": "Benutzer",
"invocieoverview": "Rechnungsübersicht",
"seriennumber": "Seriennummer",
"databasetable": "Datenbank"
},
"seriennumber": {
"seriennumbertitle": "Seriennummer eingeben",
"seriennumbertext": "Bitte gib deine Lizenz-Seriennummer ein um die Software dauerhaft freizuschalten.",
"seriennumbershort": "Seriennummer (AAAAA-AAAAA-AAAAA-AAAAA)",
"seriennumberdeclaration": "Nur Buchstaben + Zahlen. Format: 4×5 Zeichen, getrennt mit „-“. ",
"saveseriennumber": "Seriennummer Speichern"
},
"databaseoverview": {
"title": "Datenbank Konfiguration",
"text": "Hier kannst du die DB-Verbindung testen und speichern. ",
"host": "Host",
"port": "Port",
"database": "Datenbank",
"password": "Password",
"connectiontest": "Verbindung testen",
"tablecount": "Anzahl Tabellen",
"databasesize": "Datenbankgrösse",
"tableoverview": "Tabellenübersicht"
},
"patienteoverview": {
"patienttitle": "Patientenübersicht",
"newpatient": "Neuer Patient",
"nopatientfound": "Keine Patienten gefunden",
"closepatient": "Patient sperren ( inaktiv)",
"openpatient": "Patient entsperren (Aktiv)"
},
"openinvoices": {
"openinvoices": "Offene Rechnungen",
"canceledinvoices": "Stornierte Rechnungen",
"report": "Umsatzreport",
"payedinvoices": "Bezahlte Rechnungen",
"creditoverview": "Gutschrift Übersicht"
} }
} }

View File

@ -4,60 +4,8 @@
"cancel": "Cancelar", "cancel": "Cancelar",
"search": "Buscar", "search": "Buscar",
"reset": "Resetear", "reset": "Resetear",
"dashboard": "Panel", "dashboard": "Panel"
"logout": "cerrar sesión",
"title": "Título",
"firstname": "Nombre",
"lastname": "apellido",
"username": "Nombre de usuario",
"role": "desempeñar",
"action": "acción",
"status": "Estado",
"you": "su mismo",
"newuser": "Nuevo usuario",
"inactive": "inactivo",
"active": "activo",
"closed": "bloqueado",
"filter": "Filtro",
"yearcash": "volumen de negocios anual",
"monthcash": "volumen de negocios mensual",
"quartalcash": "volumen de negocios trimestral",
"year": "ano",
"nodata": "sin datos",
"month": "mes",
"patientcash": "Ingresos por paciente",
"patient": "paciente",
"systeminfo": "Información del sistema",
"table": "tablas",
"lines": "líneas",
"size": "Tamaño",
"errordatabase": "Error al leer la información de la base de datos:",
"welcome": "Bienvenido",
"waitingroomtext": "Monitor de sala de espera",
"waitingroomtextnopatient": "No hay pacientes en la sala de espera.",
"gender": "Sexo",
"birthday": "Fecha de nacimiento",
"email": "Correo electrónico",
"phone": "Teléfono",
"address": "Dirección",
"country": "País",
"notice": "Notas",
"create": "Creado",
"change": "Modificado",
"reset2": "Restablecer",
"edit": "Editar",
"selection": "Selección",
"waiting": "Ya está esperando",
"towaitingroom": "A la sala de espera",
"overview": "Resumen",
"upload": "Subir archivo",
"lock": "bloquear",
"unlock": "desbloquear",
"name": "Nombre",
"return": "Atrás",
"fileupload": "Cargar"
}, },
"sidebar": { "sidebar": {
"patients": "Pacientes", "patients": "Pacientes",
"medications": "Medicamentos", "medications": "Medicamentos",
@ -66,67 +14,14 @@
"admin": "Administración", "admin": "Administración",
"logout": "Cerrar sesión" "logout": "Cerrar sesión"
}, },
"dashboard": { "dashboard": {
"welcome": "Bienvenido", "welcome": "Bienvenido",
"waitingRoom": "Monitor sala de espera", "waitingRoom": "Monitor sala de espera",
"noWaitingPatients": "No hay pacientes en la sala de espera.", "noWaitingPatients": "No hay pacientes en la sala de espera."
"title": "Dashboard"
}, },
"adminSidebar": { "adminSidebar": {
"users": "Administración de usuarios", "users": "Administración de usuarios",
"database": "Administración de base de datos", "database": "Administración de base de datos"
"user": "usuario",
"invocieoverview": "Resumen de facturas",
"seriennumber": "número de serie",
"databasetable": "base de datos",
"companysettings": "Datos de la empresa"
},
"adminuseroverview": {
"useroverview": "Resumen de usuarios",
"usermanagement": "Administración de usuarios",
"user": "usuario",
"invocieoverview": "Resumen de facturas",
"seriennumber": "número de serie",
"databasetable": "base de datos"
},
"seriennumber": {
"seriennumbertitle": "Introduce el número de serie",
"seriennumbertext": "Introduce el número de serie de tu licencia para activar el software de forma permanente.",
"seriennumbershort": "Número de serie (AAAAA-AAAAA-AAAAA-AAAAA)",
"seriennumberdeclaration": "Solo letras y números. Formato: 4×5 caracteres, separados por «-». ",
"saveseriennumber": "Guardar número de serie"
},
"databaseoverview": {
"title": "Configuración de la base de datos",
"host": "Host",
"port": "Puerto",
"database": "Base de datos",
"password": "Contraseña",
"connectiontest": "Probar conexión",
"text": "Aquí puedes probar y guardar la conexión a la base de datos. ",
"tablecount": "Número de tablas",
"databasesize": "Tamaño de la base de datos",
"tableoverview": "Resumen de tablas"
},
"patienteoverview": {
"patienttitle": "Resumen de pacientes",
"newpatient": "Paciente nuevo",
"nopatientfound": "No se han encontrado pacientes.",
"closepatient": "Bloquear paciente (inactivo)",
"openpatient": "Desbloquear paciente (activo)"
},
"openinvoices": {
"openinvoices": "Facturas de pacientes",
"canceledinvoices": "Facturas canceladas",
"report": "Informe de ventas",
"payedinvoices": "Facturas pagadas",
"creditoverview": "Resumen de abonos"
} }
} }

View File

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

193
package-lock.json generated
View File

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

View File

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

View File

@ -172,7 +172,7 @@ a.waiting-slot {
/* ✅ Uhrzeit (oben rechts unter dem Button) */ /* ✅ Uhrzeit (oben rechts unter dem Button) */
.page-header-datetime { .page-header-datetime {
font-size: 24px; font-size: 14px;
opacity: 0.85; opacity: 0.85;
} }
@ -285,26 +285,3 @@ a.waiting-slot {
outline: none; outline: none;
box-shadow: none; box-shadow: none;
} }
/* ✅ Legende im Report */
.chart-legend {
margin-top: 20px;
text-align: left;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.legend-color {
width: 14px;
height: 14px;
border-radius: 3px;
}
.legend-text {
font-size: 14px;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -2,18 +2,7 @@
function updateDateTime() { function updateDateTime() {
const el = document.getElementById("datetime"); const el = document.getElementById("datetime");
if (!el) return; if (!el) return;
el.textContent = new Date().toLocaleString("de-DE");
const now = new Date();
const date = now.toLocaleDateString("de-DE");
const time = now.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
el.textContent = `${date} - ${time}`;
} }
updateDateTime(); 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,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

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

View File

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

View File

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

View File

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

View File

@ -1,139 +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, saveConfig } = require("../config-manager");
// ✅ DB + Session Reset (wie in deiner app.js)
const db = require("../db");
const { resetSessionStore } = require("../config/session");
/**
* Setup darf nur laufen, wenn config.enc NICHT existiert
* (sonst könnte jeder die DB später überschreiben)
*/
function blockIfInstalled(req, res, next) {
if (configExists()) {
return res.redirect("/");
}
next();
}
/**
* Setup Form anzeigen
*/
router.get("/", blockIfInstalled, (req, res) => {
return res.render("setup/index", {
title: "Erstinstallation",
defaults: {
host: "127.0.0.1",
port: 3306,
user: "",
password: "",
name: "",
},
});
});
/**
* Verbindung testen (AJAX)
*/
router.post("/test", blockIfInstalled, async (req, res) => {
try {
const { host, port, user, password, name } = req.body;
if (!host || !user || !name) {
return res.status(400).json({
ok: false,
message: "Bitte Host, Benutzer und Datenbankname ausfüllen.",
});
}
const connection = await mysql.createConnection({
host,
port: Number(port || 3306),
user,
password,
database: name,
connectTimeout: 5000,
});
await connection.query("SELECT 1");
await connection.end();
return res.json({ ok: true, message: "✅ Verbindung erfolgreich!" });
} catch (err) {
return res.status(500).json({
ok: false,
message: "❌ Verbindung fehlgeschlagen: " + err.message,
});
}
});
/**
* Setup speichern (DB Daten in config.enc)
*/
router.post("/", blockIfInstalled, async (req, res) => {
try {
const { host, port, user, password, name } = req.body;
if (!host || !user || !name) {
req.session.flash = req.session.flash || [];
req.session.flash.push({
type: "danger",
message: "❌ Bitte Host, Benutzer und Datenbankname ausfüllen.",
});
return res.redirect("/setup");
}
// ✅ Verbindung testen bevor speichern
const connection = await mysql.createConnection({
host,
port: Number(port || 3306),
user,
password,
database: name,
connectTimeout: 5000,
});
await connection.query("SELECT 1");
await connection.end();
// ✅ speichern
saveConfig({
db: {
host,
port: Number(port || 3306),
user,
password,
name,
},
});
// ✅ DB Pool neu starten (damit neue config sofort aktiv ist)
if (typeof db.resetPool === "function") {
db.resetPool();
}
// ✅ Session Store neu starten
resetSessionStore();
req.session.flash = req.session.flash || [];
req.session.flash.push({
type: "success",
message: "✅ Setup abgeschlossen. Du kannst dich jetzt einloggen.",
});
return res.redirect("/login");
} catch (err) {
req.session.flash = req.session.flash || [];
req.session.flash.push({
type: "danger",
message: "❌ Setup fehlgeschlagen: " + err.message,
});
return res.redirect("/setup");
}
});
module.exports = router;

View File

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

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,7 +1,6 @@
<!-- ✅ Header -->
<%- include("../partials/page-header", { <%- include("../partials/page-header", {
user, user,
title: t.adminSidebar.invocieoverview, title: "Rechnungsübersicht",
subtitle: "", subtitle: "",
showUserName: true showUserName: true
}) %> }) %>
@ -10,7 +9,6 @@
<!-- FILTER: JAHR VON / BIS --> <!-- FILTER: JAHR VON / BIS -->
<div class="container-fluid mt-2"> <div class="container-fluid mt-2">
<form method="get" class="row g-2 mb-4"> <form method="get" class="row g-2 mb-4">
<div class="col-auto"> <div class="col-auto">
<input <input
@ -33,7 +31,7 @@
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button class="btn btn-outline-secondary"><%= t.global.filter %></button> <button class="btn btn-outline-secondary">Filtern</button>
</div> </div>
</form> </form>
@ -43,25 +41,25 @@
<!-- JAHRESUMSATZ --> <!-- JAHRESUMSATZ -->
<div class="col-xl-3 col-lg-6"> <div class="col-xl-3 col-lg-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.yearcash%></div> <div class="card-header fw-semibold">Jahresumsatz</div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-sm table-striped mb-0"> <table class="table table-sm table-striped mb-0">
<thead> <thead>
<tr> <tr>
<th><%= t.global.year%></th> <th>Jahr</th>
<th class="text-end">€</th> <th class="text-end">€</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% if (!yearly || yearly.length === 0) { %> <% if (yearly.length === 0) { %>
<tr> <tr>
<td colspan="2" class="text-center text-muted"> <td colspan="2" class="text-center text-muted">
<%= t.global.nodata%> Keine Daten
</td> </td>
</tr> </tr>
<% } %> <% } %>
<% (yearly || []).forEach(y => { %> <% yearly.forEach(y => { %>
<tr> <tr>
<td><%= y.year %></td> <td><%= y.year %></td>
<td class="text-end fw-semibold"> <td class="text-end fw-semibold">
@ -78,26 +76,26 @@
<!-- QUARTALSUMSATZ --> <!-- QUARTALSUMSATZ -->
<div class="col-xl-3 col-lg-6"> <div class="col-xl-3 col-lg-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.quartalcash%></div> <div class="card-header fw-semibold">Quartalsumsatz</div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-sm table-striped mb-0"> <table class="table table-sm table-striped mb-0">
<thead> <thead>
<tr> <tr>
<th><%= t.global.year%></th> <th>Jahr</th>
<th>Q</th> <th>Q</th>
<th class="text-end">€</th> <th class="text-end">€</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% if (!quarterly || quarterly.length === 0) { %> <% if (quarterly.length === 0) { %>
<tr> <tr>
<td colspan="3" class="text-center text-muted"> <td colspan="3" class="text-center text-muted">
<%= t.global.nodata%> Keine Daten
</td> </td>
</tr> </tr>
<% } %> <% } %>
<% (quarterly || []).forEach(q => { %> <% quarterly.forEach(q => { %>
<tr> <tr>
<td><%= q.year %></td> <td><%= q.year %></td>
<td>Q<%= q.quarter %></td> <td>Q<%= q.quarter %></td>
@ -115,25 +113,25 @@
<!-- MONATSUMSATZ --> <!-- MONATSUMSATZ -->
<div class="col-xl-3 col-lg-6"> <div class="col-xl-3 col-lg-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.monthcash%></div> <div class="card-header fw-semibold">Monatsumsatz</div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-sm table-striped mb-0"> <table class="table table-sm table-striped mb-0">
<thead> <thead>
<tr> <tr>
<th><%= t.global.month%></th> <th>Monat</th>
<th class="text-end">€</th> <th class="text-end">€</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% if (!monthly || monthly.length === 0) { %> <% if (monthly.length === 0) { %>
<tr> <tr>
<td colspan="2" class="text-center text-muted"> <td colspan="2" class="text-center text-muted">
<%= t.global.nodata%> Keine Daten
</td> </td>
</tr> </tr>
<% } %> <% } %>
<% (monthly || []).forEach(m => { %> <% monthly.forEach(m => { %>
<tr> <tr>
<td><%= m.month %></td> <td><%= m.month %></td>
<td class="text-end fw-semibold"> <td class="text-end fw-semibold">
@ -150,7 +148,7 @@
<!-- UMSATZ PRO PATIENT --> <!-- UMSATZ PRO PATIENT -->
<div class="col-xl-3 col-lg-6"> <div class="col-xl-3 col-lg-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header fw-semibold"><%= t.global.patientcash%></div> <div class="card-header fw-semibold">Umsatz pro Patient</div>
<div class="card-body p-2"> <div class="card-body p-2">
<!-- Suche --> <!-- Suche -->
@ -166,33 +164,33 @@
placeholder="Patient suchen..." placeholder="Patient suchen..."
/> />
<button class="btn btn-sm btn-outline-primary"><%= t.global.search%></button> <button class="btn btn-sm btn-outline-primary">Suchen</button>
<a <a
href="/admin/invoices?fromYear=<%= fromYear %>&toYear=<%= toYear %>" href="/admin/invoices?fromYear=<%= fromYear %>&toYear=<%= toYear %>"
class="btn btn-sm btn-outline-secondary" class="btn btn-sm btn-outline-secondary"
> >
<%= t.global.reset%> Reset
</a> </a>
</form> </form>
<table class="table table-sm table-striped mb-0"> <table class="table table-sm table-striped mb-0">
<thead> <thead>
<tr> <tr>
<th><%= t.global.patient%></th> <th>Patient</th>
<th class="text-end">€</th> <th class="text-end">€</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% if (!patients || patients.length === 0) { %> <% if (patients.length === 0) { %>
<tr> <tr>
<td colspan="2" class="text-center text-muted"> <td colspan="2" class="text-center text-muted">
<%= t.global.nodata%> Keine Daten
</td> </td>
</tr> </tr>
<% } %> <% } %>
<% (patients || []).forEach(p => { %> <% patients.forEach(p => { %>
<tr> <tr>
<td><%= p.patient %></td> <td><%= p.patient %></td>
<td class="text-end fw-semibold"> <td class="text-end fw-semibold">

View File

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

View File

@ -1,99 +1,94 @@
<div class="layout">
<!-- ✅ SIDEBAR (wie Dashboard, aber Admin Sidebar) -->
<%- include("../partials/admin-sidebar", { user, active: "database", lang }) %>
<!-- ✅ MAIN -->
<div class="main">
<!-- ✅ HEADER (wie Dashboard) -->
<%- include("../partials/page-header", { <%- include("../partials/page-header", {
user, user,
title: t.adminSidebar.database, title: "Datenbankverwaltung",
subtitle: "", subtitle: "",
showUserName: true, showUserName: true
hideDashboardButton: true
}) %> }) %>
<div class="content p-4"> <div class="content p-4">
<!-- Flash Messages -->
<%- include("../partials/flash") %> <%- include("../partials/flash") %>
<div class="container-fluid p-0"> <div class="container-fluid p-0">
<div class="row g-3"> <div class="row g-3">
<!-- ✅ 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 --> <!-- ✅ DB Konfiguration -->
<div class="col-12">
<div class="card shadow mb-3"> <div class="card shadow mb-3">
<div class="card-body"> <div class="card-body">
<h4 class="mb-3"> <h4 class="mb-3">
<i class="bi bi-sliders"></i> <%= t.databaseoverview.title%> <i class="bi bi-sliders"></i> Datenbank Konfiguration
</h4> </h4>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
<%= t.databaseoverview.tittexte%> Hier kannst du die DB-Verbindung testen und speichern.
</p> </p>
<!-- ✅ TEST + SPEICHERN --> <!-- ✅ TEST (ohne speichern) + SPEICHERN -->
<form method="POST" action="/admin/database/test" class="row g-3 mb-3" autocomplete="off"> <form method="POST" action="/admin/database/test" class="row g-3 mb-3" autocomplete="off">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.databaseoverview.host%> / IP</label> <label class="form-label">Host / IP</label>
<input <input
type="text" type="text"
name="host" name="host"
class="form-control" class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.host) ? dbConfig.host : '' %>" value="<%= dbConfig?.host || '' %>"
autocomplete="off" autocomplete="off"
required required
> >
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label"><%= t.databaseoverview.port%></label> <label class="form-label">Port</label>
<input <input
type="number" type="number"
name="port" name="port"
class="form-control" class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.port) ? dbConfig.port : 3306 %>" value="<%= dbConfig?.port || 3306 %>"
autocomplete="off" autocomplete="off"
required required
> >
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label"><%= t.databaseoverview.database%></label> <label class="form-label">Datenbank</label>
<input <input
type="text" type="text"
name="name" name="name"
class="form-control" class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.name) ? dbConfig.name : '' %>" value="<%= dbConfig?.name || '' %>"
autocomplete="off" autocomplete="off"
required required
> >
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.global.user%></label> <label class="form-label">Benutzer</label>
<input <input
type="text" type="text"
name="user" name="user"
class="form-control" class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.user) ? dbConfig.user : '' %>" value="<%= dbConfig?.user || '' %>"
autocomplete="off" autocomplete="off"
required required
> >
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><%= t.databaseoverview.password%></label> <label class="form-label">Passwort</label>
<input <input
type="password" type="password"
name="password" name="password"
class="form-control" class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.password) ? dbConfig.password : '' %>" value="<%= dbConfig?.password || '' %>"
autocomplete="off" autocomplete="off"
required required
> >
@ -102,15 +97,16 @@
<div class="col-12 d-flex flex-wrap gap-2"> <div class="col-12 d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-outline-primary"> <button type="submit" class="btn btn-outline-primary">
<i class="bi bi-plug"></i> <%= t.databaseoverview.connectiontest%> <i class="bi bi-plug"></i> Verbindung testen
</button> </button>
<!-- ✅ Speichern + Testen -->
<button <button
type="submit" type="submit"
class="btn btn-success" class="btn btn-success"
formaction="/admin/database" formaction="/admin/database"
> >
<i class="bi bi-save"></i> <%= t.global.save%> <i class="bi bi-save"></i> Speichern
</button> </button>
</div> </div>
@ -124,21 +120,19 @@
</div> </div>
</div> </div>
</div>
<!-- ✅ System Info --> <!-- ✅ System Info -->
<div class="col-12">
<div class="card shadow mb-3"> <div class="card shadow mb-3">
<div class="card-body"> <div class="card-body">
<h4 class="mb-3"> <h4 class="mb-3">
<i class="bi bi-info-circle"></i> <%=t.global.systeminfo%> <i class="bi bi-info-circle"></i> Systeminformationen
</h4> </h4>
<% if (typeof systemInfo !== "undefined" && systemInfo && systemInfo.error) { %> <% if (typeof systemInfo !== "undefined" && systemInfo?.error) { %>
<div class="alert alert-danger mb-0"> <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 class="mt-2"><code><%= systemInfo.error %></code></div>
</div> </div>
@ -154,14 +148,14 @@
<div class="col-md-4"> <div class="col-md-4">
<div class="border rounded p-3 h-100"> <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 class="fw-bold"><%= systemInfo.tableCount %></div>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="border rounded p-3 h-100"> <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 class="fw-bold"><%= systemInfo.dbSizeMB %> MB</div>
</div> </div>
</div> </div>
@ -170,15 +164,15 @@
<% if (systemInfo.tables && systemInfo.tables.length > 0) { %> <% if (systemInfo.tables && systemInfo.tables.length > 0) { %>
<hr> <hr>
<h6 class="mb-2"><%=t.databaseoverview.tableoverview%></h6> <h6 class="mb-2">Tabellenübersicht</h6>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-bordered table-hover align-middle"> <table class="table table-sm table-bordered table-hover align-middle">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th><%=t.global.table%></th> <th>Tabelle</th>
<th class="text-end"><%=t.global.lines%></th> <th class="text-end">Zeilen</th>
<th class="text-end"><%=t.global.size%> (MB)</th> <th class="text-end">Größe (MB)</th>
</tr> </tr>
</thead> </thead>
@ -205,10 +199,8 @@
</div> </div>
</div> </div>
</div>
<!-- ✅ Backup & Restore --> <!-- ✅ Backup & Restore -->
<div class="col-12">
<div class="card shadow"> <div class="card shadow">
<div class="card-body"> <div class="card-body">
@ -253,11 +245,8 @@
</div> </div>
</div> </div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>

View File

@ -50,10 +50,9 @@
</tr> </tr>
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
<br>
</div> </div>
</body> </body>
</html> </html>

View File

@ -5,7 +5,7 @@
<!-- ✅ HEADER --> <!-- ✅ HEADER -->
<%- include("partials/page-header", { <%- include("partials/page-header", {
user, user,
title: t.adminuseroverview.usermanagement, title: "User Verwaltung",
subtitle: "", subtitle: "",
showUserName: true showUserName: true
}) %> }) %>
@ -20,11 +20,11 @@
<div class="card-body"> <div class="card-body">
<div class="d-flex align-items-center justify-content-between mb-3"> <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"> <a href="/admin/create-user" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> <i class="bi bi-plus-circle"></i>
<%= t.global.newuser %> Neuer Benutzer
</a> </a>
</div> </div>
@ -34,13 +34,13 @@
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th><%= t.global.title %></th> <th>Titel</th>
<th><%= t.global.firstname %></th> <th>Vorname</th>
<th><%= t.global.lastname %></th> <th>Nachname</th>
<th><%= t.global.username %></th> <th>Username</th>
<th><%= t.global.role %></th> <th>Rolle</th>
<th class="text-center"><%= t.global.status %></th> <th class="text-center">Status</th>
<th><%= t.global.action %></th> <th>Aktionen</th>
</tr> </tr>
</thead> </thead>
@ -79,11 +79,11 @@
<td class="text-center"> <td class="text-center">
<% if (u.active === 0) { %> <% 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()) { %> <% } 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 { %> <% } else { %>
<span class="badge bg-success"><%= t.global.active %></span> <span class="badge bg-success">Aktiv</span>
<% } %> <% } %>
</td> </td>
@ -109,7 +109,7 @@
</button> </button>
</form> </form>
<% } else { %> <% } 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> </td>
@ -128,3 +128,9 @@
</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,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", { <%- include("partials/page-header", {
user, user,
title: t.dashboard.title, title: "Dashboard",
subtitle: "", subtitle: "",
showUserName: true, showUserName: true,
hideDashboardButton: true hideDashboardButton: true
@ -10,34 +17,50 @@
<div class="content p-4"> <div class="content p-4">
<!-- Flash Messages -->
<%- include("partials/flash") %> <%- include("partials/flash") %>
<!-- =========================
WARTEZIMMER MONITOR
========================= -->
<div class="waiting-monitor"> <div class="waiting-monitor">
<h5 class="mb-3">🪑 <%= t.dashboard.waitingRoom %></h5> <h5 class="mb-3">🪑 Wartezimmer-Monitor</h5>
<div class="waiting-grid"> <div class="waiting-grid">
<% if (waitingPatients && waitingPatients.length > 0) { %> <% if (waitingPatients && waitingPatients.length > 0) { %>
<% waitingPatients.forEach(p => { %> <% waitingPatients.forEach(p => { %>
<% if (user.role === "arzt") { %> <% if (user.role === 'arzt') { %>
<form method="POST" action="/patients/<%= p.id %>/call"> <form method="POST" action="/patients/<%= p.id %>/call" style="width:100%; margin:0;">
<button class="waiting-slot occupied clickable"> <button type="submit" class="waiting-slot occupied clickable waiting-btn">
<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>
</button> </button>
</form> </form>
<% } else { %> <% } else { %>
<div class="waiting-slot occupied"> <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> </div>
<% } %> <% } %>
<% }) %> <% }) %>
<% } else { %> <% } else { %>
<div class="text-muted"> <div class="text-muted">Keine Patienten im Wartezimmer.</div>
<%= t.dashboard.noWaitingPatients %>
</div>
<% } %> <% } %>
</div> </div>
</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,57 +0,0 @@
<%- include("../partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
subtitle: "",
showUserName: true
}) %>
<!-- CONTENT -->
<div class="container mt-4">
<%- include("../partials/flash") %>
<h4>Stornierte Rechnungen</h4>
<!-- ✅ Jahresfilter -->
<form method="GET" action="/invoices/cancelled" style="margin-bottom:20px;">
<label>Jahr:</label>
<select
name="year"
onchange="this.form.submit()"
class="form-select"
style="width:150px; display:inline-block;"
>
<% years.forEach(y => { %>
<option value="<%= y %>" <%= y == selectedYear ? "selected" : "" %>>
<%= y %>
</option>
<% }) %>
</select>
</form>
<% if (invoices.length === 0) { %>
<p>Keine stornierten Rechnungen für dieses Jahr.</p>
<% } else { %>
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Patient</th>
<th>Datum</th>
<th>Betrag</th>
</tr>
</thead>
<tbody>
<% invoices.forEach(inv => { %>
<tr>
<td><%= inv.id %></td>
<td><%= inv.firstname %> <%= inv.lastname %></td>
<td><%= inv.invoice_date_formatted %></td>
<td><%= inv.total_amount_formatted %> €</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>

View File

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

View File

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

View File

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

View File

@ -20,6 +20,7 @@
<body> <body>
<div class="layout"> <div class="layout">
<!-- ✅ Sidebar dynamisch --> <!-- ✅ Sidebar dynamisch -->
<% if (typeof sidebarPartial !== "undefined" && sidebarPartial) { %> <% if (typeof sidebarPartial !== "undefined" && sidebarPartial) { %>
<%- include(sidebarPartial, { <%- include(sidebarPartial, {
@ -42,6 +43,5 @@
<!-- ✅ externes JS (CSP safe) --> <!-- ✅ externes JS (CSP safe) -->
<script src="/js/datetime.js"></script> <script src="/js/datetime.js"></script>
<script src="/js/patient-select.js" defer></script> <script src="/js/patient-select.js" defer></script>
<!-- <script src="/js/patient_sidebar.js" defer></script> -->
</body> </body>
</html> </html>

View File

@ -1,6 +1,6 @@
<%- include("partials/page-header", { <%- include("partials/page-header", {
user, user,
title: t.patienteoverview.patienttitle, title: "Medikamentenübersicht",
subtitle: "", subtitle: "",
showUserName: true showUserName: true
}) %> }) %>
@ -136,5 +136,6 @@
</div> </div>
</div> </div>
<!-- ✅ Externes JS (Helmet/CSP safe) --> <!-- ✅ Externes JS (Helmet/CSP safe) -->
<script src="/js/services-lock.js"></script> <script src="/js/services-lock.js"></script>

View File

@ -30,7 +30,7 @@
<!-- 🧾 RECHNUNG ERSTELLEN --> <!-- 🧾 RECHNUNG ERSTELLEN -->
<form <form
method="POST" method="POST"
action="/invoices/patients/<%= r.patient_id %>/create-invoice" action="/patients/<%= r.patient_id %>/create-invoice"
class="invoice-form d-inline float-end ms-2" class="invoice-form d-inline float-end ms-2"
> >
<button class="btn btn-sm btn-success"> <button class="btn btn-sm btn-success">

View File

@ -26,25 +26,13 @@
<div class="sidebar-menu"> <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' %>"
>
<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 --> <!-- ✅ User Verwaltung -->
<a <a
href="<%= hrefIfAllowed(isAdmin, '/admin/users') %>" href="<%= hrefIfAllowed(isAdmin, '/admin/users') %>"
class="nav-item <%= active === 'users' ? 'active' : '' %> <%= lockClass(isAdmin) %>" class="nav-item <%= active === 'users' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>" title="<%= isAdmin ? '' : 'Nur Admin' %>"
> >
<i class="bi bi-people"></i> <%= t.adminSidebar.user %> <i class="bi bi-people"></i> Benutzer
<% if (!isAdmin) { %> <% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span> <span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %> <% } %>
@ -56,7 +44,7 @@
class="nav-item <%= active === 'invoice_overview' ? 'active' : '' %> <%= lockClass(isAdmin) %>" class="nav-item <%= active === 'invoice_overview' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>" title="<%= isAdmin ? '' : 'Nur Admin' %>"
> >
<i class="bi bi-calculator"></i> <%= t.adminSidebar.invocieoverview %> <i class="bi bi-calculator"></i> Rechnungsübersicht
<% if (!isAdmin) { %> <% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span> <span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %> <% } %>
@ -69,7 +57,7 @@
class="nav-item <%= active === 'serialnumber' ? 'active' : '' %> <%= lockClass(isAdmin) %>" class="nav-item <%= active === 'serialnumber' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>" title="<%= isAdmin ? '' : 'Nur Admin' %>"
> >
<i class="bi bi-key"></i> <%= t.adminSidebar.seriennumber %> <i class="bi bi-key"></i> Seriennummer
<% if (!isAdmin) { %> <% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span> <span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %> <% } %>
@ -81,16 +69,11 @@
class="nav-item <%= active === 'database' ? 'active' : '' %> <%= lockClass(isAdmin) %>" class="nav-item <%= active === 'database' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
title="<%= isAdmin ? '' : 'Nur Admin' %>" title="<%= isAdmin ? '' : 'Nur Admin' %>"
> >
<i class="bi bi-hdd-stack"></i> <%= t.adminSidebar.databasetable %> <i class="bi bi-hdd-stack"></i> Datenbank
<% if (!isAdmin) { %> <% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span> <span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %> <% } %>
</a> </a>
<!-- ✅ Logout -->
<a href="/logout" class="nav-item">
<i class="bi bi-box-arrow-right"></i> <%= t.sidebar.logout %>
</a>
</div> </div>
</div> </div>

View File

@ -2,6 +2,11 @@
const titleText = typeof title !== "undefined" ? title : ""; const titleText = typeof title !== "undefined" ? title : "";
const subtitleText = typeof subtitle !== "undefined" ? subtitle : ""; const subtitleText = typeof subtitle !== "undefined" ? subtitle : "";
const showUser = typeof showUserName !== "undefined" ? showUserName : true; const showUser = typeof showUserName !== "undefined" ? showUserName : true;
// ✅ Standard: Button anzeigen
const hideDashboard = typeof hideDashboardButton !== "undefined"
? hideDashboardButton
: false;
%> %>
<div class="page-header"> <div class="page-header">
@ -13,7 +18,7 @@
<div class="page-header-center"> <div class="page-header-center">
<% if (showUser && user?.username) { %> <% if (showUser && user?.username) { %>
<div class="page-header-username"> <div class="page-header-username">
<%=t.global.welcome%>, <%= user.title + " " + user.firstname + " " + user.lastname %> Willkommen, <%= user.username %>
</div> </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,177 +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' %>"
>
<i class="bi bi-pencil-square"></i> <%= t.global.edit %>
<% if (!canUsePatient) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<!-- =========================
ÜBERSICHT (Dashboard)
========================= -->
<a
href="<%= hrefIfAllowed(canUsePatient, '/patients/' + pid) %>"
class="nav-item <%= active === 'patient_dashboard' ? 'active' : '' %> <%= lockClass(canUsePatient) %>"
title="<%= canUsePatient ? '' : 'Bitte zuerst einen Patienten auswählen' %>"
>
<i class="bi bi-clipboard2-heart"></i> <%= t.global.overview %>
<% if (!canUsePatient) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<!-- =========================
STATUS TOGGLE
========================= -->
<form
method="POST"
action="<%= canUsePatient ? (isActive ? '/patients/deactivate/' + pid : '/patients/activate/' + pid) : '#' %>"
>
<button
type="submit"
class="nav-item <%= lockClass(canUsePatient) %>"
style="width:100%; border:none; background:transparent; text-align:left;"
<%= canUsePatient ? '' : 'disabled' %>
title="<%= canUsePatient ? 'Status wechseln' : 'Bitte zuerst einen Patienten auswählen' %>"
>
<% if (isActive) { %>
<i class="bi bi-x-circle"></i> <%= t.patienteoverview.closepatient %>
<% } else { %>
<i class="bi bi-check-circle"></i> <%= t.patienteoverview.openpatient %>
<% } %>
<% if (!canUsePatient) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</button>
</form>
<!-- ✅ Upload -->
<div class="sidebar-upload <%= lockClass(canUsePatient) %>">
<div style="font-weight: 600; margin: 10px 0 6px 0; color: #e5e7eb">
<i class="bi bi-paperclip"></i> <%= t.global.fileupload %>
</div>
<% if (canUsePatient) { %>
<form
action="/patients/<%= pid %>/files"
method="POST"
enctype="multipart/form-data"
>
<% } %>
<input
id="sbUploadInput"
type="file"
name="file"
class="form-control form-control-sm mb-2"
<%= canUsePatient ? "" : "disabled" %>
required
/>
<button
id="sbUploadBtn"
type="submit"
class="btn btn-sm btn-outline-light w-100"
<%= canUsePatient ? "" : "disabled" %>
>
📎 <%= t.global.upload %>
<% if (!canUsePatient) { %>
<span class="ms-2"><i class="bi bi-lock-fill"></i></span>
<% } %>
</button>
<% if (canUsePatient) { %>
</form>
<% } %>
</div>
<div class="spacer"></div>
</div>

View File

@ -1,20 +1,5 @@
<div class="sidebar-empty"> <div class="sidebar sidebar-empty">
<!-- ✅ Logo -->
<div
style="
margin-bottom: 30px;
display: flex;
flex-direction: column;
gap: 10px;
"
>
<div style="padding: 20px; text-align: center"> <div style="padding: 20px; text-align: center">
<div class="logo" style="margin: 0">🩺 Praxis System</div> <div class="logo" style="margin: 0">🩺 Praxis System</div>
</div> </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,109 +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' %>"
>
<i class="bi bi-receipt"></i> <%= t.openinvoices.openinvoices %>
<% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/cancelled') %>"
class="nav-item <%= active === 'cancelled_invoices' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.openinvoices.canceledinvoices %>
<% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/reportview') %>"
class="nav-item <%= active === 'reportview' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.openinvoices.report %>
<% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/paid') %>"
class="nav-item <%= active === 'paid' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.openinvoices.payedinvoices %>
<% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/credit-overview') %>"
class="nav-item <%= active === 'credit-overview' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.openinvoices.creditoverview %>
<% if (!canDoctorAndStaff) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<div class="spacer"></div>
<!-- ✅ Logout -->
<a href="/logout" class="nav-item">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</div>

View File

@ -116,7 +116,7 @@
<!-- ✅ Logout --> <!-- ✅ Logout -->
<a href="/logout" class="nav-item"> <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> </a>
</div> </div>

View File

@ -1,6 +1,6 @@
<%- include("partials/page-header", { <%- include("partials/page-header", {
user, user,
title: t.patienteoverview.patienttitle, title: "Patientenübersicht",
subtitle: "", subtitle: "",
showUserName: true showUserName: true
}) %> }) %>
@ -12,7 +12,7 @@
<!-- Aktionen oben --> <!-- Aktionen oben -->
<div class="d-flex gap-2 mb-3"> <div class="d-flex gap-2 mb-3">
<a href="/patients/create" class="btn btn-success"> <a href="/patients/create" class="btn btn-success">
+ <%= t.patienteoverview.newpatient %> + Neuer Patient
</a> </a>
</div> </div>
@ -26,7 +26,7 @@
type="text" type="text"
name="firstname" name="firstname"
class="form-control" class="form-control"
placeholder="<%= t.global.firstname %>" placeholder="Vorname"
value="<%= query?.firstname || '' %>" value="<%= query?.firstname || '' %>"
/> />
</div> </div>
@ -36,7 +36,7 @@
type="text" type="text"
name="lastname" name="lastname"
class="form-control" class="form-control"
placeholder="<%= t.global.lastname %>" placeholder="Nachname"
value="<%= query?.lastname || '' %>" value="<%= query?.lastname || '' %>"
/> />
</div> </div>
@ -51,40 +51,33 @@
</div> </div>
<div class="col-md-3 d-flex gap-2"> <div class="col-md-3 d-flex gap-2">
<button class="btn btn-primary w-100"><%= t.global.search %></button> <button class="btn btn-primary w-100">Suchen</button>
<a href="/patients" class="btn btn-secondary w-100"> <a href="/patients" class="btn btn-secondary w-100">
<%= t.global.reset2 %> Zurücksetzen
</a> </a>
</div> </div>
</form> </form>
<!-- ✅ EINE Form für ALLE Radiobuttons --> <!-- Tabelle -->
<form method="GET" action="/patients">
<!-- Filter beibehalten -->
<input type="hidden" name="firstname" value="<%= query?.firstname || '' %>">
<input type="hidden" name="lastname" value="<%= query?.lastname || '' %>">
<input type="hidden" name="birthdate" value="<%= query?.birthdate || '' %>">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-bordered table-hover align-middle table-sm"> <table class="table table-bordered table-hover align-middle table-sm">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th style="width:40px;"></th> <th style="width:40px;"></th>
<th>ID</th> <th>ID</th>
<th><%= t.global.name %></th> <th>Name</th>
<th>DNI</th> <th>N.I.E. / DNI</th>
<th><%= t.global.gender %></th> <th>Geschlecht</th>
<th><%= t.global.birthday %></th> <th>Geburtstag</th>
<th><%= t.global.email %></th> <th>E-Mail</th>
<th><%= t.global.phone %></th> <th>Telefon</th>
<th><%= t.global.address %></th> <th>Adresse</th>
<th><%= t.global.country %></th> <th>Land</th>
<th><%= t.global.status %></th> <th>Status</th>
<th><%= t.global.notice %></th> <th>Notizen</th>
<th><%= t.global.create %></th> <th>Erstellt</th>
<th><%= t.global.change %></th> <th>Geändert</th>
<th>Aktionen</th>
</tr> </tr>
</thead> </thead>
@ -92,7 +85,7 @@
<% if (patients.length === 0) { %> <% if (patients.length === 0) { %>
<tr> <tr>
<td colspan="15" class="text-center text-muted"> <td colspan="15" class="text-center text-muted">
<%= t.patientoverview.nopatientfound %> Keine Patienten gefunden
</td> </td>
</tr> </tr>
<% } %> <% } %>
@ -100,16 +93,23 @@
<% patients.forEach(p => { %> <% patients.forEach(p => { %>
<tr> <tr>
<!-- ✅ EIN Radiobutton korrekt gruppiert --> <!-- ✅ RADIOBUTTON ganz vorne -->
<td class="text-center"> <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 <input
class="patient-radio" class="patient-radio"
type="radio" type="radio"
name="selectedPatientId" name="selectedPatientId"
value="<%= p.id %>" value="<%= p.id %>"
<%= selectedPatientId === p.id ? "checked" : "" %> <%= selectedPatientId === p.id ? "checked" : "" %>
onchange="this.form.submit()"
/> />
</form>
</td> </td>
<td><%= p.id %></td> <td><%= p.id %></td>
@ -118,17 +118,26 @@
<td><%= p.dni || "-" %></td> <td><%= p.dni || "-" %></td>
<td> <td>
<%= p.gender === 'm' ? 'm' : <% if (p.gender === 'm') { %>
p.gender === 'w' ? 'w' : m
p.gender === 'd' ? 'd' : '-' %> <% } else if (p.gender === 'w') { %>
w
<% } else if (p.gender === 'd') { %>
d
<% } else { %>
-
<% } %>
</td>
<td>
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
</td> </td>
<td><%= new Date(p.birthdate).toLocaleDateString("de-DE") %></td>
<td><%= p.email || "-" %></td> <td><%= p.email || "-" %></td>
<td><%= p.phone || "-" %></td> <td><%= p.phone || "-" %></td>
<td> <td>
<%= p.street || "" %> <%= p.house_number || "" %><br> <%= p.street || "" %> <%= p.house_number || "" %><br />
<%= p.postal_code || "" %> <%= p.city || "" %> <%= p.postal_code || "" %> <%= p.city || "" %>
</td> </td>
@ -142,9 +151,84 @@
<% } %> <% } %>
</td> </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.created_at).toLocaleString("de-DE") %></td>
<td><%= new Date(p.updated_at).toLocaleString("de-DE") %></td> <td><%= new Date(p.updated_at).toLocaleString("de-DE") %></td>
<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> </tr>
<% }) %> <% }) %>
</tbody> </tbody>
@ -152,7 +236,7 @@
</table> </table>
</div> </div>
</form>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -1,13 +1,35 @@
<%- include("partials/page-header", { <!DOCTYPE html>
user, <html lang="de">
title: t.patienteoverview.patienttitle, <head>
subtitle: "", <meta charset="UTF-8">
showUserName: true <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 --> <!-- CONTENT -->
<div class="container mt-4"> <div class="container mt-4">
<%- include("partials/flash") %>
<h4>Leistungen</h4> <h4>Leistungen</h4>
<!-- SUCHFORMULAR --> <!-- SUCHFORMULAR -->

View File

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