Patienten Rechungen, Gutschriften und Stornos erstellt.

This commit is contained in:
Cay 2026-02-04 15:41:40 +00:00
parent 57073ffc05
commit 64fcad77f0
38 changed files with 1327 additions and 73 deletions

12
app.js
View File

@ -28,6 +28,7 @@ const invoiceRoutes = require("./routes/invoice.routes");
const patientFileRoutes = require("./routes/patientFile.routes");
const companySettingsRoutes = require("./routes/companySettings.routes");
const authRoutes = require("./routes/auth.routes");
const reportRoutes = require("./routes/report.routes");
const app = express();
@ -72,7 +73,14 @@ app.use(express.json());
app.use(
helmet({
contentSecurityPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
},
},
}),
);
@ -401,6 +409,8 @@ app.use("/", patientFileRoutes);
app.use("/", waitingRoomRoutes);
app.use("/invoices", invoiceRoutes);
app.use("/reportview", reportRoutes);
app.get("/logout", (req, res) => {
req.session.destroy(() => res.redirect("/"));
});

View File

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

View File

@ -46,7 +46,8 @@ function listMedications(req, res, next) {
title: "Medikamentenübersicht",
// ✅ IMMER patient-sidebar verwenden
sidebarPartial: "partials/patient-sidebar",
sidebarPartial: "partials/sidebar-empty",
active: "medications",
rows,
query: { q, onlyActive },

View File

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

View File

@ -59,7 +59,7 @@
"sidebar": {
"patients": "Patienten",
"medications": "Medikamente",
"servicesOpen": "Offene Leistungen",
"servicesOpen": "Patienten Rechnungen",
"billing": "Abrechnung",
"admin": "Verwaltung",
"logout": "Logout"
@ -119,6 +119,10 @@
},
"openinvoices": {
"openinvoices": "Offene Rechnungen"
"openinvoices": "Offene Rechnungen",
"canceledinvoices": "Stornierte Rechnungen",
"report": "Umsatzreport",
"payedinvoices": "Bezahlte Rechnungen",
"creditoverview": "Gutschrift Übersicht"
}
}

View File

@ -119,6 +119,10 @@
},
"openinvoices": {
"openinvoices": "Facturas pendientes"
"openinvoices": "Facturas de pacientes",
"canceledinvoices": "Facturas canceladas",
"report": "Informe de ventas",
"payedinvoices": "Facturas pagadas",
"creditoverview": "Resumen de abonos"
}
}

62
package-lock.json generated
View File

@ -11,6 +11,7 @@
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"docxtemplater": "^3.67.6",
"dotenv": "^17.2.3",
"ejs": "^3.1.10",
@ -24,6 +25,7 @@
"multer": "^2.0.2",
"mysql2": "^3.16.0",
"node-ssh": "^13.2.1",
"pdf-lib": "^1.17.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
@ -1038,6 +1040,12 @@
"@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",
@ -1074,6 +1082,24 @@
"@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",
@ -2241,6 +2267,18 @@
"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",
@ -5674,6 +5712,12 @@
"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",
@ -5760,6 +5804,24 @@
"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",

View File

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

View File

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

14
public/js/chart.js Normal file

File diff suppressed because one or more lines are too long

View File

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

View File

@ -0,0 +1,25 @@
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`;
});
});
});

101
public/js/reports.js Normal file
View File

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

View File

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

8
routes/report.routes.js Normal file
View File

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

70
utils/creditPdf.js Normal file
View File

@ -0,0 +1,70 @@
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;
};

34
utils/pdfWatermark.js Normal file
View File

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

View File

@ -0,0 +1,57 @@
<%- include("../partials/page-header", {
user,
title: t.patienteoverview.patienttitle,
subtitle: "",
showUserName: true
}) %>
<!-- CONTENT -->
<div class="container mt-4">
<%- include("../partials/flash") %>
<h4>Stornierte Rechnungen</h4>
<!-- ✅ Jahresfilter -->
<form method="GET" action="/invoices/cancelled" style="margin-bottom:20px;">
<label>Jahr:</label>
<select
name="year"
onchange="this.form.submit()"
class="form-select"
style="width:150px; display:inline-block;"
>
<% years.forEach(y => { %>
<option value="<%= y %>" <%= y == selectedYear ? "selected" : "" %>>
<%= y %>
</option>
<% }) %>
</select>
</form>
<% if (invoices.length === 0) { %>
<p>Keine stornierten Rechnungen für dieses Jahr.</p>
<% } else { %>
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Patient</th>
<th>Datum</th>
<th>Betrag</th>
</tr>
</thead>
<tbody>
<% invoices.forEach(inv => { %>
<tr>
<td><%= inv.id %></td>
<td><%= inv.firstname %> <%= inv.lastname %></td>
<td><%= inv.invoice_date_formatted %></td>
<td><%= inv.total_amount_formatted %> €</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>

View File

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

View File

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

View File

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

View File

@ -42,6 +42,6 @@
<!-- ✅ 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>
<!-- <script src="/js/patient_sidebar.js" defer></script> -->
</body>
</html>

View File

@ -2,11 +2,6 @@
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">

View File

@ -56,79 +56,49 @@
<a
href="<%= hrefIfAllowed(canDoctorAndStaff, '/patients') %>"
class="nav-item <%= active === 'patients' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/cancelled') %>"
class="nav-item <%= active === 'cancelled_invoices' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.sidebar.patients %>
<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, '/patients') %>"
class="nav-item <%= active === 'patients' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
href="<%= hrefIfAllowed(canDoctorAndStaff, '/reportview') %>"
class="nav-item <%= active === 'reportview' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.sidebar.patients %>
<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, '/patients') %>"
class="nav-item <%= active === 'patients' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/paid') %>"
class="nav-item <%= active === 'paid' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-people"></i> <%= t.sidebar.patients %>
<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>
<!-- =========================
Ü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' %>"
href="<%= hrefIfAllowed(canDoctorAndStaff, '/invoices/credit-overview') %>"
class="nav-item <%= active === 'credit-overview' ? 'active' : '' %> <%= lockClass(canDoctorAndStaff) %>"
title="<%= canDoctorAndStaff ? '' : 'Nur Arzt + Mitarbeiter' %>"
>
<i class="bi bi-clipboard2-heart"></i> Übersicht
<% if (!canUsePatient) { %>
<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>
<!-- =========================
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> Patient sperren (Inaktiv)
<% } else { %>
<i class="bi bi-check-circle"></i> Patient aktivieren
<% } %>
<% if (!canUsePatient) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</button>
</form>
<div class="spacer"></div>
<!-- ✅ Logout -->

69
views/reportview.ejs Normal file
View File

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