Compare commits
6 Commits
main
...
Sprachensc
| Author | SHA1 | Date | |
|---|---|---|---|
| ddb6e067e8 | |||
| d38add6270 | |||
| bc7dfc0210 | |||
| 87fc63b3b0 | |||
| 321018cee4 | |||
| 642800b19a |
12
.env
Normal file
12
.env
Normal 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
190
app.js
@ -3,17 +3,17 @@ require("dotenv").config();
|
||||
const express = require("express");
|
||||
const session = require("express-session");
|
||||
const helmet = require("helmet");
|
||||
const mysql = require("mysql2/promise");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const expressLayouts = require("express-ejs-layouts");
|
||||
|
||||
// ✅ DB + Session Store
|
||||
const db = require("./db");
|
||||
const { getSessionStore } = require("./config/session");
|
||||
// ✅ Verschlüsselte Config
|
||||
const { configExists, saveConfig } = require("./config-manager");
|
||||
|
||||
// ✅ Setup Middleware + Setup Routes
|
||||
const requireSetup = require("./middleware/requireSetup");
|
||||
const setupRoutes = require("./routes/setup.routes");
|
||||
// ✅ DB + Session Reset
|
||||
const db = require("./db");
|
||||
const { getSessionStore, resetSessionStore } = require("./config/session");
|
||||
|
||||
// ✅ Routes (deine)
|
||||
const adminRoutes = require("./routes/admin.routes");
|
||||
@ -28,8 +28,6 @@ const invoiceRoutes = require("./routes/invoice.routes");
|
||||
const patientFileRoutes = require("./routes/patientFile.routes");
|
||||
const companySettingsRoutes = require("./routes/companySettings.routes");
|
||||
const authRoutes = require("./routes/auth.routes");
|
||||
const reportRoutes = require("./routes/report.routes");
|
||||
const calendarRoutes = require("./routes/calendar.routes");
|
||||
|
||||
const app = express();
|
||||
|
||||
@ -66,55 +64,85 @@ function passesModulo3(serial) {
|
||||
return sum % 3 === 0;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
SETUP HTML
|
||||
================================ */
|
||||
function setupHtml(error = "") {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Praxissoftware Setup</title>
|
||||
<style>
|
||||
body{font-family:Arial;background:#f4f4f4;padding:30px}
|
||||
.card{max-width:520px;margin:auto;background:#fff;padding:22px;border-radius:14px}
|
||||
input{width:100%;padding:10px;margin:6px 0;border:1px solid #ccc;border-radius:8px}
|
||||
button{padding:10px 14px;border:0;border-radius:8px;background:#111;color:#fff;cursor:pointer}
|
||||
.err{color:#b00020;margin:10px 0}
|
||||
.hint{color:#666;font-size:13px;margin-top:12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>🔧 Datenbank Einrichtung</h2>
|
||||
${error ? `<div class="err">❌ ${error}</div>` : ""}
|
||||
|
||||
<form method="POST" action="/setup">
|
||||
<label>DB Host</label>
|
||||
<input name="host" placeholder="85.215.63.122" required />
|
||||
|
||||
<label>DB Benutzer</label>
|
||||
<input name="user" placeholder="praxisuser" required />
|
||||
|
||||
<label>DB Passwort</label>
|
||||
<input name="password" type="password" required />
|
||||
|
||||
<label>DB Name</label>
|
||||
<input name="name" placeholder="praxissoftware" required />
|
||||
|
||||
<button type="submit">✅ Speichern</button>
|
||||
</form>
|
||||
|
||||
<div class="hint">
|
||||
Die Daten werden verschlüsselt gespeichert (<b>config.enc</b>).<br/>
|
||||
Danach wirst du automatisch auf die Loginseite weitergeleitet.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
MIDDLEWARE
|
||||
================================ */
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(helmet());
|
||||
|
||||
app.use(
|
||||
session({
|
||||
name: "praxis.sid",
|
||||
secret: process.env.SESSION_SECRET || "dev-secret",
|
||||
secret: process.env.SESSION_SECRET,
|
||||
store: getSessionStore(),
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// ✅ i18n Middleware (SAFE)
|
||||
// ✅ i18n Middleware 1 (setzt res.locals.t + lang)
|
||||
app.use((req, res, next) => {
|
||||
try {
|
||||
const lang = req.session.lang || "de";
|
||||
|
||||
const filePath = path.join(__dirname, "locales", `${lang}.json`);
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
let data = {};
|
||||
if (fs.existsSync(filePath)) {
|
||||
data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
res.locals.t = data;
|
||||
res.locals.t = JSON.parse(raw);
|
||||
res.locals.lang = lang;
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error("❌ i18n Fehler:", err.message);
|
||||
res.locals.t = {};
|
||||
res.locals.lang = "de";
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
const flashMiddleware = require("./middleware/flash.middleware");
|
||||
@ -124,24 +152,20 @@ app.use(express.static("public"));
|
||||
app.use("/uploads", express.static("uploads"));
|
||||
|
||||
app.set("view engine", "ejs");
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
app.use(expressLayouts);
|
||||
app.set("layout", "layout");
|
||||
app.set("layout", "layout"); // verwendet views/layout.ejs
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.locals.user = req.session.user || null;
|
||||
next();
|
||||
});
|
||||
|
||||
/* ===============================
|
||||
✅ SETUP ROUTES + SETUP GATE
|
||||
WICHTIG: /setup zuerst mounten, danach requireSetup
|
||||
================================ */
|
||||
app.use("/setup", setupRoutes);
|
||||
app.use(requireSetup);
|
||||
|
||||
/* ===============================
|
||||
✅ LICENSE/TRIAL GATE
|
||||
- Trial startet automatisch, wenn noch NULL
|
||||
- Wenn abgelaufen:
|
||||
Admin -> /admin/serial-number
|
||||
Arzt/Member -> /serial-number
|
||||
================================ */
|
||||
app.use(async (req, res, next) => {
|
||||
try {
|
||||
@ -206,6 +230,57 @@ app.use(async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ===============================
|
||||
SETUP ROUTES
|
||||
================================ */
|
||||
app.get("/setup", (req, res) => {
|
||||
if (configExists()) return res.redirect("/");
|
||||
return res.status(200).send(setupHtml());
|
||||
});
|
||||
|
||||
app.post("/setup", async (req, res) => {
|
||||
try {
|
||||
const { host, user, password, name } = req.body;
|
||||
|
||||
if (!host || !user || !password || !name) {
|
||||
return res.status(400).send(setupHtml("Bitte alle Felder ausfüllen."));
|
||||
}
|
||||
|
||||
const conn = await mysql.createConnection({
|
||||
host,
|
||||
user,
|
||||
password,
|
||||
database: name,
|
||||
});
|
||||
|
||||
await conn.query("SELECT 1");
|
||||
await conn.end();
|
||||
|
||||
saveConfig({
|
||||
db: { host, user, password, name },
|
||||
});
|
||||
|
||||
if (typeof db.resetPool === "function") {
|
||||
db.resetPool();
|
||||
}
|
||||
resetSessionStore();
|
||||
|
||||
return res.redirect("/");
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(500)
|
||||
.send(setupHtml("DB Verbindung fehlgeschlagen: " + err.message));
|
||||
}
|
||||
});
|
||||
|
||||
// Wenn keine config.enc → alles außer /setup auf Setup umleiten
|
||||
app.use((req, res, next) => {
|
||||
if (!configExists() && req.path !== "/setup") {
|
||||
return res.redirect("/setup");
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/* ===============================
|
||||
Sprache ändern
|
||||
================================ */
|
||||
@ -227,6 +302,14 @@ app.get("/lang/:lang", (req, res) => {
|
||||
/* ===============================
|
||||
✅ SERIAL PAGES
|
||||
================================ */
|
||||
|
||||
/**
|
||||
* ✅ /serial-number
|
||||
* - Trial aktiv: zeigt Resttage + Button Dashboard
|
||||
* - Trial abgelaufen:
|
||||
* Admin -> redirect /admin/serial-number
|
||||
* Arzt/Member -> trial_expired.ejs
|
||||
*/
|
||||
app.get("/serial-number", async (req, res) => {
|
||||
try {
|
||||
if (!req.session?.user) return res.redirect("/");
|
||||
@ -288,6 +371,9 @@ app.get("/serial-number", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* ✅ Admin Seite: Seriennummer eingeben
|
||||
*/
|
||||
app.get("/admin/serial-number", async (req, res) => {
|
||||
try {
|
||||
if (!req.session?.user) return res.redirect("/");
|
||||
@ -316,6 +402,9 @@ app.get("/admin/serial-number", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* ✅ Admin Seite: Seriennummer speichern
|
||||
*/
|
||||
app.post("/admin/serial-number", async (req, res) => {
|
||||
try {
|
||||
if (!req.session?.user) return res.redirect("/");
|
||||
@ -408,11 +497,7 @@ app.use("/services", serviceRoutes);
|
||||
|
||||
app.use("/", patientFileRoutes);
|
||||
app.use("/", waitingRoomRoutes);
|
||||
app.use("/invoices", invoiceRoutes);
|
||||
|
||||
app.use("/reportview", reportRoutes);
|
||||
|
||||
app.use("/calendar", calendarRoutes);
|
||||
app.use("/", invoiceRoutes);
|
||||
|
||||
app.get("/logout", (req, res) => {
|
||||
req.session.destroy(() => res.redirect("/"));
|
||||
@ -430,10 +515,7 @@ app.use((err, req, res, next) => {
|
||||
SERVER
|
||||
================================ */
|
||||
const PORT = process.env.PORT || 51777;
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
|
||||
console.log("DB HOST:", process.env.DBSERVER_HOST);
|
||||
console.log("DB PORT:", process.env.DBSERVER_PORT);
|
||||
const HOST = "127.0.0.1";
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`Server läuft auf http://${HOST}:${PORT}`);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
MgmDGURt7NfYtetWb79ghkifQA6ztKwK/7Hl1BNBG2QA+kIbDtHM+1R8XPRiTtDtBHPo+T8UmzvmOuztdphLvMnMW7/Jlqo+VAg4mbYDRLz8WQja5KBmIQJf1eF5riHPu0zQDjY7VU1AX2mzR8xfWrB+CngkagEHXv7OsigsRmxlrB3oGTd6GY6PeAYq3jTblo4kjDDg6GWeDJoF
|
||||
4PsgCvoOJLNXPpxOHOvm+KbVYz3pNxg8oOXO7zoH3MPffEhZLI7i5qf3o6oqZDI04us8xSSz9j3KIN+Atno/VFlYzSoq3ki1F+WSTz37LfcE3goPqhm6UaH8c9lHdulemH9tqgGq/DxgbKaup5t/ZJnLseaHHpdyTZok1jWULN0nlDuL/HvVVtqw5sboPqU=
|
||||
@ -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",
|
||||
|
||||
@ -1,257 +0,0 @@
|
||||
/**
|
||||
* controllers/calendar.controller.js
|
||||
*/
|
||||
|
||||
const db = require("../db");
|
||||
const Holidays = require("date-holidays");
|
||||
|
||||
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
|
||||
|
||||
function pad(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function toISO(d) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
// ── Hauptseite (EJS rendern) ─────────────────────────────────────────────────
|
||||
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
// Alle aktiven Ärzte (users mit role = 'arzt')
|
||||
const [doctors] = await db.promise().query(`
|
||||
SELECT id, username AS name, doctor_color AS color
|
||||
FROM users
|
||||
WHERE role = 'arzt' AND active = 1
|
||||
ORDER BY username
|
||||
`);
|
||||
|
||||
const today = toISO(new Date());
|
||||
|
||||
return res.render("calendar/index", {
|
||||
active: "calendar",
|
||||
doctors,
|
||||
today,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("❌ calendar.index:", err.message);
|
||||
return res.status(500).send("Interner Serverfehler");
|
||||
}
|
||||
};
|
||||
|
||||
// ── API: Termine eines Tages ─────────────────────────────────────────────────
|
||||
|
||||
exports.getAppointments = async (req, res) => {
|
||||
try {
|
||||
const { date } = req.params; // YYYY-MM-DD
|
||||
|
||||
const [rows] = await db.promise().query(
|
||||
`SELECT
|
||||
a.id, a.doctor_id, a.date,
|
||||
TIME_FORMAT(a.time, '%H:%i') AS time,
|
||||
a.duration, a.patient_name, a.notes, a.status,
|
||||
u.username AS doctor_name,
|
||||
u.doctor_color AS doctor_color
|
||||
FROM appointments a
|
||||
JOIN users u ON u.id = a.doctor_id
|
||||
WHERE a.date = ?
|
||||
ORDER BY a.time, u.username`,
|
||||
[date]
|
||||
);
|
||||
|
||||
return res.json(rows);
|
||||
} catch (err) {
|
||||
console.error("❌ getAppointments:", err.message);
|
||||
return res.status(500).json({ error: "Datenbankfehler" });
|
||||
}
|
||||
};
|
||||
|
||||
// ── API: Termin erstellen ────────────────────────────────────────────────────
|
||||
|
||||
exports.createAppointment = async (req, res) => {
|
||||
try {
|
||||
const { doctor_id, date, time, duration = 15, patient_name, notes = "" } =
|
||||
req.body;
|
||||
|
||||
if (!doctor_id || !date || !time || !patient_name?.trim()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "doctor_id, date, time und patient_name sind Pflicht" });
|
||||
}
|
||||
|
||||
// Kollisionsprüfung
|
||||
const [conflict] = await db.promise().query(
|
||||
`SELECT id FROM appointments
|
||||
WHERE doctor_id = ? AND date = ? AND time = ? AND status != 'cancelled'`,
|
||||
[doctor_id, date, time]
|
||||
);
|
||||
|
||||
if (conflict.length > 0) {
|
||||
return res.status(409).json({ error: "Dieser Zeitslot ist bereits belegt" });
|
||||
}
|
||||
|
||||
const [result] = await db.promise().query(
|
||||
`INSERT INTO appointments (doctor_id, date, time, duration, patient_name, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[doctor_id, date, time, duration, patient_name.trim(), notes]
|
||||
);
|
||||
|
||||
return res.status(201).json({ id: result.insertId });
|
||||
} catch (err) {
|
||||
console.error("❌ createAppointment:", err.message);
|
||||
return res.status(500).json({ error: "Datenbankfehler" });
|
||||
}
|
||||
};
|
||||
|
||||
// ── API: Termin aktualisieren ────────────────────────────────────────────────
|
||||
|
||||
exports.updateAppointment = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { doctor_id, date, time, duration, patient_name, notes, status } =
|
||||
req.body;
|
||||
|
||||
await db.promise().query(
|
||||
`UPDATE appointments
|
||||
SET doctor_id = ?, date = ?, time = ?, duration = ?,
|
||||
patient_name = ?, notes = ?, status = ?
|
||||
WHERE id = ?`,
|
||||
[doctor_id, date, time, duration, patient_name, notes, status, id]
|
||||
);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("❌ updateAppointment:", err.message);
|
||||
return res.status(500).json({ error: "Datenbankfehler" });
|
||||
}
|
||||
};
|
||||
|
||||
// ── API: Termin löschen ──────────────────────────────────────────────────────
|
||||
|
||||
exports.deleteAppointment = async (req, res) => {
|
||||
try {
|
||||
await db.promise().query("DELETE FROM appointments WHERE id = ?", [
|
||||
req.params.id,
|
||||
]);
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("❌ deleteAppointment:", err.message);
|
||||
return res.status(500).json({ error: "Datenbankfehler" });
|
||||
}
|
||||
};
|
||||
|
||||
// ── API: Status ändern ───────────────────────────────────────────────────────
|
||||
|
||||
exports.patchStatus = async (req, res) => {
|
||||
try {
|
||||
const allowed = ["scheduled", "completed", "cancelled"];
|
||||
const { status } = req.body;
|
||||
|
||||
if (!allowed.includes(status)) {
|
||||
return res.status(400).json({ error: "Ungültiger Status" });
|
||||
}
|
||||
|
||||
await db
|
||||
.promise()
|
||||
.query("UPDATE appointments SET status = ? WHERE id = ?", [
|
||||
status,
|
||||
req.params.id,
|
||||
]);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("❌ patchStatus:", err.message);
|
||||
return res.status(500).json({ error: "Datenbankfehler" });
|
||||
}
|
||||
};
|
||||
|
||||
// ── API: Feiertage eines Jahres ──────────────────────────────────────────────
|
||||
|
||||
exports.getHolidays = (req, res) => {
|
||||
try {
|
||||
const year = parseInt(req.params.year);
|
||||
const country = (req.query.country || process.env.HOLIDAY_COUNTRY || "DE").toUpperCase();
|
||||
const state = (req.query.state || process.env.HOLIDAY_STATE || "").toUpperCase();
|
||||
|
||||
if (isNaN(year) || year < 1900 || year > 2100) {
|
||||
return res.status(400).json({ error: "Ungültiges Jahr" });
|
||||
}
|
||||
|
||||
const hd = new Holidays();
|
||||
const inited = state ? hd.init(country, state) : hd.init(country);
|
||||
|
||||
if (!inited) {
|
||||
return res.status(400).json({ error: `Unbekanntes Land/Bundesland: ${country}/${state}` });
|
||||
}
|
||||
|
||||
const holidays = hd
|
||||
.getHolidays(year)
|
||||
.filter((h) => ["public", "bank"].includes(h.type))
|
||||
.map((h) => ({
|
||||
date: h.date.substring(0, 10),
|
||||
name: h.name,
|
||||
type: h.type,
|
||||
}));
|
||||
|
||||
return res.json({ country, state, year, holidays });
|
||||
} catch (err) {
|
||||
console.error("❌ getHolidays:", err.message);
|
||||
return res.status(500).json({ error: "Fehler beim Laden der Feiertage" });
|
||||
}
|
||||
};
|
||||
|
||||
// ── API: Patienten-Suche (Autocomplete) ─────────────────────────────────────
|
||||
|
||||
exports.searchPatients = async (req, res) => {
|
||||
try {
|
||||
const q = (req.query.q || "").trim();
|
||||
|
||||
if (q.length < 1) return res.json([]);
|
||||
|
||||
const like = `%${q}%`;
|
||||
|
||||
const [rows] = await db.promise().query(
|
||||
`SELECT
|
||||
id,
|
||||
firstname,
|
||||
lastname,
|
||||
birthdate,
|
||||
CONCAT(firstname, ' ', lastname) AS full_name
|
||||
FROM patients
|
||||
WHERE active = 1
|
||||
AND (
|
||||
firstname LIKE ? OR
|
||||
lastname LIKE ? OR
|
||||
CONCAT(firstname, ' ', lastname) LIKE ?
|
||||
)
|
||||
ORDER BY lastname, firstname
|
||||
LIMIT 10`,
|
||||
[like, like, like]
|
||||
);
|
||||
|
||||
return res.json(rows);
|
||||
} catch (err) {
|
||||
console.error("❌ searchPatients:", err.message);
|
||||
return res.status(500).json({ error: "Datenbankfehler" });
|
||||
}
|
||||
};
|
||||
|
||||
// ── API: Arzt-Farbe speichern ────────────────────────────────────────────────
|
||||
|
||||
exports.updateDoctorColor = async (req, res) => {
|
||||
try {
|
||||
const { color } = req.body;
|
||||
await db
|
||||
.promise()
|
||||
.query("UPDATE users SET doctor_color = ? WHERE id = ?", [
|
||||
color,
|
||||
req.params.id,
|
||||
]);
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("❌ updateDoctorColor:", err.message);
|
||||
return res.status(500).json({ error: "Datenbankfehler" });
|
||||
}
|
||||
};
|
||||
@ -13,27 +13,14 @@ const safe = (v) => {
|
||||
* GET: Firmendaten anzeigen
|
||||
*/
|
||||
async function getCompanySettings(req, res) {
|
||||
try {
|
||||
const [[company]] = await db
|
||||
.promise()
|
||||
.query("SELECT * FROM company_settings LIMIT 1");
|
||||
const [[company]] = await db.promise().query(
|
||||
"SELECT * FROM company_settings LIMIT 1"
|
||||
);
|
||||
|
||||
res.render("admin/company-settings", {
|
||||
layout: "layout", // 🔥 wichtig
|
||||
title: "Firmendaten", // 🔥 DAS FEHLTE
|
||||
active: "companySettings", // 🔥 Sidebar aktiv
|
||||
sidebarPartial: "partials/admin-sidebar",
|
||||
|
||||
company: company || {},
|
||||
|
||||
user: req.session.user, // 🔥 konsistent
|
||||
lang: req.session.lang || "de"
|
||||
// t kommt aus res.locals
|
||||
user: req.user,
|
||||
company: company || {}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).send("Datenbankfehler");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
};
|
||||
@ -44,10 +44,7 @@ function listMedications(req, res, next) {
|
||||
|
||||
res.render("medications", {
|
||||
title: "Medikamentenübersicht",
|
||||
|
||||
// ✅ IMMER patient-sidebar verwenden
|
||||
sidebarPartial: "partials/sidebar-empty",
|
||||
backUrl: "/dashboard",
|
||||
sidebarPartial: "partials/sidebar-empty", // ✅ schwarzer Balken links
|
||||
active: "medications",
|
||||
|
||||
rows,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
}
|
||||
};
|
||||
@ -37,7 +37,6 @@ function listServices(req, res) {
|
||||
res.render("services", {
|
||||
title: "Leistungen",
|
||||
sidebarPartial: "partials/sidebar-empty",
|
||||
backUrl: "/dashboard",
|
||||
active: "services",
|
||||
|
||||
services,
|
||||
@ -106,7 +105,6 @@ function listServicesAdmin(req, res) {
|
||||
res.render("services", {
|
||||
title: "Leistungen (Admin)",
|
||||
sidebarPartial: "partials/admin-sidebar",
|
||||
backUrl: "/dashboard",
|
||||
active: "services",
|
||||
|
||||
services,
|
||||
@ -121,7 +119,6 @@ function showCreateService(req, res) {
|
||||
res.render("service_create", {
|
||||
title: "Leistung anlegen",
|
||||
sidebarPartial: "partials/sidebar-empty",
|
||||
backUrl: "/dashboard",
|
||||
active: "services",
|
||||
|
||||
user: req.session.user,
|
||||
@ -138,7 +135,6 @@ function createService(req, res) {
|
||||
return res.render("service_create", {
|
||||
title: "Leistung anlegen",
|
||||
sidebarPartial: "partials/sidebar-empty",
|
||||
backUrl: "/dashboard",
|
||||
active: "services",
|
||||
|
||||
user: req.session.user,
|
||||
@ -291,8 +287,7 @@ async function listOpenServices(req, res, next) {
|
||||
|
||||
res.render("open_services", {
|
||||
title: "Offene Leistungen",
|
||||
sidebarPartial: "partials/sidebar-invoices",
|
||||
backUrl: "/dashboard",
|
||||
sidebarPartial: "partials/sidebar-empty",
|
||||
active: "services",
|
||||
|
||||
rows,
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
/**
|
||||
* calendar_migrate.js
|
||||
* Führe einmalig aus: node db/calendar_migrate.js
|
||||
*
|
||||
* Erstellt die appointments-Tabelle für den Kalender.
|
||||
* Ärzte werden aus der bestehenden `users`-Tabelle (role = 'arzt') gezogen.
|
||||
*/
|
||||
|
||||
// ✅ MUSS als erstes stehen – lädt CONFIG_KEY bevor config-manager greift
|
||||
require("dotenv").config();
|
||||
|
||||
const db = require("../db");
|
||||
|
||||
async function migrate() {
|
||||
const conn = db.promise();
|
||||
|
||||
console.log("→ Erstelle Kalender-Tabellen …");
|
||||
|
||||
// ── Termine ──────────────────────────────────────────────────────────────
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS appointments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
doctor_id INT NOT NULL COMMENT 'Referenz auf users.id (role=arzt)',
|
||||
date DATE NOT NULL,
|
||||
time TIME NOT NULL,
|
||||
duration INT NOT NULL DEFAULT 15 COMMENT 'Minuten',
|
||||
patient_name VARCHAR(150) NOT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
status ENUM('scheduled','completed','cancelled') DEFAULT 'scheduled',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_date (date),
|
||||
INDEX idx_doctor (doctor_id),
|
||||
INDEX idx_date_doc (date, doctor_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
console.log("✓ Tabelle `appointments` bereit");
|
||||
|
||||
// ── Farben für Ärzte ─────────────────────────────────────────────────────
|
||||
// Falls die users-Tabelle noch keine doctor_color-Spalte hat, fügen wir sie hinzu.
|
||||
// Fehler = Spalte existiert schon → ignorieren.
|
||||
try {
|
||||
await conn.query(`
|
||||
ALTER TABLE users
|
||||
ADD COLUMN doctor_color VARCHAR(20) DEFAULT '#3B82F6'
|
||||
AFTER role;
|
||||
`);
|
||||
console.log("✓ Spalte `users.doctor_color` hinzugefügt");
|
||||
} catch (e) {
|
||||
if (e.code === "ER_DUP_FIELDNAME") {
|
||||
console.log("ℹ️ Spalte `users.doctor_color` existiert bereits – übersprungen");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n✅ Kalender-Migration abgeschlossen.\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error("❌ Migration fehlgeschlagen:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
390
locales/de.json
390
locales/de.json
@ -4,405 +4,23 @@
|
||||
"cancel": "Abbrechen",
|
||||
"search": "Suchen",
|
||||
"reset": "Reset",
|
||||
"reset2": "Zurücksetzen",
|
||||
"dashboard": "Dashboard",
|
||||
"logout": "Logout",
|
||||
"title": "Titel",
|
||||
"firstname": "Vorname",
|
||||
"lastname": "Nachname",
|
||||
"username": "Username",
|
||||
"role": "Rolle",
|
||||
"action": "Aktionen",
|
||||
"status": "Status",
|
||||
"you": "Du Selbst",
|
||||
"newuser": "Neuer Benutzer",
|
||||
"inactive": "Inaktiv",
|
||||
"active": "Aktiv",
|
||||
"closed": "Gesperrt",
|
||||
"filter": "Filtern",
|
||||
"yearcash": "Jahresumsatz",
|
||||
"monthcash": "Monatsumsatz",
|
||||
"quartalcash": "Quartalsumsatz",
|
||||
"year": "Jahr",
|
||||
"nodata": "Keine Daten",
|
||||
"month": "Monat",
|
||||
"patientcash": "Umsatz pro Patient",
|
||||
"patient": "Patient",
|
||||
"systeminfo": "Systeminformationen",
|
||||
"table": "Tabelle",
|
||||
"lines": "Zeilen",
|
||||
"size": "Größe",
|
||||
"errordatabase": "Fehler beim Auslesen der Datenbankinfos:",
|
||||
"welcome": "Willkommen",
|
||||
"waitingroomtext": "Wartezimmer-Monitor",
|
||||
"waitingroomtextnopatient": "Keine Patienten im Wartezimmer.",
|
||||
"gender": "Geschlecht",
|
||||
"birthday": "Geburtstag",
|
||||
"birthdate": "Geburtsdatum",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"address": "Adresse",
|
||||
"country": "Land",
|
||||
"notice": "Notizen",
|
||||
"notes": "Notizen",
|
||||
"create": "Erstellt",
|
||||
"change": "Geändert",
|
||||
"edit": "Bearbeiten",
|
||||
"selection": "Auswahl",
|
||||
"waiting": "Wartet bereits",
|
||||
"towaitingroom": "Ins Wartezimmer",
|
||||
"overview": "Übersicht",
|
||||
"upload": "Hochladen",
|
||||
"fileupload": "Hochladen",
|
||||
"lock": "Sperren",
|
||||
"unlock": "Entsperren",
|
||||
"name": "Name",
|
||||
"return": "Zurück",
|
||||
"back": "Zurück",
|
||||
"date": "Datum",
|
||||
"amount": "Betrag",
|
||||
"quantity": "Menge",
|
||||
"price": "Preis (€)",
|
||||
"sum": "Summe (€)",
|
||||
"pdf": "PDF",
|
||||
"open": "Öffnen",
|
||||
"from": "Von",
|
||||
"to": "Bis",
|
||||
"street": "Straße",
|
||||
"housenumber": "Hausnummer",
|
||||
"zip": "PLZ",
|
||||
"city": "Ort",
|
||||
"dni": "N.I.E. / DNI",
|
||||
"dosage": "Dosierung",
|
||||
"form": "Darreichungsform",
|
||||
"package": "Packung",
|
||||
"specialty": "Fachrichtung",
|
||||
"doctornumber": "Arztnummer",
|
||||
"category": "Kategorie"
|
||||
"dashboard": "Dashboard"
|
||||
},
|
||||
|
||||
"sidebar": {
|
||||
"patients": "Patienten",
|
||||
"medications": "Medikamente",
|
||||
"servicesOpen": "Patienten Rechnungen",
|
||||
"servicesOpen": "Offene Leistungen",
|
||||
"billing": "Abrechnung",
|
||||
"admin": "Verwaltung",
|
||||
"logout": "Logout"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
"welcome": "Willkommen",
|
||||
"waitingRoom": "Wartezimmer-Monitor",
|
||||
"noWaitingPatients": "Keine Patienten im Wartezimmer.",
|
||||
"title": "Dashboard"
|
||||
"noWaitingPatients": "Keine Patienten im Wartezimmer."
|
||||
},
|
||||
|
||||
"adminSidebar": {
|
||||
"users": "Userverwaltung",
|
||||
"database": "Datenbankverwaltung",
|
||||
"user": "Benutzer",
|
||||
"invocieoverview": "Rechnungsübersicht",
|
||||
"seriennumber": "Seriennummer",
|
||||
"databasetable": "Datenbank",
|
||||
"companysettings": "Firmendaten"
|
||||
},
|
||||
|
||||
"adminuseroverview": {
|
||||
"useroverview": "Benutzerübersicht",
|
||||
"usermanagement": "Benutzer Verwaltung",
|
||||
"user": "Benutzer",
|
||||
"invocieoverview": "Rechnungsübersicht",
|
||||
"seriennumber": "Seriennummer",
|
||||
"databasetable": "Datenbank"
|
||||
},
|
||||
|
||||
"adminCreateUser": {
|
||||
"title": "Benutzer anlegen",
|
||||
"firstname": "Vorname",
|
||||
"lastname": "Nachname",
|
||||
"usertitle": "Titel (z.B. Dr., Prof.)",
|
||||
"username": "Benutzername (Login)",
|
||||
"password": "Passwort",
|
||||
"specialty": "Fachrichtung",
|
||||
"doctornumber": "Arztnummer",
|
||||
"createuser": "Benutzer erstellen",
|
||||
"back": "Zurück"
|
||||
},
|
||||
|
||||
"adminServiceLogs": {
|
||||
"title": "Service-Änderungsprotokoll",
|
||||
"date": "Datum",
|
||||
"user": "User",
|
||||
"action": "Aktion",
|
||||
"before": "Vorher",
|
||||
"after": "Nachher"
|
||||
},
|
||||
|
||||
"companySettings": {
|
||||
"title": "Firmendaten",
|
||||
"companyname": "Firmenname",
|
||||
"legalform": "Rechtsform",
|
||||
"owner": "Inhaber / Geschäftsführer",
|
||||
"email": "E-Mail",
|
||||
"street": "Straße",
|
||||
"housenumber": "Hausnummer",
|
||||
"zip": "PLZ",
|
||||
"city": "Ort",
|
||||
"country": "Land",
|
||||
"taxid": "USt-ID / Steuernummer",
|
||||
"bank": "Bank",
|
||||
"iban": "IBAN",
|
||||
"bic": "BIC",
|
||||
"invoicefooter": "Rechnungs-Footer",
|
||||
"companylogo": "Firmenlogo",
|
||||
"currentlogo": "Aktuelles Logo:",
|
||||
"back": "Zurück"
|
||||
},
|
||||
|
||||
"databaseoverview": {
|
||||
"title": "Datenbank Konfiguration",
|
||||
"text": "Hier kannst du die DB-Verbindung testen und speichern.",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"database": "Datenbank",
|
||||
"password": "Passwort",
|
||||
"connectiontest": "Verbindung testen",
|
||||
"tablecount": "Anzahl Tabellen",
|
||||
"databasesize": "Datenbankgröße",
|
||||
"tableoverview": "Tabellenübersicht",
|
||||
"mysqlversion": "MySQL Version",
|
||||
"nodbinfo": "Keine Systeminfos verfügbar (DB ist evtl. nicht konfiguriert oder Verbindung fehlgeschlagen)"
|
||||
},
|
||||
|
||||
"invoiceAdmin": {
|
||||
"fromyear": "Von Jahr",
|
||||
"toyear": "Bis Jahr",
|
||||
"searchpatient": "Patient suchen..."
|
||||
},
|
||||
|
||||
"cancelledInvoices": {
|
||||
"title": "Stornierte Rechnungen",
|
||||
"year": "Jahr:",
|
||||
"noinvoices": "Keine stornierten Rechnungen für dieses Jahr.",
|
||||
"patient": "Patient",
|
||||
"date": "Datum",
|
||||
"amount": "Betrag"
|
||||
},
|
||||
|
||||
"creditOverview": {
|
||||
"title": "Gutschrift Übersicht",
|
||||
"year": "Jahr:",
|
||||
"invoice": "Rechnung",
|
||||
"date": "Datum",
|
||||
"pdf": "PDF",
|
||||
"creditnote": "Gutschrift",
|
||||
"patient": "Patient",
|
||||
"amount": "Betrag",
|
||||
"open": "Öffnen"
|
||||
},
|
||||
|
||||
"invoice": {
|
||||
"title": "RECHNUNG / FACTURA",
|
||||
"invoicenumber": "Rechnungsnummer:",
|
||||
"nie": "N.I.E / DNI:",
|
||||
"birthdate": "Geburtsdatum:",
|
||||
"patient": "Patient:",
|
||||
"servicetext": "Für unsere Leistungen erlauben wir uns Ihnen folgendes in Rechnung zu stellen:",
|
||||
"quantity": "Menge",
|
||||
"treatment": "Behandlung",
|
||||
"price": "Preis (€)",
|
||||
"sum": "Summe (€)",
|
||||
"doctor": "Behandelnder Arzt:",
|
||||
"specialty": "Fachrichtung:",
|
||||
"doctornumber": "Arztnummer:",
|
||||
"legal": "Privatärztliche Rechnung"
|
||||
},
|
||||
|
||||
"openInvoices": {
|
||||
"title": "Offene Leistungen",
|
||||
"noinvoices": "Keine offenen Rechnungen 🎉",
|
||||
"patient": "Patient",
|
||||
"date": "Datum",
|
||||
"amount": "Betrag",
|
||||
"status": "Status",
|
||||
"open": "Offen"
|
||||
},
|
||||
|
||||
"paidInvoices": {
|
||||
"title": "Bezahlte Rechnungen",
|
||||
"year": "Jahr",
|
||||
"quarter": "Quartal",
|
||||
"patient": "Patient",
|
||||
"date": "Datum",
|
||||
"amount": "Betrag"
|
||||
},
|
||||
|
||||
"openinvoices": {
|
||||
"openinvoices": "Offene Rechnungen",
|
||||
"canceledinvoices": "Stornierte Rechnungen",
|
||||
"report": "Umsatzreport",
|
||||
"payedinvoices": "Bezahlte Rechnungen",
|
||||
"creditoverview": "Gutschrift Übersicht"
|
||||
},
|
||||
|
||||
"medications": {
|
||||
"title": "Medikamentenübersicht",
|
||||
"newmedication": "Neues Medikament",
|
||||
"searchplaceholder": "Suche nach Medikament, Form, Dosierung",
|
||||
"search": "Suchen",
|
||||
"reset": "Reset",
|
||||
"medication": "Medikament",
|
||||
"form": "Darreichungsform",
|
||||
"dosage": "Dosierung",
|
||||
"package": "Packung",
|
||||
"status": "Status",
|
||||
"actions": "Aktionen"
|
||||
},
|
||||
|
||||
"medicationCreate": {
|
||||
"title": "Neues Medikament",
|
||||
"medication": "Medikament",
|
||||
"form": "Darreichungsform",
|
||||
"dosage": "Dosierung",
|
||||
"package": "Packung",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
|
||||
"openServices": {
|
||||
"title": "Offene Leistungen",
|
||||
"noopenservices": "Keine offenen Leistungen vorhanden"
|
||||
},
|
||||
|
||||
"patienteoverview": {
|
||||
"patienttitle": "Patientenübersicht",
|
||||
"newpatient": "Neuer Patient",
|
||||
"nopatientfound": "Keine Patienten gefunden",
|
||||
"closepatient": "Patient sperren (inaktiv)",
|
||||
"openpatient": "Patient entsperren (Aktiv)",
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"dni": "DNI"
|
||||
},
|
||||
|
||||
"patientCreate": {
|
||||
"title": "Neuer Patient",
|
||||
"firstname": "Vorname",
|
||||
"lastname": "Nachname",
|
||||
"dni": "N.I.E. / DNI",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"street": "Straße",
|
||||
"housenumber": "Hausnummer",
|
||||
"zip": "PLZ",
|
||||
"city": "Ort",
|
||||
"country": "Land",
|
||||
"notes": "Notizen"
|
||||
},
|
||||
|
||||
"patientEdit": {
|
||||
"firstname": "Vorname",
|
||||
"lastname": "Nachname",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"street": "Straße",
|
||||
"housenumber": "Hausnummer",
|
||||
"zip": "PLZ",
|
||||
"city": "Ort",
|
||||
"country": "Land",
|
||||
"notes": "Notizen",
|
||||
"save": "Änderungen speichern"
|
||||
},
|
||||
|
||||
"patientMedications": {
|
||||
"selectmedication": "Medikament auswählen",
|
||||
"dosageinstructions": "Dosierungsanweisung",
|
||||
"example": "z.B. 1-0-1",
|
||||
"startdate": "Startdatum",
|
||||
"enddate": "Enddatum",
|
||||
"save": "Speichern",
|
||||
"backoverview": "Zur Übersicht",
|
||||
"nomedication": "Keine Medikation vorhanden.",
|
||||
"medication": "Medikament",
|
||||
"form": "Form",
|
||||
"dosage": "Dosierung",
|
||||
"instruction": "Anweisung",
|
||||
"from": "Von",
|
||||
"to": "Bis"
|
||||
},
|
||||
|
||||
"patientOverview": {
|
||||
"patientdata": "Patientendaten",
|
||||
"firstname": "Vorname",
|
||||
"lastname": "Nachname",
|
||||
"birthdate": "Geburtsdatum",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"notes": "Notizen",
|
||||
"newnote": "Neue Notiz hinzufügen…",
|
||||
"nonotes": "Keine Notizen vorhanden",
|
||||
"createrecipe": "Rezept erstellen",
|
||||
"searchservice": "Leistung suchen…",
|
||||
"noservices": "Noch keine Leistungen für heute.",
|
||||
"addservice": "Leistung hinzufügen"
|
||||
},
|
||||
|
||||
"patientDashboard": {
|
||||
"email": "E-Mail:",
|
||||
"phone": "Telefon:",
|
||||
"address": "Adresse:",
|
||||
"medications": "Aktuelle Medikamente",
|
||||
"nomedications": "Keine aktiven Medikamente",
|
||||
"medication": "Medikament",
|
||||
"variant": "Variante",
|
||||
"instruction": "Anweisung",
|
||||
"invoices": "Rechnungen",
|
||||
"noinvoices": "Keine Rechnungen vorhanden",
|
||||
"date": "Datum",
|
||||
"amount": "Betrag",
|
||||
"pdf": "PDF",
|
||||
"open": "Öffnen"
|
||||
},
|
||||
|
||||
"services": {
|
||||
"title": "Leistungen",
|
||||
"newservice": "Neue Leistung",
|
||||
"searchplaceholder": "Suche nach Name oder Kategorie",
|
||||
"namede": "Bezeichnung (DE)",
|
||||
"namees": "Bezeichnung (ES)",
|
||||
"price": "Preis",
|
||||
"pricec70": "Preis C70",
|
||||
"status": "Status",
|
||||
"actions": "Aktionen",
|
||||
"editunlock": "Bearbeiten freigeben"
|
||||
},
|
||||
|
||||
"serviceCreate": {
|
||||
"title": "Neue Leistung",
|
||||
"back": "Zurück",
|
||||
"newservice": "Neue Leistung anlegen",
|
||||
"namede": "Bezeichnung (Deutsch) *",
|
||||
"namees": "Bezeichnung (Spanisch)",
|
||||
"category": "Kategorie",
|
||||
"price": "Preis (€) *",
|
||||
"pricec70": "Preis C70 (€)"
|
||||
},
|
||||
|
||||
"reportview": {
|
||||
"title": "Abrechnungsreport",
|
||||
"year": "Jahr",
|
||||
"quarter": "Quartal"
|
||||
},
|
||||
|
||||
"seriennumber": {
|
||||
"seriennumbertitle": "Seriennummer eingeben",
|
||||
"seriennumbertext": "Bitte gib deine Lizenz-Seriennummer ein um die Software dauerhaft freizuschalten.",
|
||||
"seriennumbershort": "Seriennummer (AAAAA-AAAAA-AAAAA-AAAAA)",
|
||||
"seriennumberdeclaration": "Nur Buchstaben + Zahlen. Format: 4x5 Zeichen, getrennt mit Bindestrich.",
|
||||
"saveseriennumber": "Seriennummer Speichern"
|
||||
},
|
||||
|
||||
"patientoverview": {
|
||||
"nopatientfound": "Keine Patienten gefunden"
|
||||
"database": "Datenbankverwaltung"
|
||||
}
|
||||
}
|
||||
|
||||
389
locales/es.json
389
locales/es.json
@ -4,405 +4,24 @@
|
||||
"cancel": "Cancelar",
|
||||
"search": "Buscar",
|
||||
"reset": "Resetear",
|
||||
"reset2": "Restablecer",
|
||||
"dashboard": "Panel",
|
||||
"logout": "Cerrar sesión",
|
||||
"title": "Título",
|
||||
"firstname": "Nombre",
|
||||
"lastname": "Apellido",
|
||||
"username": "Nombre de usuario",
|
||||
"role": "Rol",
|
||||
"action": "Acciones",
|
||||
"status": "Estado",
|
||||
"you": "Usted mismo",
|
||||
"newuser": "Nuevo usuario",
|
||||
"inactive": "Inactivo",
|
||||
"active": "Activo",
|
||||
"closed": "Bloqueado",
|
||||
"filter": "Filtro",
|
||||
"yearcash": "Facturación anual",
|
||||
"monthcash": "Facturación mensual",
|
||||
"quartalcash": "Facturación trimestral",
|
||||
"year": "Año",
|
||||
"nodata": "Sin datos",
|
||||
"month": "Mes",
|
||||
"patientcash": "Ingresos por paciente",
|
||||
"patient": "Paciente",
|
||||
"systeminfo": "Información del sistema",
|
||||
"table": "Tabla",
|
||||
"lines": "Líneas",
|
||||
"size": "Tamaño",
|
||||
"errordatabase": "Error al leer la información de la base de datos:",
|
||||
"welcome": "Bienvenido",
|
||||
"waitingroomtext": "Monitor de sala de espera",
|
||||
"waitingroomtextnopatient": "No hay pacientes en la sala de espera.",
|
||||
"gender": "Sexo",
|
||||
"birthday": "Fecha de nacimiento",
|
||||
"birthdate": "Fecha de nacimiento",
|
||||
"email": "Correo electrónico",
|
||||
"phone": "Teléfono",
|
||||
"address": "Dirección",
|
||||
"country": "País",
|
||||
"notice": "Notas",
|
||||
"notes": "Notas",
|
||||
"create": "Creado",
|
||||
"change": "Modificado",
|
||||
"edit": "Editar",
|
||||
"selection": "Selección",
|
||||
"waiting": "Ya está esperando",
|
||||
"towaitingroom": "A la sala de espera",
|
||||
"overview": "Resumen",
|
||||
"upload": "Subir archivo",
|
||||
"fileupload": "Cargar",
|
||||
"lock": "Bloquear",
|
||||
"unlock": "Desbloquear",
|
||||
"name": "Nombre",
|
||||
"return": "Atrás",
|
||||
"back": "Atrás",
|
||||
"date": "Fecha",
|
||||
"amount": "Importe",
|
||||
"quantity": "Cantidad",
|
||||
"price": "Precio (€)",
|
||||
"sum": "Total (€)",
|
||||
"pdf": "PDF",
|
||||
"open": "Abrir",
|
||||
"from": "Desde",
|
||||
"to": "Hasta",
|
||||
"street": "Calle",
|
||||
"housenumber": "Número",
|
||||
"zip": "Código postal",
|
||||
"city": "Ciudad",
|
||||
"dni": "N.I.E. / DNI",
|
||||
"dosage": "Dosificación",
|
||||
"form": "Forma farmacéutica",
|
||||
"package": "Envase",
|
||||
"specialty": "Especialidad",
|
||||
"doctornumber": "Número de médico",
|
||||
"category": "Categoría"
|
||||
"dashboard": "Panel"
|
||||
},
|
||||
|
||||
"sidebar": {
|
||||
"patients": "Pacientes",
|
||||
"medications": "Medicamentos",
|
||||
"servicesOpen": "Facturas de pacientes",
|
||||
"servicesOpen": "Servicios abiertos",
|
||||
"billing": "Facturación",
|
||||
"admin": "Administración",
|
||||
"logout": "Cerrar sesión"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
"welcome": "Bienvenido",
|
||||
"waitingRoom": "Monitor sala de espera",
|
||||
"noWaitingPatients": "No hay pacientes en la sala de espera.",
|
||||
"title": "Panel"
|
||||
"noWaitingPatients": "No hay pacientes en la sala de espera."
|
||||
},
|
||||
|
||||
"adminSidebar": {
|
||||
"users": "Administración de usuarios",
|
||||
"database": "Administración de base de datos",
|
||||
"user": "Usuario",
|
||||
"invocieoverview": "Resumen de facturas",
|
||||
"seriennumber": "Número de serie",
|
||||
"databasetable": "Base de datos",
|
||||
"companysettings": "Datos de la empresa"
|
||||
},
|
||||
|
||||
"adminuseroverview": {
|
||||
"useroverview": "Resumen de usuarios",
|
||||
"usermanagement": "Administración de usuarios",
|
||||
"user": "Usuario",
|
||||
"invocieoverview": "Resumen de facturas",
|
||||
"seriennumber": "Número de serie",
|
||||
"databasetable": "Base de datos"
|
||||
},
|
||||
|
||||
"adminCreateUser": {
|
||||
"title": "Crear usuario",
|
||||
"firstname": "Nombre",
|
||||
"lastname": "Apellido",
|
||||
"usertitle": "Título (p. ej. Dr., Prof.)",
|
||||
"username": "Nombre de usuario (login)",
|
||||
"password": "Contraseña",
|
||||
"specialty": "Especialidad",
|
||||
"doctornumber": "Número de médico",
|
||||
"createuser": "Crear usuario",
|
||||
"back": "Atrás"
|
||||
},
|
||||
|
||||
"adminServiceLogs": {
|
||||
"title": "Registro de cambios de servicios",
|
||||
"date": "Fecha",
|
||||
"user": "Usuario",
|
||||
"action": "Acción",
|
||||
"before": "Antes",
|
||||
"after": "Después"
|
||||
},
|
||||
|
||||
"companySettings": {
|
||||
"title": "Datos de la empresa",
|
||||
"companyname": "Nombre de la empresa",
|
||||
"legalform": "Forma jurídica",
|
||||
"owner": "Propietario / Director",
|
||||
"email": "Correo electrónico",
|
||||
"street": "Calle",
|
||||
"housenumber": "Número",
|
||||
"zip": "Código postal",
|
||||
"city": "Ciudad",
|
||||
"country": "País",
|
||||
"taxid": "NIF / Número fiscal",
|
||||
"bank": "Banco",
|
||||
"iban": "IBAN",
|
||||
"bic": "BIC",
|
||||
"invoicefooter": "Pie de factura",
|
||||
"companylogo": "Logotipo de la empresa",
|
||||
"currentlogo": "Logotipo actual:",
|
||||
"back": "Atrás"
|
||||
},
|
||||
|
||||
"databaseoverview": {
|
||||
"title": "Configuración de la base de datos",
|
||||
"text": "Aquí puedes probar y guardar la conexión a la base de datos.",
|
||||
"host": "Host",
|
||||
"port": "Puerto",
|
||||
"database": "Base de datos",
|
||||
"password": "Contraseña",
|
||||
"connectiontest": "Probar conexión",
|
||||
"tablecount": "Número de tablas",
|
||||
"databasesize": "Tamaño de la base de datos",
|
||||
"tableoverview": "Resumen de tablas",
|
||||
"mysqlversion": "Versión de MySQL",
|
||||
"nodbinfo": "No hay información del sistema disponible (la BD puede no estar configurada o la conexión falló)"
|
||||
},
|
||||
|
||||
"invoiceAdmin": {
|
||||
"fromyear": "Año desde",
|
||||
"toyear": "Año hasta",
|
||||
"searchpatient": "Buscar paciente..."
|
||||
},
|
||||
|
||||
"cancelledInvoices": {
|
||||
"title": "Facturas canceladas",
|
||||
"year": "Año:",
|
||||
"noinvoices": "No hay facturas canceladas para este año.",
|
||||
"patient": "Paciente",
|
||||
"date": "Fecha",
|
||||
"amount": "Importe"
|
||||
},
|
||||
|
||||
"creditOverview": {
|
||||
"title": "Resumen de abonos",
|
||||
"year": "Año:",
|
||||
"invoice": "Factura",
|
||||
"date": "Fecha",
|
||||
"pdf": "PDF",
|
||||
"creditnote": "Abono",
|
||||
"patient": "Paciente",
|
||||
"amount": "Importe",
|
||||
"open": "Abrir"
|
||||
},
|
||||
|
||||
"invoice": {
|
||||
"title": "RECHNUNG / FACTURA",
|
||||
"invoicenumber": "Número de factura:",
|
||||
"nie": "N.I.E / DNI:",
|
||||
"birthdate": "Fecha de nacimiento:",
|
||||
"patient": "Paciente:",
|
||||
"servicetext": "Por nuestros servicios, nos permitimos facturarle lo siguiente:",
|
||||
"quantity": "Cantidad",
|
||||
"treatment": "Tratamiento",
|
||||
"price": "Precio (€)",
|
||||
"sum": "Total (€)",
|
||||
"doctor": "Médico responsable:",
|
||||
"specialty": "Especialidad:",
|
||||
"doctornumber": "Número de médico:",
|
||||
"legal": "Factura médica privada"
|
||||
},
|
||||
|
||||
"openInvoices": {
|
||||
"title": "Servicios abiertos",
|
||||
"noinvoices": "No hay facturas abiertas 🎉",
|
||||
"patient": "Paciente",
|
||||
"date": "Fecha",
|
||||
"amount": "Importe",
|
||||
"status": "Estado",
|
||||
"open": "Abierto"
|
||||
},
|
||||
|
||||
"paidInvoices": {
|
||||
"title": "Facturas pagadas",
|
||||
"year": "Año",
|
||||
"quarter": "Trimestre",
|
||||
"patient": "Paciente",
|
||||
"date": "Fecha",
|
||||
"amount": "Importe"
|
||||
},
|
||||
|
||||
"openinvoices": {
|
||||
"openinvoices": "Facturas de pacientes",
|
||||
"canceledinvoices": "Facturas canceladas",
|
||||
"report": "Informe de ventas",
|
||||
"payedinvoices": "Facturas pagadas",
|
||||
"creditoverview": "Resumen de abonos"
|
||||
},
|
||||
|
||||
"medications": {
|
||||
"title": "Resumen de medicamentos",
|
||||
"newmedication": "Nuevo medicamento",
|
||||
"searchplaceholder": "Buscar medicamento, forma, dosificación",
|
||||
"search": "Buscar",
|
||||
"reset": "Restablecer",
|
||||
"medication": "Medicamento",
|
||||
"form": "Forma farmacéutica",
|
||||
"dosage": "Dosificación",
|
||||
"package": "Envase",
|
||||
"status": "Estado",
|
||||
"actions": "Acciones"
|
||||
},
|
||||
|
||||
"medicationCreate": {
|
||||
"title": "Nuevo medicamento",
|
||||
"medication": "Medicamento",
|
||||
"form": "Forma farmacéutica",
|
||||
"dosage": "Dosificación",
|
||||
"package": "Envase",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
|
||||
"openServices": {
|
||||
"title": "Servicios abiertos",
|
||||
"noopenservices": "No hay servicios abiertos"
|
||||
},
|
||||
|
||||
"patienteoverview": {
|
||||
"patienttitle": "Resumen de pacientes",
|
||||
"newpatient": "Paciente nuevo",
|
||||
"nopatientfound": "No se han encontrado pacientes.",
|
||||
"closepatient": "Bloquear paciente (inactivo)",
|
||||
"openpatient": "Desbloquear paciente (activo)",
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
"dni": "DNI"
|
||||
},
|
||||
|
||||
"patientCreate": {
|
||||
"title": "Nuevo paciente",
|
||||
"firstname": "Nombre",
|
||||
"lastname": "Apellido",
|
||||
"dni": "N.I.E. / DNI",
|
||||
"email": "Correo electrónico",
|
||||
"phone": "Teléfono",
|
||||
"street": "Calle",
|
||||
"housenumber": "Número",
|
||||
"zip": "Código postal",
|
||||
"city": "Ciudad",
|
||||
"country": "País",
|
||||
"notes": "Notas"
|
||||
},
|
||||
|
||||
"patientEdit": {
|
||||
"firstname": "Nombre",
|
||||
"lastname": "Apellido",
|
||||
"email": "Correo electrónico",
|
||||
"phone": "Teléfono",
|
||||
"street": "Calle",
|
||||
"housenumber": "Número",
|
||||
"zip": "Código postal",
|
||||
"city": "Ciudad",
|
||||
"country": "País",
|
||||
"notes": "Notas",
|
||||
"save": "Guardar cambios"
|
||||
},
|
||||
|
||||
"patientMedications": {
|
||||
"selectmedication": "Seleccionar medicamento",
|
||||
"dosageinstructions": "Instrucciones de dosificación",
|
||||
"example": "p.ej. 1-0-1",
|
||||
"startdate": "Fecha de inicio",
|
||||
"enddate": "Fecha de fin",
|
||||
"save": "Guardar",
|
||||
"backoverview": "Volver al resumen",
|
||||
"nomedication": "No hay medicación registrada.",
|
||||
"medication": "Medicamento",
|
||||
"form": "Forma",
|
||||
"dosage": "Dosificación",
|
||||
"instruction": "Instrucción",
|
||||
"from": "Desde",
|
||||
"to": "Hasta"
|
||||
},
|
||||
|
||||
"patientOverview": {
|
||||
"patientdata": "Datos del paciente",
|
||||
"firstname": "Nombre",
|
||||
"lastname": "Apellido",
|
||||
"birthdate": "Fecha de nacimiento",
|
||||
"email": "Correo electrónico",
|
||||
"phone": "Teléfono",
|
||||
"notes": "Notas",
|
||||
"newnote": "Añadir nueva nota…",
|
||||
"nonotes": "No hay notas",
|
||||
"createrecipe": "Crear receta",
|
||||
"searchservice": "Buscar servicio…",
|
||||
"noservices": "Todavía no hay servicios para hoy.",
|
||||
"addservice": "Añadir servicio"
|
||||
},
|
||||
|
||||
"patientDashboard": {
|
||||
"email": "Correo electrónico:",
|
||||
"phone": "Teléfono:",
|
||||
"address": "Dirección:",
|
||||
"medications": "Medicamentos actuales",
|
||||
"nomedications": "Sin medicamentos activos",
|
||||
"medication": "Medicamento",
|
||||
"variant": "Variante",
|
||||
"instruction": "Instrucción",
|
||||
"invoices": "Facturas",
|
||||
"noinvoices": "No hay facturas",
|
||||
"date": "Fecha",
|
||||
"amount": "Importe",
|
||||
"pdf": "PDF",
|
||||
"open": "Abrir"
|
||||
},
|
||||
|
||||
"services": {
|
||||
"title": "Servicios",
|
||||
"newservice": "Nuevo servicio",
|
||||
"searchplaceholder": "Buscar por nombre o categoría",
|
||||
"namede": "Denominación (DE)",
|
||||
"namees": "Denominación (ES)",
|
||||
"price": "Precio",
|
||||
"pricec70": "Precio C70",
|
||||
"status": "Estado",
|
||||
"actions": "Acciones",
|
||||
"editunlock": "Desbloquear edición"
|
||||
},
|
||||
|
||||
"serviceCreate": {
|
||||
"title": "Nuevo servicio",
|
||||
"back": "Atrás",
|
||||
"newservice": "Crear nuevo servicio",
|
||||
"namede": "Denominación (Alemán) *",
|
||||
"namees": "Denominación (Español)",
|
||||
"category": "Categoría",
|
||||
"price": "Precio (€) *",
|
||||
"pricec70": "Precio C70 (€)"
|
||||
},
|
||||
|
||||
"reportview": {
|
||||
"title": "Informe de facturación",
|
||||
"year": "Año",
|
||||
"quarter": "Trimestre"
|
||||
},
|
||||
|
||||
"seriennumber": {
|
||||
"seriennumbertitle": "Introduce el número de serie",
|
||||
"seriennumbertext": "Introduce el número de serie de tu licencia para activar el software de forma permanente.",
|
||||
"seriennumbershort": "Número de serie (AAAAA-AAAAA-AAAAA-AAAAA)",
|
||||
"seriennumberdeclaration": "Solo letras y números. Formato: 4x5 caracteres, separados por guion.",
|
||||
"saveseriennumber": "Guardar número de serie"
|
||||
},
|
||||
|
||||
"patientoverview": {
|
||||
"nopatientfound": "No se han encontrado pacientes."
|
||||
"database": "Administración de base de datos"
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,49 +7,40 @@ function requireLogin(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
// ── Hilfsfunktion: Zugriff verweigern mit Flash + Redirect ────────────────────
|
||||
function denyAccess(req, res, message) {
|
||||
// Zurück zur vorherigen Seite, oder zum Dashboard
|
||||
const back = req.get("Referrer") || "/dashboard";
|
||||
|
||||
req.session.flash = req.session.flash || [];
|
||||
req.session.flash.push({ type: "danger", message });
|
||||
|
||||
return res.redirect(back);
|
||||
}
|
||||
|
||||
// ✅ Arzt-only
|
||||
// ✅ NEU: Arzt-only (das war früher dein requireAdmin)
|
||||
function requireArzt(req, res, next) {
|
||||
if (!req.session.user) return res.redirect("/");
|
||||
console.log("ARZT CHECK:", req.session.user);
|
||||
|
||||
if (!req.session.user) {
|
||||
return res.redirect("/");
|
||||
}
|
||||
|
||||
if (req.session.user.role !== "arzt") {
|
||||
return denyAccess(req, res, "⛔ Kein Zugriff – diese Seite ist nur für Ärzte.");
|
||||
return res
|
||||
.status(403)
|
||||
.send(
|
||||
"⛔ Kein Zugriff (Arzt erforderlich). Rolle: " + req.session.user.role,
|
||||
);
|
||||
}
|
||||
|
||||
req.user = req.session.user;
|
||||
next();
|
||||
}
|
||||
|
||||
// ✅ Admin-only
|
||||
// ✅ NEU: Admin-only
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.session.user) return res.redirect("/");
|
||||
console.log("ADMIN CHECK:", req.session.user);
|
||||
|
||||
if (!req.session.user) {
|
||||
return res.redirect("/");
|
||||
}
|
||||
|
||||
if (req.session.user.role !== "admin") {
|
||||
return denyAccess(req, res, "⛔ Kein Zugriff – diese Seite ist nur für Administratoren.");
|
||||
}
|
||||
|
||||
req.user = req.session.user;
|
||||
next();
|
||||
}
|
||||
|
||||
// ✅ Arzt + Mitarbeiter
|
||||
function requireArztOrMitarbeiter(req, res, next) {
|
||||
if (!req.session.user) return res.redirect("/");
|
||||
|
||||
const allowed = ["arzt", "mitarbeiter"];
|
||||
|
||||
if (!allowed.includes(req.session.user.role)) {
|
||||
return denyAccess(req, res, "⛔ Kein Zugriff – diese Seite ist nur für Ärzte und Mitarbeiter.");
|
||||
return res
|
||||
.status(403)
|
||||
.send(
|
||||
"⛔ Kein Zugriff (Admin erforderlich). Rolle: " + req.session.user.role,
|
||||
);
|
||||
}
|
||||
|
||||
req.user = req.session.user;
|
||||
@ -60,5 +51,4 @@ module.exports = {
|
||||
requireLogin,
|
||||
requireArzt,
|
||||
requireAdmin,
|
||||
requireArztOrMitarbeiter,
|
||||
};
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
const { configExists, loadConfig } = require("../config-manager");
|
||||
|
||||
/**
|
||||
* Leitet beim ersten Programmstart automatisch zu /setup um,
|
||||
* solange config.enc fehlt oder DB-Daten unvollständig sind.
|
||||
*/
|
||||
module.exports = function requireSetup(req, res, next) {
|
||||
// ✅ Setup immer erlauben
|
||||
if (req.path.startsWith("/setup")) return next();
|
||||
|
||||
// ✅ Static niemals blockieren
|
||||
if (req.path.startsWith("/public")) return next();
|
||||
if (req.path.startsWith("/css")) return next();
|
||||
if (req.path.startsWith("/js")) return next();
|
||||
if (req.path.startsWith("/images")) return next();
|
||||
if (req.path.startsWith("/uploads")) return next();
|
||||
if (req.path.startsWith("/favicon")) return next();
|
||||
|
||||
// ✅ Login/Logout erlauben
|
||||
if (req.path.startsWith("/login")) return next();
|
||||
if (req.path.startsWith("/logout")) return next();
|
||||
|
||||
// ✅ Wenn config.enc fehlt -> Setup erzwingen
|
||||
if (!configExists()) {
|
||||
return res.redirect("/setup");
|
||||
}
|
||||
|
||||
// ✅ Wenn config existiert aber DB Daten fehlen -> Setup erzwingen
|
||||
let cfg = null;
|
||||
try {
|
||||
cfg = loadConfig();
|
||||
} catch (e) {
|
||||
cfg = null;
|
||||
}
|
||||
|
||||
const ok =
|
||||
cfg?.db?.host &&
|
||||
cfg?.db?.user &&
|
||||
cfg?.db?.password &&
|
||||
cfg?.db?.name;
|
||||
|
||||
if (!ok) {
|
||||
return res.redirect("/setup");
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
Binary file not shown.
343
package-lock.json
generated
343
package-lock.json
generated
@ -11,8 +11,6 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"date-holidays": "^3.26.11",
|
||||
"docxtemplater": "^3.67.6",
|
||||
"dotenv": "^17.2.3",
|
||||
"ejs": "^3.1.10",
|
||||
@ -25,8 +23,6 @@
|
||||
"html-pdf-node": "^1.0.8",
|
||||
"multer": "^2.0.2",
|
||||
"mysql2": "^3.16.0",
|
||||
"node-ssh": "^13.2.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -1041,12 +1037,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@ -1083,24 +1073,6 @@
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@ -1676,23 +1648,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/astronomia": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/astronomia/-/astronomia-4.2.0.tgz",
|
||||
"integrity": "sha512-mTvpBGyXB80aSsDhAAiuwza5VqAyqmj5yzhjBrFhRy17DcWDzJrb8Vdl4Sm+g276S+mY7bk/5hi6akZ5RQFeHg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
@ -1880,14 +1835,6 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@ -2115,15 +2062,6 @@
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
@ -2144,18 +2082,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/caldate": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/caldate/-/caldate-2.0.5.tgz",
|
||||
"integrity": "sha512-JndhrUuDuE975KUhFqJaVR1OQkCHZqpOrJur/CFXEIEhWhBMjxO85cRSK8q4FW+B+yyPq6GYua2u4KvNzTcq0w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"moment-timezone": "^0.5.43"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@ -2289,18 +2215,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz",
|
||||
@ -2600,20 +2514,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
@ -2677,91 +2577,6 @@
|
||||
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-bengali-revised": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/date-bengali-revised/-/date-bengali-revised-2.0.2.tgz",
|
||||
"integrity": "sha512-q9iDru4+TSA9k4zfm0CFHJj6nBsxP7AYgWC/qodK/i7oOIlj5K2z5IcQDtESfs/Qwqt/xJYaP86tkazd/vRptg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/date-chinese": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/date-chinese/-/date-chinese-2.1.4.tgz",
|
||||
"integrity": "sha512-WY+6+Qw92ZGWFvGtStmNQHEYpNa87b8IAQ5T8VKt4wqrn24lBXyyBnWI5jAIyy7h/KVwJZ06bD8l/b7yss82Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"astronomia": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/date-easter": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/date-easter/-/date-easter-1.0.3.tgz",
|
||||
"integrity": "sha512-aOViyIgpM4W0OWUiLqivznwTtuMlD/rdUWhc5IatYnplhPiWrLv75cnifaKYhmQwUBLAMWLNG4/9mlLIbXoGBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/date-holidays": {
|
||||
"version": "3.26.11",
|
||||
"resolved": "https://registry.npmjs.org/date-holidays/-/date-holidays-3.26.11.tgz",
|
||||
"integrity": "sha512-A8997Xv4k6fhpfu1xg2hEMfhB5MvWk/7TWIt1YmRFM2QPMENgL2WiaSe4zpSRzfnHSpkozcea9+R+Y5IvGJimQ==",
|
||||
"license": "(ISC AND CC-BY-3.0)",
|
||||
"dependencies": {
|
||||
"date-holidays-parser": "^3.4.7",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lodash": "^4.17.23",
|
||||
"prepin": "^1.0.3"
|
||||
},
|
||||
"bin": {
|
||||
"holidays2json": "scripts/holidays2json.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/date-holidays-parser": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/date-holidays-parser/-/date-holidays-parser-3.4.7.tgz",
|
||||
"integrity": "sha512-h09ZEtM6u5cYM6m1bX+1Ny9f+nLO9KVZUKNPEnH7lhbXYTfqZogaGTnhONswGeIJFF91UImIftS3CdM9HLW5oQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"astronomia": "^4.1.1",
|
||||
"caldate": "^2.0.5",
|
||||
"date-bengali-revised": "^2.0.2",
|
||||
"date-chinese": "^2.1.4",
|
||||
"date-easter": "^1.0.3",
|
||||
"deepmerge": "^4.3.1",
|
||||
"jalaali-js": "^1.2.7",
|
||||
"moment-timezone": "^0.5.47"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/date-holidays/node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/date-holidays/node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@ -2798,6 +2613,7 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -4295,6 +4111,7 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -4437,12 +4254,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jalaali-js": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
|
||||
"integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jest": {
|
||||
"version": "30.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz",
|
||||
@ -5151,12 +4962,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.assignin": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz",
|
||||
@ -5438,27 +5243,6 @@
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/moment-timezone": {
|
||||
"version": "0.5.48",
|
||||
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
|
||||
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"moment": "^2.29.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@ -5515,12 +5299,6 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
|
||||
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/napi-postinstall": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
|
||||
@ -5602,44 +5380,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-ssh": {
|
||||
"version": "13.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-ssh/-/node-ssh-13.2.1.tgz",
|
||||
"integrity": "sha512-rfl4GWMygQfzlExPkQ2LWyya5n2jOBm5vhEnup+4mdw7tQhNpJWbP5ldr09Jfj93k5SfY5lxcn8od5qrQ/6mBg==",
|
||||
"dependencies": {
|
||||
"is-stream": "^2.0.0",
|
||||
"make-dir": "^3.1.0",
|
||||
"sb-promise-queue": "^2.1.0",
|
||||
"sb-scandir": "^3.1.0",
|
||||
"shell-escape": "^0.2.0",
|
||||
"ssh2": "^1.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-ssh/node_modules/make-dir": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
|
||||
"dependencies": {
|
||||
"semver": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/node-ssh/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
||||
@ -5851,12 +5591,6 @@
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
@ -5943,24 +5677,6 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
@ -6017,15 +5733,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/prepin": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/prepin/-/prepin-1.0.3.tgz",
|
||||
"integrity": "sha512-0XL2hreherEEvUy0fiaGEfN/ioXFV+JpImqIzQjxk6iBg4jQ2ARKqvC4+BmRD8w/pnpD+lbxvh0Ub+z7yBEjvA==",
|
||||
"license": "Unlicense",
|
||||
"bin": {
|
||||
"prepin": "bin/prepin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "30.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz",
|
||||
@ -6329,25 +6036,6 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sb-promise-queue": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/sb-promise-queue/-/sb-promise-queue-2.1.1.tgz",
|
||||
"integrity": "sha512-qXfdcJQMxMljxmPprn4Q4hl3pJmoljSCzUvvEBa9Kscewnv56n0KqrO6yWSrGLOL9E021wcGdPa39CHGKA6G0w==",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/sb-scandir": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/sb-scandir/-/sb-scandir-3.1.1.tgz",
|
||||
"integrity": "sha512-Q5xiQMtoragW9z8YsVYTAZcew+cRzdVBefPbb9theaIKw6cBo34WonP9qOCTKgyAmn/Ch5gmtAxT/krUgMILpA==",
|
||||
"dependencies": {
|
||||
"sb-promise-queue": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@ -6449,11 +6137,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-escape": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz",
|
||||
"integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw=="
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
@ -6628,23 +6311,6 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-utils": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||
@ -7060,11 +6726,6 @@
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
|
||||
},
|
||||
"node_modules/type-detect": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
||||
|
||||
@ -15,8 +15,6 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"date-holidays": "^3.26.11",
|
||||
"docxtemplater": "^3.67.6",
|
||||
"dotenv": "^17.2.3",
|
||||
"ejs": "^3.1.10",
|
||||
@ -29,8 +27,6 @@
|
||||
"html-pdf-node": "^1.0.8",
|
||||
"multer": "^2.0.2",
|
||||
"mysql2": "^3.16.0",
|
||||
"node-ssh": "^13.2.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,506 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── Daten aus DOM (CSP-sicher via <script type="application/json">) ──── */
|
||||
const ALL_DOCTORS = JSON.parse(
|
||||
document.getElementById('calDoctorsData').textContent
|
||||
);
|
||||
const BASE = '/calendar/api';
|
||||
|
||||
/* ── State ──────────────────────────────────────────────────────────────── */
|
||||
let currentDate = new Date();
|
||||
let appointments = [];
|
||||
let holidays = {};
|
||||
let visibleDocs = new Set(ALL_DOCTORS.map(d => d.id));
|
||||
let editingId = null;
|
||||
|
||||
/* ── Hilfsfunktionen ────────────────────────────────────────────────────── */
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const toISO = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
||||
const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r; };
|
||||
|
||||
const WDAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
|
||||
const MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||
|
||||
const TIME_SLOTS = (() => {
|
||||
const s = [];
|
||||
for (let h = 0; h < 24; h++)
|
||||
for (let m = 0; m < 60; m += 15)
|
||||
s.push(`${pad(h)}:${pad(m)}`);
|
||||
return s;
|
||||
})();
|
||||
|
||||
async function apiFetch(path, opts = {}) {
|
||||
const res = await fetch(BASE + path, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'API-Fehler');
|
||||
return data;
|
||||
}
|
||||
|
||||
function showToast(msg, isError = false) {
|
||||
const el = document.getElementById('calToast');
|
||||
const txt = document.getElementById('calToastMsg');
|
||||
txt.textContent = msg;
|
||||
el.className = `toast align-items-center border-0 ${isError ? 'text-bg-danger' : 'text-bg-dark'}`;
|
||||
bootstrap.Toast.getOrCreateInstance(el, { delay: 2800 }).show();
|
||||
}
|
||||
|
||||
/* ── Tages-Daten laden ──────────────────────────────────────────────────── */
|
||||
async function loadDay() {
|
||||
const iso = toISO(currentDate);
|
||||
appointments = await apiFetch(`/appointments/${iso}`);
|
||||
await ensureHolidays(currentDate.getFullYear());
|
||||
renderToolbar();
|
||||
renderHolidayBanner();
|
||||
renderColumns();
|
||||
renderMiniCal();
|
||||
}
|
||||
|
||||
async function ensureHolidays(year) {
|
||||
if (holidays[year] !== undefined) return;
|
||||
try {
|
||||
const data = await apiFetch(`/holidays/${year}`);
|
||||
holidays[year] = {};
|
||||
for (const h of data.holidays) {
|
||||
if (!holidays[year][h.date]) holidays[year][h.date] = [];
|
||||
holidays[year][h.date].push(h);
|
||||
}
|
||||
} catch { holidays[year] = {}; }
|
||||
}
|
||||
|
||||
/* ── Toolbar ────────────────────────────────────────────────────────────── */
|
||||
function renderToolbar() {
|
||||
const wd = WDAYS[currentDate.getDay()];
|
||||
const day = currentDate.getDate();
|
||||
const mon = MONTHS[currentDate.getMonth()];
|
||||
const yr = currentDate.getFullYear();
|
||||
document.getElementById('btnDateDisplay').textContent =
|
||||
`${wd}, ${day}. ${mon} ${yr}`;
|
||||
}
|
||||
|
||||
/* ── Feiertagsbanner ────────────────────────────────────────────────────── */
|
||||
function renderHolidayBanner() {
|
||||
const iso = toISO(currentDate);
|
||||
const list = holidays[currentDate.getFullYear()]?.[iso];
|
||||
const el = document.getElementById('calHolidayBanner');
|
||||
if (list?.length) {
|
||||
document.getElementById('calHolidayText').textContent =
|
||||
'Feiertag: ' + list.map(h => h.name).join(' · ');
|
||||
el.style.display = 'flex';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Zeitachse ──────────────────────────────────────────────────────────── */
|
||||
function buildTimeAxis() {
|
||||
const ax = document.getElementById('calTimeAxis');
|
||||
ax.innerHTML = TIME_SLOTS.map(t => {
|
||||
const h = t.endsWith(':00');
|
||||
return `<div class="cal-time-label ${h ? 'hour' : ''}">${h ? t : ''}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ── Spalten rendern ────────────────────────────────────────────────────── */
|
||||
function renderColumns() {
|
||||
const visible = ALL_DOCTORS.filter(d => visibleDocs.has(d.id));
|
||||
const headers = document.getElementById('calColHeadersInner');
|
||||
const cols = document.getElementById('calColumnsInner');
|
||||
const iso = toISO(currentDate);
|
||||
const isWEnd = [0, 6].includes(currentDate.getDay());
|
||||
|
||||
const countMap = {};
|
||||
for (const a of appointments)
|
||||
countMap[a.doctor_id] = (countMap[a.doctor_id] || 0) + 1;
|
||||
|
||||
if (!visible.length) {
|
||||
headers.innerHTML = '';
|
||||
cols.innerHTML = `
|
||||
<div class="d-flex flex-column align-items-center justify-content-center w-100 text-muted py-5">
|
||||
<i class="bi bi-person-x fs-1 mb-2"></i>
|
||||
<div>Keine Ärzte ausgewählt</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
headers.innerHTML = visible.map(d => `
|
||||
<div class="col-header">
|
||||
<span class="doc-dot" style="background:${d.color}"></span>
|
||||
<div>
|
||||
<div class="col-header-name">${esc(d.name)}</div>
|
||||
</div>
|
||||
<span class="col-header-count">${countMap[d.id] || 0}</span>
|
||||
<input type="color" class="col-header-color ms-1" value="${d.color}"
|
||||
title="Farbe ändern" data-doc="${d.id}">
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
cols.innerHTML = visible.map(d => `
|
||||
<div class="doc-col" id="docCol-${d.id}" data-doc="${d.id}">
|
||||
${TIME_SLOTS.map(t => `
|
||||
<div class="slot-row ${t.endsWith(':00') ? 'hour-start' : ''} ${isWEnd ? 'weekend' : ''}"
|
||||
data-time="${t}" data-doc="${d.id}"></div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
/* Termin-Blöcke */
|
||||
const byDoc = {};
|
||||
for (const a of appointments) {
|
||||
if (!byDoc[a.doctor_id]) byDoc[a.doctor_id] = [];
|
||||
byDoc[a.doctor_id].push(a);
|
||||
}
|
||||
for (const d of visible) {
|
||||
const col = document.getElementById(`docCol-${d.id}`);
|
||||
if (col) (byDoc[d.id] || []).forEach(a => renderApptBlock(col, a, d.color));
|
||||
}
|
||||
|
||||
updateNowLine();
|
||||
|
||||
/* Slot-Klick */
|
||||
cols.querySelectorAll('.slot-row').forEach(slot =>
|
||||
slot.addEventListener('click', () =>
|
||||
openApptModal(null, slot.dataset.doc, iso, slot.dataset.time))
|
||||
);
|
||||
|
||||
/* Farb-Picker */
|
||||
headers.querySelectorAll('.col-header-color').forEach(inp => {
|
||||
inp.addEventListener('change', async () => {
|
||||
const docId = parseInt(inp.dataset.doc);
|
||||
const color = inp.value;
|
||||
await apiFetch(`/doctors/${docId}/color`, { method: 'PATCH', body: { color } });
|
||||
const doc = ALL_DOCTORS.find(d => d.id === docId);
|
||||
if (doc) doc.color = color;
|
||||
renderDocList();
|
||||
renderColumns();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderApptBlock(col, a, color) {
|
||||
const idx = TIME_SLOTS.indexOf(a.time);
|
||||
if (idx < 0) return;
|
||||
const slots = Math.max(1, Math.round(a.duration / 15));
|
||||
const block = document.createElement('div');
|
||||
block.className = `appt-block status-${a.status}`;
|
||||
block.style.cssText =
|
||||
`top:${idx * 40 + 2}px; height:${slots * 40 - 4}px; background:${color}28; border-color:${color};`;
|
||||
block.innerHTML = `
|
||||
<div class="appt-patient">${esc(a.patient_name)}</div>
|
||||
${slots > 1 ? `<div class="appt-time">${a.time} · ${a.duration} min</div>` : ''}
|
||||
`;
|
||||
block.addEventListener('click', e => { e.stopPropagation(); openApptModal(a); });
|
||||
col.appendChild(block);
|
||||
}
|
||||
|
||||
function updateNowLine() {
|
||||
document.querySelectorAll('.now-line').forEach(n => n.remove());
|
||||
if (toISO(new Date()) !== toISO(currentDate)) return;
|
||||
const mins = new Date().getHours() * 60 + new Date().getMinutes();
|
||||
const top = (mins / 15) * 40;
|
||||
document.querySelectorAll('.doc-col').forEach(col => {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'now-line';
|
||||
line.style.top = `${top}px`;
|
||||
line.innerHTML = '<div class="now-dot"></div>';
|
||||
col.appendChild(line);
|
||||
});
|
||||
}
|
||||
setInterval(updateNowLine, 30000);
|
||||
|
||||
/* ── Arztliste (Sidebar) ────────────────────────────────────────────────── */
|
||||
function renderDocList() {
|
||||
const el = document.getElementById('docList');
|
||||
el.innerHTML = ALL_DOCTORS.map(d => `
|
||||
<div class="doc-item ${visibleDocs.has(d.id) ? 'active' : ''}" data-id="${d.id}">
|
||||
<span class="doc-dot" style="background:${d.color}"></span>
|
||||
<span style="font-size:13px; flex:1;">${esc(d.name)}</span>
|
||||
<span class="doc-check">
|
||||
${visibleDocs.has(d.id)
|
||||
? '<i class="bi bi-check text-white" style="font-size:11px;"></i>'
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
el.querySelectorAll('.doc-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const id = parseInt(item.dataset.id);
|
||||
visibleDocs.has(id) ? visibleDocs.delete(id) : visibleDocs.add(id);
|
||||
renderDocList();
|
||||
renderColumns();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Mini-Kalender ──────────────────────────────────────────────────────── */
|
||||
let miniYear = new Date().getFullYear();
|
||||
let miniMonth = new Date().getMonth();
|
||||
|
||||
async function renderMiniCal(yr, mo) {
|
||||
if (yr !== undefined) { miniYear = yr; miniMonth = mo; }
|
||||
await ensureHolidays(miniYear);
|
||||
|
||||
const first = new Date(miniYear, miniMonth, 1);
|
||||
const last = new Date(miniYear, miniMonth + 1, 0);
|
||||
const startWd = (first.getDay() + 6) % 7;
|
||||
|
||||
let html = `
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<button class="btn btn-sm btn-link p-0 text-muted" id="miniPrev">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<small class="fw-semibold">${MONTHS[miniMonth].substring(0,3)} ${miniYear}</small>
|
||||
<button class="btn btn-sm btn-link p-0 text-muted" id="miniNext">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mini-cal-grid">
|
||||
${['Mo','Di','Mi','Do','Fr','Sa','So'].map(w => `<div class="mini-wd">${w}</div>`).join('')}
|
||||
`;
|
||||
|
||||
for (let i = 0; i < startWd; i++) html += '<div></div>';
|
||||
|
||||
for (let day = 1; day <= last.getDate(); day++) {
|
||||
const d2 = new Date(miniYear, miniMonth, day);
|
||||
const iso = toISO(d2);
|
||||
const tod = toISO(new Date()) === iso;
|
||||
const sel = toISO(currentDate) === iso;
|
||||
const hol = !!(holidays[miniYear]?.[iso]);
|
||||
html += `<div class="mini-day ${tod?'today':''} ${sel?'selected':''} ${hol?'holiday':''}"
|
||||
data-iso="${iso}">${day}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
const mc = document.getElementById('miniCal');
|
||||
mc.innerHTML = html;
|
||||
|
||||
mc.querySelector('#miniPrev').addEventListener('click', () => {
|
||||
let m = miniMonth - 1, y = miniYear;
|
||||
if (m < 0) { m = 11; y--; }
|
||||
renderMiniCal(y, m);
|
||||
});
|
||||
mc.querySelector('#miniNext').addEventListener('click', () => {
|
||||
let m = miniMonth + 1, y = miniYear;
|
||||
if (m > 11) { m = 0; y++; }
|
||||
renderMiniCal(y, m);
|
||||
});
|
||||
mc.querySelectorAll('.mini-day[data-iso]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const [y, m, d] = el.dataset.iso.split('-').map(Number);
|
||||
currentDate = new Date(y, m - 1, d);
|
||||
loadDay();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Patienten-Autocomplete ─────────────────────────────────────────────── */
|
||||
let acTimer = null;
|
||||
|
||||
function initPatientAutocomplete() {
|
||||
const input = document.getElementById('fPatient');
|
||||
const dropdown = document.getElementById('patientDropdown');
|
||||
const hiddenId = document.getElementById('fPatientId');
|
||||
|
||||
function hideDropdown() {
|
||||
dropdown.style.display = 'none';
|
||||
dropdown.innerHTML = '';
|
||||
}
|
||||
|
||||
function selectPatient(p) {
|
||||
input.value = `${p.firstname} ${p.lastname}`;
|
||||
hiddenId.value = p.id;
|
||||
hideDropdown();
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(acTimer);
|
||||
hiddenId.value = ''; // Freitext → ID zurücksetzen
|
||||
const q = input.value.trim();
|
||||
|
||||
if (q.length < 1) { hideDropdown(); return; }
|
||||
|
||||
acTimer = setTimeout(async () => {
|
||||
try {
|
||||
const results = await apiFetch(
|
||||
`/patients/search?q=${encodeURIComponent(q)}`
|
||||
);
|
||||
|
||||
if (!results.length) { hideDropdown(); return; }
|
||||
|
||||
dropdown.innerHTML = results.map(p => {
|
||||
const bd = p.birthdate
|
||||
? new Date(p.birthdate).toLocaleDateString('de-DE')
|
||||
: '';
|
||||
return `
|
||||
<div class="ac-item d-flex align-items-center gap-2 px-3 py-2"
|
||||
style="cursor:pointer; font-size:13px; border-bottom:1px solid #f0f0f0;"
|
||||
data-id="${p.id}"
|
||||
data-name="${esc(p.firstname)} ${esc(p.lastname)}">
|
||||
<i class="bi bi-person text-muted"></i>
|
||||
<div>
|
||||
<div class="fw-semibold">${esc(p.firstname)} ${esc(p.lastname)}</div>
|
||||
${bd ? `<div class="text-muted" style="font-size:11px;">*${bd}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
dropdown.style.display = 'block';
|
||||
|
||||
dropdown.querySelectorAll('.ac-item').forEach(item => {
|
||||
// Hover-Effekt
|
||||
item.addEventListener('mouseenter', () =>
|
||||
item.style.background = '#f0f5ff'
|
||||
);
|
||||
item.addEventListener('mouseleave', () =>
|
||||
item.style.background = ''
|
||||
);
|
||||
// Auswahl
|
||||
item.addEventListener('mousedown', e => {
|
||||
e.preventDefault(); // verhindert blur vor click
|
||||
selectPatient({
|
||||
id: parseInt(item.dataset.id),
|
||||
firstname: item.dataset.name.split(' ')[0],
|
||||
lastname: item.dataset.name.split(' ').slice(1).join(' '),
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch { hideDropdown(); }
|
||||
}, 220);
|
||||
});
|
||||
|
||||
// Dropdown schließen wenn Fokus woanders hin geht
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(hideDropdown, 200);
|
||||
});
|
||||
|
||||
// Modal schließt → Dropdown aufräumen
|
||||
document.getElementById('apptModal').addEventListener('hidden.bs.modal', hideDropdown);
|
||||
}
|
||||
|
||||
/* ── Termin-Modal ───────────────────────────────────────────────────────── */
|
||||
function populateTimeSelect() {
|
||||
const sel = document.getElementById('fTime');
|
||||
sel.innerHTML = TIME_SLOTS.map(t =>
|
||||
`<option value="${t}">${t}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function populateDoctorSelect() {
|
||||
const sel = document.getElementById('fDoctor');
|
||||
sel.innerHTML = ALL_DOCTORS.map(d =>
|
||||
`<option value="${d.id}">${esc(d.name)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function openApptModal(appt, docId, date, time) {
|
||||
editingId = appt?.id ?? null;
|
||||
document.getElementById('apptModalTitle').textContent =
|
||||
appt ? 'Termin bearbeiten' : 'Neuer Termin';
|
||||
document.getElementById('btnApptDelete').style.display = appt ? '' : 'none';
|
||||
|
||||
populateTimeSelect();
|
||||
populateDoctorSelect();
|
||||
|
||||
document.getElementById('fDate').value = appt?.date ?? (date || toISO(currentDate));
|
||||
document.getElementById('fTime').value = appt?.time ?? (time || '08:00');
|
||||
document.getElementById('fDoctor').value = appt?.doctor_id ?? (docId || ALL_DOCTORS[0]?.id || '');
|
||||
document.getElementById('fPatient').value = appt?.patient_name ?? '';
|
||||
document.getElementById('fPatientId').value = ''; // ← immer zurücksetzen
|
||||
document.getElementById('fDuration').value = appt?.duration ?? 15;
|
||||
document.getElementById('fStatus').value = appt?.status ?? 'scheduled';
|
||||
document.getElementById('fNotes').value = appt?.notes ?? '';
|
||||
|
||||
bootstrap.Modal.getOrCreateInstance(
|
||||
document.getElementById('apptModal')
|
||||
).show();
|
||||
setTimeout(() => document.getElementById('fPatient').focus(), 300);
|
||||
}
|
||||
|
||||
async function saveAppt() {
|
||||
const payload = {
|
||||
doctor_id: parseInt(document.getElementById('fDoctor').value),
|
||||
date: document.getElementById('fDate').value,
|
||||
time: document.getElementById('fTime').value,
|
||||
duration: parseInt(document.getElementById('fDuration').value),
|
||||
patient_name: document.getElementById('fPatient').value.trim(),
|
||||
notes: document.getElementById('fNotes').value.trim(),
|
||||
status: document.getElementById('fStatus').value,
|
||||
};
|
||||
if (!payload.patient_name) { showToast('Patientenname fehlt', true); return; }
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await apiFetch(`/appointments/${editingId}`, { method: 'PUT', body: payload });
|
||||
showToast('Termin gespeichert');
|
||||
} else {
|
||||
await apiFetch('/appointments', { method: 'POST', body: payload });
|
||||
showToast('Termin erstellt');
|
||||
}
|
||||
bootstrap.Modal.getInstance(document.getElementById('apptModal')).hide();
|
||||
await loadDay();
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
}
|
||||
|
||||
async function deleteAppt() {
|
||||
if (!confirm('Termin wirklich löschen?')) return;
|
||||
try {
|
||||
await apiFetch(`/appointments/${editingId}`, { method: 'DELETE' });
|
||||
showToast('Termin gelöscht');
|
||||
bootstrap.Modal.getInstance(document.getElementById('apptModal')).hide();
|
||||
await loadDay();
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
}
|
||||
|
||||
/* ── Events ─────────────────────────────────────────────────────────────── */
|
||||
function setupEvents() {
|
||||
document.getElementById('btnPrev').addEventListener('click', () => {
|
||||
currentDate = addDays(currentDate, -1); loadDay();
|
||||
});
|
||||
document.getElementById('btnNext').addEventListener('click', () => {
|
||||
currentDate = addDays(currentDate, 1); loadDay();
|
||||
});
|
||||
document.getElementById('btnToday').addEventListener('click', () => {
|
||||
currentDate = new Date(); loadDay();
|
||||
});
|
||||
document.getElementById('btnNewAppt').addEventListener('click', () =>
|
||||
openApptModal(null)
|
||||
);
|
||||
document.getElementById('btnApptSave').addEventListener('click', saveAppt);
|
||||
document.getElementById('btnApptDelete').addEventListener('click', deleteAppt);
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (document.querySelector('.modal.show')) return;
|
||||
if (e.key === 'ArrowLeft') { currentDate = addDays(currentDate, -1); loadDay(); }
|
||||
if (e.key === 'ArrowRight') { currentDate = addDays(currentDate, 1); loadDay(); }
|
||||
if (e.key === 't') { currentDate = new Date(); loadDay(); }
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/* ── Start ──────────────────────────────────────────────────────────────── */
|
||||
buildTimeAxis();
|
||||
renderDocList();
|
||||
setupEvents();
|
||||
initPatientAutocomplete();
|
||||
loadDay()
|
||||
.then(() => {
|
||||
// Scroll zu 07:00 (Slot 28)
|
||||
document.getElementById('calScroll').scrollTop = 28 * 40 - 60;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast('Verbindung zum Server fehlgeschlagen', true);
|
||||
});
|
||||
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@ -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();
|
||||
|
||||
@ -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
|
||||
});
|
||||
@ -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`;
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,14 +1,24 @@
|
||||
/**
|
||||
* public/js/patient-select.js
|
||||
*
|
||||
* Ersetzt den inline onchange="this.form.submit()" Handler
|
||||
* an den Patienten-Radiobuttons (CSP-sicher).
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.patient-radio').forEach(function (radio) {
|
||||
radio.addEventListener('change', function () {
|
||||
var form = this.closest('form');
|
||||
if (form) form.submit();
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const radios = document.querySelectorAll(".patient-radio");
|
||||
|
||||
if (!radios || radios.length === 0) return;
|
||||
|
||||
radios.forEach((radio) => {
|
||||
radio.addEventListener("change", async () => {
|
||||
const patientId = radio.value;
|
||||
|
||||
try {
|
||||
await fetch("/patients/select", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ patientId }),
|
||||
});
|
||||
|
||||
// ✅ neu laden -> Sidebar wird neu gerendert & Bearbeiten wird aktiv
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
console.error("❌ patient-select Fehler:", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -1,24 +0,0 @@
|
||||
/**
|
||||
* public/js/sidebar-lock.js
|
||||
*
|
||||
* Fängt Klicks auf gesperrte Menüpunkte ab und zeigt einen
|
||||
* Bootstrap-Toast statt auf eine Fehlerseite zu navigieren.
|
||||
*
|
||||
* Voraussetzung: bootstrap.bundle.min.js ist geladen.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const toastEl = document.getElementById('lockToast');
|
||||
const toastMsg = document.getElementById('lockToastMsg');
|
||||
|
||||
if (!toastEl || !toastMsg) return;
|
||||
|
||||
const toast = bootstrap.Toast.getOrCreateInstance(toastEl, { delay: 3000 });
|
||||
|
||||
document.querySelectorAll('.nav-item[data-locked]').forEach(function (link) {
|
||||
link.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
toastMsg.textContent = link.dataset.locked;
|
||||
toast.show();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -5,9 +5,6 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { exec } = require("child_process");
|
||||
const multer = require("multer");
|
||||
const { NodeSSH } = require("node-ssh");
|
||||
const uploadLogo = require("../middleware/uploadLogo");
|
||||
|
||||
|
||||
// ✅ Upload Ordner für Restore Dumps
|
||||
const upload = multer({ dest: path.join(__dirname, "..", "uploads_tmp") });
|
||||
@ -24,7 +21,7 @@ const {
|
||||
updateUser,
|
||||
} = require("../controllers/admin.controller");
|
||||
|
||||
const { requireArztOrMitarbeiter, requireAdmin } = require("../middleware/auth.middleware");
|
||||
const { requireArzt, requireAdmin } = require("../middleware/auth.middleware");
|
||||
|
||||
// ✅ config.enc Manager
|
||||
const { loadConfig, saveConfig } = require("../config-manager");
|
||||
@ -32,13 +29,6 @@ const { loadConfig, saveConfig } = require("../config-manager");
|
||||
// ✅ DB (für resetPool)
|
||||
const db = require("../db");
|
||||
|
||||
// ✅ Firmendaten
|
||||
const {
|
||||
getCompanySettings,
|
||||
saveCompanySettings
|
||||
} = require("../controllers/companySettings.controller");
|
||||
|
||||
|
||||
/* ==========================
|
||||
✅ VERWALTUNG (NUR ADMIN)
|
||||
========================== */
|
||||
@ -319,37 +309,33 @@ router.post("/database", requireAdmin, async (req, res) => {
|
||||
/* ==========================
|
||||
✅ BACKUP (NUR ADMIN)
|
||||
========================== */
|
||||
router.post("/database/backup", requireAdmin, async (req, res) => {
|
||||
router.post("/database/backup", requireAdmin, (req, res) => {
|
||||
// ✅ Flash Safe (funktioniert auch ohne req.flash)
|
||||
function flashSafe(type, msg) {
|
||||
if (typeof req.flash === "function") return req.flash(type, msg);
|
||||
if (typeof req.flash === "function") {
|
||||
req.flash(type, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
req.session.flash = req.session.flash || [];
|
||||
req.session.flash.push({ type, message: msg });
|
||||
|
||||
console.log(`[FLASH-${type}]`, msg);
|
||||
}
|
||||
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
|
||||
if (!cfg?.db) {
|
||||
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
const { host, port, user, password, name } = cfg.db;
|
||||
const { host, user, password, name } = cfg.db;
|
||||
|
||||
// ✅ Programmserver Backup Dir
|
||||
const backupDir = path.join(__dirname, "..", "backups");
|
||||
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir);
|
||||
|
||||
// ✅ SSH Ziel (DB-Server)
|
||||
const sshHost = process.env.DBSERVER_HOST;
|
||||
const sshUser = process.env.DBSERVER_USER;
|
||||
const sshPort = Number(process.env.DBSERVER_PORT || 22);
|
||||
|
||||
if (!sshHost || !sshUser) {
|
||||
flashSafe("danger", "❌ SSH Config fehlt (DBSERVER_HOST / DBSERVER_USER).");
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/T/, "_")
|
||||
@ -357,134 +343,120 @@ router.post("/database/backup", requireAdmin, async (req, res) => {
|
||||
.split(".")[0];
|
||||
|
||||
const fileName = `${name}_${stamp}.sql`;
|
||||
const filePath = path.join(backupDir, fileName);
|
||||
|
||||
// ✅ Datei wird zuerst auf DB-Server erstellt (tmp)
|
||||
const remoteTmpPath = `/tmp/${fileName}`;
|
||||
// ✅ mysqldump.exe im Root
|
||||
const mysqldumpPath = path.join(__dirname, "..", "mysqldump.exe");
|
||||
|
||||
// ✅ Datei wird dann lokal (Programmserver) gespeichert
|
||||
const localPath = path.join(backupDir, fileName);
|
||||
// ✅ plugin Ordner im Root (muss existieren)
|
||||
const pluginDir = path.join(__dirname, "..", "plugin");
|
||||
|
||||
const ssh = new NodeSSH();
|
||||
await ssh.connect({
|
||||
host: sshHost,
|
||||
username: sshUser,
|
||||
port: sshPort,
|
||||
privateKeyPath: "/home/cay/.ssh/id_ed25519",
|
||||
});
|
||||
|
||||
// ✅ 1) Dump auf DB-Server erstellen
|
||||
const dumpCmd =
|
||||
`mysqldump -h "${host}" -P "${port || 3306}" -u "${user}" -p'${password}' "${name}" > "${remoteTmpPath}"`;
|
||||
|
||||
const dumpRes = await ssh.execCommand(dumpCmd);
|
||||
|
||||
if (dumpRes.code !== 0) {
|
||||
ssh.dispose();
|
||||
flashSafe("danger", "❌ Backup fehlgeschlagen: " + (dumpRes.stderr || "mysqldump Fehler"));
|
||||
if (!fs.existsSync(mysqldumpPath)) {
|
||||
flashSafe("danger", "❌ mysqldump.exe nicht gefunden: " + mysqldumpPath);
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
// ✅ 2) Dump Datei vom DB-Server auf Programmserver kopieren
|
||||
await ssh.getFile(localPath, remoteTmpPath);
|
||||
|
||||
// ✅ 3) Temp Datei auf DB-Server löschen
|
||||
await ssh.execCommand(`rm -f "${remoteTmpPath}"`);
|
||||
|
||||
ssh.dispose();
|
||||
|
||||
flashSafe("success", `✅ Backup gespeichert (Programmserver): ${fileName}`);
|
||||
if (!fs.existsSync(pluginDir)) {
|
||||
flashSafe("danger", "❌ plugin Ordner nicht gefunden: " + pluginDir);
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
const cmd = `"${mysqldumpPath}" --plugin-dir="${pluginDir}" -h ${host} -u ${user} -p${password} ${name} > "${filePath}"`;
|
||||
|
||||
exec(cmd, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error("❌ BACKUP ERROR:", error);
|
||||
console.error("STDERR:", stderr);
|
||||
|
||||
flashSafe(
|
||||
"danger",
|
||||
"❌ Backup fehlgeschlagen: " + (stderr || error.message),
|
||||
);
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
flashSafe("success", `✅ Backup erstellt: ${fileName}`);
|
||||
return res.redirect("/admin/database");
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("❌ BACKUP SSH ERROR:", err);
|
||||
console.error("❌ BACKUP ERROR:", err);
|
||||
flashSafe("danger", "❌ Backup fehlgeschlagen: " + err.message);
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/* ==========================
|
||||
✅ RESTORE (NUR ADMIN)
|
||||
========================== */
|
||||
router.post("/database/restore", requireAdmin, async (req, res) => {
|
||||
router.post("/database/restore", requireAdmin, (req, res) => {
|
||||
function flashSafe(type, msg) {
|
||||
if (typeof req.flash === "function") return req.flash(type, msg);
|
||||
if (typeof req.flash === "function") {
|
||||
req.flash(type, msg);
|
||||
return;
|
||||
}
|
||||
req.session.flash = req.session.flash || [];
|
||||
req.session.flash.push({ type, message: msg });
|
||||
console.log(`[FLASH-${type}]`, msg);
|
||||
}
|
||||
|
||||
const ssh = new NodeSSH();
|
||||
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
|
||||
if (!cfg?.db) {
|
||||
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
const { host, port, user, password, name } = cfg.db;
|
||||
|
||||
const backupFile = req.body.backupFile;
|
||||
if (!backupFile) {
|
||||
flashSafe("danger", "❌ Kein Backup ausgewählt.");
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
if (backupFile.includes("..") || backupFile.includes("/") || backupFile.includes("\\")) {
|
||||
flashSafe("danger", "❌ Ungültiger Dateiname.");
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
const { host, user, password, name } = cfg.db;
|
||||
|
||||
const backupDir = path.join(__dirname, "..", "backups");
|
||||
const localPath = path.join(backupDir, backupFile);
|
||||
const selectedFile = req.body.backupFile;
|
||||
|
||||
if (!fs.existsSync(localPath)) {
|
||||
flashSafe("danger", "❌ Datei nicht gefunden (Programmserver): " + backupFile);
|
||||
if (!selectedFile) {
|
||||
flashSafe("danger", "❌ Bitte ein Backup auswählen.");
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
const sshHost = process.env.DBSERVER_HOST;
|
||||
const sshUser = process.env.DBSERVER_USER;
|
||||
const sshPort = Number(process.env.DBSERVER_PORT || 22);
|
||||
const fullPath = path.join(backupDir, selectedFile);
|
||||
|
||||
if (!sshHost || !sshUser) {
|
||||
flashSafe("danger", "❌ SSH Config fehlt (DBSERVER_HOST / DBSERVER_USER).");
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
flashSafe("danger", "❌ Backup Datei nicht gefunden: " + selectedFile);
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
const remoteTmpPath = `/tmp/${backupFile}`;
|
||||
// ✅ mysql.exe im Root
|
||||
const mysqlPath = path.join(__dirname, "..", "mysql.exe");
|
||||
const pluginDir = path.join(__dirname, "..", "plugin");
|
||||
|
||||
await ssh.connect({
|
||||
host: sshHost,
|
||||
username: sshUser,
|
||||
port: sshPort,
|
||||
privateKeyPath: "/home/cay/.ssh/id_ed25519",
|
||||
if (!fs.existsSync(mysqlPath)) {
|
||||
flashSafe("danger", "❌ mysql.exe nicht gefunden im Root: " + mysqlPath);
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
const cmd = `"${mysqlPath}" --plugin-dir="${pluginDir}" -h ${host} -u ${user} -p${password} ${name} < "${fullPath}"`;
|
||||
|
||||
exec(cmd, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error("❌ RESTORE ERROR:", error);
|
||||
console.error("STDERR:", stderr);
|
||||
|
||||
flashSafe(
|
||||
"danger",
|
||||
"❌ Restore fehlgeschlagen: " + (stderr || error.message),
|
||||
);
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
flashSafe(
|
||||
"success",
|
||||
"✅ Restore erfolgreich abgeschlossen: " + selectedFile,
|
||||
);
|
||||
return res.redirect("/admin/database");
|
||||
});
|
||||
|
||||
await ssh.putFile(localPath, remoteTmpPath);
|
||||
|
||||
const restoreCmd =
|
||||
`mysql -h "${host}" -P "${port || 3306}" -u "${user}" -p'${password}' "${name}" < "${remoteTmpPath}"`;
|
||||
|
||||
const restoreRes = await ssh.execCommand(restoreCmd);
|
||||
|
||||
await ssh.execCommand(`rm -f "${remoteTmpPath}"`);
|
||||
|
||||
if (restoreRes.code !== 0) {
|
||||
flashSafe("danger", "❌ Restore fehlgeschlagen: " + (restoreRes.stderr || "mysql Fehler"));
|
||||
return res.redirect("/admin/database");
|
||||
}
|
||||
|
||||
flashSafe("success", `✅ Restore erfolgreich: ${backupFile}`);
|
||||
return res.redirect("/admin/database");
|
||||
} catch (err) {
|
||||
console.error("❌ RESTORE SSH ERROR:", err);
|
||||
console.error("❌ RESTORE ERROR:", err);
|
||||
flashSafe("danger", "❌ Restore fehlgeschlagen: " + err.message);
|
||||
return res.redirect("/admin/database");
|
||||
} finally {
|
||||
try {
|
||||
ssh.dispose();
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
@ -493,20 +465,4 @@ router.post("/database/restore", requireAdmin, async (req, res) => {
|
||||
========================== */
|
||||
router.get("/invoices", requireAdmin, showInvoiceOverview);
|
||||
|
||||
/* ==========================
|
||||
✅ Firmendaten
|
||||
========================== */
|
||||
router.get(
|
||||
"/company-settings",
|
||||
requireAdmin,
|
||||
getCompanySettings
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/company-settings",
|
||||
requireAdmin,
|
||||
uploadLogo.single("logo"),
|
||||
saveCompanySettings
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
/**
|
||||
* routes/calendar.routes.js
|
||||
*
|
||||
* Einbinden in app.js:
|
||||
* const calendarRoutes = require("./routes/calendar.routes");
|
||||
* app.use("/calendar", calendarRoutes);
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { requireArztOrMitarbeiter } = require("../middleware/auth.middleware");
|
||||
const ctrl = require("../controllers/calendar.controller");
|
||||
|
||||
// ── Seite ────────────────────────────────────────────────────────────────────
|
||||
router.get("/", requireArztOrMitarbeiter, ctrl.index);
|
||||
|
||||
// ── Appointments API ─────────────────────────────────────────────────────────
|
||||
router.get( "/api/appointments/:date", requireArztOrMitarbeiter, ctrl.getAppointments);
|
||||
router.post("/api/appointments", requireArztOrMitarbeiter, ctrl.createAppointment);
|
||||
router.put( "/api/appointments/:id", requireArztOrMitarbeiter, ctrl.updateAppointment);
|
||||
router.patch("/api/appointments/:id/status", requireArztOrMitarbeiter, ctrl.patchStatus);
|
||||
router.delete("/api/appointments/:id", requireArztOrMitarbeiter, ctrl.deleteAppointment);
|
||||
|
||||
// ── Patienten-Suche (Autocomplete) ───────────────────────────────────────────
|
||||
router.get("/api/patients/search", requireArztOrMitarbeiter, ctrl.searchPatients);
|
||||
|
||||
// ── Feiertage API ─────────────────────────────────────────────────────────────
|
||||
router.get("/api/holidays/:year", requireArztOrMitarbeiter, ctrl.getHolidays);
|
||||
|
||||
// ── Arzt-Farbe ────────────────────────────────────────────────────────────────
|
||||
router.patch("/api/doctors/:id/color", requireArztOrMitarbeiter, ctrl.updateDoctorColor);
|
||||
|
||||
module.exports = router;
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -1,40 +1,8 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
|
||||
const { requireArztOrMitarbeiter } = require("../middleware/auth.middleware");
|
||||
const { requireArzt } = require("../middleware/auth.middleware");
|
||||
const { createInvoicePdf } = require("../controllers/invoicePdf.controller");
|
||||
const {
|
||||
openInvoices,
|
||||
markAsPaid,
|
||||
cancelInvoice,
|
||||
cancelledInvoices,
|
||||
paidInvoices,
|
||||
createCreditNote,
|
||||
creditOverview,
|
||||
} = require("../controllers/invoice.controller");
|
||||
|
||||
// ✅ NEU: Offene Rechnungen anzeigen
|
||||
router.get("/open", requireArztOrMitarbeiter, openInvoices);
|
||||
|
||||
// Bezahlt
|
||||
router.post("/:id/pay", requireArztOrMitarbeiter, markAsPaid);
|
||||
|
||||
// Storno
|
||||
router.post("/:id/cancel", requireArztOrMitarbeiter, cancelInvoice);
|
||||
|
||||
// Bestehend
|
||||
router.post("/patients/:id/create-invoice", requireArztOrMitarbeiter, createInvoicePdf);
|
||||
|
||||
// Stornierte Rechnungen mit Jahr
|
||||
router.get("/cancelled", requireArztOrMitarbeiter, cancelledInvoices);
|
||||
|
||||
// Bezahlte Rechnungen
|
||||
router.get("/paid", requireArztOrMitarbeiter, paidInvoices);
|
||||
|
||||
// Gutschrift erstellen
|
||||
router.post("/:id/credit", requireArztOrMitarbeiter, createCreditNote);
|
||||
|
||||
// Gutschriften-Übersicht
|
||||
router.get("/credit-overview", requireArztOrMitarbeiter, creditOverview);
|
||||
router.post("/patients/:id/create-invoice", requireArzt, createInvoicePdf);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
|
||||
const { requireArztOrMitarbeiter } = require("../middleware/auth.middleware");
|
||||
const { requireArzt } = require("../middleware/auth.middleware");
|
||||
const {
|
||||
addMedication,
|
||||
endMedication,
|
||||
deleteMedication,
|
||||
} = require("../controllers/patientMedication.controller");
|
||||
|
||||
router.post("/:id/medications", requireArztOrMitarbeiter, addMedication);
|
||||
router.post("/patient-medications/end/:id", requireArztOrMitarbeiter, endMedication);
|
||||
router.post("/patient-medications/delete/:id", requireArztOrMitarbeiter, deleteMedication);
|
||||
router.post("/:id/medications", requireArzt, addMedication);
|
||||
router.post("/patient-medications/end/:id", requireArzt, endMedication);
|
||||
router.post("/patient-medications/delete/:id", requireArzt, deleteMedication);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
|
||||
const { requireLogin, requireArztOrMitarbeiter } = require("../middleware/auth.middleware");
|
||||
const { requireLogin, requireArzt } = require("../middleware/auth.middleware");
|
||||
const {
|
||||
addPatientService,
|
||||
deletePatientService,
|
||||
@ -10,10 +10,10 @@ const {
|
||||
} = require("../controllers/patientService.controller");
|
||||
|
||||
router.post("/:id/services", requireLogin, addPatientService);
|
||||
router.post("/services/delete/:id", requireArztOrMitarbeiter, deletePatientService);
|
||||
router.post("/services/delete/:id", requireArzt, deletePatientService);
|
||||
router.post(
|
||||
"/services/update-price/:id",
|
||||
requireArztOrMitarbeiter,
|
||||
requireArzt,
|
||||
updatePatientServicePrice,
|
||||
);
|
||||
router.post("/services/update-quantity/:id", updatePatientServiceQuantity);
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { requireArztOrMitarbeiter } = require("../middleware/auth.middleware");
|
||||
const { statusReport } = require("../controllers/report.controller");
|
||||
|
||||
router.get("/", requireArztOrMitarbeiter, statusReport);
|
||||
|
||||
module.exports = router;
|
||||
@ -1,7 +1,7 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
|
||||
const { requireLogin, requireArztOrMitarbeiter } = require("../middleware/auth.middleware");
|
||||
const { requireLogin, requireArzt } = require("../middleware/auth.middleware");
|
||||
const {
|
||||
listServices,
|
||||
showCreateService,
|
||||
@ -14,12 +14,12 @@ const {
|
||||
} = require("../controllers/service.controller");
|
||||
|
||||
router.get("/", requireLogin, listServicesAdmin);
|
||||
router.get("/", requireArztOrMitarbeiter, listServices);
|
||||
router.get("/create", requireArztOrMitarbeiter, showCreateService);
|
||||
router.post("/create", requireArztOrMitarbeiter, createService);
|
||||
router.post("/:id/update-price", requireArztOrMitarbeiter, updateServicePrice);
|
||||
router.post("/:id/toggle", requireArztOrMitarbeiter, toggleService);
|
||||
router.get("/", requireArzt, listServices);
|
||||
router.get("/create", requireArzt, showCreateService);
|
||||
router.post("/create", requireArzt, createService);
|
||||
router.post("/:id/update-price", requireArzt, updateServicePrice);
|
||||
router.post("/:id/toggle", requireArzt, toggleService);
|
||||
router.get("/open", requireLogin, listOpenServices);
|
||||
router.get("/logs", requireArztOrMitarbeiter, showServiceLogs);
|
||||
router.get("/logs", requireArzt, showServiceLogs);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -1,181 +0,0 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const mysql = require("mysql2/promise");
|
||||
|
||||
// ✅ nutzt deinen bestehenden config-manager (NICHT utils/config!)
|
||||
const { configExists, loadConfig, saveConfig } = require("../config-manager");
|
||||
|
||||
// ✅ DB + Session Reset (wie in deiner app.js)
|
||||
const db = require("../db");
|
||||
const { resetSessionStore } = require("../config/session");
|
||||
|
||||
/**
|
||||
* Setup ist immer erreichbar – auch wenn config.enc schon existiert.
|
||||
* So kann die DB-Verbindung jederzeit korrigiert werden.
|
||||
* Schutz: Nur wenn DB bereits erreichbar ist UND User eingeloggt ist → blockieren.
|
||||
*/
|
||||
function blockIfInstalled(req, res, next) {
|
||||
// Immer durchlassen – Setup muss auch zur Korrektur nutzbar sein
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Form anzeigen – vorhandene Werte aus config.enc als Defaults laden
|
||||
*/
|
||||
router.get("/", blockIfInstalled, (req, res) => {
|
||||
// Bestehende Config als Vorausfüllung laden (Passwort bleibt leer)
|
||||
let existing = {};
|
||||
try {
|
||||
if (configExists()) {
|
||||
const cfg = loadConfig();
|
||||
existing = cfg?.db || {};
|
||||
}
|
||||
} catch (e) {
|
||||
existing = {};
|
||||
}
|
||||
|
||||
return res.render("setup/index", {
|
||||
title: configExists() ? "DB-Verbindung ändern" : "Erstinstallation",
|
||||
isUpdate: configExists(),
|
||||
defaults: {
|
||||
host: existing.host || "85.215.63.122",
|
||||
port: existing.port || 3306,
|
||||
user: existing.user || "",
|
||||
password: "", // Passwort aus Sicherheitsgründen nie vorausfüllen
|
||||
name: existing.name || "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Passwort auflösen: wenn leer → altes Passwort aus config.enc nehmen
|
||||
*/
|
||||
function resolvePassword(inputPassword) {
|
||||
if (inputPassword && inputPassword.trim() !== "") {
|
||||
return inputPassword;
|
||||
}
|
||||
// Passwort-Feld leer → altes Passwort aus bestehender Config beibehalten
|
||||
try {
|
||||
if (configExists()) {
|
||||
const old = loadConfig();
|
||||
return old?.db?.password || "";
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Verbindung testen (AJAX)
|
||||
*/
|
||||
router.post("/test", blockIfInstalled, async (req, res) => {
|
||||
try {
|
||||
const { host, port, user, name } = req.body;
|
||||
const password = resolvePassword(req.body.password);
|
||||
|
||||
if (!host || !user || !name) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
message: "Bitte Host, Benutzer und Datenbankname ausfüllen.",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await mysql.createConnection({
|
||||
host: host.trim(),
|
||||
port: Number(port || 3306),
|
||||
user: user.trim(),
|
||||
password,
|
||||
database: name.trim(),
|
||||
connectTimeout: 6000,
|
||||
});
|
||||
|
||||
await connection.query("SELECT 1");
|
||||
await connection.end();
|
||||
|
||||
return res.json({ ok: true, message: "✅ Verbindung erfolgreich!" });
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
message: "❌ Verbindung fehlgeschlagen: " + err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* ✅ Setup speichern (DB Daten in config.enc)
|
||||
*/
|
||||
router.post("/", blockIfInstalled, async (req, res) => {
|
||||
try {
|
||||
const { host, port, user, name } = req.body;
|
||||
// Passwort: leer = altes Passwort beibehalten
|
||||
const password = resolvePassword(req.body.password);
|
||||
|
||||
if (!host || !user || !name) {
|
||||
req.session.flash = req.session.flash || [];
|
||||
req.session.flash.push({
|
||||
type: "danger",
|
||||
message: "❌ Bitte Host, Benutzer und Datenbankname ausfüllen.",
|
||||
});
|
||||
return res.redirect("/setup");
|
||||
}
|
||||
|
||||
// ✅ Verbindung testen bevor speichern
|
||||
let connection;
|
||||
try {
|
||||
connection = await mysql.createConnection({
|
||||
host: host.trim(),
|
||||
port: Number(port || 3306),
|
||||
user: user.trim(),
|
||||
password,
|
||||
database: name.trim(),
|
||||
connectTimeout: 6000,
|
||||
});
|
||||
await connection.query("SELECT 1");
|
||||
await connection.end();
|
||||
} catch (connErr) {
|
||||
req.session.flash = req.session.flash || [];
|
||||
req.session.flash.push({
|
||||
type: "danger",
|
||||
message: "❌ DB-Verbindung fehlgeschlagen: " + connErr.message,
|
||||
});
|
||||
return res.redirect("/setup");
|
||||
}
|
||||
|
||||
// ✅ In config.enc speichern
|
||||
saveConfig({
|
||||
db: {
|
||||
host: host.trim(),
|
||||
port: Number(port || 3306),
|
||||
user: user.trim(),
|
||||
password,
|
||||
name: name.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ DB Pool neu initialisieren (neue Config sofort aktiv)
|
||||
if (typeof db.resetPool === "function") {
|
||||
db.resetPool();
|
||||
}
|
||||
|
||||
// ✅ Session Store neu initialisieren
|
||||
resetSessionStore();
|
||||
|
||||
req.session.flash = req.session.flash || [];
|
||||
req.session.flash.push({
|
||||
type: "success",
|
||||
message: "✅ DB-Verbindung gespeichert. Du kannst dich jetzt einloggen.",
|
||||
});
|
||||
|
||||
return res.redirect("/login");
|
||||
} catch (err) {
|
||||
req.session.flash = req.session.flash || [];
|
||||
req.session.flash.push({
|
||||
type: "danger",
|
||||
message: "❌ Setup fehlgeschlagen: " + err.message,
|
||||
});
|
||||
return res.redirect("/setup");
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDgIWJidh
|
||||
WoA1VkDl2ScVZmAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIM0AYnTTnboA6hm+
|
||||
zQcoG121zH1s0Jv2eOAuk+BS1UbfAAAAoJyvcKBc26mTti/T2iAbdEATM15fgtMs9Nlsi4
|
||||
itZSVPfQ7OdYY2QMQpaiw0whrcQIdWlxUrbcYpmwPj7DATYUP00szFN2KbdRdsarfPqHFQ
|
||||
zi8P2p9bj7/naRyLIENsfTj8JERxItd4xHvwL01keBmTty2hnprXcZHw4SkyIOIZyigTgS
|
||||
7gkivG/j9jIOn4g0p0J1eaHdFNvJScpIs19SI=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0AYnTTnboA6hm+zQcoG121zH1s0Jv2eOAuk+BS1Ubf cay@Cay-Workstation-VM
|
||||
@ -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 };
|
||||
@ -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;
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
@ -1,51 +1,70 @@
|
||||
<%- include("../partials/page-header", {
|
||||
user,
|
||||
title: t.adminSidebar.invocieoverview,
|
||||
title: "Rechnungsübersicht",
|
||||
subtitle: "",
|
||||
showUserName: true
|
||||
}) %>
|
||||
|
||||
<div class="content p-4">
|
||||
<div class="container-fluid mt-2">
|
||||
|
||||
<!-- FILTER: JAHR VON / BIS -->
|
||||
<div class="container-fluid mt-2">
|
||||
<form method="get" class="row g-2 mb-4">
|
||||
<div class="col-auto">
|
||||
<input type="number" name="fromYear" class="form-control"
|
||||
placeholder="<%= t.invoiceAdmin.fromyear %>"
|
||||
value="<%= fromYear %>" />
|
||||
<input
|
||||
type="number"
|
||||
name="fromYear"
|
||||
class="form-control"
|
||||
placeholder="Von Jahr"
|
||||
value="<%= fromYear %>"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<input type="number" name="toYear" class="form-control"
|
||||
placeholder="<%= t.invoiceAdmin.toyear %>"
|
||||
value="<%= toYear %>" />
|
||||
<input
|
||||
type="number"
|
||||
name="toYear"
|
||||
class="form-control"
|
||||
placeholder="Bis Jahr"
|
||||
value="<%= toYear %>"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary"><%= t.global.filter %></button>
|
||||
<button class="btn btn-outline-secondary">Filtern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- GRID – 4 SPALTEN -->
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- Jahresumsatz -->
|
||||
<!-- JAHRESUMSATZ -->
|
||||
<div class="col-xl-3 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header fw-semibold"><%= t.global.yearcash %></div>
|
||||
<div class="card-header fw-semibold">Jahresumsatz</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t.global.year %></th>
|
||||
<th>Jahr</th>
|
||||
<th class="text-end">€</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (!yearly || yearly.length === 0) { %>
|
||||
<tr><td colspan="2" class="text-center text-muted"><%= t.global.nodata %></td></tr>
|
||||
<% if (yearly.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="2" class="text-center text-muted">
|
||||
Keine Daten
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% (yearly || []).forEach(y => { %>
|
||||
|
||||
<% yearly.forEach(y => { %>
|
||||
<tr>
|
||||
<td><%= y.year %></td>
|
||||
<td class="text-end fw-semibold"><%= Number(y.total).toFixed(2) %></td>
|
||||
<td class="text-end fw-semibold">
|
||||
<%= Number(y.total).toFixed(2) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
@ -54,28 +73,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quartalsumsatz -->
|
||||
<!-- QUARTALSUMSATZ -->
|
||||
<div class="col-xl-3 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header fw-semibold"><%= t.global.quartalcash %></div>
|
||||
<div class="card-header fw-semibold">Quartalsumsatz</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t.global.year %></th>
|
||||
<th>Jahr</th>
|
||||
<th>Q</th>
|
||||
<th class="text-end">€</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (!quarterly || quarterly.length === 0) { %>
|
||||
<tr><td colspan="3" class="text-center text-muted"><%= t.global.nodata %></td></tr>
|
||||
<% if (quarterly.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted">
|
||||
Keine Daten
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% (quarterly || []).forEach(q => { %>
|
||||
|
||||
<% quarterly.forEach(q => { %>
|
||||
<tr>
|
||||
<td><%= q.year %></td>
|
||||
<td>Q<%= q.quarter %></td>
|
||||
<td class="text-end fw-semibold"><%= Number(q.total).toFixed(2) %></td>
|
||||
<td class="text-end fw-semibold">
|
||||
<%= Number(q.total).toFixed(2) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
@ -84,26 +110,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monatsumsatz -->
|
||||
<!-- MONATSUMSATZ -->
|
||||
<div class="col-xl-3 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header fw-semibold"><%= t.global.monthcash %></div>
|
||||
<div class="card-header fw-semibold">Monatsumsatz</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t.global.month %></th>
|
||||
<th>Monat</th>
|
||||
<th class="text-end">€</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (!monthly || monthly.length === 0) { %>
|
||||
<tr><td colspan="2" class="text-center text-muted"><%= t.global.nodata %></td></tr>
|
||||
<% if (monthly.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="2" class="text-center text-muted">
|
||||
Keine Daten
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% (monthly || []).forEach(m => { %>
|
||||
|
||||
<% monthly.forEach(m => { %>
|
||||
<tr>
|
||||
<td><%= m.month %></td>
|
||||
<td class="text-end fw-semibold"><%= Number(m.total).toFixed(2) %></td>
|
||||
<td class="text-end fw-semibold">
|
||||
<%= Number(m.total).toFixed(2) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
@ -112,44 +145,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Umsatz pro Patient -->
|
||||
<!-- UMSATZ PRO PATIENT -->
|
||||
<div class="col-xl-3 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header fw-semibold"><%= t.global.patientcash %></div>
|
||||
<div class="card-header fw-semibold">Umsatz pro Patient</div>
|
||||
<div class="card-body p-2">
|
||||
|
||||
<!-- Suche -->
|
||||
<form method="get" class="mb-2 d-flex gap-2">
|
||||
<input type="hidden" name="fromYear" value="<%= fromYear %>" />
|
||||
<input type="hidden" name="toYear" value="<%= toYear %>" />
|
||||
<input type="text" name="q" value="<%= search %>"
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="<%= search %>"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="<%= t.invoiceAdmin.searchpatient %>" />
|
||||
<button class="btn btn-sm btn-outline-primary"><%= t.global.search %></button>
|
||||
<a href="/admin/invoices?fromYear=<%= fromYear %>&toYear=<%= toYear %>"
|
||||
class="btn btn-sm btn-outline-secondary"><%= t.global.reset %></a>
|
||||
placeholder="Patient suchen..."
|
||||
/>
|
||||
|
||||
<button class="btn btn-sm btn-outline-primary">Suchen</button>
|
||||
|
||||
<a
|
||||
href="/admin/invoices?fromYear=<%= fromYear %>&toYear=<%= toYear %>"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
>
|
||||
Reset
|
||||
</a>
|
||||
</form>
|
||||
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t.global.patient %></th>
|
||||
<th>Patient</th>
|
||||
<th class="text-end">€</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (!patients || patients.length === 0) { %>
|
||||
<tr><td colspan="2" class="text-center text-muted"><%= t.global.nodata %></td></tr>
|
||||
<% if (patients.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="2" class="text-center text-muted">
|
||||
Keine Daten
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% (patients || []).forEach(p => { %>
|
||||
|
||||
<% patients.forEach(p => { %>
|
||||
<tr>
|
||||
<td><%= p.patient %></td>
|
||||
<td class="text-end fw-semibold"><%= Number(p.total).toFixed(2) %></td>
|
||||
<td class="text-end fw-semibold">
|
||||
<%= Number(p.total).toFixed(2) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -1,138 +1,132 @@
|
||||
<%- include("../partials/page-header", {
|
||||
user,
|
||||
title: t.companySettings.title,
|
||||
subtitle: "",
|
||||
showUserName: true
|
||||
}) %>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Firmendaten</title>
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
<div class="content p-4">
|
||||
|
||||
<%- include("../partials/flash") %>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
|
||||
<h5 class="mb-4">
|
||||
<i class="bi bi-building"></i>
|
||||
<%= t.companySettings.title %>
|
||||
</h5>
|
||||
<div class="container mt-4">
|
||||
<h3 class="mb-4">🏢 Firmendaten</h3>
|
||||
|
||||
<form method="POST" action="/admin/company-settings" enctype="multipart/form-data">
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.companySettings.companyname %></label>
|
||||
<label class="form-label">Firmenname</label>
|
||||
<input class="form-control" name="company_name"
|
||||
value="<%= settings.company_name || '' %>" required>
|
||||
value="<%= company.company_name || '' %>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.companySettings.legalform %></label>
|
||||
<label class="form-label">Rechtsform</label>
|
||||
<input class="form-control" name="company_legal_form"
|
||||
value="<%= settings.company_legal_form || '' %>">
|
||||
value="<%= company.company_legal_form || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.companySettings.owner %></label>
|
||||
<label class="form-label">Inhaber / Geschäftsführer</label>
|
||||
<input class="form-control" name="company_owner"
|
||||
value="<%= settings.company_owner || '' %>">
|
||||
value="<%= company.company_owner || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.companySettings.email %></label>
|
||||
<label class="form-label">E-Mail</label>
|
||||
<input class="form-control" name="email"
|
||||
value="<%= settings.email || '' %>">
|
||||
value="<%= company.email || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<label class="form-label"><%= t.companySettings.street %></label>
|
||||
<label class="form-label">Straße</label>
|
||||
<input class="form-control" name="street"
|
||||
value="<%= settings.street || '' %>">
|
||||
value="<%= company.street || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><%= t.companySettings.housenumber %></label>
|
||||
<label class="form-label">Hausnummer</label>
|
||||
<input class="form-control" name="house_number"
|
||||
value="<%= settings.house_number || '' %>">
|
||||
value="<%= company.house_number || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><%= t.companySettings.zip %></label>
|
||||
<label class="form-label">PLZ</label>
|
||||
<input class="form-control" name="postal_code"
|
||||
value="<%= settings.postal_code || '' %>">
|
||||
value="<%= company.postal_code || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<label class="form-label"><%= t.companySettings.city %></label>
|
||||
<label class="form-label">Ort</label>
|
||||
<input class="form-control" name="city"
|
||||
value="<%= settings.city || '' %>">
|
||||
value="<%= company.city || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.companySettings.country %></label>
|
||||
<label class="form-label">Land</label>
|
||||
<input class="form-control" name="country"
|
||||
value="<%= settings.country || 'Deutschland' %>">
|
||||
value="<%= company.country || 'Deutschland' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.companySettings.taxid %></label>
|
||||
<label class="form-label">USt-ID / Steuernummer</label>
|
||||
<input class="form-control" name="vat_id"
|
||||
value="<%= settings.vat_id || '' %>">
|
||||
value="<%= company.vat_id || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.companySettings.bank %></label>
|
||||
<label class="form-label">Bank</label>
|
||||
<input class="form-control" name="bank_name"
|
||||
value="<%= settings.bank_name || '' %>">
|
||||
value="<%= company.bank_name || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.companySettings.iban %></label>
|
||||
<label class="form-label">IBAN</label>
|
||||
<input class="form-control" name="iban"
|
||||
value="<%= settings.iban || '' %>">
|
||||
value="<%= company.iban || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.companySettings.bic %></label>
|
||||
<label class="form-label">BIC</label>
|
||||
<input class="form-control" name="bic"
|
||||
value="<%= settings.bic || '' %>">
|
||||
value="<%= company.bic || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label"><%= t.companySettings.invoicefooter %></label>
|
||||
<textarea class="form-control" rows="3" name="invoice_footer_text"
|
||||
><%= settings.invoice_footer_text || '' %></textarea>
|
||||
<label class="form-label">Rechnungs-Footer</label>
|
||||
<textarea class="form-control" rows="3"
|
||||
name="invoice_footer_text"><%= company.invoice_footer_text || '' %></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label"><%= t.companySettings.companylogo %></label>
|
||||
<input type="file" name="logo" class="form-control"
|
||||
accept="image/png, image/jpeg">
|
||||
<% if (settings.invoice_logo_path) { %>
|
||||
<label class="form-label">Firmenlogo</label>
|
||||
<input
|
||||
type="file"
|
||||
name="logo"
|
||||
class="form-control"
|
||||
accept="image/png, image/jpeg"
|
||||
>
|
||||
|
||||
<% if (company.invoice_logo_path) { %>
|
||||
<div class="mt-2">
|
||||
<small class="text-muted"><%= t.companySettings.currentlogo %></small><br>
|
||||
<img src="<%= settings.invoice_logo_path %>"
|
||||
style="max-height:80px; border:1px solid #ccc; padding:4px;">
|
||||
<small class="text-muted">Aktuelles Logo:</small><br>
|
||||
<img
|
||||
src="<%= company.invoice_logo_path %>"
|
||||
style="max-height:80px; border:1px solid #ccc; padding:4px;"
|
||||
>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-4 d-flex gap-2">
|
||||
<button class="btn btn-primary">
|
||||
<i class="bi bi-save"></i> <%= t.global.save %>
|
||||
</button>
|
||||
<a href="/dashboard" class="btn btn-secondary">
|
||||
<%= t.companySettings.back %>
|
||||
</a>
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-primary">💾 Speichern</button>
|
||||
<a href="/dashboard" class="btn btn-secondary">Zurück</a>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,82 +1,115 @@
|
||||
<div class="layout">
|
||||
|
||||
<%- include("../partials/admin-sidebar", { user, active: "database", lang }) %>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<%- include("../partials/page-header", {
|
||||
<%- include("../partials/page-header", {
|
||||
user,
|
||||
title: t.adminSidebar.database,
|
||||
title: "Datenbankverwaltung",
|
||||
subtitle: "",
|
||||
showUserName: true,
|
||||
hideDashboardButton: true
|
||||
}) %>
|
||||
showUserName: true
|
||||
}) %>
|
||||
|
||||
<div class="content p-4">
|
||||
<div class="content p-4">
|
||||
|
||||
<%- include("../partials/flash") %>
|
||||
|
||||
<div class="container-fluid p-0">
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- DB Konfiguration -->
|
||||
<div class="col-12">
|
||||
<!-- ✅ Sidebar -->
|
||||
<div class="col-md-3 col-lg-2 p-0">
|
||||
<%- include("../partials/admin-sidebar", { user, active: "database" }) %>
|
||||
</div>
|
||||
|
||||
<!-- ✅ Content -->
|
||||
<div class="col-md-9 col-lg-10">
|
||||
|
||||
<!-- ✅ DB Konfiguration -->
|
||||
<div class="card shadow mb-3">
|
||||
<div class="card-body">
|
||||
|
||||
<h4 class="mb-3">
|
||||
<i class="bi bi-sliders"></i> <%= t.databaseoverview.title %>
|
||||
<i class="bi bi-sliders"></i> Datenbank Konfiguration
|
||||
</h4>
|
||||
|
||||
<p class="text-muted mb-4"><%= t.databaseoverview.text %></p>
|
||||
|
||||
<form method="POST" action="/admin/database/test"
|
||||
class="row g-3 mb-3" autocomplete="off">
|
||||
<p class="text-muted mb-4">
|
||||
Hier kannst du die DB-Verbindung testen und speichern.
|
||||
</p>
|
||||
|
||||
<!-- ✅ TEST (ohne speichern) + SPEICHERN -->
|
||||
<form method="POST" action="/admin/database/test" class="row g-3 mb-3" autocomplete="off">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.databaseoverview.host %> / IP</label>
|
||||
<input type="text" name="host" class="form-control"
|
||||
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.host) ? dbConfig.host : '' %>"
|
||||
autocomplete="off" required>
|
||||
<label class="form-label">Host / IP</label>
|
||||
<input
|
||||
type="text"
|
||||
name="host"
|
||||
class="form-control"
|
||||
value="<%= dbConfig?.host || '' %>"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"><%= t.databaseoverview.port %></label>
|
||||
<input type="number" name="port" class="form-control"
|
||||
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.port) ? dbConfig.port : 3306 %>"
|
||||
autocomplete="off" required>
|
||||
<label class="form-label">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
name="port"
|
||||
class="form-control"
|
||||
value="<%= dbConfig?.port || 3306 %>"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"><%= t.databaseoverview.database %></label>
|
||||
<input type="text" name="name" class="form-control"
|
||||
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.name) ? dbConfig.name : '' %>"
|
||||
autocomplete="off" required>
|
||||
<label class="form-label">Datenbank</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
class="form-control"
|
||||
value="<%= dbConfig?.name || '' %>"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.global.username %></label>
|
||||
<input type="text" name="user" class="form-control"
|
||||
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.user) ? dbConfig.user : '' %>"
|
||||
autocomplete="off" required>
|
||||
<label class="form-label">Benutzer</label>
|
||||
<input
|
||||
type="text"
|
||||
name="user"
|
||||
class="form-control"
|
||||
value="<%= dbConfig?.user || '' %>"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.databaseoverview.password %></label>
|
||||
<input type="password" name="password" class="form-control"
|
||||
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.password) ? dbConfig.password : '' %>"
|
||||
autocomplete="off" required>
|
||||
<label class="form-label">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
class="form-control"
|
||||
value="<%= dbConfig?.password || '' %>"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex flex-wrap gap-2">
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
<i class="bi bi-plug"></i> <%= t.databaseoverview.connectiontest %>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success" formaction="/admin/database">
|
||||
<i class="bi bi-save"></i> <%= t.global.save %>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
<i class="bi bi-plug"></i> Verbindung testen
|
||||
</button>
|
||||
|
||||
<!-- ✅ Speichern + Testen -->
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-success"
|
||||
formaction="/admin/database"
|
||||
>
|
||||
<i class="bi bi-save"></i> Speichern
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<% if (typeof testResult !== "undefined" && testResult) { %>
|
||||
@ -87,20 +120,19 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Info -->
|
||||
<div class="col-12">
|
||||
<!-- ✅ System Info -->
|
||||
<div class="card shadow mb-3">
|
||||
<div class="card-body">
|
||||
|
||||
<h4 class="mb-3">
|
||||
<i class="bi bi-info-circle"></i> <%= t.global.systeminfo %>
|
||||
<i class="bi bi-info-circle"></i> Systeminformationen
|
||||
</h4>
|
||||
|
||||
<% if (typeof systemInfo !== "undefined" && systemInfo && systemInfo.error) { %>
|
||||
<% if (typeof systemInfo !== "undefined" && systemInfo?.error) { %>
|
||||
|
||||
<div class="alert alert-danger mb-0">
|
||||
❌ <%= t.global.errordatabase %>
|
||||
❌ Fehler beim Auslesen der Datenbankinfos:
|
||||
<div class="mt-2"><code><%= systemInfo.error %></code></div>
|
||||
</div>
|
||||
|
||||
@ -109,19 +141,21 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="text-muted small"><%= t.databaseoverview.mysqlversion %></div>
|
||||
<div class="text-muted small">MySQL Version</div>
|
||||
<div class="fw-bold"><%= systemInfo.version %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="text-muted small"><%= t.databaseoverview.tablecount %></div>
|
||||
<div class="text-muted small">Anzahl Tabellen</div>
|
||||
<div class="fw-bold"><%= systemInfo.tableCount %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="text-muted small"><%= t.databaseoverview.databasesize %></div>
|
||||
<div class="text-muted small">Datenbankgröße</div>
|
||||
<div class="fw-bold"><%= systemInfo.dbSizeMB %> MB</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -129,22 +163,25 @@
|
||||
|
||||
<% if (systemInfo.tables && systemInfo.tables.length > 0) { %>
|
||||
<hr>
|
||||
<h6 class="mb-2"><%= t.databaseoverview.tableoverview %></h6>
|
||||
|
||||
<h6 class="mb-2">Tabellenübersicht</h6>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered table-hover align-middle">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th><%= t.global.table %></th>
|
||||
<th class="text-end"><%= t.global.lines %></th>
|
||||
<th class="text-end"><%= t.global.size %> (MB)</th>
|
||||
<th>Tabelle</th>
|
||||
<th class="text-end">Zeilen</th>
|
||||
<th class="text-end">Größe (MB)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% systemInfo.tables.forEach(tbl => { %>
|
||||
<% systemInfo.tables.forEach(t => { %>
|
||||
<tr>
|
||||
<td><%= tbl.name %></td>
|
||||
<td class="text-end"><%= tbl.row_count %></td>
|
||||
<td class="text-end"><%= tbl.size_mb %></td>
|
||||
<td><%= t.name %></td>
|
||||
<td class="text-end"><%= t.row_count %></td>
|
||||
<td class="text-end"><%= t.size_mb %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
@ -153,17 +190,17 @@
|
||||
<% } %>
|
||||
|
||||
<% } else { %>
|
||||
|
||||
<div class="alert alert-warning mb-0">
|
||||
⚠️ <%= t.databaseoverview.nodbinfo %>
|
||||
⚠️ Keine Systeminfos verfügbar (DB ist evtl. nicht konfiguriert oder Verbindung fehlgeschlagen).
|
||||
</div>
|
||||
|
||||
<% } %>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
<div class="col-12">
|
||||
<!-- ✅ Backup & Restore -->
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
|
||||
@ -173,20 +210,25 @@
|
||||
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
|
||||
<!-- ✅ Backup erstellen -->
|
||||
<form action="/admin/database/backup" method="POST">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-download"></i> Backup erstellen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- ✅ Restore auswählen -->
|
||||
<form action="/admin/database/restore" method="POST">
|
||||
<div class="input-group">
|
||||
|
||||
<select name="backupFile" class="form-select" required>
|
||||
<option value="">Backup auswählen...</option>
|
||||
|
||||
<% (typeof backupFiles !== "undefined" && backupFiles ? backupFiles : []).forEach(file => { %>
|
||||
<option value="<%= file %>"><%= file %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-upload"></i> Restore starten
|
||||
</button>
|
||||
@ -203,11 +245,8 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="<%= lang %>">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title><%= t.adminCreateUser.title %></title>
|
||||
<title>Benutzer anlegen</title>
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css" />
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
@ -11,52 +11,98 @@
|
||||
|
||||
<div class="card shadow mx-auto" style="max-width: 500px">
|
||||
<div class="card-body">
|
||||
<h3 class="text-center mb-3"><%= t.adminCreateUser.title %></h3>
|
||||
<h3 class="text-center mb-3">Benutzer anlegen</h3>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form method="POST" action="/admin/create-user">
|
||||
<!-- VORNAME -->
|
||||
<input
|
||||
class="form-control mb-3"
|
||||
name="first_name"
|
||||
placeholder="Vorname"
|
||||
required
|
||||
/>
|
||||
|
||||
<input class="form-control mb-3" name="first_name"
|
||||
placeholder="<%= t.adminCreateUser.firstname %>" required />
|
||||
<!-- NACHNAME -->
|
||||
<input
|
||||
class="form-control mb-3"
|
||||
name="last_name"
|
||||
placeholder="Nachname"
|
||||
required
|
||||
/>
|
||||
|
||||
<input class="form-control mb-3" name="last_name"
|
||||
placeholder="<%= t.adminCreateUser.lastname %>" required />
|
||||
<!-- TITEL -->
|
||||
<input
|
||||
class="form-control mb-3"
|
||||
name="title"
|
||||
placeholder="Titel (z.B. Dr., Prof.)"
|
||||
/>
|
||||
|
||||
<input class="form-control mb-3" name="title"
|
||||
placeholder="<%= t.adminCreateUser.usertitle %>" />
|
||||
<!-- BENUTZERNAME (LOGIN) -->
|
||||
<input
|
||||
class="form-control mb-3"
|
||||
name="username"
|
||||
placeholder="Benutzername (Login)"
|
||||
required
|
||||
/>
|
||||
|
||||
<input class="form-control mb-3" name="username"
|
||||
placeholder="<%= t.adminCreateUser.username %>" required />
|
||||
<!-- PASSWORT -->
|
||||
<input
|
||||
class="form-control mb-3"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Passwort"
|
||||
required
|
||||
/>
|
||||
|
||||
<input class="form-control mb-3" type="password" name="password"
|
||||
placeholder="<%= t.adminCreateUser.password %>" required />
|
||||
|
||||
<select class="form-select mb-3" name="role" id="roleSelect" required>
|
||||
<option value=""><%= t.global.role %></option>
|
||||
<!-- ROLLE -->
|
||||
<select
|
||||
class="form-select mb-3"
|
||||
name="role"
|
||||
id="roleSelect"
|
||||
required
|
||||
>
|
||||
<option value="">Rolle wählen</option>
|
||||
<option value="mitarbeiter">Mitarbeiter</option>
|
||||
<option value="arzt">Arzt</option>
|
||||
</select>
|
||||
|
||||
<!-- ARZT-FELDER -->
|
||||
<div id="arztFields" style="display: none">
|
||||
<input class="form-control mb-3" name="fachrichtung"
|
||||
placeholder="<%= t.adminCreateUser.specialty %>" />
|
||||
<input class="form-control mb-3" name="arztnummer"
|
||||
placeholder="<%= t.adminCreateUser.doctornumber %>" />
|
||||
<input
|
||||
class="form-control mb-3"
|
||||
name="fachrichtung"
|
||||
placeholder="Fachrichtung"
|
||||
/>
|
||||
|
||||
<input
|
||||
class="form-control mb-3"
|
||||
name="arztnummer"
|
||||
placeholder="Arztnummer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary w-100"><%= t.adminCreateUser.createuser %></button>
|
||||
<button class="btn btn-primary w-100">Benutzer erstellen</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="/dashboard"><%= t.adminCreateUser.back %></a>
|
||||
<a href="/dashboard">Zurück</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document
|
||||
.getElementById("roleSelect")
|
||||
.addEventListener("change", function () {
|
||||
const arztFields = document.getElementById("arztFields");
|
||||
arztFields.style.display = this.value === "arzt" ? "block" : "none";
|
||||
});
|
||||
</script>
|
||||
<script src="/js/admin_create_user.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,37 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="<%= lang %>">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><%= t.adminServiceLogs.title %></title>
|
||||
<title>Service-Logs</title>
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-dark bg-dark position-relative px-3">
|
||||
|
||||
<!-- 🟢 ZENTRIERTER TITEL -->
|
||||
<div class="position-absolute top-50 start-50 translate-middle
|
||||
d-flex align-items-center gap-2 text-white">
|
||||
<span style="font-size:1.3rem;">📜</span>
|
||||
<span class="fw-semibold fs-5"><%= t.adminServiceLogs.title %></span>
|
||||
<span class="fw-semibold fs-5">Service-Änderungsprotokoll</span>
|
||||
</div>
|
||||
|
||||
<!-- 🔵 RECHTS: DASHBOARD -->
|
||||
<div class="ms-auto">
|
||||
<a href="/dashboard" class="btn btn-outline-light btn-sm">
|
||||
⬅️ <%= t.global.dashboard %>
|
||||
⬅️ Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
|
||||
<div class="container mt-4">
|
||||
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th><%= t.adminServiceLogs.date %></th>
|
||||
<th><%= t.adminServiceLogs.user %></th>
|
||||
<th><%= t.adminServiceLogs.action %></th>
|
||||
<th><%= t.adminServiceLogs.before %></th>
|
||||
<th><%= t.adminServiceLogs.after %></th>
|
||||
<th>Datum</th>
|
||||
<th>User</th>
|
||||
<th>Aktion</th>
|
||||
<th>Vorher</th>
|
||||
<th>Nachher</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<% logs.forEach(l => { %>
|
||||
<tr>
|
||||
<td><%= new Date(l.created_at).toLocaleString("de-DE") %></td>
|
||||
@ -41,9 +49,10 @@
|
||||
<td><pre><%= l.new_value || "-" %></pre></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<br>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<div class="layout">
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- ✅ HEADER -->
|
||||
<%- include("partials/page-header", {
|
||||
user,
|
||||
title: t.adminuseroverview.usermanagement,
|
||||
title: "User Verwaltung",
|
||||
subtitle: "",
|
||||
showUserName: true
|
||||
}) %>
|
||||
@ -13,28 +15,32 @@
|
||||
<%- include("partials/flash") %>
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h4 class="mb-0"><%= t.adminuseroverview.useroverview %></h4>
|
||||
<h4 class="mb-0">Benutzerübersicht</h4>
|
||||
|
||||
<a href="/admin/create-user" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> <%= t.global.newuser %>
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
Neuer Benutzer
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ✅ Tabelle -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th><%= t.global.title %></th>
|
||||
<th><%= t.global.firstname %></th>
|
||||
<th><%= t.global.lastname %></th>
|
||||
<th><%= t.global.username %></th>
|
||||
<th><%= t.global.role %></th>
|
||||
<th class="text-center"><%= t.global.status %></th>
|
||||
<th><%= t.global.action %></th>
|
||||
<th>Titel</th>
|
||||
<th>Vorname</th>
|
||||
<th>Nachname</th>
|
||||
<th>Username</th>
|
||||
<th>Rolle</th>
|
||||
<th class="text-center">Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@ -42,25 +48,25 @@
|
||||
<% users.forEach(u => { %>
|
||||
<tr class="<%= u.active ? '' : 'table-secondary' %>">
|
||||
|
||||
<!-- ✅ Update Form -->
|
||||
<form method="POST" action="/admin/users/update/<%= u.id %>">
|
||||
|
||||
<td class="fw-semibold"><%= u.id %></td>
|
||||
|
||||
<td>
|
||||
<input type="text" name="title" value="<%= u.title || '' %>"
|
||||
class="form-control form-control-sm" disabled />
|
||||
<input type="text" name="title" value="<%= u.title || '' %>" class="form-control form-control-sm" disabled />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="text" name="first_name" value="<%= u.first_name %>"
|
||||
class="form-control form-control-sm" disabled />
|
||||
<input type="text" name="first_name" value="<%= u.first_name %>" class="form-control form-control-sm" disabled />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="text" name="last_name" value="<%= u.last_name %>"
|
||||
class="form-control form-control-sm" disabled />
|
||||
<input type="text" name="last_name" value="<%= u.last_name %>" class="form-control form-control-sm" disabled />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="text" name="username" value="<%= u.username %>"
|
||||
class="form-control form-control-sm" disabled />
|
||||
<input type="text" name="username" value="<%= u.username %>" class="form-control form-control-sm" disabled />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
@ -73,34 +79,37 @@
|
||||
|
||||
<td class="text-center">
|
||||
<% if (u.active === 0) { %>
|
||||
<span class="badge bg-secondary"><%= t.global.inactive %></span>
|
||||
<span class="badge bg-secondary">Inaktiv</span>
|
||||
<% } else if (u.lock_until && new Date(u.lock_until) > new Date()) { %>
|
||||
<span class="badge bg-danger"><%= t.global.closed %></span>
|
||||
<span class="badge bg-danger">Gesperrt</span>
|
||||
<% } else { %>
|
||||
<span class="badge bg-success"><%= t.global.active %></span>
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
<% } %>
|
||||
</td>
|
||||
|
||||
<td class="d-flex gap-2 align-items-center">
|
||||
|
||||
<!-- Save -->
|
||||
<button class="btn btn-outline-success btn-sm save-btn" disabled>
|
||||
<i class="bi bi-save"></i>
|
||||
</button>
|
||||
|
||||
<!-- Edit -->
|
||||
<button type="button" class="btn btn-outline-warning btn-sm lock-btn">
|
||||
<i class="bi bi-pencil-square"></i>
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- Aktiv/Deaktiv -->
|
||||
<% if (u.id !== currentUser.id) { %>
|
||||
<form method="POST"
|
||||
action="/admin/users/<%= u.active ? 'deactivate' : 'activate' %>/<%= u.id %>">
|
||||
<form method="POST" action="/admin/users/<%= u.active ? 'deactivate' : 'activate' %>/<%= u.id %>">
|
||||
<button class="btn btn-sm <%= u.active ? 'btn-outline-danger' : 'btn-outline-success' %>">
|
||||
<i class="bi <%= u.active ? 'bi-person-x' : 'bi-person-check' %>"></i>
|
||||
</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<span class="badge bg-light text-dark border">👤 <%= t.global.you %></span>
|
||||
<span class="badge bg-light text-dark border">👤 Du selbst</span>
|
||||
<% } %>
|
||||
|
||||
</td>
|
||||
@ -113,8 +122,15 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ⚠️ Inline Script wird von CSP blockiert!
|
||||
// Wenn du diese Buttons brauchst, sag Bescheid,
|
||||
// dann verlagern wir das sauber in /public/js/admin-users.js (CSP safe).
|
||||
</script>
|
||||
|
||||
@ -1,285 +0,0 @@
|
||||
<%# views/calendar/index.ejs %>
|
||||
<%# Eingebettet in das bestehende layout.ejs via express-ejs-layouts %>
|
||||
|
||||
<style>
|
||||
/* ── Kalender-Variablen ──────────────────────────────────────────────── */
|
||||
:root {
|
||||
--cal-slot-h: 40px;
|
||||
--cal-time-w: 60px;
|
||||
--cal-min-col: 160px;
|
||||
--cal-border: #dee2e6;
|
||||
--cal-hover: #e8f0fe;
|
||||
--cal-now: #dc3545;
|
||||
--cal-holiday: #fff3cd;
|
||||
--cal-weekend: #f8f9fa;
|
||||
}
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────────────── */
|
||||
#calendarPage { display:flex; flex-direction:column; height:calc(100vh - 70px); overflow:hidden; }
|
||||
#calToolbar { flex-shrink:0; }
|
||||
#calHolidayBanner { flex-shrink:0; display:none; }
|
||||
#calBody { flex:1; display:flex; overflow:hidden; }
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────────────────── */
|
||||
#calSidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--cal-border);
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
}
|
||||
.mini-cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mini-wd { color:#6c757d; font-weight:600; padding-bottom:3px; }
|
||||
.mini-day { padding:3px 1px; border-radius:4px; cursor:pointer; line-height:1.6; }
|
||||
.mini-day:hover { background:#e9ecef; }
|
||||
.mini-day.today { background:#cfe2ff; color:#0d6efd; font-weight:600; }
|
||||
.mini-day.selected { background:#0d6efd; color:#fff; font-weight:600; }
|
||||
.mini-day.holiday { color:#dc3545; }
|
||||
.mini-day.other-month { color:#ced4da; }
|
||||
|
||||
.doc-item {
|
||||
display:flex; align-items:center; gap:8px;
|
||||
padding:6px 4px; border-radius:6px; cursor:pointer;
|
||||
font-size:13px; transition:background .12s;
|
||||
user-select:none;
|
||||
}
|
||||
.doc-item:hover { background:#f8f9fa; }
|
||||
.doc-dot { width:10px; height:10px; border-radius:50%; flex-shrink:0; }
|
||||
.doc-check {
|
||||
width:16px; height:16px; border-radius:3px; flex-shrink:0;
|
||||
border:1.5px solid #ced4da; margin-left:auto;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.doc-item.active .doc-check { background:#0d6efd; border-color:#0d6efd; }
|
||||
|
||||
/* ── Kalender-Bereich ────────────────────────────────────────────────── */
|
||||
#calMain { flex:1; display:flex; flex-direction:column; overflow:hidden; }
|
||||
#calColHeaders { display:flex; background:#fff; border-bottom:2px solid var(--cal-border); flex-shrink:0; z-index:10; }
|
||||
#calScroll { flex:1; overflow-y:auto; overflow-x:hidden; }
|
||||
#calGrid { display:flex; }
|
||||
|
||||
/* Zeitachse */
|
||||
.cal-time-axis { width:var(--cal-time-w); flex-shrink:0; background:#f8f9fa; }
|
||||
.cal-time-label {
|
||||
height:var(--cal-slot-h); display:flex; align-items:flex-start; justify-content:flex-end;
|
||||
padding:0 6px; font-size:11px; color:#6c757d; transform:translateY(-6px);
|
||||
font-variant-numeric:tabular-nums;
|
||||
}
|
||||
.cal-time-label.hour { font-weight:600; color:#495057; }
|
||||
|
||||
/* Spalten */
|
||||
#calColHeadersInner { display:flex; flex:1; overflow:hidden; }
|
||||
#calColumnsInner { display:flex; flex:1; }
|
||||
|
||||
.col-header {
|
||||
flex:1; min-width:var(--cal-min-col);
|
||||
padding:8px 10px; border-left:1px solid var(--cal-border);
|
||||
display:flex; align-items:center; gap:6px;
|
||||
}
|
||||
.col-header-name { font-weight:600; font-size:13px; }
|
||||
.col-header-spec { font-size:11px; color:#6c757d; }
|
||||
.col-header-count {
|
||||
margin-left:auto; font-size:11px; background:#e9ecef;
|
||||
padding:1px 7px; border-radius:20px; color:#6c757d; white-space:nowrap;
|
||||
}
|
||||
.col-header-color {
|
||||
width:32px; height:20px; border-radius:4px; border:none;
|
||||
cursor:pointer; padding:0 2px;
|
||||
}
|
||||
|
||||
.doc-col { flex:1; min-width:var(--cal-min-col); border-left:1px solid var(--cal-border); position:relative; }
|
||||
|
||||
.slot-row {
|
||||
height:var(--cal-slot-h); border-bottom:1px solid #f0f0f0;
|
||||
cursor:pointer; position:relative; transition:background .08s;
|
||||
}
|
||||
.slot-row.hour-start { border-bottom-color:#dee2e6; }
|
||||
.slot-row.weekend { background:var(--cal-weekend); }
|
||||
.slot-row:hover { background:var(--cal-hover); }
|
||||
|
||||
/* Terminblock */
|
||||
.appt-block {
|
||||
position:absolute; left:3px; right:3px;
|
||||
border-radius:5px; padding:3px 6px;
|
||||
cursor:pointer; z-index:3; overflow:hidden;
|
||||
border-left:3px solid; font-size:12px;
|
||||
transition:filter .12s;
|
||||
}
|
||||
.appt-block:hover { filter:brightness(.9); }
|
||||
.appt-block.cancelled { opacity:.45; text-decoration:line-through; }
|
||||
.appt-block.completed { opacity:.65; }
|
||||
.appt-patient { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.appt-time { font-size:10px; opacity:.75; font-variant-numeric:tabular-nums; }
|
||||
|
||||
/* Jetzt-Linie */
|
||||
.now-line { position:absolute; left:0; right:0; height:2px; background:var(--cal-now); z-index:4; pointer-events:none; }
|
||||
.now-dot { position:absolute; left:-4px; top:-4px; width:10px; height:10px; border-radius:50%; background:var(--cal-now); }
|
||||
|
||||
/* Scrollbar */
|
||||
#calScroll::-webkit-scrollbar { width:5px; }
|
||||
#calScroll::-webkit-scrollbar-thumb { background:#ced4da; border-radius:3px; }
|
||||
</style>
|
||||
|
||||
<div id="calendarPage">
|
||||
|
||||
<%# ── Toolbar ── %>
|
||||
<div id="calToolbar" class="d-flex align-items-center gap-2 p-2 border-bottom bg-white">
|
||||
<i class="bi bi-calendar3 text-primary fs-5"></i>
|
||||
<strong class="me-2">Kalender</strong>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btnPrev">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary fw-semibold" id="btnDateDisplay" style="min-width:220px">
|
||||
Lädt …
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btnNext">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-primary" id="btnToday">Heute</button>
|
||||
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" id="btnNewAppt">
|
||||
<i class="bi bi-plus-lg me-1"></i>Neuer Termin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Feiertagsbanner ── %>
|
||||
<div id="calHolidayBanner" class="alert alert-warning d-flex align-items-center gap-2 mb-0 rounded-0 py-2 px-3">
|
||||
<i class="bi bi-star-fill text-warning"></i>
|
||||
<span id="calHolidayText"></span>
|
||||
</div>
|
||||
|
||||
<%# ── Haupt-Body ── %>
|
||||
<div id="calBody">
|
||||
|
||||
<%# ── Sidebar ── %>
|
||||
<div id="calSidebar">
|
||||
<%# Mini-Kalender %>
|
||||
<div class="mb-3">
|
||||
<div id="miniCal"></div>
|
||||
</div>
|
||||
|
||||
<hr class="my-2">
|
||||
<div class="text-uppercase fw-bold" style="font-size:11px;color:#6c757d;letter-spacing:.06em;">Ärzte</div>
|
||||
<div id="docList" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<%# ── Kalender-Spalten ── %>
|
||||
<div id="calMain">
|
||||
<div id="calColHeaders">
|
||||
<div style="width:var(--cal-time-w);flex-shrink:0;"></div>
|
||||
<div id="calColHeadersInner"></div>
|
||||
</div>
|
||||
<div id="calScroll">
|
||||
<div id="calGrid">
|
||||
<div class="cal-time-axis" id="calTimeAxis"></div>
|
||||
<div id="calColumnsInner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><%# /calBody %>
|
||||
</div><%# /calendarPage %>
|
||||
|
||||
<%# ── Modal: Termin ── %>
|
||||
<div class="modal fade" id="apptModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="apptModalTitle">Neuer Termin</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-7">
|
||||
<label class="form-label small fw-semibold">Arzt</label>
|
||||
<select class="form-select form-select-sm" id="fDoctor"></select>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label small fw-semibold">Status</label>
|
||||
<select class="form-select form-select-sm" id="fStatus">
|
||||
<option value="scheduled">Geplant</option>
|
||||
<option value="completed">Abgeschlossen</option>
|
||||
<option value="cancelled">Abgesagt</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Patient</label>
|
||||
<div class="position-relative">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm"
|
||||
id="fPatient"
|
||||
placeholder="Name eingeben …"
|
||||
autocomplete="off">
|
||||
<input type="hidden" id="fPatientId">
|
||||
<div id="patientDropdown"
|
||||
class="position-absolute w-100 bg-white border rounded shadow-sm"
|
||||
style="display:none; z-index:1060; top:100%; max-height:200px; overflow-y:auto;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small fw-semibold">Datum</label>
|
||||
<input type="date" class="form-control form-control-sm" id="fDate">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small fw-semibold">Uhrzeit</label>
|
||||
<select class="form-select form-select-sm" id="fTime"></select>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small fw-semibold">Dauer</label>
|
||||
<select class="form-select form-select-sm" id="fDuration">
|
||||
<option value="15">15 min</option>
|
||||
<option value="30">30 min</option>
|
||||
<option value="45">45 min</option>
|
||||
<option value="60">60 min</option>
|
||||
<option value="90">90 min</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Notizen</label>
|
||||
<textarea class="form-control form-control-sm" id="fNotes" rows="2" placeholder="Optional …"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm btn-outline-danger me-auto" id="btnApptDelete" style="display:none">
|
||||
<i class="bi bi-trash me-1"></i>Löschen
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="btnApptSave">
|
||||
<i class="bi bi-check-lg me-1"></i>Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Toast ── %>
|
||||
<div class="position-fixed bottom-0 start-50 translate-middle-x p-3" style="z-index:9999">
|
||||
<div id="calToast" class="toast align-items-center text-bg-dark border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body" id="calToastMsg"></div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<%# ── Ärzte-Daten CSP-sicher übergeben (type="application/json" wird NICHT geblockt) ── %>
|
||||
<script type="application/json" id="calDoctorsData"><%- JSON.stringify(doctors) %></script>
|
||||
|
||||
<%# ── Externes Script (script-src 'self' erlaubt dies) ── %>
|
||||
<script src="/js/calendar.js"></script>
|
||||
@ -1,43 +1,66 @@
|
||||
<!-- KEIN layout, KEINE sidebar, KEIN main -->
|
||||
<div class="layout">
|
||||
|
||||
<%- include("partials/page-header", {
|
||||
<!-- ✅ SIDEBAR -->
|
||||
<%- include("partials/sidebar", { user, active: "patients", lang }) %>
|
||||
|
||||
<!-- ✅ MAIN -->
|
||||
<div class="main">
|
||||
|
||||
<!-- ✅ HEADER (inkl. Uhrzeit) -->
|
||||
<%- include("partials/page-header", {
|
||||
user,
|
||||
title: t.dashboard.title,
|
||||
title: "Dashboard",
|
||||
subtitle: "",
|
||||
showUserName: true,
|
||||
hideDashboardButton: true
|
||||
}) %>
|
||||
}) %>
|
||||
|
||||
<div class="content p-4">
|
||||
<div class="content p-4">
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<%- include("partials/flash") %>
|
||||
|
||||
<!-- =========================
|
||||
WARTEZIMMER MONITOR
|
||||
========================= -->
|
||||
<div class="waiting-monitor">
|
||||
<h5 class="mb-3">🪑 <%= t.dashboard.waitingRoom %></h5>
|
||||
<h5 class="mb-3">🪑 Wartezimmer-Monitor</h5>
|
||||
|
||||
<div class="waiting-grid">
|
||||
<% if (waitingPatients && waitingPatients.length > 0) { %>
|
||||
|
||||
<% waitingPatients.forEach(p => { %>
|
||||
|
||||
<% if (user.role === "arzt") { %>
|
||||
<form method="POST" action="/patients/<%= p.id %>/call">
|
||||
<button class="waiting-slot occupied clickable">
|
||||
<div><%= p.firstname %> <%= p.lastname %></div>
|
||||
<% if (user.role === 'arzt') { %>
|
||||
<form method="POST" action="/patients/<%= p.id %>/call" style="width:100%; margin:0;">
|
||||
<button type="submit" class="waiting-slot occupied clickable waiting-btn">
|
||||
<div class="patient-text">
|
||||
<div class="name"><%= p.firstname %> <%= p.lastname %></div>
|
||||
<div class="birthdate">
|
||||
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<div class="waiting-slot occupied">
|
||||
<div><%= p.firstname %> <%= p.lastname %></div>
|
||||
<div class="patient-text">
|
||||
<div class="name"><%= p.firstname %> <%= p.lastname %></div>
|
||||
<div class="birthdate">
|
||||
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% }) %>
|
||||
|
||||
<% } else { %>
|
||||
<div class="text-muted">
|
||||
<%= t.dashboard.noWaitingPatients %>
|
||||
</div>
|
||||
<div class="text-muted">Keine Patienten im Wartezimmer.</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
@ -1,12 +0,0 @@
|
||||
/**
|
||||
* public/js/invoice-confirm.js
|
||||
* Ersetzt onsubmit="return confirm(...)" in offenen Rechnungen (CSP-sicher)
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.js-confirm-pay, .js-confirm-cancel').forEach(function (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
const msg = form.dataset.msg || 'Wirklich fortfahren?';
|
||||
if (!confirm(msg)) e.preventDefault();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,16 +0,0 @@
|
||||
/**
|
||||
* public/js/invoice-select.js
|
||||
* Ersetzt onchange="this.form.submit()" in Rechnungs-Filtern (CSP-sicher)
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const ids = ['cancelledYear', 'creditYear', 'paidYear', 'paidQuarter'];
|
||||
ids.forEach(function (id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.addEventListener('change', function () {
|
||||
const form = this.closest('form');
|
||||
if (form) form.submit();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,48 +0,0 @@
|
||||
<%- include("../partials/page-header", {
|
||||
user,
|
||||
title: t.cancelledInvoices.title,
|
||||
subtitle: "",
|
||||
showUserName: true
|
||||
}) %>
|
||||
|
||||
<div class="container mt-4">
|
||||
<%- include("../partials/flash") %>
|
||||
<h4><%= t.cancelledInvoices.title %></h4>
|
||||
|
||||
<form method="GET" action="/invoices/cancelled" style="margin-bottom:20px;">
|
||||
<label><%= t.cancelledInvoices.year %></label>
|
||||
<select name="year" class="form-select" id="cancelledYear"
|
||||
style="width:150px; display:inline-block;">
|
||||
<% years.forEach(y => { %>
|
||||
<option value="<%= y %>" <%= y == selectedYear ? "selected" : "" %>><%= y %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<% if (invoices.length === 0) { %>
|
||||
<p><%= t.cancelledInvoices.noinvoices %></p>
|
||||
<% } else { %>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th><%= t.cancelledInvoices.patient %></th>
|
||||
<th><%= t.cancelledInvoices.date %></th>
|
||||
<th><%= t.cancelledInvoices.amount %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% invoices.forEach(inv => { %>
|
||||
<tr>
|
||||
<td><%= inv.id %></td>
|
||||
<td><%= inv.firstname %> <%= inv.lastname %></td>
|
||||
<td><%= inv.invoice_date_formatted %></td>
|
||||
<td><%= inv.total_amount_formatted %> €</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<script src="/js/invoice-select.js" defer></script>
|
||||
@ -1,67 +0,0 @@
|
||||
<%- include("../partials/page-header", {
|
||||
user,
|
||||
title: t.creditOverview.title,
|
||||
subtitle: "",
|
||||
showUserName: true
|
||||
}) %>
|
||||
|
||||
<div class="container mt-4">
|
||||
<%- include("../partials/flash") %>
|
||||
<h4><%= t.creditOverview.title %></h4>
|
||||
|
||||
<form method="GET" action="/invoices/credits" style="margin-bottom:20px">
|
||||
<label><%= t.creditOverview.year %></label>
|
||||
<select name="year" class="form-select" id="creditYear"
|
||||
style="width:150px; display:inline-block">
|
||||
<option value="0">Alle</option>
|
||||
<% years.forEach(y => { %>
|
||||
<option value="<%= y %>" <%= y == selectedYear ? "selected" : "" %>><%= y %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t.creditOverview.invoice %></th>
|
||||
<th><%= t.creditOverview.date %></th>
|
||||
<th><%= t.creditOverview.pdf %></th>
|
||||
<th><%= t.creditOverview.creditnote %></th>
|
||||
<th><%= t.creditOverview.date %></th>
|
||||
<th><%= t.creditOverview.pdf %></th>
|
||||
<th><%= t.creditOverview.patient %></th>
|
||||
<th><%= t.creditOverview.amount %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% items.forEach(i => { %>
|
||||
<tr>
|
||||
<td>#<%= i.invoice_id %></td>
|
||||
<td><%= i.invoice_date_fmt %></td>
|
||||
<td>
|
||||
<% if (i.invoice_file) { %>
|
||||
<a href="<%= i.invoice_file %>" target="_blank"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
📄 <%= t.creditOverview.open %>
|
||||
</a>
|
||||
<% } %>
|
||||
</td>
|
||||
<td>#<%= i.credit_id %></td>
|
||||
<td><%= i.credit_date_fmt %></td>
|
||||
<td>
|
||||
<% if (i.credit_file) { %>
|
||||
<a href="<%= i.credit_file %>" target="_blank"
|
||||
class="btn btn-sm btn-outline-danger">
|
||||
📄 <%= t.creditOverview.open %>
|
||||
</a>
|
||||
<% } %>
|
||||
</td>
|
||||
<td><%= i.firstname %> <%= i.lastname %></td>
|
||||
<td><%= i.invoice_amount_fmt %> € / <%= i.credit_amount_fmt %> €</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script src="/js/invoice-select.js" defer></script>
|
||||
@ -1,65 +0,0 @@
|
||||
<%- include("../partials/page-header", {
|
||||
user,
|
||||
title: t.openInvoices.title,
|
||||
subtitle: "",
|
||||
showUserName: true
|
||||
}) %>
|
||||
|
||||
<div class="container mt-4">
|
||||
<%- include("../partials/flash") %>
|
||||
<h4><%= t.openInvoices.title %></h4>
|
||||
|
||||
<% if (invoices.length === 0) { %>
|
||||
<p><%= t.openInvoices.noinvoices %></p>
|
||||
<% } else { %>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th><%= t.openInvoices.patient %></th>
|
||||
<th><%= t.openInvoices.date %></th>
|
||||
<th><%= t.openInvoices.amount %></th>
|
||||
<th><%= t.openInvoices.status %></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% invoices.forEach(inv => { %>
|
||||
<tr>
|
||||
<td><%= inv.id %></td>
|
||||
<td><%= inv.firstname %> <%= inv.lastname %></td>
|
||||
<td><%= inv.invoice_date_formatted %></td>
|
||||
<td><%= inv.total_amount_formatted %> €</td>
|
||||
<td><%= t.openInvoices.open %></td>
|
||||
|
||||
<td style="text-align:right; white-space:nowrap;">
|
||||
|
||||
<!-- BEZAHLT -->
|
||||
<form action="/invoices/<%= inv.id %>/pay" method="POST"
|
||||
style="display:inline;"
|
||||
class="js-confirm-pay"
|
||||
data-msg="<%= t.global.save %>?">
|
||||
<button type="submit" class="btn btn-sm btn-success">
|
||||
<%= t.global.save %>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- STORNO -->
|
||||
<form action="/invoices/<%= inv.id %>/cancel" method="POST"
|
||||
style="display:inline;"
|
||||
class="js-confirm-cancel"
|
||||
data-msg="Storno?">
|
||||
<button type="submit" class="btn btn-sm btn-danger" style="margin-left:6px;">
|
||||
STORNO
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<script src="/js/invoice-confirm.js" defer></script>
|
||||
@ -1,72 +0,0 @@
|
||||
<%- include("../partials/page-header", {
|
||||
user,
|
||||
title: t.paidInvoices.title,
|
||||
subtitle: "",
|
||||
showUserName: true
|
||||
}) %>
|
||||
|
||||
<% if (query?.error === "already_credited") { %>
|
||||
<div class="alert alert-warning">
|
||||
⚠️ <%= t.global.nodata %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="container mt-4">
|
||||
<%- include("../partials/flash") %>
|
||||
<h4><%= t.paidInvoices.title %></h4>
|
||||
|
||||
<form method="GET" action="/invoices/paid"
|
||||
style="margin-bottom:20px; display:flex; gap:15px;">
|
||||
|
||||
<div>
|
||||
<label><%= t.paidInvoices.year %></label>
|
||||
<select name="year" class="form-select" id="paidYear">
|
||||
<% years.forEach(y => { %>
|
||||
<option value="<%= y %>" <%= y==selectedYear?"selected":"" %>><%= y %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label><%= t.paidInvoices.quarter %></label>
|
||||
<select name="quarter" class="form-select" id="paidQuarter">
|
||||
<option value="0">Alle</option>
|
||||
<option value="1" <%= selectedQuarter==1?"selected":"" %>>Q1</option>
|
||||
<option value="2" <%= selectedQuarter==2?"selected":"" %>>Q2</option>
|
||||
<option value="3" <%= selectedQuarter==3?"selected":"" %>>Q3</option>
|
||||
<option value="4" <%= selectedQuarter==4?"selected":"" %>>Q4</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<form id="creditForm" method="POST" action="" style="margin-bottom:15px;">
|
||||
<button id="creditBtn" type="submit" class="btn btn-warning" disabled>
|
||||
➖ <%= t.creditOverview.creditnote %>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th><%= t.paidInvoices.patient %></th>
|
||||
<th><%= t.paidInvoices.date %></th>
|
||||
<th><%= t.paidInvoices.amount %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% invoices.forEach(inv => { %>
|
||||
<tr class="invoice-row" data-id="<%= inv.id %>" style="cursor:pointer;">
|
||||
<td><%= inv.id %></td>
|
||||
<td><%= inv.firstname %> <%= inv.lastname %></td>
|
||||
<td><%= inv.invoice_date_formatted %></td>
|
||||
<td><%= inv.total_amount_formatted %> €</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script src="/js/paid-invoices.js"></script>
|
||||
<script src="/js/invoice-select.js" defer></script>
|
||||
</div>
|
||||
@ -20,6 +20,7 @@
|
||||
|
||||
<body>
|
||||
<div class="layout">
|
||||
|
||||
<!-- ✅ Sidebar dynamisch -->
|
||||
<% if (typeof sidebarPartial !== "undefined" && sidebarPartial) { %>
|
||||
<%- include(sidebarPartial, {
|
||||
@ -39,15 +40,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ✅ Bootstrap JS (Pflicht für Modals, Toasts usw.) -->
|
||||
<script src="/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- ✅ externes JS (CSP safe) -->
|
||||
<script src="/js/datetime.js"></script>
|
||||
<script src="/js/patient-select.js" defer></script>
|
||||
<!-- <script src="/js/patient_sidebar.js" defer></script> -->
|
||||
|
||||
<!-- ✅ Sidebar: gesperrte Menüpunkte abfangen -->
|
||||
<script src="/js/sidebar-lock.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title><%= t.medicationCreate.title %></title>
|
||||
<title>Neues Medikament</title>
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css" />
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container mt-4">
|
||||
<h4>➕ <%= t.medicationCreate.title %></h4>
|
||||
<h4>➕ Neues Medikament</h4>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger"><%= error %></div>
|
||||
@ -14,12 +14,12 @@
|
||||
|
||||
<form method="POST" action="/medications/create">
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><%= t.medicationCreate.medication %></label>
|
||||
<label class="form-label">Medikament</label>
|
||||
<input name="name" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><%= t.medicationCreate.form %></label>
|
||||
<label class="form-label">Darreichungsform</label>
|
||||
<select name="form_id" class="form-control" required>
|
||||
<% forms.forEach(f => { %>
|
||||
<option value="<%= f.id %>"><%= f.name %></option>
|
||||
@ -28,17 +28,17 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><%= t.medicationCreate.dosage %></label>
|
||||
<label class="form-label">Dosierung</label>
|
||||
<input name="dosage" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><%= t.medicationCreate.package %></label>
|
||||
<label class="form-label">Packung</label>
|
||||
<input name="package" class="form-control" />
|
||||
</div>
|
||||
|
||||
<button class="btn btn-success"><%= t.medicationCreate.save %></button>
|
||||
<a href="/medications" class="btn btn-secondary"><%= t.medicationCreate.cancel %></a>
|
||||
<button class="btn btn-success">Speichern</button>
|
||||
<a href="/medications" class="btn btn-secondary">Abbrechen</a>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
<%- include("partials/page-header", {
|
||||
user,
|
||||
title: t.medications.title,
|
||||
title: "Medikamentenübersicht",
|
||||
subtitle: "",
|
||||
showUserName: true
|
||||
}) %>
|
||||
|
||||
<div class="content p-4">
|
||||
<div class="content p-4">
|
||||
|
||||
<%- include("partials/flash") %>
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Suche -->
|
||||
<!-- 🔍 Suche -->
|
||||
<form method="GET" action="/medications" class="row g-2 mb-3">
|
||||
|
||||
<div class="col-md-6">
|
||||
@ -22,14 +22,14 @@
|
||||
type="text"
|
||||
name="q"
|
||||
class="form-control"
|
||||
placeholder="🔍 <%= t.medications.searchplaceholder %>"
|
||||
placeholder="🔍 Suche nach Medikament, Form, Dosierung"
|
||||
value="<%= query?.q || '' %>"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 d-flex gap-2">
|
||||
<button class="btn btn-primary w-100"><%= t.medications.search %></button>
|
||||
<a href="/medications" class="btn btn-secondary w-100"><%= t.medications.reset %></a>
|
||||
<button class="btn btn-primary w-100">Suchen</button>
|
||||
<a href="/medications" class="btn btn-secondary w-100">Reset</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 d-flex align-items-center">
|
||||
@ -42,16 +42,16 @@
|
||||
<%= query?.onlyActive === "1" ? "checked" : "" %>
|
||||
>
|
||||
<label class="form-check-label">
|
||||
<%= t.global.active %>
|
||||
Nur aktive Medikamente
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- Neu -->
|
||||
<!-- ➕ Neu -->
|
||||
<a href="/medications/create" class="btn btn-success mb-3">
|
||||
➕ <%= t.medications.newmedication %>
|
||||
➕ Neues Medikament
|
||||
</a>
|
||||
|
||||
<div class="table-responsive">
|
||||
@ -59,12 +59,12 @@
|
||||
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th><%= t.medications.medication %></th>
|
||||
<th><%= t.medications.form %></th>
|
||||
<th><%= t.medications.dosage %></th>
|
||||
<th><%= t.medications.package %></th>
|
||||
<th><%= t.medications.status %></th>
|
||||
<th><%= t.medications.actions %></th>
|
||||
<th>Medikament</th>
|
||||
<th>Darreichungsform</th>
|
||||
<th>Dosierung</th>
|
||||
<th>Packung</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@ -73,31 +73,49 @@
|
||||
|
||||
<tr class="<%= r.active ? '' : 'table-secondary' %>">
|
||||
|
||||
<!-- UPDATE-FORM -->
|
||||
<form method="POST" action="/medications/update/<%= r.id %>">
|
||||
|
||||
<td><%= r.medication %></td>
|
||||
<td><%= r.form %></td>
|
||||
|
||||
<td>
|
||||
<input type="text" name="dosage" value="<%= r.dosage %>"
|
||||
class="form-control form-control-sm" disabled>
|
||||
<input
|
||||
type="text"
|
||||
name="dosage"
|
||||
value="<%= r.dosage %>"
|
||||
class="form-control form-control-sm"
|
||||
disabled
|
||||
>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="text" name="package" value="<%= r.package %>"
|
||||
class="form-control form-control-sm" disabled>
|
||||
<input
|
||||
type="text"
|
||||
name="package"
|
||||
value="<%= r.package %>"
|
||||
class="form-control form-control-sm"
|
||||
disabled
|
||||
>
|
||||
</td>
|
||||
|
||||
<td class="text-center">
|
||||
<%= r.active ? t.global.active : t.global.inactive %>
|
||||
<%= r.active ? "Aktiv" : "Inaktiv" %>
|
||||
</td>
|
||||
|
||||
<td class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-success save-btn" disabled>💾</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning lock-btn">🔓</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-success save-btn" disabled>
|
||||
💾
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-warning lock-btn">
|
||||
🔓
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- TOGGLE-FORM -->
|
||||
<form method="POST" action="/medications/toggle/<%= r.medication_id %>">
|
||||
<button class="btn btn-sm <%= r.active ? 'btn-outline-danger' : 'btn-outline-success' %>">
|
||||
<%= r.active ? "⛔" : "✅" %>
|
||||
@ -117,5 +135,7 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/services-lock.js"></script>
|
||||
</div>
|
||||
|
||||
<!-- ✅ Externes JS (Helmet/CSP safe) -->
|
||||
<script src="/js/services-lock.js"></script>
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
<%- include("partials/page-header", {
|
||||
user,
|
||||
title: t.openServices.title,
|
||||
subtitle: "",
|
||||
title: "Offene Leistungen",
|
||||
subtitle: "Offene Rechnungen",
|
||||
showUserName: true
|
||||
}) %>
|
||||
|
||||
<div class="content p-4">
|
||||
|
||||
<div class="container-fluid p-0">
|
||||
|
||||
<% let currentPatient = null; %>
|
||||
|
||||
<% if (!rows.length) { %>
|
||||
<div class="alert alert-success">
|
||||
✅ <%= t.openServices.noopenservices %>
|
||||
✅ Keine offenen Leistungen vorhanden
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
@ -20,41 +21,71 @@
|
||||
|
||||
<% if (!currentPatient || currentPatient !== r.patient_id) { %>
|
||||
<% currentPatient = r.patient_id; %>
|
||||
|
||||
<hr />
|
||||
|
||||
<h5 class="clearfix">
|
||||
👤 <%= r.firstname %> <%= r.lastname %>
|
||||
<form method="POST"
|
||||
action="/invoices/patients/<%= r.patient_id %>/create-invoice"
|
||||
class="invoice-form d-inline float-end ms-2">
|
||||
<button class="btn btn-sm btn-success">🧾 <%= t.global.create %></button>
|
||||
|
||||
<!-- 🧾 RECHNUNG ERSTELLEN -->
|
||||
<form
|
||||
method="POST"
|
||||
action="/patients/<%= r.patient_id %>/create-invoice"
|
||||
class="invoice-form d-inline float-end ms-2"
|
||||
>
|
||||
<button class="btn btn-sm btn-success">
|
||||
🧾 Rechnung erstellen
|
||||
</button>
|
||||
</form>
|
||||
</h5>
|
||||
|
||||
<% } %>
|
||||
|
||||
<!-- LEISTUNG -->
|
||||
<div class="border rounded p-2 mb-2 d-flex align-items-center gap-2 flex-wrap">
|
||||
<strong class="flex-grow-1"><%= r.name %></strong>
|
||||
|
||||
<form method="POST"
|
||||
<!-- 🔢 MENGE -->
|
||||
<form
|
||||
method="POST"
|
||||
action="/patients/services/update-quantity/<%= r.patient_service_id %>"
|
||||
class="d-flex gap-1 me-2">
|
||||
<input type="number" name="quantity" min="1" step="1"
|
||||
class="d-flex gap-1 me-2"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
name="quantity"
|
||||
min="1"
|
||||
step="1"
|
||||
value="<%= r.quantity %>"
|
||||
class="form-control form-control-sm" style="width:70px" />
|
||||
class="form-control form-control-sm"
|
||||
style="width:70px"
|
||||
/>
|
||||
<button class="btn btn-sm btn-outline-primary">💾</button>
|
||||
</form>
|
||||
|
||||
<form method="POST"
|
||||
<!-- 💰 PREIS -->
|
||||
<form
|
||||
method="POST"
|
||||
action="/patients/services/update-price/<%= r.patient_service_id %>"
|
||||
class="d-flex gap-1 me-2">
|
||||
<input type="number" step="0.01" name="price"
|
||||
class="d-flex gap-1 me-2"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
name="price"
|
||||
value="<%= Number(r.price).toFixed(2) %>"
|
||||
class="form-control form-control-sm" style="width:100px" />
|
||||
class="form-control form-control-sm"
|
||||
style="width:100px"
|
||||
/>
|
||||
<button class="btn btn-sm btn-outline-primary">💾</button>
|
||||
</form>
|
||||
|
||||
<form method="POST"
|
||||
<!-- ❌ LÖSCHEN -->
|
||||
<form
|
||||
method="POST"
|
||||
action="/patients/services/delete/<%= r.patient_service_id %>"
|
||||
class="js-confirm-delete">
|
||||
class="js-confirm-delete"
|
||||
>
|
||||
<button class="btn btn-sm btn-outline-danger">❌</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -62,6 +93,8 @@
|
||||
<% }) %>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ✅ Externes JS (Helmet safe) -->
|
||||
<script src="/js/open-services.js"></script>
|
||||
|
||||
@ -26,27 +26,13 @@
|
||||
|
||||
<div class="sidebar-menu">
|
||||
|
||||
<!-- ✅ Firmendaten Verwaltung -->
|
||||
<a
|
||||
href="<%= hrefIfAllowed(isAdmin, '/admin/company-settings') %>"
|
||||
class="nav-item <%= active === 'companySettings' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
|
||||
title="<%= isAdmin ? '' : 'Nur Admin' %>"
|
||||
<% if (!isAdmin) { %>data-locked="Kein Zugriff – nur für Administratoren"<% } %>
|
||||
>
|
||||
<i class="bi bi-people"></i> <%= t.adminSidebar.companysettings %>
|
||||
<% if (!isAdmin) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<!-- ✅ User Verwaltung -->
|
||||
<a
|
||||
href="<%= hrefIfAllowed(isAdmin, '/admin/users') %>"
|
||||
class="nav-item <%= active === 'users' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
|
||||
title="<%= isAdmin ? '' : 'Nur Admin' %>"
|
||||
<% if (!isAdmin) { %>data-locked="Kein Zugriff – nur für Administratoren"<% } %>
|
||||
>
|
||||
<i class="bi bi-people"></i> <%= t.adminSidebar.user %>
|
||||
<i class="bi bi-people"></i> Benutzer
|
||||
<% if (!isAdmin) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
@ -57,9 +43,8 @@
|
||||
href="<%= hrefIfAllowed(isAdmin, '/admin/invoices') %>"
|
||||
class="nav-item <%= active === 'invoice_overview' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
|
||||
title="<%= isAdmin ? '' : 'Nur Admin' %>"
|
||||
<% if (!isAdmin) { %>data-locked="Kein Zugriff – nur für Administratoren"<% } %>
|
||||
>
|
||||
<i class="bi bi-calculator"></i> <%= t.adminSidebar.invocieoverview %>
|
||||
<i class="bi bi-calculator"></i> Rechnungsübersicht
|
||||
<% if (!isAdmin) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
@ -71,9 +56,8 @@
|
||||
href="<%= hrefIfAllowed(isAdmin, '/admin/serial-number') %>"
|
||||
class="nav-item <%= active === 'serialnumber' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
|
||||
title="<%= isAdmin ? '' : 'Nur Admin' %>"
|
||||
<% if (!isAdmin) { %>data-locked="Kein Zugriff – nur für Administratoren"<% } %>
|
||||
>
|
||||
<i class="bi bi-key"></i> <%= t.adminSidebar.seriennumber %>
|
||||
<i class="bi bi-key"></i> Seriennummer
|
||||
<% if (!isAdmin) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
@ -84,31 +68,12 @@
|
||||
href="<%= hrefIfAllowed(isAdmin, '/admin/database') %>"
|
||||
class="nav-item <%= active === 'database' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
|
||||
title="<%= isAdmin ? '' : 'Nur Admin' %>"
|
||||
<% if (!isAdmin) { %>data-locked="Kein Zugriff – nur für Administratoren"<% } %>
|
||||
>
|
||||
<i class="bi bi-hdd-stack"></i> <%= t.adminSidebar.databasetable %>
|
||||
<i class="bi bi-hdd-stack"></i> Datenbank
|
||||
<% if (!isAdmin) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<!-- ✅ Logout -->
|
||||
<a href="/logout" class="nav-item">
|
||||
<i class="bi bi-box-arrow-right"></i> <%= t.sidebar.logout %>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ Kein-Zugriff Toast (wird von /js/sidebar-lock.js gesteuert) -->
|
||||
<div class="position-fixed top-0 start-50 translate-middle-x p-3" style="z-index:9999; margin-top:16px;">
|
||||
<div id="lockToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body d-flex align-items-center gap-2">
|
||||
<i class="bi bi-lock-fill"></i>
|
||||
<span id="lockToastMsg">Kein Zugriff</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
<% } %>
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -1,192 +0,0 @@
|
||||
<%
|
||||
// =========================
|
||||
// BASISDATEN
|
||||
// =========================
|
||||
const role = user?.role || null;
|
||||
|
||||
// Arzt + Mitarbeiter dürfen Patienten bedienen
|
||||
const canPatientArea = role === "arzt" || role === "mitarbeiter";
|
||||
|
||||
const pid = patient && patient.id ? patient.id : null;
|
||||
const isActive = patient && patient.active ? true : false;
|
||||
const isWaiting = patient && patient.waiting_room ? true : false;
|
||||
|
||||
const canUsePatient = canPatientArea && !!pid;
|
||||
|
||||
function lockClass(allowed) {
|
||||
return allowed ? "" : "locked";
|
||||
}
|
||||
|
||||
function hrefIfAllowed(allowed, href) {
|
||||
return allowed ? href : "#";
|
||||
}
|
||||
%>
|
||||
|
||||
|
||||
<div class="sidebar">
|
||||
|
||||
<!-- ✅ Logo -->
|
||||
<div style="margin-bottom:30px; display:flex; flex-direction:column; gap:10px;">
|
||||
<div style="padding:20px; text-align:center;">
|
||||
<div class="logo" style="margin:0;">🩺 Praxis System</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ Zurück -->
|
||||
<a href="<%= backUrl || '/patients' %>" class="nav-item">
|
||||
<i class="bi bi-arrow-left-circle"></i> <%= t.global.return %>
|
||||
</a>
|
||||
|
||||
<div style="margin:10px 0; border-top:1px solid rgba(255,255,255,0.12);"></div>
|
||||
|
||||
<!-- ✅ Kein Patient gewählt -->
|
||||
<% if (!pid) { %>
|
||||
<div class="nav-item locked" style="opacity:0.7;">
|
||||
<i class="bi bi-info-circle"></i> Bitte Patient auswählen
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- =========================
|
||||
WARTEZIMMER
|
||||
========================= -->
|
||||
<% if (pid && canPatientArea) { %>
|
||||
|
||||
<% if (isWaiting) { %>
|
||||
<div class="nav-item locked" style="opacity:0.75;">
|
||||
<i class="bi bi-hourglass-split"></i> <%= t.global.waiting %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-check-circle-fill"></i></span>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<form method="POST" action="/patients/waiting-room/<%= pid %>">
|
||||
<button
|
||||
type="submit"
|
||||
class="nav-item"
|
||||
style="width:100%; border:none; background:transparent; text-align:left;"
|
||||
title="Patient ins Wartezimmer setzen"
|
||||
>
|
||||
<i class="bi bi-door-open"></i><%= t.global.towaitingroom %>
|
||||
</button>
|
||||
</form>
|
||||
<% } %>
|
||||
|
||||
<% } else { %>
|
||||
<div class="nav-item locked" style="opacity:0.7;">
|
||||
<i class="bi bi-door-open"></i> <%= t.global.towaitingroom %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- =========================
|
||||
BEARBEITEN
|
||||
========================= -->
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canUsePatient, '/patients/edit/' + pid) %>"
|
||||
class="nav-item <%= active === 'patient_edit' ? 'active' : '' %> <%= lockClass(canUsePatient) %>"
|
||||
title="<%= canUsePatient ? '' : 'Bitte zuerst einen Patienten auswählen' %>"
|
||||
<% if (!canUsePatient) { %>data-locked="Bitte zuerst einen Patienten auswählen"<% } %>
|
||||
>
|
||||
<i class="bi bi-pencil-square"></i> <%= t.global.edit %>
|
||||
<% if (!canUsePatient) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<!-- =========================
|
||||
ÜBERSICHT (Dashboard)
|
||||
========================= -->
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canUsePatient, '/patients/' + pid) %>"
|
||||
class="nav-item <%= active === 'patient_dashboard' ? 'active' : '' %> <%= lockClass(canUsePatient) %>"
|
||||
title="<%= canUsePatient ? '' : 'Bitte zuerst einen Patienten auswählen' %>"
|
||||
<% if (!canUsePatient) { %>data-locked="Bitte zuerst einen Patienten auswählen"<% } %>
|
||||
>
|
||||
<i class="bi bi-clipboard2-heart"></i> <%= t.global.overview %>
|
||||
<% if (!canUsePatient) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<!-- =========================
|
||||
STATUS TOGGLE
|
||||
========================= -->
|
||||
<form
|
||||
method="POST"
|
||||
action="<%= canUsePatient ? (isActive ? '/patients/deactivate/' + pid : '/patients/activate/' + pid) : '#' %>"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="nav-item <%= lockClass(canUsePatient) %>"
|
||||
style="width:100%; border:none; background:transparent; text-align:left;"
|
||||
<%= canUsePatient ? '' : 'disabled' %>
|
||||
title="<%= canUsePatient ? 'Status wechseln' : 'Bitte zuerst einen Patienten auswählen' %>"
|
||||
>
|
||||
<% if (isActive) { %>
|
||||
<i class="bi bi-x-circle"></i> <%= t.patienteoverview.closepatient %>
|
||||
<% } else { %>
|
||||
<i class="bi bi-check-circle"></i> <%= t.patienteoverview.openpatient %>
|
||||
<% } %>
|
||||
|
||||
<% if (!canUsePatient) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- ✅ Upload -->
|
||||
<div class="sidebar-upload <%= lockClass(canUsePatient) %>">
|
||||
|
||||
<div style="font-weight: 600; margin: 10px 0 6px 0; color: #e5e7eb">
|
||||
<i class="bi bi-paperclip"></i> <%= t.global.fileupload %>
|
||||
</div>
|
||||
|
||||
<% if (canUsePatient) { %>
|
||||
<form
|
||||
action="/patients/<%= pid %>/files"
|
||||
method="POST"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
<% } %>
|
||||
|
||||
<input
|
||||
id="sbUploadInput"
|
||||
type="file"
|
||||
name="file"
|
||||
class="form-control form-control-sm mb-2"
|
||||
<%= canUsePatient ? "" : "disabled" %>
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
id="sbUploadBtn"
|
||||
type="submit"
|
||||
class="btn btn-sm btn-outline-light w-100"
|
||||
<%= canUsePatient ? "" : "disabled" %>
|
||||
>
|
||||
📎 <%= t.global.upload %>
|
||||
<% if (!canUsePatient) { %>
|
||||
<span class="ms-2"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</button>
|
||||
|
||||
<% if (canUsePatient) { %>
|
||||
</form>
|
||||
<% } %>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ Kein-Zugriff Toast (wird von /js/sidebar-lock.js gesteuert) -->
|
||||
<div class="position-fixed top-0 start-50 translate-middle-x p-3" style="z-index:9999; margin-top:16px;">
|
||||
<div id="lockToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body d-flex align-items-center gap-2">
|
||||
<i class="bi bi-lock-fill"></i>
|
||||
<span id="lockToastMsg">Kein Zugriff</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,20 +1,5 @@
|
||||
<div class="sidebar-empty">
|
||||
<!-- ✅ Logo -->
|
||||
<div
|
||||
style="
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
"
|
||||
>
|
||||
<div class="sidebar sidebar-empty">
|
||||
<div style="padding: 20px; text-align: center">
|
||||
<div class="logo" style="margin: 0">🩺 Praxis System</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ Zurück -->
|
||||
<a href="<%= backUrl || '/patients' %>" class="nav-item">
|
||||
<i class="bi bi-arrow-left-circle"></i> Zurück
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
<%
|
||||
// =========================
|
||||
// BASISDATEN
|
||||
// =========================
|
||||
const role = user?.role || null;
|
||||
// ✅ Bereich 1: Arzt + Mitarbeiter
|
||||
const canDoctorAndStaff = role === "arzt" || role === "mitarbeiter";
|
||||
|
||||
// Arzt + Mitarbeiter dürfen Patienten bedienen
|
||||
const canPatientArea = role === "arzt" || role === "mitarbeiter";
|
||||
|
||||
const pid = patient && patient.id ? patient.id : null;
|
||||
const isActive = patient && patient.active ? true : false;
|
||||
const isWaiting = patient && patient.waiting_room ? true : false;
|
||||
|
||||
const canUsePatient = canPatientArea && !!pid;
|
||||
|
||||
function lockClass(allowed) {
|
||||
return allowed ? "" : "locked";
|
||||
}
|
||||
|
||||
function hrefIfAllowed(allowed, href) {
|
||||
return allowed ? href : "#";
|
||||
}
|
||||
%>
|
||||
|
||||
<div class="sidebar">
|
||||
|
||||
<!-- ✅ Logo -->
|
||||
<div style="margin-bottom:30px; display:flex; flex-direction:column; gap:10px;">
|
||||
<div style="padding:20px; text-align:center;">
|
||||
<div class="logo" style="margin:0;">🩺 Praxis System</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ Zurück -->
|
||||
<a href="<%= backUrl || '/patients' %>" class="nav-item">
|
||||
<i class="bi bi-arrow-left-circle"></i> Zurück
|
||||
</a>
|
||||
|
||||
<div style="margin:10px 0; border-top:1px solid rgba(255,255,255,0.12);"></div>
|
||||
|
||||
<!-- =========================
|
||||
Rechnungen
|
||||
========================= -->
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/open') %>"
|
||||
class="nav-item <%= active === 'open_invoices' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
|
||||
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
|
||||
<% if (!canDoctorAndStaff) { %>data-locked="Kein Zugriff – nur für Ärzte und Mitarbeiter"<% } %>
|
||||
>
|
||||
<i class="bi bi-receipt"></i> <%= t.openinvoices.openinvoices %>
|
||||
<% if (!canDoctorAndStaff) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/cancelled') %>"
|
||||
class="nav-item <%= active === 'cancelled_invoices' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
|
||||
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
|
||||
<% if (!canDoctorAndStaff) { %>data-locked="Kein Zugriff – nur für Ärzte und Mitarbeiter"<% } %>
|
||||
>
|
||||
<i class="bi bi-people"></i> <%= t.openinvoices.canceledinvoices %>
|
||||
<% if (!canDoctorAndStaff) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canDoctorAndStaff, '/reportview') %>"
|
||||
class="nav-item <%= active === 'reportview' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
|
||||
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
|
||||
<% if (!canDoctorAndStaff) { %>data-locked="Kein Zugriff – nur für Ärzte und Mitarbeiter"<% } %>
|
||||
>
|
||||
<i class="bi bi-people"></i> <%= t.openinvoices.report %>
|
||||
<% if (!canDoctorAndStaff) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/paid') %>"
|
||||
class="nav-item <%= active === 'paid' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
|
||||
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
|
||||
<% if (!canDoctorAndStaff) { %>data-locked="Kein Zugriff – nur für Ärzte und Mitarbeiter"<% } %>
|
||||
>
|
||||
<i class="bi bi-people"></i> <%= t.openinvoices.payedinvoices %>
|
||||
<% if (!canDoctorAndStaff) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/credit-overview') %>"
|
||||
class="nav-item <%= active === 'credit-overview' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
|
||||
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
|
||||
<% if (!canDoctorAndStaff) { %>data-locked="Kein Zugriff – nur für Ärzte und Mitarbeiter"<% } %>
|
||||
>
|
||||
<i class="bi bi-people"></i> <%= t.openinvoices.creditoverview %>
|
||||
<% if (!canDoctorAndStaff) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<!-- ✅ Logout -->
|
||||
<a href="/logout" class="nav-item">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ✅ Kein-Zugriff Toast (wird von /js/sidebar-lock.js gesteuert) -->
|
||||
<div class="position-fixed top-0 start-50 translate-middle-x p-3" style="z-index:9999; margin-top:16px;">
|
||||
<div id="lockToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body d-flex align-items-center gap-2">
|
||||
<i class="bi bi-lock-fill"></i>
|
||||
<span id="lockToastMsg">Kein Zugriff</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -36,7 +36,11 @@
|
||||
<%
|
||||
const role = user?.role || null;
|
||||
|
||||
// ✅ Regeln:
|
||||
// ✅ Bereich 1: Arzt + Mitarbeiter
|
||||
const canDoctorAndStaff = role === "arzt" || role === "mitarbeiter";
|
||||
|
||||
// ✅ Bereich 2: NUR Admin
|
||||
const canOnlyAdmin = role === "admin";
|
||||
|
||||
function hrefIfAllowed(allowed, href) {
|
||||
@ -46,20 +50,13 @@
|
||||
function lockClass(allowed) {
|
||||
return allowed ? "" : "locked";
|
||||
}
|
||||
|
||||
// Nachricht je Berechtigungsgruppe
|
||||
function lockMsg(allowed, requiredRole) {
|
||||
if (allowed) return "";
|
||||
if (requiredRole === "admin") return "Kein Zugriff – nur für Administratoren";
|
||||
return "Kein Zugriff – nur für Ärzte und Mitarbeiter";
|
||||
}
|
||||
%>
|
||||
|
||||
<!-- ✅ Patienten (Arzt + Mitarbeiter) -->
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canDoctorAndStaff, '/patients') %>"
|
||||
class="nav-item <%= active === 'patients' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
|
||||
<% if (!canDoctorAndStaff) { %>data-locked="<%= lockMsg(canDoctorAndStaff, 'arzt') %>"<% } %>
|
||||
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
|
||||
>
|
||||
<i class="bi bi-people"></i> <%= t.sidebar.patients %>
|
||||
<% if (!canDoctorAndStaff) { %>
|
||||
@ -67,23 +64,11 @@
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<!-- ✅ Kalender (Arzt + Mitarbeiter) -->
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canDoctorAndStaff, '/calendar') %>"
|
||||
class="nav-item <%= active === 'calendar' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
|
||||
<% if (!canDoctorAndStaff) { %>data-locked="<%= lockMsg(canDoctorAndStaff, 'arzt') %>"<% } %>
|
||||
>
|
||||
<i class="bi bi-calendar3"></i> Kalender
|
||||
<% if (!canDoctorAndStaff) { %>
|
||||
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
<!-- ✅ Medikamente (Arzt + Mitarbeiter) -->
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canDoctorAndStaff, '/medications') %>"
|
||||
class="nav-item <%= active === 'medications' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
|
||||
<% if (!canDoctorAndStaff) { %>data-locked="<%= lockMsg(canDoctorAndStaff, 'arzt') %>"<% } %>
|
||||
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
|
||||
>
|
||||
<i class="bi bi-capsule"></i> <%= t.sidebar.medications %>
|
||||
<% if (!canDoctorAndStaff) { %>
|
||||
@ -95,7 +80,7 @@
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canDoctorAndStaff, '/services/open') %>"
|
||||
class="nav-item <%= active === 'services' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
|
||||
<% if (!canDoctorAndStaff) { %>data-locked="<%= lockMsg(canDoctorAndStaff, 'arzt') %>"<% } %>
|
||||
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
|
||||
>
|
||||
<i class="bi bi-receipt"></i> <%= t.sidebar.servicesOpen %>
|
||||
<% if (!canDoctorAndStaff) { %>
|
||||
@ -107,7 +92,7 @@
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canDoctorAndStaff, '/admin/invoices') %>"
|
||||
class="nav-item <%= active === 'billing' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
|
||||
<% if (!canDoctorAndStaff) { %>data-locked="<%= lockMsg(canDoctorAndStaff, 'arzt') %>"<% } %>
|
||||
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
|
||||
>
|
||||
<i class="bi bi-cash-coin"></i> <%= t.sidebar.billing %>
|
||||
<% if (!canDoctorAndStaff) { %>
|
||||
@ -119,7 +104,7 @@
|
||||
<a
|
||||
href="<%= hrefIfAllowed(canOnlyAdmin, '/admin/users') %>"
|
||||
class="nav-item <%= active === 'admin' ? 'active' : '' %> <%= lockClass(canOnlyAdmin) %>"
|
||||
<% if (!canOnlyAdmin) { %>data-locked="<%= lockMsg(canOnlyAdmin, 'admin') %>"<% } %>
|
||||
title="<%= canOnlyAdmin ? '' : 'Nur Admin' %>"
|
||||
>
|
||||
<i class="bi bi-gear"></i> <%= t.sidebar.admin %>
|
||||
<% if (!canOnlyAdmin) { %>
|
||||
@ -131,20 +116,7 @@
|
||||
|
||||
<!-- ✅ Logout -->
|
||||
<a href="/logout" class="nav-item">
|
||||
<i class="bi bi-box-arrow-right"></i> <%= t.sidebar.logout %>
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ✅ Kein-Zugriff Toast (CSP-sicher, kein Inline-Script) -->
|
||||
<div class="position-fixed top-0 start-50 translate-middle-x p-3" style="z-index:9999; margin-top:16px;">
|
||||
<div id="lockToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body d-flex align-items-center gap-2">
|
||||
<i class="bi bi-lock-fill"></i>
|
||||
<span id="lockToastMsg">Kein Zugriff</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="<%= lang %>">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><%= t.patientCreate.title %></title>
|
||||
<title>Patient anlegen</title>
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
@ -12,7 +12,7 @@
|
||||
<div class="card shadow mx-auto" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
|
||||
<h3 class="mb-3"><%= t.patientCreate.title %></h3>
|
||||
<h3 class="mb-3">Neuer Patient</h3>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger"><%= error %></div>
|
||||
@ -20,41 +20,32 @@
|
||||
|
||||
<form method="POST" action="/patients/create">
|
||||
|
||||
<input class="form-control mb-2" name="firstname"
|
||||
placeholder="<%= t.patientCreate.firstname %>" required>
|
||||
<input class="form-control mb-2" name="lastname"
|
||||
placeholder="<%= t.patientCreate.lastname %>" required>
|
||||
<input class="form-control mb-2" name="dni"
|
||||
placeholder="<%= t.patientCreate.dni %>" required>
|
||||
|
||||
<input class="form-control mb-2" name="firstname" placeholder="Vorname" required>
|
||||
<input class="form-control mb-2" name="lastname" placeholder="Nachname" required>
|
||||
<input class="form-control mb-2" name="dni" placeholder="N.I.E. / DNI" required>
|
||||
<select class="form-select mb-2" name="gender">
|
||||
<option value=""><%= t.global.gender %></option>
|
||||
<option value="">Geschlecht</option>
|
||||
<option value="m">Männlich</option>
|
||||
<option value="w">Weiblich</option>
|
||||
<option value="d">Divers</option>
|
||||
</select>
|
||||
|
||||
<input class="form-control mb-2" type="date" name="birthdate" required>
|
||||
<input class="form-control mb-2" name="email"
|
||||
placeholder="<%= t.patientCreate.email %>">
|
||||
<input class="form-control mb-2" name="phone"
|
||||
placeholder="<%= t.patientCreate.phone %>">
|
||||
<input class="form-control mb-2" name="street"
|
||||
placeholder="<%= t.patientCreate.street %>">
|
||||
<input class="form-control mb-2" name="house_number"
|
||||
placeholder="<%= t.patientCreate.housenumber %>">
|
||||
<input class="form-control mb-2" name="postal_code"
|
||||
placeholder="<%= t.patientCreate.zip %>">
|
||||
<input class="form-control mb-2" name="city"
|
||||
placeholder="<%= t.patientCreate.city %>">
|
||||
<input class="form-control mb-2" name="country"
|
||||
placeholder="<%= t.patientCreate.country %>" value="Deutschland">
|
||||
<input class="form-control mb-2" name="email" placeholder="E-Mail">
|
||||
<input class="form-control mb-2" name="phone" placeholder="Telefon">
|
||||
|
||||
<textarea class="form-control mb-3" name="notes"
|
||||
placeholder="<%= t.patientCreate.notes %>"></textarea>
|
||||
<input class="form-control mb-2" name="street" placeholder="Straße">
|
||||
<input class="form-control mb-2" name="house_number" placeholder="Hausnummer">
|
||||
<input class="form-control mb-2" name="postal_code" placeholder="PLZ">
|
||||
<input class="form-control mb-2" name="city" placeholder="Ort">
|
||||
<input class="form-control mb-2" name="country" placeholder="Land" value="Deutschland">
|
||||
|
||||
<textarea class="form-control mb-3"
|
||||
name="notes"
|
||||
placeholder="Notizen"></textarea>
|
||||
|
||||
<button class="btn btn-primary w-100">
|
||||
<%= t.global.save %>
|
||||
Patient speichern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
<div class="layout">
|
||||
|
||||
<!-- ✅ Sidebar dynamisch über layout.ejs -->
|
||||
<!-- wird automatisch geladen -->
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- ✅ Neuer Header -->
|
||||
<%- include("partials/page-header", {
|
||||
user,
|
||||
title: t.global.edit,
|
||||
title: "Patient bearbeiten",
|
||||
subtitle: patient.firstname + " " + patient.lastname,
|
||||
showUserName: true,
|
||||
hideDashboardButton: false
|
||||
@ -23,66 +27,73 @@
|
||||
<div class="alert alert-danger"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<!-- ✅ POST geht auf /patients/update/:id -->
|
||||
<form method="POST" action="/patients/update/<%= patient.id %>">
|
||||
|
||||
<!-- ✅ returnTo per POST mitschicken -->
|
||||
<input type="hidden" name="returnTo" value="<%= returnTo || '' %>">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-2">
|
||||
<input class="form-control" name="firstname"
|
||||
<input
|
||||
class="form-control"
|
||||
name="firstname"
|
||||
value="<%= patient.firstname %>"
|
||||
placeholder="<%= t.patientEdit.firstname %>" required />
|
||||
placeholder="Vorname"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-2">
|
||||
<input class="form-control" name="lastname"
|
||||
<input
|
||||
class="form-control"
|
||||
name="lastname"
|
||||
value="<%= patient.lastname %>"
|
||||
placeholder="<%= t.patientEdit.lastname %>" required />
|
||||
placeholder="Nachname"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-2">
|
||||
<select class="form-select" name="gender">
|
||||
<option value=""><%= t.global.gender %></option>
|
||||
<option value="">Geschlecht</option>
|
||||
<option value="m" <%= patient.gender === "m" ? "selected" : "" %>>Männlich</option>
|
||||
<option value="w" <%= patient.gender === "w" ? "selected" : "" %>>Weiblich</option>
|
||||
<option value="d" <%= patient.gender === "d" ? "selected" : "" %>>Divers</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 mb-2">
|
||||
<input class="form-control" type="date" name="birthdate"
|
||||
<input
|
||||
class="form-control"
|
||||
type="date"
|
||||
name="birthdate"
|
||||
value="<%= patient.birthdate ? new Date(patient.birthdate).toISOString().split('T')[0] : '' %>"
|
||||
required />
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input class="form-control mb-2" name="email"
|
||||
value="<%= patient.email || '' %>"
|
||||
placeholder="<%= t.patientEdit.email %>" />
|
||||
<input class="form-control mb-2" name="phone"
|
||||
value="<%= patient.phone || '' %>"
|
||||
placeholder="<%= t.patientEdit.phone %>" />
|
||||
<input class="form-control mb-2" name="street"
|
||||
value="<%= patient.street || '' %>"
|
||||
placeholder="<%= t.patientEdit.street %>" />
|
||||
<input class="form-control mb-2" name="house_number"
|
||||
value="<%= patient.house_number || '' %>"
|
||||
placeholder="<%= t.patientEdit.housenumber %>" />
|
||||
<input class="form-control mb-2" name="postal_code"
|
||||
value="<%= patient.postal_code || '' %>"
|
||||
placeholder="<%= t.patientEdit.zip %>" />
|
||||
<input class="form-control mb-2" name="city"
|
||||
value="<%= patient.city || '' %>"
|
||||
placeholder="<%= t.patientEdit.city %>" />
|
||||
<input class="form-control mb-2" name="country"
|
||||
value="<%= patient.country || '' %>"
|
||||
placeholder="<%= t.patientEdit.country %>" />
|
||||
<input class="form-control mb-2" name="email" value="<%= patient.email || '' %>" placeholder="E-Mail" />
|
||||
<input class="form-control mb-2" name="phone" value="<%= patient.phone || '' %>" placeholder="Telefon" />
|
||||
|
||||
<textarea class="form-control mb-3" name="notes" rows="4"
|
||||
placeholder="<%= t.patientEdit.notes %>"><%= patient.notes || '' %></textarea>
|
||||
<input class="form-control mb-2" name="street" value="<%= patient.street || '' %>" placeholder="Straße" />
|
||||
<input class="form-control mb-2" name="house_number" value="<%= patient.house_number || '' %>" placeholder="Hausnummer" />
|
||||
<input class="form-control mb-2" name="postal_code" value="<%= patient.postal_code || '' %>" placeholder="PLZ" />
|
||||
<input class="form-control mb-2" name="city" value="<%= patient.city || '' %>" placeholder="Ort" />
|
||||
<input class="form-control mb-2" name="country" value="<%= patient.country || '' %>" placeholder="Land" />
|
||||
|
||||
<textarea
|
||||
class="form-control mb-3"
|
||||
name="notes"
|
||||
rows="4"
|
||||
placeholder="Notizen"
|
||||
><%= patient.notes || '' %></textarea>
|
||||
|
||||
<button class="btn btn-primary w-100">
|
||||
<%= t.patientEdit.save %>
|
||||
Änderungen speichern
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<%- include("partials/page-header", {
|
||||
user,
|
||||
title: "💊 " + t.patientMedications.selectmedication,
|
||||
title: "💊 Medikation",
|
||||
subtitle: patient.firstname + " " + patient.lastname,
|
||||
showUserName: true,
|
||||
showDashboardButton: false
|
||||
@ -12,11 +12,14 @@
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- ✅ Patient Info -->
|
||||
<div class="card shadow-sm mb-3 patient-box">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-1"><%= patient.firstname %> <%= patient.lastname %></h5>
|
||||
<h5 class="mb-1">
|
||||
<%= patient.firstname %> <%= patient.lastname %>
|
||||
</h5>
|
||||
<div class="text-muted small">
|
||||
<%= t.global.birthdate %>:
|
||||
Geboren am:
|
||||
<%= new Date(patient.birthdate).toLocaleDateString("de-DE") %>
|
||||
</div>
|
||||
</div>
|
||||
@ -24,52 +27,60 @@
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- Medikament hinzufügen -->
|
||||
<!-- ✅ Medikament hinzufügen -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header fw-semibold">
|
||||
➕ <%= t.patientMedications.selectmedication %>
|
||||
➕ Medikament zuweisen
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<form method="POST" action="/patients/<%= patient.id %>/medications/assign">
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label"><%= t.patientMedications.selectmedication %></label>
|
||||
<label class="form-label">Medikament auswählen</label>
|
||||
<select name="medication_variant_id" class="form-select" required>
|
||||
<option value="">-- <%= t.global.selection %> --</option>
|
||||
<option value="">-- auswählen --</option>
|
||||
<% meds.forEach(m => { %>
|
||||
<option value="<%= m.id %>">
|
||||
<%= m.medication %> | <%= m.form %> | <%= m.dosage %>
|
||||
<% if (m.package) { %> | <%= m.package %><% } %>
|
||||
<% if (m.package) { %>
|
||||
| <%= m.package %>
|
||||
<% } %>
|
||||
</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label"><%= t.patientMedications.dosageinstructions %></label>
|
||||
<input type="text" class="form-control" name="dosage_instruction"
|
||||
placeholder="<%= t.patientMedications.example %>" />
|
||||
<label class="form-label">Dosierungsanweisung</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="dosage_instruction"
|
||||
placeholder="z.B. 1-0-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.patientMedications.startdate %></label>
|
||||
<label class="form-label">Startdatum</label>
|
||||
<input type="date" class="form-control" name="start_date" />
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><%= t.patientMedications.enddate %></label>
|
||||
<label class="form-label">Enddatum</label>
|
||||
<input type="date" class="form-control" name="end_date" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary">
|
||||
✅ <%= t.patientMedications.save %>
|
||||
✅ Speichern
|
||||
</button>
|
||||
|
||||
<a href="/patients/<%= patient.id %>/overview" class="btn btn-outline-secondary">
|
||||
⬅️ <%= t.patientMedications.backoverview %>
|
||||
⬅️ Zur Übersicht
|
||||
</a>
|
||||
|
||||
</form>
|
||||
@ -78,30 +89,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aktuelle Medikation -->
|
||||
<!-- ✅ Aktuelle Medikation -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header fw-semibold">
|
||||
📋 <%= t.patientMedications.selectmedication %>
|
||||
📋 Aktuelle Medikation
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<% if (!currentMeds || currentMeds.length === 0) { %>
|
||||
<div class="text-muted"><%= t.patientMedications.nomedication %></div>
|
||||
<div class="text-muted">
|
||||
Keine Medikation vorhanden.
|
||||
</div>
|
||||
<% } else { %>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t.patientMedications.medication %></th>
|
||||
<th><%= t.patientMedications.form %></th>
|
||||
<th><%= t.patientMedications.dosage %></th>
|
||||
<th><%= t.patientMedications.instruction %></th>
|
||||
<th><%= t.patientMedications.from %></th>
|
||||
<th><%= t.patientMedications.to %></th>
|
||||
<th>Medikament</th>
|
||||
<th>Form</th>
|
||||
<th>Dosierung</th>
|
||||
<th>Anweisung</th>
|
||||
<th>Von</th>
|
||||
<th>Bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% currentMeds.forEach(cm => { %>
|
||||
<tr>
|
||||
@ -109,8 +124,12 @@
|
||||
<td><%= cm.form %></td>
|
||||
<td><%= cm.dosage %></td>
|
||||
<td><%= cm.dosage_instruction || "-" %></td>
|
||||
<td><%= cm.start_date ? new Date(cm.start_date).toLocaleDateString("de-DE") : "-" %></td>
|
||||
<td><%= cm.end_date ? new Date(cm.end_date).toLocaleDateString("de-DE") : "-" %></td>
|
||||
<td>
|
||||
<%= cm.start_date ? new Date(cm.start_date).toLocaleDateString("de-DE") : "-" %>
|
||||
</td>
|
||||
<td>
|
||||
<%= cm.end_date ? new Date(cm.end_date).toLocaleDateString("de-DE") : "-" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
<div class="layout">
|
||||
|
||||
<!-- ✅ Sidebar: Patient -->
|
||||
<!-- kommt automatisch über layout.ejs, wenn sidebarPartial gesetzt ist -->
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- ✅ Neuer Header -->
|
||||
<%- include("partials/page-header", {
|
||||
user,
|
||||
title: t.global.patient,
|
||||
title: "Patient",
|
||||
subtitle: patient.firstname + " " + patient.lastname,
|
||||
showUserName: true
|
||||
}) %>
|
||||
@ -13,50 +17,58 @@
|
||||
|
||||
<%- include("partials/flash") %>
|
||||
|
||||
<!-- Patientendaten -->
|
||||
<!-- ✅ PATIENTENDATEN -->
|
||||
<div class="card shadow-sm mb-3 patient-data-box">
|
||||
<div class="card-body">
|
||||
<h4><%= t.patientOverview.patientdata %></h4>
|
||||
<h4>Patientendaten</h4>
|
||||
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<th><%= t.patientOverview.firstname %></th>
|
||||
<th>Vorname</th>
|
||||
<td><%= patient.firstname %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><%= t.patientOverview.lastname %></th>
|
||||
<th>Nachname</th>
|
||||
<td><%= patient.lastname %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><%= t.patientOverview.birthdate %></th>
|
||||
<td><%= patient.birthdate ? new Date(patient.birthdate).toLocaleDateString("de-DE") : "-" %></td>
|
||||
<th>Geburtsdatum</th>
|
||||
<td>
|
||||
<%= patient.birthdate ? new Date(patient.birthdate).toLocaleDateString("de-DE") : "-" %>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><%= t.patientOverview.email %></th>
|
||||
<th>E-Mail</th>
|
||||
<td><%= patient.email || "-" %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><%= t.patientOverview.phone %></th>
|
||||
<th>Telefon</th>
|
||||
<td><%= patient.phone || "-" %></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ UNTERER BEREICH -->
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- Notizen -->
|
||||
<!-- 📝 NOTIZEN -->
|
||||
<div class="col-lg-5 col-md-12">
|
||||
<div class="card shadow h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5>📝 <%= t.patientOverview.notes %></h5>
|
||||
<h5>📝 Notizen</h5>
|
||||
|
||||
<form method="POST" action="/patients/<%= patient.id %>/notes">
|
||||
<textarea class="form-control mb-2" name="note" rows="3"
|
||||
<textarea
|
||||
class="form-control mb-2"
|
||||
name="note"
|
||||
rows="3"
|
||||
style="resize: none"
|
||||
placeholder="<%= t.patientOverview.newnote %>"></textarea>
|
||||
placeholder="Neue Notiz hinzufügen…"
|
||||
></textarea>
|
||||
|
||||
<button class="btn btn-sm btn-primary">
|
||||
➕ <%= t.global.save %>
|
||||
➕ Notiz speichern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@ -64,7 +76,7 @@
|
||||
|
||||
<div style="max-height: 320px; overflow-y: auto;">
|
||||
<% if (!notes || notes.length === 0) { %>
|
||||
<p class="text-muted"><%= t.patientOverview.nonotes %></p>
|
||||
<p class="text-muted">Keine Notizen vorhanden</p>
|
||||
<% } else { %>
|
||||
<% notes.forEach(n => { %>
|
||||
<div class="mb-3 p-2 border rounded bg-light">
|
||||
@ -84,15 +96,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rezept -->
|
||||
<!-- 💊 MEDIKAMENT -->
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card shadow h-100">
|
||||
<div class="card-body">
|
||||
<h5>💊 <%= t.patientOverview.createrecipe %></h5>
|
||||
<h5>💊 Rezept erstellen</h5>
|
||||
|
||||
<form method="POST" action="/patients/<%= patient.id %>/medications">
|
||||
<select name="medication_variant_id" class="form-select mb-2" required>
|
||||
<option value=""><%=t.global.selection %>…</option>
|
||||
<option value="">Bitte auswählen…</option>
|
||||
<% medicationVariants.forEach(mv => { %>
|
||||
<option value="<%= mv.variant_id %>">
|
||||
<%= mv.medication_name %> – <%= mv.form_name %> – <%= mv.dosage %>
|
||||
@ -100,33 +112,51 @@
|
||||
<% }) %>
|
||||
</select>
|
||||
|
||||
<input type="text" name="dosage_instruction" class="form-control mb-2"
|
||||
placeholder="<%= t.patientMedications.example %>" />
|
||||
<input
|
||||
type="text"
|
||||
name="dosage_instruction"
|
||||
class="form-control mb-2"
|
||||
placeholder="z. B. 1–0–1"
|
||||
/>
|
||||
|
||||
<input type="date" name="start_date" class="form-control mb-2"
|
||||
value="<%= new Date().toISOString().split('T')[0] %>" />
|
||||
<input
|
||||
type="date"
|
||||
name="start_date"
|
||||
class="form-control mb-2"
|
||||
value="<%= new Date().toISOString().split('T')[0] %>"
|
||||
/>
|
||||
|
||||
<input type="date" name="end_date" class="form-control mb-3" />
|
||||
|
||||
<button class="btn btn-sm btn-success w-100">
|
||||
➕ <%= t.global.save %>
|
||||
➕ Verordnen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heutige Leistungen -->
|
||||
<!-- 🧾 HEUTIGE LEISTUNGEN -->
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card shadow h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5>🧾 <%= t.patientOverview.searchservice %></h5>
|
||||
<h5>🧾 Heutige Leistungen</h5>
|
||||
|
||||
<form method="POST" action="/patients/<%= patient.id %>/services">
|
||||
<input type="text" id="serviceSearch" class="form-control mb-2"
|
||||
placeholder="<%= t.patientOverview.searchservice %>" />
|
||||
<input
|
||||
type="text"
|
||||
id="serviceSearch"
|
||||
class="form-control mb-2"
|
||||
placeholder="Leistung suchen…"
|
||||
/>
|
||||
|
||||
<select name="service_id" id="serviceSelect" class="form-select mb-2" size="5" required>
|
||||
<select
|
||||
name="service_id"
|
||||
id="serviceSelect"
|
||||
class="form-select mb-2"
|
||||
size="5"
|
||||
required
|
||||
>
|
||||
<% services.forEach(s => { %>
|
||||
<option value="<%= s.id %>">
|
||||
<%= s.name %> – <%= Number(s.price || 0).toFixed(2) %> €
|
||||
@ -134,10 +164,16 @@
|
||||
<% }) %>
|
||||
</select>
|
||||
|
||||
<input type="number" name="quantity" class="form-control mb-2" value="1" min="1" />
|
||||
<input
|
||||
type="number"
|
||||
name="quantity"
|
||||
class="form-control mb-2"
|
||||
value="1"
|
||||
min="1"
|
||||
/>
|
||||
|
||||
<button class="btn btn-sm btn-success w-100">
|
||||
➕ <%= t.patientOverview.addservice %>
|
||||
➕ Leistung hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@ -145,13 +181,13 @@
|
||||
|
||||
<div style="max-height: 320px; overflow-y: auto;">
|
||||
<% if (!todayServices || todayServices.length === 0) { %>
|
||||
<p class="text-muted"><%= t.patientOverview.noservices %></p>
|
||||
<p class="text-muted">Noch keine Leistungen für heute.</p>
|
||||
<% } else { %>
|
||||
<% todayServices.forEach(ls => { %>
|
||||
<div class="border rounded p-2 mb-2 bg-light">
|
||||
<strong><%= ls.name %></strong><br />
|
||||
<%= t.global.quantity %>: <%= ls.quantity %><br />
|
||||
<%= t.global.price %>: <%= Number(ls.price).toFixed(2) %> €
|
||||
Menge: <%= ls.quantity %><br />
|
||||
Preis: <%= Number(ls.price).toFixed(2) %> €
|
||||
</div>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- ✅ Neuer globaler Header -->
|
||||
<%- include("partials/page-header", {
|
||||
user,
|
||||
title: t.patienteoverview.patienttitle,
|
||||
title: "Patientenübersicht",
|
||||
subtitle: patient.firstname + " " + patient.lastname,
|
||||
showUserName: true,
|
||||
hideDashboardButton: false
|
||||
@ -16,22 +17,26 @@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
|
||||
<!-- Patient Info -->
|
||||
<!-- =========================
|
||||
PATIENT INFO
|
||||
========================== -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-body">
|
||||
<h4 class="mb-1">👤 <%= patient.firstname %> <%= patient.lastname %></h4>
|
||||
|
||||
<p class="text-muted mb-3">
|
||||
<%= t.global.birthday %> <%= new Date(patient.birthdate).toLocaleDateString("de-DE") %>
|
||||
Geboren am <%= new Date(patient.birthdate).toLocaleDateString("de-DE") %>
|
||||
</p>
|
||||
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<strong><%= t.patientDashboard.email %></strong> <%= patient.email || "-" %>
|
||||
<strong>E-Mail:</strong> <%= patient.email || "-" %>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<strong><%= t.patientDashboard.phone %></strong> <%= patient.phone || "-" %>
|
||||
<strong>Telefon:</strong> <%= patient.phone || "-" %>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<strong><%= t.patientDashboard.address %></strong>
|
||||
<strong>Adresse:</strong>
|
||||
<%= patient.street || "" %> <%= patient.house_number || "" %>,
|
||||
<%= patient.postal_code || "" %> <%= patient.city || "" %>
|
||||
</li>
|
||||
@ -39,23 +44,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3" style="height: calc(100vh - 420px); min-height: 300px; padding-bottom: 3rem; overflow: hidden;">
|
||||
<!-- =========================
|
||||
MEDIKAMENTE & RECHNUNGEN
|
||||
========================== -->
|
||||
<div
|
||||
class="row g-3"
|
||||
style="
|
||||
height: calc(100vh - 420px);
|
||||
min-height: 300px;
|
||||
padding-bottom: 3rem;
|
||||
overflow: hidden;
|
||||
"
|
||||
>
|
||||
|
||||
<!-- Medikamente -->
|
||||
<!-- 💊 MEDIKAMENTE -->
|
||||
<div class="col-lg-6 h-100">
|
||||
<div class="card shadow h-100">
|
||||
<div class="card-body d-flex flex-column h-100">
|
||||
<h5>💊 <%= t.patientDashboard.medications %></h5>
|
||||
<div style="flex: 1 1 auto; overflow-y: auto; min-height: 0; padding-bottom: 1.5rem;">
|
||||
<h5>💊 Aktuelle Medikamente</h5>
|
||||
|
||||
<div
|
||||
style="
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding-bottom: 1.5rem;
|
||||
"
|
||||
>
|
||||
<% if (medications.length === 0) { %>
|
||||
<p class="text-muted"><%= t.patientDashboard.nomedications %></p>
|
||||
<p class="text-muted">Keine aktiven Medikamente</p>
|
||||
<% } else { %>
|
||||
<table class="table table-sm table-bordered mt-2">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th><%= t.patientDashboard.medication %></th>
|
||||
<th><%= t.patientDashboard.variant %></th>
|
||||
<th><%= t.patientDashboard.instruction %></th>
|
||||
<th>Medikament</th>
|
||||
<th>Variante</th>
|
||||
<th>Anweisung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -70,25 +94,34 @@
|
||||
</table>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechnungen -->
|
||||
<!-- 🧾 RECHNUNGEN -->
|
||||
<div class="col-lg-6 h-100">
|
||||
<div class="card shadow h-100">
|
||||
<div class="card-body d-flex flex-column h-100">
|
||||
<h5>🧾 <%= t.patientDashboard.invoices %></h5>
|
||||
<div style="flex: 1 1 auto; overflow-y: auto; min-height: 0; padding-bottom: 1.5rem;">
|
||||
<h5>🧾 Rechnungen</h5>
|
||||
|
||||
<div
|
||||
style="
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding-bottom: 1.5rem;
|
||||
"
|
||||
>
|
||||
<% if (invoices.length === 0) { %>
|
||||
<p class="text-muted"><%= t.patientDashboard.noinvoices %></p>
|
||||
<p class="text-muted">Keine Rechnungen vorhanden</p>
|
||||
<% } else { %>
|
||||
<table class="table table-sm table-bordered mt-2">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th><%= t.patientDashboard.date %></th>
|
||||
<th><%= t.patientDashboard.amount %></th>
|
||||
<th><%= t.patientDashboard.pdf %></th>
|
||||
<th>Datum</th>
|
||||
<th>Betrag</th>
|
||||
<th>PDF</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -98,9 +131,12 @@
|
||||
<td><%= Number(i.total_amount).toFixed(2) %> €</td>
|
||||
<td>
|
||||
<% if (i.file_path) { %>
|
||||
<a href="<%= i.file_path %>" target="_blank"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
📄 <%= t.patientDashboard.open %>
|
||||
<a
|
||||
href="<%= i.file_path %>"
|
||||
target="_blank"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
>
|
||||
📄 Öffnen
|
||||
</a>
|
||||
<% } else { %>
|
||||
-
|
||||
@ -112,6 +148,7 @@
|
||||
</table>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<%- include("partials/page-header", {
|
||||
user,
|
||||
title: t.patienteoverview.patienttitle,
|
||||
title: "Patientenübersicht",
|
||||
subtitle: "",
|
||||
showUserName: true
|
||||
}) %>
|
||||
@ -9,9 +9,10 @@
|
||||
|
||||
<%- include("partials/flash") %>
|
||||
|
||||
<!-- Aktionen oben -->
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<a href="/patients/create" class="btn btn-success">
|
||||
+ <%= t.patienteoverview.newpatient %>
|
||||
+ Neuer Patient
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -21,51 +22,62 @@
|
||||
<!-- Suchformular -->
|
||||
<form method="GET" action="/patients" class="row g-2 mb-4">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="firstname" class="form-control"
|
||||
placeholder="<%= t.global.firstname %>"
|
||||
value="<%= query?.firstname || '' %>" />
|
||||
<input
|
||||
type="text"
|
||||
name="firstname"
|
||||
class="form-control"
|
||||
placeholder="Vorname"
|
||||
value="<%= query?.firstname || '' %>"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="lastname" class="form-control"
|
||||
placeholder="<%= t.global.lastname %>"
|
||||
value="<%= query?.lastname || '' %>" />
|
||||
<input
|
||||
type="text"
|
||||
name="lastname"
|
||||
class="form-control"
|
||||
placeholder="Nachname"
|
||||
value="<%= query?.lastname || '' %>"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<input type="date" name="birthdate" class="form-control"
|
||||
value="<%= query?.birthdate || '' %>" />
|
||||
<input
|
||||
type="date"
|
||||
name="birthdate"
|
||||
class="form-control"
|
||||
value="<%= query?.birthdate || '' %>"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 d-flex gap-2">
|
||||
<button class="btn btn-primary w-100"><%= t.global.search %></button>
|
||||
<a href="/patients" class="btn btn-secondary w-100"><%= t.global.reset2 %></a>
|
||||
<button class="btn btn-primary w-100">Suchen</button>
|
||||
<a href="/patients" class="btn btn-secondary w-100">
|
||||
Zurücksetzen
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Patienten-Tabelle -->
|
||||
<form method="GET" action="/patients">
|
||||
|
||||
<input type="hidden" name="firstname" value="<%= query?.firstname || '' %>">
|
||||
<input type="hidden" name="lastname" value="<%= query?.lastname || '' %>">
|
||||
<input type="hidden" name="birthdate" value="<%= query?.birthdate || '' %>">
|
||||
|
||||
<!-- Tabelle -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover align-middle table-sm">
|
||||
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th style="width:40px;"></th>
|
||||
<th>ID</th>
|
||||
<th><%= t.global.name %></th>
|
||||
<th><%= t.patienteoverview.dni %></th>
|
||||
<th><%= t.global.gender %></th>
|
||||
<th><%= t.global.birthday %></th>
|
||||
<th><%= t.global.email %></th>
|
||||
<th><%= t.global.phone %></th>
|
||||
<th><%= t.global.address %></th>
|
||||
<th><%= t.global.country %></th>
|
||||
<th><%= t.global.status %></th>
|
||||
<th><%= t.global.notice %></th>
|
||||
<th><%= t.global.create %></th>
|
||||
<th><%= t.global.change %></th>
|
||||
<th>Name</th>
|
||||
<th>N.I.E. / DNI</th>
|
||||
<th>Geschlecht</th>
|
||||
<th>Geburtstag</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Telefon</th>
|
||||
<th>Adresse</th>
|
||||
<th>Land</th>
|
||||
<th>Status</th>
|
||||
<th>Notizen</th>
|
||||
<th>Erstellt</th>
|
||||
<th>Geändert</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@ -73,14 +85,22 @@
|
||||
<% if (patients.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="15" class="text-center text-muted">
|
||||
<%= t.patientoverview.nopatientfound %>
|
||||
Keine Patienten gefunden
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
|
||||
<% patients.forEach(p => { %>
|
||||
<tr>
|
||||
|
||||
<!-- ✅ RADIOBUTTON ganz vorne -->
|
||||
<td class="text-center">
|
||||
<form method="GET" action="/patients">
|
||||
<!-- Filter beibehalten -->
|
||||
<input type="hidden" name="firstname" value="<%= query.firstname || '' %>">
|
||||
<input type="hidden" name="lastname" value="<%= query.lastname || '' %>">
|
||||
<input type="hidden" name="birthdate" value="<%= query.birthdate || '' %>">
|
||||
|
||||
<input
|
||||
class="patient-radio"
|
||||
type="radio"
|
||||
@ -88,24 +108,36 @@
|
||||
value="<%= p.id %>"
|
||||
<%= selectedPatientId === p.id ? "checked" : "" %>
|
||||
/>
|
||||
|
||||
</form>
|
||||
</td>
|
||||
|
||||
<td><%= p.id %></td>
|
||||
|
||||
<td><strong><%= p.firstname %> <%= p.lastname %></strong></td>
|
||||
<td><%= p.dni || "-" %></td>
|
||||
|
||||
<td>
|
||||
<%= p.gender === 'm' ? 'm' :
|
||||
p.gender === 'w' ? 'w' :
|
||||
p.gender === 'd' ? 'd' : '-' %>
|
||||
<% if (p.gender === 'm') { %>
|
||||
m
|
||||
<% } else if (p.gender === 'w') { %>
|
||||
w
|
||||
<% } else if (p.gender === 'd') { %>
|
||||
d
|
||||
<% } else { %>
|
||||
-
|
||||
<% } %>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<%= new Date(p.birthdate).toLocaleDateString("de-DE") %>
|
||||
</td>
|
||||
|
||||
<td><%= new Date(p.birthdate).toLocaleDateString("de-DE") %></td>
|
||||
<td><%= p.email || "-" %></td>
|
||||
<td><%= p.phone || "-" %></td>
|
||||
|
||||
<td>
|
||||
<%= p.street || "" %> <%= p.house_number || "" %><br>
|
||||
<%= p.street || "" %> <%= p.house_number || "" %><br />
|
||||
<%= p.postal_code || "" %> <%= p.city || "" %>
|
||||
</td>
|
||||
|
||||
@ -113,15 +145,90 @@
|
||||
|
||||
<td>
|
||||
<% if (p.active) { %>
|
||||
<span class="badge bg-success"><%= t.patienteoverview.active %></span>
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
<% } else { %>
|
||||
<span class="badge bg-secondary"><%= t.patienteoverview.inactive %></span>
|
||||
<span class="badge bg-secondary">Inaktiv</span>
|
||||
<% } %>
|
||||
</td>
|
||||
|
||||
<td><%= p.notes ? p.notes.substring(0, 80) : "-" %></td>
|
||||
<td style="max-width: 200px">
|
||||
<%= p.notes ? p.notes.substring(0, 80) : "-" %>
|
||||
</td>
|
||||
|
||||
<td><%= new Date(p.created_at).toLocaleString("de-DE") %></td>
|
||||
<td><%= new Date(p.updated_at).toLocaleString("de-DE") %></td>
|
||||
|
||||
<td class="text-nowrap">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown">
|
||||
Auswahl ▾
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item" href="/patients/edit/<%= p.id %>">
|
||||
✏️ Bearbeiten
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
|
||||
<% if (p.waiting_room) { %>
|
||||
<li>
|
||||
<span class="dropdown-item text-muted">🪑 Wartet bereits</span>
|
||||
</li>
|
||||
<% } else { %>
|
||||
<li>
|
||||
<form method="POST" action="/patients/waiting-room/<%= p.id %>">
|
||||
<button class="dropdown-item">🪑 Ins Wartezimmer</button>
|
||||
</form>
|
||||
</li>
|
||||
<% } %>
|
||||
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item" href="/patients/<%= p.id %>/medications">
|
||||
💊 Medikamente
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
|
||||
<li>
|
||||
<% if (p.active) { %>
|
||||
<form method="POST" action="/patients/deactivate/<%= p.id %>">
|
||||
<button class="dropdown-item text-warning">🔒 Sperren</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<form method="POST" action="/patients/activate/<%= p.id %>">
|
||||
<button class="dropdown-item text-success">🔓 Entsperren</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item" href="/patients/<%= p.id %>">
|
||||
📋 Übersicht
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
|
||||
<li class="px-3 py-2">
|
||||
<form method="POST" action="/patients/<%= p.id %>/files" enctype="multipart/form-data">
|
||||
<input type="file" name="file" class="form-control form-control-sm mb-2" required />
|
||||
<button class="btn btn-sm btn-secondary w-100">
|
||||
📎 Hochladen
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
@ -129,7 +236,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
/**
|
||||
* public/js/reportview-select.js
|
||||
* Ersetzt onchange="this.form.submit()" im Report-Filter (CSP-sicher)
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
['reportYear', 'reportQuarter'].forEach(function (id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.addEventListener('change', function () {
|
||||
const form = this.closest('form');
|
||||
if (form) form.submit();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,49 +0,0 @@
|
||||
<%- include("partials/page-header", {
|
||||
user,
|
||||
title: t.reportview.title,
|
||||
subtitle: "",
|
||||
showUserName: true
|
||||
}) %>
|
||||
|
||||
<div class="container mt-4">
|
||||
<%- include("partials/flash") %>
|
||||
<h4><%= t.reportview.title %></h4>
|
||||
|
||||
<form method="GET" action="/reports"
|
||||
style="margin-bottom:25px; display:flex; gap:15px; align-items:end;">
|
||||
|
||||
<div>
|
||||
<label><%= t.reportview.year %></label>
|
||||
<select name="year" class="form-select" id="reportYear">
|
||||
<% years.forEach(y => { %>
|
||||
<option value="<%= y %>" <%= y == selectedYear ? "selected" : "" %>><%= y %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label><%= t.reportview.quarter %></label>
|
||||
<select name="quarter" class="form-select" id="reportQuarter">
|
||||
<option value="0">Alle</option>
|
||||
<option value="1" <%= selectedQuarter == 1 ? "selected" : "" %>>Q1</option>
|
||||
<option value="2" <%= selectedQuarter == 2 ? "selected" : "" %>>Q2</option>
|
||||
<option value="3" <%= selectedQuarter == 3 ? "selected" : "" %>>Q3</option>
|
||||
<option value="4" <%= selectedQuarter == 4 ? "selected" : "" %>>Q4</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<div style="max-width: 400px; margin: auto">
|
||||
<canvas id="statusChart"></canvas>
|
||||
<div id="custom-legend" class="chart-legend"></div>
|
||||
</div>
|
||||
|
||||
<script id="stats-data" type="application/json">
|
||||
<%- JSON.stringify(stats) %>
|
||||
</script>
|
||||
|
||||
<script src="/js/chart.js"></script>
|
||||
<script src="/js/reports.js"></script>
|
||||
<script src="/js/reportview-select.js" defer></script>
|
||||
</div>
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -1,22 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="<%= lang %>">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><%= t.serviceCreate.title %></title>
|
||||
<title>Neue Leistung</title>
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-dark bg-dark px-3">
|
||||
<span class="navbar-brand">➕ <%= t.serviceCreate.title %></span>
|
||||
<a href="/services" class="btn btn-outline-light btn-sm"><%= t.serviceCreate.back %></a>
|
||||
<span class="navbar-brand">➕ Neue Leistung</span>
|
||||
<a href="/services" class="btn btn-outline-light btn-sm">Zurück</a>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
|
||||
<div class="card shadow mx-auto" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
|
||||
<h4 class="mb-3"><%= t.serviceCreate.newservice %></h4>
|
||||
<h4 class="mb-3">Neue Leistung anlegen</h4>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger"><%= error %></div>
|
||||
@ -25,39 +26,47 @@
|
||||
<form method="POST">
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label"><%= t.serviceCreate.namede %></label>
|
||||
<label class="form-label">Bezeichnung (Deutsch) *</label>
|
||||
<input name="name_de" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label"><%= t.serviceCreate.namees %></label>
|
||||
<label class="form-label">Bezeichnung (Spanisch)</label>
|
||||
<input name="name_es" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label"><%= t.serviceCreate.category %></label>
|
||||
<label class="form-label">Kategorie</label>
|
||||
<input name="category" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label class="form-label"><%= t.serviceCreate.price %></label>
|
||||
<input name="price" type="number" step="0.01" class="form-control" required>
|
||||
<label class="form-label">Preis (€) *</label>
|
||||
<input name="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="form-control"
|
||||
required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label"><%= t.serviceCreate.pricec70 %></label>
|
||||
<input name="price_c70" type="number" step="0.01" class="form-control">
|
||||
<label class="form-label">Preis C70 (€)</label>
|
||||
<input name="price_c70"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-success w-100 mt-3">
|
||||
💾 <%= t.global.save %>
|
||||
💾 Leistung speichern
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,38 +1,81 @@
|
||||
<%- include("partials/page-header", {
|
||||
user,
|
||||
title: t.services.title,
|
||||
subtitle: "",
|
||||
showUserName: true
|
||||
}) %>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Leistungen</title>
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
<script src="/js/services-lock.js"></script> ✔ erlaubt
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- NAVBAR -->
|
||||
<nav class="navbar navbar-dark bg-dark position-relative px-3">
|
||||
|
||||
<!-- ZENTRIERTER TITEL -->
|
||||
<div class="position-absolute top-50 start-50 translate-middle
|
||||
d-flex align-items-center gap-2 text-white">
|
||||
<span style="font-size:1.3rem;">🧾</span>
|
||||
<span class="fw-semibold fs-5">Leistungen</span>
|
||||
</div>
|
||||
|
||||
<!-- DASHBOARD -->
|
||||
<div class="ms-auto">
|
||||
<a href="/dashboard" class="btn btn-outline-light btn-sm">
|
||||
⬅️ Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<!-- CONTENT -->
|
||||
<div class="container mt-4">
|
||||
<%- include("partials/flash") %>
|
||||
<h4><%= t.services.title %></h4>
|
||||
|
||||
<h4>Leistungen</h4>
|
||||
|
||||
<!-- SUCHFORMULAR -->
|
||||
<form method="GET" action="/services" class="row g-2 mb-3">
|
||||
|
||||
<div class="col-md-6">
|
||||
<input type="text" name="q" class="form-control"
|
||||
placeholder="🔍 <%= t.services.searchplaceholder %>"
|
||||
<input type="text"
|
||||
name="q"
|
||||
class="form-control"
|
||||
placeholder="🔍 Suche nach Name oder Kategorie"
|
||||
value="<%= query?.q || '' %>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 d-flex align-items-center">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="onlyActive" value="1"
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
name="onlyActive"
|
||||
value="1"
|
||||
<%= query?.onlyActive === "1" ? "checked" : "" %>>
|
||||
<label class="form-check-label"><%= t.global.active %></label>
|
||||
<label class="form-check-label">
|
||||
Nur aktive Leistungen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 d-flex gap-2">
|
||||
<button class="btn btn-primary w-100"><%= t.global.search %></button>
|
||||
<a href="/services" class="btn btn-secondary w-100"><%= t.global.reset %></a>
|
||||
<button class="btn btn-primary w-100">
|
||||
Suchen
|
||||
</button>
|
||||
<a href="/services" class="btn btn-secondary w-100">
|
||||
Reset
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- NEUE LEISTUNG -->
|
||||
<a href="/services/create" class="btn btn-success mb-3">
|
||||
➕ <%= t.services.newservice %>
|
||||
➕ Neue Leistung
|
||||
</a>
|
||||
|
||||
<!-- TABELLE -->
|
||||
<table class="table table-bordered table-sm align-middle">
|
||||
|
||||
<!-- FIXE SPALTENBREITEN -->
|
||||
<colgroup>
|
||||
<col style="width:35%">
|
||||
<col style="width:25%">
|
||||
@ -41,43 +84,81 @@
|
||||
<col style="width:8%">
|
||||
<col style="width:12%">
|
||||
</colgroup>
|
||||
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th><%= t.services.namede %></th>
|
||||
<th><%= t.services.namees %></th>
|
||||
<th><%= t.services.price %></th>
|
||||
<th><%= t.services.pricec70 %></th>
|
||||
<th><%= t.services.status %></th>
|
||||
<th><%= t.services.actions %></th>
|
||||
<th>Bezeichnung (DE)</th>
|
||||
<th>Bezeichnung (ES)</th>
|
||||
<th>Preis</th>
|
||||
<th>Preis C70</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% services.forEach(s => { %>
|
||||
<tr class="<%= s.active ? '' : 'table-secondary' %>">
|
||||
<% services.forEach(s => { %>
|
||||
<tr class="<%= s.active ? '' : 'table-secondary' %>">
|
||||
|
||||
<!-- DE -->
|
||||
<td><%= s.name_de %></td>
|
||||
|
||||
<!-- ES -->
|
||||
<td><%= s.name_es || "-" %></td>
|
||||
|
||||
<!-- FORM BEGINNT -->
|
||||
<form method="POST" action="/services/<%= s.id %>/update-price">
|
||||
|
||||
<!-- PREIS -->
|
||||
<td>
|
||||
<input name="price" value="<%= s.price %>"
|
||||
class="form-control form-control-sm text-end w-100" disabled>
|
||||
<input name="price"
|
||||
value="<%= s.price %>"
|
||||
class="form-control form-control-sm text-end w-100"
|
||||
disabled>
|
||||
</td>
|
||||
|
||||
<!-- PREIS C70 -->
|
||||
<td>
|
||||
<input name="price_c70" value="<%= s.price_c70 %>"
|
||||
class="form-control form-control-sm text-end w-100" disabled>
|
||||
<input name="price_c70"
|
||||
value="<%= s.price_c70 %>"
|
||||
class="form-control form-control-sm text-end w-100"
|
||||
disabled>
|
||||
</td>
|
||||
|
||||
<!-- STATUS -->
|
||||
<td class="text-center">
|
||||
<%= s.active ? t.global.active : t.global.inactive %>
|
||||
<%= s.active ? 'Aktiv' : 'Inaktiv' %>
|
||||
</td>
|
||||
|
||||
<!-- AKTIONEN -->
|
||||
<td class="d-flex justify-content-center gap-2">
|
||||
<button type="submit" class="btn btn-sm btn-primary save-btn" disabled>💾</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning lock-btn"
|
||||
title="<%= t.services.editunlock %>">🔓</button>
|
||||
|
||||
<!-- SPEICHERN -->
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-primary save-btn"
|
||||
disabled>
|
||||
💾
|
||||
</button>
|
||||
|
||||
<!-- SPERREN / ENTSPERREN -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-warning lock-btn"
|
||||
title="Bearbeiten freigeben">
|
||||
🔓
|
||||
</button>
|
||||
|
||||
</td>
|
||||
|
||||
</form>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,218 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title><%= title %></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f0f2f5;
|
||||
padding: 40px 20px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 580px;
|
||||
background: white;
|
||||
padding: 32px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 22px;
|
||||
color: #111827;
|
||||
}
|
||||
.card p {
|
||||
margin: 0 0 24px 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
.badge-update {
|
||||
display: inline-block;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37,99,235,0.12);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.row > div { flex: 1; }
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
button {
|
||||
padding: 10px 18px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
button:hover { opacity: 0.88; }
|
||||
.btn-primary { background: #2563eb; color: white; flex: 1; }
|
||||
.btn-secondary { background: #111827; color: white; }
|
||||
.msg {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
.msg.ok { background: #dcfce7; color: #166534; border: 1px solid #86efac; }
|
||||
.msg.bad { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
.msg.pending { background: #eff6ff; color: #1d4ed8; border: 1px solid #93c5fd; }
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin: 24px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>🛠️ <%= title %></h2>
|
||||
|
||||
<% if (typeof isUpdate !== 'undefined' && isUpdate) { %>
|
||||
<span class="badge-update">⚠️ Bestehende Konfiguration wird überschrieben</span>
|
||||
<% } %>
|
||||
|
||||
<p>DB-Verbindungsdaten eingeben. Die Verbindung wird vor dem Speichern geprüft.</p>
|
||||
|
||||
<form method="POST" action="/setup" id="setupForm">
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="host">DB Host</label>
|
||||
<input
|
||||
id="host"
|
||||
name="host"
|
||||
placeholder="85.215.63.122"
|
||||
value="<%= defaults.host %>"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div style="max-width:110px">
|
||||
<label for="port">Port</label>
|
||||
<input
|
||||
id="port"
|
||||
name="port"
|
||||
placeholder="3306"
|
||||
value="<%= defaults.port %>"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="user">DB Benutzer</label>
|
||||
<input
|
||||
id="user"
|
||||
name="user"
|
||||
placeholder="praxisuser"
|
||||
value="<%= defaults.user %>"
|
||||
required
|
||||
/>
|
||||
|
||||
<label for="password">DB Passwort</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="<%= typeof isUpdate !== 'undefined' && isUpdate ? '(unverändert lassen = leer lassen)' : '' %>"
|
||||
/>
|
||||
|
||||
<label for="name">DB Name</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="praxissoftware"
|
||||
value="<%= defaults.name %>"
|
||||
required
|
||||
/>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-secondary" onclick="testConnection()">
|
||||
🔍 Verbindung testen
|
||||
</button>
|
||||
<button type="submit" class="btn-primary">
|
||||
✅ Speichern & abschließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="msg" class="msg"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function testConnection() {
|
||||
const msg = document.getElementById("msg");
|
||||
msg.className = "msg pending";
|
||||
msg.style.display = "block";
|
||||
msg.textContent = "⏳ Teste Verbindung...";
|
||||
|
||||
// Felder einzeln auslesen (sicherer als FormData bei doppelten Namen)
|
||||
const body = {
|
||||
host: document.getElementById("host").value.trim(),
|
||||
port: document.getElementById("port").value.trim(),
|
||||
user: document.getElementById("user").value.trim(),
|
||||
password: document.getElementById("password").value,
|
||||
name: document.getElementById("name").value.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/setup/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
msg.className = "msg " + (json.ok ? "ok" : "bad");
|
||||
msg.textContent = json.message;
|
||||
} catch (e) {
|
||||
msg.className = "msg bad";
|
||||
msg.textContent = "❌ Netzwerkfehler: " + e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user