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

190
app.js
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,129 +4,23 @@
"cancel": "Abbrechen",
"search": "Suchen",
"reset": "Reset",
"dashboard": "Dashboard",
"logout": "Logout",
"title": "Titel",
"firstname": "Vorname",
"lastname": "Nachname",
"username": "Username",
"role": "Rolle",
"action": "Aktionen",
"status": "Status",
"you": "Du Selbst",
"newuser": "Neuer benutzer",
"inactive": "inaktive",
"active": "aktive",
"closed": "gesperrt",
"filter": "Filtern",
"yearcash": "Jahresumsatz",
"monthcash": "Monatsumsatz",
"quartalcash": "Quartalsumsatz",
"year": "Jahr",
"nodata": "keine Daten",
"month": "Monat",
"patientcash": "Umsatz pro Patient",
"patient": "Patient",
"systeminfo": "Systeminformationen",
"table": "Tabelle",
"lines": "Zeilen",
"size": "Grösse",
"errordatabase": "Fehler beim Auslesen der Datenbankinfos:",
"welcome": "Willkommen",
"waitingroomtext": "Wartezimmer-Monitor",
"waitingroomtextnopatient": "Keine Patienten im Wartezimmer.",
"gender": "Geschlecht",
"birthday": "Geburtstag",
"email": "E-Mail",
"phone": "Telefon",
"address": "Adresse",
"country": "Land",
"notice": "Notizen",
"create": "Erstellt",
"change": "Geändert",
"reset2": "Zurücksetzen",
"edit": "Bearbeiten",
"selection": "Auswahl",
"waiting": "Wartet bereits",
"towaitingroom": "Ins Wartezimmer",
"overview": "Übersicht",
"upload": "Hochladen",
"lock": "Sperren",
"unlock": "Enrsperren",
"name": "Name",
"return": "Zurück",
"fileupload": "Hochladen"
"dashboard": "Dashboard"
},
"sidebar": {
"patients": "Patienten",
"medications": "Medikamente",
"servicesOpen": "Patienten Rechnungen",
"servicesOpen": "Offene Leistungen",
"billing": "Abrechnung",
"admin": "Verwaltung",
"logout": "Logout"
},
"dashboard": {
"welcome": "Willkommen",
"waitingRoom": "Wartezimmer-Monitor",
"noWaitingPatients": "Keine Patienten im Wartezimmer.",
"title": "Dashboard"
"noWaitingPatients": "Keine Patienten im Wartezimmer."
},
"adminSidebar": {
"users": "Userverwaltung",
"database": "Datenbankverwaltung",
"user": "Benutzer",
"invocieoverview": "Rechnungsübersicht",
"seriennumber": "Seriennummer",
"databasetable": "Datenbank",
"companysettings": "Firmendaten"
},
"adminuseroverview": {
"useroverview": "Benutzerübersicht",
"usermanagement": "Benutzer Verwaltung",
"user": "Benutzer",
"invocieoverview": "Rechnungsübersicht",
"seriennumber": "Seriennummer",
"databasetable": "Datenbank"
},
"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"
"database": "Datenbankverwaltung"
}
}

View File

@ -4,60 +4,8 @@
"cancel": "Cancelar",
"search": "Buscar",
"reset": "Resetear",
"dashboard": "Panel",
"logout": "cerrar sesión",
"title": "Título",
"firstname": "Nombre",
"lastname": "apellido",
"username": "Nombre de usuario",
"role": "desempeñar",
"action": "acción",
"status": "Estado",
"you": "su mismo",
"newuser": "Nuevo usuario",
"inactive": "inactivo",
"active": "activo",
"closed": "bloqueado",
"filter": "Filtro",
"yearcash": "volumen de negocios anual",
"monthcash": "volumen de negocios mensual",
"quartalcash": "volumen de negocios trimestral",
"year": "ano",
"nodata": "sin datos",
"month": "mes",
"patientcash": "Ingresos por paciente",
"patient": "paciente",
"systeminfo": "Información del sistema",
"table": "tablas",
"lines": "líneas",
"size": "Tamaño",
"errordatabase": "Error al leer la información de la base de datos:",
"welcome": "Bienvenido",
"waitingroomtext": "Monitor de sala de espera",
"waitingroomtextnopatient": "No hay pacientes en la sala de espera.",
"gender": "Sexo",
"birthday": "Fecha de nacimiento",
"email": "Correo electrónico",
"phone": "Teléfono",
"address": "Dirección",
"country": "País",
"notice": "Notas",
"create": "Creado",
"change": "Modificado",
"reset2": "Restablecer",
"edit": "Editar",
"selection": "Selección",
"waiting": "Ya está esperando",
"towaitingroom": "A la sala de espera",
"overview": "Resumen",
"upload": "Subir archivo",
"lock": "bloquear",
"unlock": "desbloquear",
"name": "Nombre",
"return": "Atrás",
"fileupload": "Cargar"
"dashboard": "Panel"
},
"sidebar": {
"patients": "Pacientes",
"medications": "Medicamentos",
@ -66,67 +14,14 @@
"admin": "Administración",
"logout": "Cerrar sesión"
},
"dashboard": {
"welcome": "Bienvenido",
"waitingRoom": "Monitor sala de espera",
"noWaitingPatients": "No hay pacientes en la sala de espera.",
"title": "Dashboard"
"noWaitingPatients": "No hay pacientes en la sala de espera."
},
"adminSidebar": {
"users": "Administración de usuarios",
"database": "Administración de base de datos",
"user": "usuario",
"invocieoverview": "Resumen de facturas",
"seriennumber": "número de serie",
"databasetable": "base de datos",
"companysettings": "Datos de la empresa"
},
"adminuseroverview": {
"useroverview": "Resumen de usuarios",
"usermanagement": "Administración de usuarios",
"user": "usuario",
"invocieoverview": "Resumen de facturas",
"seriennumber": "número de serie",
"databasetable": "base de datos"
},
"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"
"database": "Administración de base de datos"
}
}

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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({
id: user.id,
username: user.username,
role: user.role,
title: user.title,
firstname: user.first_name,
lastname: user.last_name
role: user.role
});
}
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,263 +1,252 @@
<div class="layout">
<%- include("../partials/page-header", {
user,
title: "Datenbankverwaltung",
subtitle: "",
showUserName: true
}) %>
<!-- ✅ SIDEBAR (wie Dashboard, aber Admin Sidebar) -->
<%- include("../partials/admin-sidebar", { user, active: "database", lang }) %>
<div class="content p-4">
<!-- ✅ MAIN -->
<div class="main">
<%- include("../partials/flash") %>
<!-- ✅ HEADER (wie Dashboard) -->
<%- include("../partials/page-header", {
user,
title: t.adminSidebar.database,
subtitle: "",
showUserName: true,
hideDashboardButton: true
}) %>
<div class="container-fluid p-0">
<div class="row g-3">
<div class="content p-4">
<!-- Flash Messages -->
<%- include("../partials/flash") %>
<div class="container-fluid p-0">
<div class="row g-3">
<!-- ✅ DB Konfiguration -->
<div class="col-12">
<div class="card shadow mb-3">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-sliders"></i> <%= t.databaseoverview.title%>
</h4>
<p class="text-muted mb-4">
<%= t.databaseoverview.tittexte%>
</p>
<!-- ✅ TEST + SPEICHERN -->
<form method="POST" action="/admin/database/test" class="row g-3 mb-3" autocomplete="off">
<div class="col-md-6">
<label class="form-label"><%= t.databaseoverview.host%> / IP</label>
<input
type="text"
name="host"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.host) ? dbConfig.host : '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-3">
<label class="form-label"><%= t.databaseoverview.port%></label>
<input
type="number"
name="port"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.port) ? dbConfig.port : 3306 %>"
autocomplete="off"
required
>
</div>
<div class="col-md-3">
<label class="form-label"><%= t.databaseoverview.database%></label>
<input
type="text"
name="name"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.name) ? dbConfig.name : '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-6">
<label class="form-label"><%= t.global.user%></label>
<input
type="text"
name="user"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.user) ? dbConfig.user : '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-6">
<label class="form-label"><%= t.databaseoverview.password%></label>
<input
type="password"
name="password"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.password) ? dbConfig.password : '' %>"
autocomplete="off"
required
>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-plug"></i> <%= t.databaseoverview.connectiontest%>
</button>
<button
type="submit"
class="btn btn-success"
formaction="/admin/database"
>
<i class="bi bi-save"></i> <%= t.global.save%>
</button>
</div>
</form>
<% if (typeof testResult !== "undefined" && testResult) { %>
<div class="alert <%= testResult.ok ? 'alert-success' : 'alert-danger' %> mb-0">
<%= testResult.message %>
</div>
<% } %>
</div>
</div>
</div>
<!-- ✅ System Info -->
<div class="col-12">
<div class="card shadow mb-3">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-info-circle"></i> <%=t.global.systeminfo%>
</h4>
<% if (typeof systemInfo !== "undefined" && systemInfo && systemInfo.error) { %>
<div class="alert alert-danger mb-0">
❌ <%=t.global.errordatabase%>
<div class="mt-2"><code><%= systemInfo.error %></code></div>
</div>
<% } else if (typeof systemInfo !== "undefined" && systemInfo) { %>
<div class="row g-3">
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small">MySQL Version</div>
<div class="fw-bold"><%= systemInfo.version %></div>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small"><%=t.databaseoverview.tablecount%></div>
<div class="fw-bold"><%= systemInfo.tableCount %></div>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small"><%=t.databaseoverview.databasesize%></div>
<div class="fw-bold"><%= systemInfo.dbSizeMB %> MB</div>
</div>
</div>
</div>
<% if (systemInfo.tables && systemInfo.tables.length > 0) { %>
<hr>
<h6 class="mb-2"><%=t.databaseoverview.tableoverview%></h6>
<div class="table-responsive">
<table class="table table-sm table-bordered table-hover align-middle">
<thead class="table-dark">
<tr>
<th><%=t.global.table%></th>
<th class="text-end"><%=t.global.lines%></th>
<th class="text-end"><%=t.global.size%> (MB)</th>
</tr>
</thead>
<tbody>
<% systemInfo.tables.forEach(t => { %>
<tr>
<td><%= t.name %></td>
<td class="text-end"><%= t.row_count %></td>
<td class="text-end"><%= t.size_mb %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
<% } else { %>
<div class="alert alert-warning mb-0">
⚠️ Keine Systeminfos verfügbar (DB ist evtl. nicht konfiguriert oder Verbindung fehlgeschlagen).
</div>
<% } %>
</div>
</div>
</div>
<!-- ✅ Backup & Restore -->
<div class="col-12">
<div class="card shadow">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-hdd-stack"></i> Backup & Restore
</h4>
<div class="d-flex flex-wrap gap-3">
<!-- ✅ Backup erstellen -->
<form action="/admin/database/backup" method="POST">
<button type="submit" class="btn btn-primary">
<i class="bi bi-download"></i> Backup erstellen
</button>
</form>
<!-- ✅ Restore auswählen -->
<form action="/admin/database/restore" method="POST">
<div class="input-group">
<select name="backupFile" class="form-select" required>
<option value="">Backup auswählen...</option>
<% (typeof backupFiles !== "undefined" && backupFiles ? backupFiles : []).forEach(file => { %>
<option value="<%= file %>"><%= file %></option>
<% }) %>
</select>
<button type="submit" class="btn btn-warning">
<i class="bi bi-upload"></i> Restore starten
</button>
</div>
</form>
</div>
<% if (typeof backupFiles === "undefined" || !backupFiles || backupFiles.length === 0) { %>
<div class="alert alert-secondary mt-3 mb-0">
Noch keine Backups vorhanden.
</div>
<% } %>
</div>
</div>
</div>
</div>
<!-- ✅ Sidebar -->
<div class="col-md-3 col-lg-2 p-0">
<%- include("../partials/admin-sidebar", { user, active: "database" }) %>
</div>
<!-- ✅ Content -->
<div class="col-md-9 col-lg-10">
<!-- ✅ DB Konfiguration -->
<div class="card shadow mb-3">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-sliders"></i> Datenbank Konfiguration
</h4>
<p class="text-muted mb-4">
Hier kannst du die DB-Verbindung testen und speichern.
</p>
<!-- ✅ TEST (ohne speichern) + SPEICHERN -->
<form method="POST" action="/admin/database/test" class="row g-3 mb-3" autocomplete="off">
<div class="col-md-6">
<label class="form-label">Host / IP</label>
<input
type="text"
name="host"
class="form-control"
value="<%= dbConfig?.host || '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-3">
<label class="form-label">Port</label>
<input
type="number"
name="port"
class="form-control"
value="<%= dbConfig?.port || 3306 %>"
autocomplete="off"
required
>
</div>
<div class="col-md-3">
<label class="form-label">Datenbank</label>
<input
type="text"
name="name"
class="form-control"
value="<%= dbConfig?.name || '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-6">
<label class="form-label">Benutzer</label>
<input
type="text"
name="user"
class="form-control"
value="<%= dbConfig?.user || '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-6">
<label class="form-label">Passwort</label>
<input
type="password"
name="password"
class="form-control"
value="<%= dbConfig?.password || '' %>"
autocomplete="off"
required
>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-plug"></i> Verbindung testen
</button>
<!-- ✅ Speichern + Testen -->
<button
type="submit"
class="btn btn-success"
formaction="/admin/database"
>
<i class="bi bi-save"></i> Speichern
</button>
</div>
</form>
<% if (typeof testResult !== "undefined" && testResult) { %>
<div class="alert <%= testResult.ok ? 'alert-success' : 'alert-danger' %> mb-0">
<%= testResult.message %>
</div>
<% } %>
</div>
</div>
<!-- ✅ System Info -->
<div class="card shadow mb-3">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-info-circle"></i> Systeminformationen
</h4>
<% if (typeof systemInfo !== "undefined" && systemInfo?.error) { %>
<div class="alert alert-danger mb-0">
❌ Fehler beim Auslesen der Datenbankinfos:
<div class="mt-2"><code><%= systemInfo.error %></code></div>
</div>
<% } else if (typeof systemInfo !== "undefined" && systemInfo) { %>
<div class="row g-3">
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small">MySQL Version</div>
<div class="fw-bold"><%= systemInfo.version %></div>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small">Anzahl Tabellen</div>
<div class="fw-bold"><%= systemInfo.tableCount %></div>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small">Datenbankgröße</div>
<div class="fw-bold"><%= systemInfo.dbSizeMB %> MB</div>
</div>
</div>
</div>
<% if (systemInfo.tables && systemInfo.tables.length > 0) { %>
<hr>
<h6 class="mb-2">Tabellenübersicht</h6>
<div class="table-responsive">
<table class="table table-sm table-bordered table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Tabelle</th>
<th class="text-end">Zeilen</th>
<th class="text-end">Größe (MB)</th>
</tr>
</thead>
<tbody>
<% systemInfo.tables.forEach(t => { %>
<tr>
<td><%= t.name %></td>
<td class="text-end"><%= t.row_count %></td>
<td class="text-end"><%= t.size_mb %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
<% } else { %>
<div class="alert alert-warning mb-0">
⚠️ Keine Systeminfos verfügbar (DB ist evtl. nicht konfiguriert oder Verbindung fehlgeschlagen).
</div>
<% } %>
</div>
</div>
<!-- ✅ Backup & Restore -->
<div class="card shadow">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-hdd-stack"></i> Backup & Restore
</h4>
<div class="d-flex flex-wrap gap-3">
<!-- ✅ Backup erstellen -->
<form action="/admin/database/backup" method="POST">
<button type="submit" class="btn btn-primary">
<i class="bi bi-download"></i> Backup erstellen
</button>
</form>
<!-- ✅ Restore auswählen -->
<form action="/admin/database/restore" method="POST">
<div class="input-group">
<select name="backupFile" class="form-select" required>
<option value="">Backup auswählen...</option>
<% (typeof backupFiles !== "undefined" && backupFiles ? backupFiles : []).forEach(file => { %>
<option value="<%= file %>"><%= file %></option>
<% }) %>
</select>
<button type="submit" class="btn btn-warning">
<i class="bi bi-upload"></i> Restore starten
</button>
</div>
</form>
</div>
<% if (typeof backupFiles === "undefined" || !backupFiles || backupFiles.length === 0) { %>
<div class="alert alert-secondary mt-3 mb-0">
Noch keine Backups vorhanden.
</div>
<% } %>
</div>
</div>
</div>
</div>
</div>
</div>

View File

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

View File

@ -5,7 +5,7 @@
<!-- ✅ HEADER -->
<%- include("partials/page-header", {
user,
title: t.adminuseroverview.usermanagement,
title: "User Verwaltung",
subtitle: "",
showUserName: true
}) %>
@ -20,11 +20,11 @@
<div class="card-body">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0"><%= t.adminuseroverview.useroverview %></h4>
<h4 class="mb-0">Benutzerübersicht</h4>
<a href="/admin/create-user" class="btn btn-primary">
<i class="bi bi-plus-circle"></i>
<%= t.global.newuser %>
Neuer Benutzer
</a>
</div>
@ -34,13 +34,13 @@
<thead>
<tr>
<th>ID</th>
<th><%= t.global.title %></th>
<th><%= t.global.firstname %></th>
<th><%= t.global.lastname %></th>
<th><%= t.global.username %></th>
<th><%= t.global.role %></th>
<th class="text-center"><%= t.global.status %></th>
<th><%= t.global.action %></th>
<th>Titel</th>
<th>Vorname</th>
<th>Nachname</th>
<th>Username</th>
<th>Rolle</th>
<th class="text-center">Status</th>
<th>Aktionen</th>
</tr>
</thead>
@ -79,11 +79,11 @@
<td class="text-center">
<% if (u.active === 0) { %>
<span class="badge bg-secondary"><%= t.global.inactive %></span>
<span class="badge bg-secondary">Inaktiv</span>
<% } else if (u.lock_until && new Date(u.lock_until) > new Date()) { %>
<span class="badge bg-danger"><%= t.global.closed %></span>
<span class="badge bg-danger">Gesperrt</span>
<% } else { %>
<span class="badge bg-success"><%= t.global.active %></span>
<span class="badge bg-success">Aktiv</span>
<% } %>
</td>
@ -109,7 +109,7 @@
</button>
</form>
<% } else { %>
<span class="badge bg-light text-dark border">👤 <%= t.global.you %></span>
<span class="badge bg-light text-dark border">👤 Du selbst</span>
<% } %>
</td>
@ -128,3 +128,9 @@
</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,43 +1,66 @@
<!-- KEIN layout, KEINE sidebar, KEIN main -->
<div class="layout">
<%- include("partials/page-header", {
user,
title: t.dashboard.title,
subtitle: "",
showUserName: true,
hideDashboardButton: true
}) %>
<!-- ✅ SIDEBAR -->
<%- include("partials/sidebar", { user, active: "patients", lang }) %>
<div class="content p-4">
<!-- ✅ MAIN -->
<div class="main">
<%- include("partials/flash") %>
<!-- ✅ HEADER (inkl. Uhrzeit) -->
<%- include("partials/page-header", {
user,
title: "Dashboard",
subtitle: "",
showUserName: true,
hideDashboardButton: true
}) %>
<div class="waiting-monitor">
<h5 class="mb-3">🪑 <%= t.dashboard.waitingRoom %></h5>
<div class="content p-4">
<div class="waiting-grid">
<% if (waitingPatients && waitingPatients.length > 0) { %>
<% waitingPatients.forEach(p => { %>
<!-- Flash Messages -->
<%- include("partials/flash") %>
<!-- =========================
WARTEZIMMER MONITOR
========================= -->
<div class="waiting-monitor">
<h5 class="mb-3">🪑 Wartezimmer-Monitor</h5>
<div class="waiting-grid">
<% if (waitingPatients && waitingPatients.length > 0) { %>
<% waitingPatients.forEach(p => { %>
<% if (user.role === 'arzt') { %>
<form method="POST" action="/patients/<%= p.id %>/call" style="width:100%; margin:0;">
<button type="submit" class="waiting-slot occupied clickable waiting-btn">
<div class="patient-text">
<div class="name"><%= p.firstname %> <%= p.lastname %></div>
<div class="birthdate">
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
</div>
</div>
</button>
</form>
<% } else { %>
<div class="waiting-slot occupied">
<div class="patient-text">
<div class="name"><%= p.firstname %> <%= p.lastname %></div>
<div class="birthdate">
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
</div>
</div>
</div>
<% } %>
<% }) %>
<% if (user.role === "arzt") { %>
<form method="POST" action="/patients/<%= p.id %>/call">
<button class="waiting-slot occupied clickable">
<div><%= p.firstname %> <%= p.lastname %></div>
</button>
</form>
<% } else { %>
<div class="waiting-slot occupied">
<div><%= p.firstname %> <%= p.lastname %></div>
</div>
<div class="text-muted">Keine Patienten im Wartezimmer.</div>
<% } %>
<% }) %>
<% } else { %>
<div class="text-muted">
<%= t.dashboard.noWaitingPatients %>
</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>
<div class="layout">
<!-- ✅ Sidebar dynamisch -->
<% if (typeof sidebarPartial !== "undefined" && sidebarPartial) { %>
<%- include(sidebarPartial, {
@ -42,6 +43,5 @@
<!-- ✅ externes JS (CSP safe) -->
<script src="/js/datetime.js"></script>
<script src="/js/patient-select.js" defer></script>
<!-- <script src="/js/patient_sidebar.js" defer></script> -->
</body>
</html>

View File

@ -1,140 +1,141 @@
<%- include("partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
title: "Medikamentenübersicht",
subtitle: "",
showUserName: true
}) %>
<div class="content p-4">
<div class="content p-4">
<%- include("partials/flash") %>
<%- include("partials/flash") %>
<div class="container-fluid p-0">
<div class="container-fluid p-0">
<div class="card shadow">
<div class="card-body">
<div class="card shadow">
<div class="card-body">
<!-- 🔍 Suche -->
<form method="GET" action="/medications" class="row g-2 mb-3">
<div class="col-md-6">
<input
type="text"
name="q"
class="form-control"
placeholder="🔍 Suche nach Medikament, Form, Dosierung"
value="<%= query?.q || '' %>"
>
</div>
<div class="col-md-3 d-flex gap-2">
<button class="btn btn-primary w-100">Suchen</button>
<a href="/medications" class="btn btn-secondary w-100">Reset</a>
</div>
<div class="col-md-3 d-flex align-items-center">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="onlyActive"
value="1"
<%= query?.onlyActive === "1" ? "checked" : "" %>
>
<label class="form-check-label">
Nur aktive Medikamente
</label>
</div>
</div>
</form>
<!-- Neu -->
<a href="/medications/create" class="btn btn-success mb-3">
Neues Medikament
</a>
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm align-middle">
<thead class="table-dark">
<tr>
<th>Medikament</th>
<th>Darreichungsform</th>
<th>Dosierung</th>
<th>Packung</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<% rows.forEach(r => { %>
<tr class="<%= r.active ? '' : 'table-secondary' %>">
<!-- UPDATE-FORM -->
<form method="POST" action="/medications/update/<%= r.id %>">
<td><%= r.medication %></td>
<td><%= r.form %></td>
<td>
<input
type="text"
name="dosage"
value="<%= r.dosage %>"
class="form-control form-control-sm"
disabled
>
</td>
<td>
<input
type="text"
name="package"
value="<%= r.package %>"
class="form-control form-control-sm"
disabled
>
</td>
<td class="text-center">
<%= r.active ? "Aktiv" : "Inaktiv" %>
</td>
<td class="d-flex gap-2">
<button class="btn btn-sm btn-outline-success save-btn" disabled>
💾
</button>
<button type="button" class="btn btn-sm btn-outline-warning lock-btn">
🔓
</button>
</form>
<!-- TOGGLE-FORM -->
<form method="POST" action="/medications/toggle/<%= r.medication_id %>">
<button class="btn btn-sm <%= r.active ? 'btn-outline-danger' : 'btn-outline-success' %>">
<%= r.active ? "⛔" : "✅" %>
</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<!-- 🔍 Suche -->
<form method="GET" action="/medications" class="row g-2 mb-3">
<div class="col-md-6">
<input
type="text"
name="q"
class="form-control"
placeholder="🔍 Suche nach Medikament, Form, Dosierung"
value="<%= query?.q || '' %>"
>
</div>
<div class="col-md-3 d-flex gap-2">
<button class="btn btn-primary w-100">Suchen</button>
<a href="/medications" class="btn btn-secondary w-100">Reset</a>
</div>
<div class="col-md-3 d-flex align-items-center">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="onlyActive"
value="1"
<%= query?.onlyActive === "1" ? "checked" : "" %>
>
<label class="form-check-label">
Nur aktive Medikamente
</label>
</div>
</div>
</form>
<!-- Neu -->
<a href="/medications/create" class="btn btn-success mb-3">
Neues Medikament
</a>
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm align-middle">
<thead class="table-dark">
<tr>
<th>Medikament</th>
<th>Darreichungsform</th>
<th>Dosierung</th>
<th>Packung</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<% rows.forEach(r => { %>
<tr class="<%= r.active ? '' : 'table-secondary' %>">
<!-- UPDATE-FORM -->
<form method="POST" action="/medications/update/<%= r.id %>">
<td><%= r.medication %></td>
<td><%= r.form %></td>
<td>
<input
type="text"
name="dosage"
value="<%= r.dosage %>"
class="form-control form-control-sm"
disabled
>
</td>
<td>
<input
type="text"
name="package"
value="<%= r.package %>"
class="form-control form-control-sm"
disabled
>
</td>
<td class="text-center">
<%= r.active ? "Aktiv" : "Inaktiv" %>
</td>
<td class="d-flex gap-2">
<button class="btn btn-sm btn-outline-success save-btn" disabled>
💾
</button>
<button type="button" class="btn btn-sm btn-outline-warning lock-btn">
🔓
</button>
</form>
<!-- TOGGLE-FORM -->
<form method="POST" action="/medications/toggle/<%= r.medication_id %>">
<button class="btn btn-sm <%= r.active ? 'btn-outline-danger' : 'btn-outline-success' %>">
<%= r.active ? "⛔" : "✅" %>
</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
<!-- ✅ Externes JS (Helmet/CSP safe) -->
<script src="/js/services-lock.js"></script>
</div>
</div>
<!-- ✅ Externes JS (Helmet/CSP safe) -->
<script src="/js/services-lock.js"></script>

View File

@ -30,7 +30,7 @@
<!-- 🧾 RECHNUNG ERSTELLEN -->
<form
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"
>
<button class="btn btn-sm btn-success">

View File

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

View File

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

View File

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

View File

@ -1,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">
<!-- ✅ 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 class="sidebar sidebar-empty">
<div style="padding: 20px; text-align: center">
<div class="logo" style="margin: 0">🩺 Praxis System</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 -->
<a href="/logout" class="nav-item">
<i class="bi bi-box-arrow-right"></i> <%= t.sidebar.logout %>
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</div>

View File

@ -1,6 +1,6 @@
<%- include("partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
title: "Patientenübersicht",
subtitle: "",
showUserName: true
}) %>
@ -12,7 +12,7 @@
<!-- Aktionen oben -->
<div class="d-flex gap-2 mb-3">
<a href="/patients/create" class="btn btn-success">
+ <%= t.patienteoverview.newpatient %>
+ Neuer Patient
</a>
</div>
@ -26,7 +26,7 @@
type="text"
name="firstname"
class="form-control"
placeholder="<%= t.global.firstname %>"
placeholder="Vorname"
value="<%= query?.firstname || '' %>"
/>
</div>
@ -36,7 +36,7 @@
type="text"
name="lastname"
class="form-control"
placeholder="<%= t.global.lastname %>"
placeholder="Nachname"
value="<%= query?.lastname || '' %>"
/>
</div>
@ -51,108 +51,192 @@
</div>
<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">
<%= t.global.reset2 %>
Zurücksetzen
</a>
</div>
</form>
<!-- ✅ EINE Form für ALLE Radiobuttons -->
<form method="GET" action="/patients">
<!-- Tabelle -->
<div class="table-responsive">
<table class="table table-bordered table-hover align-middle table-sm">
<thead class="table-dark">
<tr>
<th style="width:40px;"></th>
<th>ID</th>
<th>Name</th>
<th>N.I.E. / DNI</th>
<th>Geschlecht</th>
<th>Geburtstag</th>
<th>E-Mail</th>
<th>Telefon</th>
<th>Adresse</th>
<th>Land</th>
<th>Status</th>
<th>Notizen</th>
<th>Erstellt</th>
<th>Geändert</th>
<th>Aktionen</th>
</tr>
</thead>
<!-- Filter beibehalten -->
<input type="hidden" name="firstname" value="<%= query?.firstname || '' %>">
<input type="hidden" name="lastname" value="<%= query?.lastname || '' %>">
<input type="hidden" name="birthdate" value="<%= query?.birthdate || '' %>">
<div class="table-responsive">
<table class="table table-bordered table-hover align-middle table-sm">
<thead class="table-dark">
<tbody>
<% if (patients.length === 0) { %>
<tr>
<th style="width:40px;"></th>
<th>ID</th>
<th><%= t.global.name %></th>
<th>DNI</th>
<th><%= t.global.gender %></th>
<th><%= t.global.birthday %></th>
<th><%= t.global.email %></th>
<th><%= t.global.phone %></th>
<th><%= t.global.address %></th>
<th><%= t.global.country %></th>
<th><%= t.global.status %></th>
<th><%= t.global.notice %></th>
<th><%= t.global.create %></th>
<th><%= t.global.change %></th>
<td colspan="15" class="text-center text-muted">
Keine Patienten gefunden
</td>
</tr>
</thead>
<% } %>
<tbody>
<% if (patients.length === 0) { %>
<tr>
<td colspan="15" class="text-center text-muted">
<%= t.patientoverview.nopatientfound %>
</td>
</tr>
<% } %>
<% patients.forEach(p => { %>
<tr>
<% patients.forEach(p => { %>
<tr>
<!-- ✅ RADIOBUTTON ganz vorne -->
<td class="text-center">
<form method="GET" action="/patients">
<!-- Filter beibehalten -->
<input type="hidden" name="firstname" value="<%= query.firstname || '' %>">
<input type="hidden" name="lastname" value="<%= query.lastname || '' %>">
<input type="hidden" name="birthdate" value="<%= query.birthdate || '' %>">
<!-- ✅ EIN Radiobutton korrekt gruppiert -->
<td class="text-center">
<input
class="patient-radio"
type="radio"
name="selectedPatientId"
value="<%= p.id %>"
<%= selectedPatientId === p.id ? "checked" : "" %>
onchange="this.form.submit()"
/>
</td>
<td><%= p.id %></td>
</form>
</td>
<td><strong><%= p.firstname %> <%= p.lastname %></strong></td>
<td><%= p.dni || "-" %></td>
<td><%= p.id %></td>
<td>
<%= p.gender === 'm' ? 'm' :
p.gender === 'w' ? 'w' :
p.gender === 'd' ? 'd' : '-' %>
</td>
<td><strong><%= p.firstname %> <%= p.lastname %></strong></td>
<td><%= p.dni || "-" %></td>
<td><%= new Date(p.birthdate).toLocaleDateString("de-DE") %></td>
<td><%= p.email || "-" %></td>
<td><%= p.phone || "-" %></td>
<td>
<% if (p.gender === 'm') { %>
m
<% } else if (p.gender === 'w') { %>
w
<% } else if (p.gender === 'd') { %>
d
<% } else { %>
-
<% } %>
</td>
<td>
<%= p.street || "" %> <%= p.house_number || "" %><br>
<%= p.postal_code || "" %> <%= p.city || "" %>
</td>
<td>
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
</td>
<td><%= p.country || "-" %></td>
<td><%= p.email || "-" %></td>
<td><%= p.phone || "-" %></td>
<td>
<% if (p.active) { %>
<span class="badge bg-success">Aktiv</span>
<% } else { %>
<span class="badge bg-secondary">Inaktiv</span>
<% } %>
</td>
<td>
<%= p.street || "" %> <%= p.house_number || "" %><br />
<%= p.postal_code || "" %> <%= p.city || "" %>
</td>
<td><%= p.notes ? p.notes.substring(0, 80) : "-" %></td>
<td><%= new Date(p.created_at).toLocaleString("de-DE") %></td>
<td><%= new Date(p.updated_at).toLocaleString("de-DE") %></td>
</tr>
<% }) %>
</tbody>
<td><%= p.country || "-" %></td>
</table>
</div>
<td>
<% if (p.active) { %>
<span class="badge bg-success">Aktiv</span>
<% } else { %>
<span class="badge bg-secondary">Inaktiv</span>
<% } %>
</td>
<td style="max-width: 200px">
<%= p.notes ? p.notes.substring(0, 80) : "-" %>
</td>
<td><%= new Date(p.created_at).toLocaleString("de-DE") %></td>
<td><%= new Date(p.updated_at).toLocaleString("de-DE") %></td>
<td class="text-nowrap">
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown">
Auswahl ▾
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="/patients/edit/<%= p.id %>">
✏️ Bearbeiten
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<% if (p.waiting_room) { %>
<li>
<span class="dropdown-item text-muted">🪑 Wartet bereits</span>
</li>
<% } else { %>
<li>
<form method="POST" action="/patients/waiting-room/<%= p.id %>">
<button class="dropdown-item">🪑 Ins Wartezimmer</button>
</form>
</li>
<% } %>
<li><hr class="dropdown-divider" /></li>
<li>
<a class="dropdown-item" href="/patients/<%= p.id %>/medications">
💊 Medikamente
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<% if (p.active) { %>
<form method="POST" action="/patients/deactivate/<%= p.id %>">
<button class="dropdown-item text-warning">🔒 Sperren</button>
</form>
<% } else { %>
<form method="POST" action="/patients/activate/<%= p.id %>">
<button class="dropdown-item text-success">🔓 Entsperren</button>
</form>
<% } %>
</li>
<li>
<a class="dropdown-item" href="/patients/<%= p.id %>">
📋 Übersicht
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<li class="px-3 py-2">
<form method="POST" action="/patients/<%= p.id %>/files" enctype="multipart/form-data">
<input type="file" name="file" class="form-control form-control-sm mb-2" required />
<button class="btn btn-sm btn-secondary w-100">
📎 Hochladen
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</form>
</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;">
<h2>🔑 <%= t.seriennumber.seriennumbertitle %></h2>
<h2>🔑 Seriennummer eingeben</h2>
<p style="color:#777;">
<%= t.seriennumber.seriennumbertext %>
Bitte gib deine Lizenz-Seriennummer ein um die Software dauerhaft freizuschalten.
</p>
<% if (error) { %>
@ -31,7 +31,7 @@
<form method="POST" action="/admin/serial-number" style="max-width: 500px;">
<div class="form-group">
<label><%= t.seriennumber.seriennumbershort %></label>
<label>Seriennummer (AAAAA-AAAAA-AAAAA-AAAAA)</label>
<input
type="text"
name="serial_number"
@ -42,12 +42,12 @@
required
/>
<small style="color:#777; display:block; margin-top:6px;">
<%= t.seriennumber.seriennumberdeclaration %>
Nur Buchstaben + Zahlen. Format: 4×5 Zeichen, getrennt mit „-“.
</small>
</div>
<button class="btn btn-primary" style="margin-top: 15px;">
<%= t.seriennumber.saveseriennumber %>
Seriennummer speichern
</button>
</form>

View File

@ -1,13 +1,35 @@
<%- include("partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
subtitle: "",
showUserName: true
}) %>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Leistungen</title>
<link rel="stylesheet" href="/css/bootstrap.min.css">
<script src="/js/services-lock.js"></script> ✔ erlaubt
</head>
<body>
<!-- NAVBAR -->
<nav class="navbar navbar-dark bg-dark position-relative px-3">
<!-- ZENTRIERTER TITEL -->
<div class="position-absolute top-50 start-50 translate-middle
d-flex align-items-center gap-2 text-white">
<span style="font-size:1.3rem;">🧾</span>
<span class="fw-semibold fs-5">Leistungen</span>
</div>
<!-- DASHBOARD -->
<div class="ms-auto">
<a href="/dashboard" class="btn btn-outline-light btn-sm">
⬅️ Dashboard
</a>
</div>
</nav>
<!-- CONTENT -->
<div class="container mt-4">
<%- include("partials/flash") %>
<h4>Leistungen</h4>
<!-- 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>