diff --git a/app.js b/app.js
index cf2da39..502e039 100644
--- a/app.js
+++ b/app.js
@@ -33,10 +33,13 @@ app.use(session({
const indexRouter = require('./routes/index');
const adminRouter = require('./routes/admin');
const apiRouter = require('./routes/api');
+const billingRouter = require('./routes/billing');
+const cron = require('node-cron');
app.use('/', indexRouter);
app.use('/admin', adminRouter);
app.use('/api', apiRouter);
+app.use('/admin/billing', billingRouter);
// 404 Handler
app.use((req, res) => {
@@ -66,6 +69,45 @@ async function initAdmin() {
}
}
+// Auto-Abrechnungslauf jeden 1. des Monats um 06:00 Uhr
+cron.schedule('0 6 1 * *', async () => {
+ const { currentPeriod } = require('./routes/billing');
+ const period = currentPeriod();
+ console.log(`⏰ Auto-Abrechnungslauf gestartet für ${period}`);
+ try {
+ const [existing] = await db.query('SELECT COUNT(*) as c FROM invoices WHERE period = ?', [period]);
+ if (existing[0].c > 0) {
+ console.log(`⏭ Abrechnungslauf für ${period} bereits vorhanden – übersprungen`);
+ return;
+ }
+ const [members] = await db.query(`
+ SELECT m.*, t.price_monthly FROM memberships m
+ JOIN tariffs t ON m.tariff_id = t.id
+ WHERE m.status IN ('active','paused')
+ AND m.contract_start <= LAST_DAY(STR_TO_DATE(CONCAT(?, '-01'), '%Y-%m-%d'))
+ AND (m.contract_end IS NULL OR m.contract_end >= STR_TO_DATE(CONCAT(?, '-01'), '%Y-%m-%d'))
+ `, [period, period]);
+ const [runResult] = await db.query(
+ 'INSERT INTO billing_runs (run_date, period, created_by) VALUES (CURDATE(), ?, ?)',
+ [period, 'system-auto']
+ );
+ let total = 0, count = 0;
+ for (const m of members) {
+ const firstPeriod = m.first_payment_date ? m.first_payment_date.toISOString().substring(0,7) : null;
+ const amount = firstPeriod === period && m.first_payment_amt ? parseFloat(m.first_payment_amt) : parseFloat(m.price_monthly);
+ await db.query(
+ 'INSERT IGNORE INTO invoices (billing_run_id, membership_id, period, amount, description, iban, account_holder, bank_name) VALUES (?,?,?,?,?,?,?,?)',
+ [runResult.insertId, m.id, period, amount, `Mitgliedsbeitrag ${period}`, m.iban||'', m.account_holder||'', m.bank_name||'']
+ );
+ total += amount; count++;
+ }
+ await db.query('UPDATE billing_runs SET total_amount=?, invoice_count=? WHERE id=?', [total, count, runResult.insertId]);
+ console.log(`✅ Auto-Abrechnungslauf abgeschlossen: ${count} Rechnungen, ${total.toFixed(2)} €`);
+ } catch (err) {
+ console.error('❌ Auto-Abrechnungslauf Fehler:', err.message);
+ }
+});
+
const PORT = process.env.PORT || 3100;
app.listen(PORT, async () => {
console.log(`🚀 PlusFit24 Server läuft auf Port ${PORT}`);
diff --git a/package.json b/package.json
index 1b5db89..a652886 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,9 @@
"mysql2": "^3.6.0",
"bcryptjs": "^2.4.3",
"dotenv": "^16.3.1",
- "dns": "^0.2.2"
+ "dns": "^0.2.2",
+ "pdfkit": "^0.14.0",
+ "node-cron": "^3.0.3"
},
"devDependencies": {
"nodemon": "^3.0.1"
diff --git a/public/css/style.css b/public/css/style.css
index 443e9b3..15cb8c7 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1090,3 +1090,57 @@ body:not(.admin-body) > * {
.auszeit-form-row .karte-field {
min-width: 130px;
}
+
+/* ================================================
+ ABRECHNUNG / BILLING
+ ================================================ */
+.billing-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+.billing-header h1 { font-size: 1.8rem; font-weight: 800; margin: 0; }
+.period-form { display: flex; gap: 8px; align-items: center; }
+.period-input { padding: 8px 12px; border: 1.5px solid var(--border); border-radius: 8px;
+ font-family: 'Outfit', sans-serif; font-size: 0.92rem; outline: none; }
+.period-input:focus { border-color: var(--primary); }
+.billing-period-title {
+ font-size: 1.3rem; font-weight: 700; color: var(--primary);
+ margin-bottom: 20px;
+}
+.billing-actions { margin-bottom: 24px; }
+.billing-preview-box {
+ display: flex; align-items: center; justify-content: space-between;
+ background: #eff6ff; border: 1.5px solid #bfdbfe;
+ border-radius: 14px; padding: 16px 24px; gap: 16px; flex-wrap: wrap;
+}
+.preview-info { display: flex; flex-direction: column; gap: 4px; }
+.preview-info strong { font-size: 1rem; }
+.preview-info span { font-size: 0.88rem; color: var(--text-muted); }
+.billing-run-done { display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
+.billing-action-btns { display: flex; gap: 10px; flex-wrap: wrap; }
+
+.invoice-nr { font-family: monospace; font-size: 0.82rem; color: var(--text-muted); }
+.amount-cell { font-size: 1rem; }
+.text-muted { color: var(--text-muted); }
+
+.invoice-status { font-size: 0.82rem; font-weight: 700; padding: 3px 8px; border-radius: 12px; }
+.invoice-status.open { background: #fee2e2; color: var(--error); }
+.invoice-status.paid { background: #dcfce7; color: var(--success); }
+.invoice-status.cancelled { background: #f3f4f6; color: var(--text-muted); }
+
+.invoice-actions { display: flex; gap: 6px; align-items: center; }
+
+.preview-table-wrap { margin-bottom: 24px; }
+.preview-title {
+ font-size: 1rem; font-weight: 700; color: var(--text-muted);
+ margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px;
+}
+.runs-section { margin-top: 32px; }
+.runs-section h3 {
+ font-size: 1rem; font-weight: 700; color: var(--text-muted);
+ margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px;
+}
diff --git a/routes/billing.js b/routes/billing.js
new file mode 100644
index 0000000..57275b9
--- /dev/null
+++ b/routes/billing.js
@@ -0,0 +1,332 @@
+const express = require('express');
+const router = express.Router();
+const db = require('../config/database');
+const { requireAdmin } = require('../middleware/auth');
+const PDFDocument = require('pdfkit');
+
+// ============================================
+// Hilfsfunktionen
+// ============================================
+
+// Nächsten Rechnungsbetrag für ein Mitglied berechnen
+function calcInvoiceAmount(member, period) {
+ // Pausiert → 0€
+ if (member.status === 'paused') return 0;
+
+ // Erster Monat (anteilig)?
+ const firstPeriod = member.first_payment_date
+ ? member.first_payment_date.toISOString().substring(0, 7)
+ : null;
+
+ if (firstPeriod === period && member.first_payment_amt) {
+ return parseFloat(member.first_payment_amt);
+ }
+
+ return parseFloat(member.price_monthly);
+}
+
+// Periode als lesbarer Text: "2026-04" → "April 2026"
+function periodLabel(period) {
+ const [year, month] = period.split('-');
+ const months = ['Januar','Februar','März','April','Mai','Juni',
+ 'Juli','August','September','Oktober','November','Dezember'];
+ return `${months[parseInt(month) - 1]} ${year}`;
+}
+
+// Aktuelle Periode: "YYYY-MM"
+function currentPeriod() {
+ const now = new Date();
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
+}
+
+// ============================================
+// GET /admin/billing – Übersicht
+// ============================================
+router.get('/', requireAdmin, async (req, res) => {
+ try {
+ const period = req.query.period || currentPeriod();
+
+ const [runs] = await db.query(
+ 'SELECT * FROM billing_runs ORDER BY created_at DESC LIMIT 12'
+ );
+
+ const [invoices] = await db.query(`
+ SELECT i.*,
+ m.first_name, m.last_name, m.email,
+ t.name as tariff_name
+ FROM invoices i
+ JOIN memberships m ON i.membership_id = m.id
+ LEFT JOIN tariffs t ON m.tariff_id = t.id
+ WHERE i.period = ?
+ ORDER BY m.last_name ASC
+ `, [period]);
+
+ const [summary] = await db.query(`
+ SELECT
+ COUNT(*) as total,
+ SUM(amount) as total_amount,
+ SUM(CASE WHEN status='open' THEN 1 ELSE 0 END) as open_count,
+ SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) as paid_count,
+ SUM(CASE WHEN status='open' THEN amount ELSE 0 END) as open_amount,
+ SUM(CASE WHEN status='paid' THEN amount ELSE 0 END) as paid_amount
+ FROM invoices WHERE period = ?
+ `, [period]);
+
+ // Vorschau: Mitglieder die noch keine Rechnung für diese Periode haben
+ const [eligible] = await db.query(`
+ SELECT m.*, t.price_monthly, t.name as tariff_name
+ FROM memberships m
+ JOIN tariffs t ON m.tariff_id = t.id
+ WHERE m.status IN ('active','paused')
+ AND m.contract_start <= LAST_DAY(STR_TO_DATE(CONCAT(?, '-01'), '%Y-%m-%d'))
+ AND m.contract_end >= STR_TO_DATE(CONCAT(?, '-01'), '%Y-%m-%d')
+ AND m.id NOT IN (
+ SELECT membership_id FROM invoices WHERE period = ?
+ )
+ `, [period, period, period]);
+
+ const preview_total = eligible.reduce((sum, m) => sum + calcInvoiceAmount(m, period), 0);
+
+ res.render('admin/billing', {
+ period, runs, invoices,
+ summary: summary[0],
+ eligible, preview_total,
+ periodLabel: periodLabel(period),
+ currentPeriod: currentPeriod(),
+ admin: req.session.adminUser,
+ success: req.query.success || null,
+ error: req.query.error || null
+ });
+ } catch (err) {
+ console.error(err);
+ res.redirect('/admin?error=Fehler+in+der+Abrechnung:+' + err.message);
+ }
+});
+
+// ============================================
+// POST /admin/billing/run – Abrechnungslauf
+// ============================================
+router.post('/run', requireAdmin, async (req, res) => {
+ const period = req.body.period || currentPeriod();
+ try {
+ // Bereits existierende Rechnungen für diesen Monat prüfen
+ const [existing] = await db.query(
+ 'SELECT COUNT(*) as c FROM invoices WHERE period = ?', [period]
+ );
+ if (existing[0].c > 0) {
+ return res.redirect(`/admin/billing?period=${period}&error=Abrechnungslauf+für+${period}+wurde+bereits+durchgeführt`);
+ }
+
+ // Alle aktiven/pausierten Mitglieder im Vertragszeitraum
+ const [members] = await db.query(`
+ SELECT m.*, t.price_monthly, t.name as tariff_name
+ FROM memberships m
+ JOIN tariffs t ON m.tariff_id = t.id
+ WHERE m.status IN ('active','paused')
+ AND m.contract_start <= LAST_DAY(STR_TO_DATE(CONCAT(?, '-01'), '%Y-%m-%d'))
+ AND (m.contract_end IS NULL OR m.contract_end >= STR_TO_DATE(CONCAT(?, '-01'), '%Y-%m-%d'))
+ `, [period, period]);
+
+ if (members.length === 0) {
+ return res.redirect(`/admin/billing?period=${period}&error=Keine+aktiven+Mitglieder+für+diesen+Zeitraum`);
+ }
+
+ // Billing Run erstellen
+ const [runResult] = await db.query(
+ 'INSERT INTO billing_runs (run_date, period, created_by) VALUES (CURDATE(), ?, ?)',
+ [period, req.session.adminUser]
+ );
+ const runId = runResult.insertId;
+
+ // Rechnungen erstellen
+ let totalAmount = 0;
+ let invoiceCount = 0;
+
+ for (const member of members) {
+ const amount = calcInvoiceAmount(member, period);
+ const description = `Mitgliedsbeitrag ${periodLabel(period)} – ${member.tariff_name}`;
+
+ if (amount >= 0) {
+ await db.query(`
+ INSERT INTO invoices
+ (billing_run_id, membership_id, period, amount, description, iban, account_holder, bank_name)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE amount = VALUES(amount)
+ `, [runId, member.id, period, amount, description,
+ member.iban || '', member.account_holder || '', member.bank_name || '']);
+ totalAmount += amount;
+ invoiceCount++;
+ }
+ }
+
+ // Run-Summen aktualisieren
+ await db.query(
+ 'UPDATE billing_runs SET total_amount = ?, invoice_count = ? WHERE id = ?',
+ [totalAmount, invoiceCount, runId]
+ );
+
+ res.redirect(`/admin/billing?period=${period}&success=${invoiceCount}+Rechnungen+erstellt+(${totalAmount.toFixed(2).replace('.', ',')}+€+gesamt)`);
+ } catch (err) {
+ console.error(err);
+ res.redirect(`/admin/billing?error=Fehler+beim+Abrechnungslauf:+` + encodeURIComponent(err.message));
+ }
+});
+
+// ============================================
+// POST /admin/billing/invoices/:id/paid – Bezahlt markieren
+// ============================================
+router.post('/invoices/:id/paid', requireAdmin, async (req, res) => {
+ const period = req.body.period || currentPeriod();
+ try {
+ await db.query(
+ "UPDATE invoices SET status='paid', paid_at=NOW() WHERE id=?",
+ [req.params.id]
+ );
+ res.redirect(`/admin/billing?period=${period}&success=Rechnung+als+bezahlt+markiert`);
+ } catch (err) {
+ res.redirect(`/admin/billing?period=${period}&error=Fehler`);
+ }
+});
+
+// POST – Alle offen als bezahlt markieren
+router.post('/mark-all-paid', requireAdmin, async (req, res) => {
+ const period = req.body.period || currentPeriod();
+ try {
+ const [result] = await db.query(
+ "UPDATE invoices SET status='paid', paid_at=NOW() WHERE period=? AND status='open'",
+ [period]
+ );
+ res.redirect(`/admin/billing?period=${period}&success=${result.affectedRows}+Rechnungen+als+bezahlt+markiert`);
+ } catch (err) {
+ res.redirect(`/admin/billing?period=${period}&error=Fehler`);
+ }
+});
+
+// ============================================
+// GET /admin/billing/export/csv – SEPA CSV
+// ============================================
+router.get('/export/csv', requireAdmin, async (req, res) => {
+ const period = req.query.period || currentPeriod();
+ try {
+ const [invoices] = await db.query(`
+ SELECT i.*, m.first_name, m.last_name, m.email
+ FROM invoices i
+ JOIN memberships m ON i.membership_id = m.id
+ WHERE i.period = ? AND i.status = 'open' AND i.amount > 0
+ ORDER BY m.last_name ASC
+ `, [period]);
+
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename="SEPA_${period}.csv"`);
+
+ // BOM für Excel
+ res.write('\uFEFF');
+ // Header
+ res.write('Name;IBAN;BIC;Betrag;Verwendungszweck;Mandatsreferenz;Mandatsdatum\n');
+
+ for (const inv of invoices) {
+ const name = `${inv.last_name} ${inv.first_name}`.replace(/;/g, ' ');
+ const iban = (inv.iban || '').replace(/\s/g, '');
+ const amount = Number(inv.amount).toFixed(2).replace('.', ',');
+ const purpose = `Mitgliedsbeitrag ${periodLabel(period)}`.replace(/;/g, ' ');
+ const mandateRef = `PF24-${String(inv.membership_id).padStart(5, '0')}`;
+ res.write(`${name};${iban};;${amount};${purpose};${mandateRef};${inv.created_at.toISOString().split('T')[0]}\n`);
+ }
+
+ res.end();
+ } catch (err) {
+ console.error(err);
+ res.redirect(`/admin/billing?error=CSV+Fehler`);
+ }
+});
+
+// ============================================
+// GET /admin/billing/export/pdf/:invoiceId – Einzelrechnung PDF
+// ============================================
+router.get('/export/pdf/:invoiceId', requireAdmin, async (req, res) => {
+ try {
+ const [rows] = await db.query(`
+ SELECT i.*,
+ m.first_name, m.last_name, m.email, m.phone,
+ m.street, m.zip, m.city,
+ t.name as tariff_name, t.duration_months
+ FROM invoices i
+ JOIN memberships m ON i.membership_id = m.id
+ LEFT JOIN tariffs t ON m.tariff_id = t.id
+ WHERE i.id = ?
+ `, [req.params.invoiceId]);
+
+ if (rows.length === 0) return res.status(404).send('Rechnung nicht gefunden');
+ const inv = rows[0];
+
+ res.setHeader('Content-Type', 'application/pdf');
+ res.setHeader('Content-Disposition', `attachment; filename="Rechnung_${inv.id}_${inv.period}.pdf"`);
+
+ const doc = new PDFDocument({ margin: 60, size: 'A4' });
+ doc.pipe(res);
+
+ // Absender
+ doc.fontSize(10).fillColor('#666')
+ .text('PlusFit24 UG · Moosleiten 12 · 84089 Aiglsbach', 60, 60);
+
+ // Empfänger
+ doc.fontSize(11).fillColor('#000')
+ .text(`${inv.first_name} ${inv.last_name}`, 60, 110)
+ .text(inv.street || '')
+ .text(`${inv.zip} ${inv.city}`);
+
+ // Rechnungstitel
+ doc.fontSize(20).fillColor('#2d2dcc')
+ .text('RECHNUNG', 60, 220);
+
+ doc.fontSize(10).fillColor('#333')
+ .text(`Rechnungsnummer: PF24-${String(inv.id).padStart(6, '0')}`, 60, 255)
+ .text(`Datum: ${new Date().toLocaleDateString('de-DE')}`)
+ .text(`Zeitraum: ${periodLabel(inv.period)}`);
+
+ // Trennlinie
+ doc.moveTo(60, 310).lineTo(535, 310).strokeColor('#ddd').stroke();
+
+ // Tabelle
+ doc.fontSize(10).fillColor('#999')
+ .text('Beschreibung', 60, 325)
+ .text('Betrag', 460, 325, { align: 'right', width: 75 });
+
+ doc.moveTo(60, 340).lineTo(535, 340).strokeColor('#ddd').stroke();
+
+ doc.fontSize(11).fillColor('#000')
+ .text(inv.description || `Mitgliedsbeitrag ${periodLabel(inv.period)}`, 60, 350)
+ .text(`${Number(inv.amount).toFixed(2).replace('.', ',')} €`, 460, 350, { align: 'right', width: 75 });
+
+ doc.moveTo(60, 375).lineTo(535, 375).strokeColor('#ddd').stroke();
+
+ // Summe
+ doc.fontSize(12).fillColor('#2d2dcc')
+ .text('Gesamt:', 380, 390)
+ .text(`${Number(inv.amount).toFixed(2).replace('.', ',')} €`, 460, 390, { align: 'right', width: 75 });
+
+ doc.fontSize(9).fillColor('#999')
+ .text('Alle Beträge inkl. MwSt. gem. § 19 UStG (Kleinunternehmerregelung)', 60, 415);
+
+ // Bankdaten
+ doc.moveTo(60, 450).lineTo(535, 450).strokeColor('#eee').stroke();
+ doc.fontSize(10).fillColor('#333')
+ .text('Bankverbindung des Mitglieds:', 60, 460)
+ .text(`IBAN: ${inv.iban || '–'}`, 60, 475)
+ .text(`Kontoinhaber: ${inv.account_holder || '–'}`)
+ .text(`Geldinstitut: ${inv.bank_name || '–'}`);
+
+ // Footer
+ doc.fontSize(9).fillColor('#999')
+ .text('PlusFit24 UG · Moosleiten 12 · 84089 Aiglsbach · Gläubiger-ID: DE1200100002549495',
+ 60, 730, { align: 'center', width: 475 });
+
+ doc.end();
+ } catch (err) {
+ console.error(err);
+ res.status(500).send('PDF Fehler: ' + err.message);
+ }
+});
+
+module.exports = router;
+module.exports.currentPeriod = currentPeriod;
diff --git a/views/admin/billing.ejs b/views/admin/billing.ejs
new file mode 100644
index 0000000..d014def
--- /dev/null
+++ b/views/admin/billing.ejs
@@ -0,0 +1,229 @@
+
+
+
+
+
+ PlusFit24 – Abrechnung
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <% if (success) { %><%= success %>
<% } %>
+ <% if (error) { %><%= error %>
<% } %>
+
+
+ <%= periodLabel %>
+
+
+
+
+
<%= summary.total || 0 %>
+
Rechnungen gesamt
+
+
+
<%= summary.open_count || 0 %>
+
Offen
+
+
+
<%= summary.paid_count || 0 %>
+
Bezahlt
+
+
+
<%= summary.total_amount ? Number(summary.total_amount).toFixed(2).replace('.', ',') + ' €' : '0,00 €' %>
+
Gesamtbetrag
+
+
+
<%= summary.open_amount ? Number(summary.open_amount).toFixed(2).replace('.', ',') + ' €' : '0,00 €' %>
+
Noch offen
+
+
+
<%= summary.paid_amount ? Number(summary.paid_amount).toFixed(2).replace('.', ',') + ' €' : '0,00 €' %>
+
Bereits bezahlt
+
+
+
+
+
+ <% if (invoices.length === 0) { %>
+
+
+
+ Bereit für Abrechnungslauf <%= periodLabel %>
+ <%= eligible.length %> Mitglieder · Voraussichtlich <%= Number(preview_total).toFixed(2).replace('.', ',') %> €
+
+
+
+ <% } else { %>
+
+
+ <% } %>
+
+
+
+ <% if (invoices.length > 0) { %>
+
+
+
+
+ | Nr. |
+ Mitglied |
+ Tarif |
+ Betrag |
+ IBAN |
+ Status |
+ Aktionen |
+
+
+
+ <% invoices.forEach(inv => { %>
+
+ | PF24-<%= String(inv.id).padStart(6, '0') %> |
+
+ <%= inv.last_name %>, <%= inv.first_name %>
+ <%= inv.email %>
+ |
+ <%= inv.tariff_name || '–' %> |
+
+ <%= Number(inv.amount).toFixed(2).replace('.', ',') %> €
+ |
+
+ <%= inv.iban ? inv.iban.replace(/(.{4})/g, '$1 ').trim() : '–' %>
+ |
+
+
+ <%= inv.status === 'paid' ? '✅ Bezahlt' : inv.status === 'open' ? '🔴 Offen' : '❌ Storniert' %>
+
+ <% if (inv.paid_at) { %>
+ <%= new Date(inv.paid_at).toLocaleDateString('de-DE') %>
+ <% } %>
+ |
+
+
+
+ 📄 PDF
+
+ <% if (inv.status === 'open') { %>
+
+ <% } %>
+
+ |
+
+ <% }) %>
+
+
+
+ <% } else if (eligible.length > 0) { %>
+
+
+
Vorschau – wird abgerechnet
+
+
+
+ | Mitglied |
+ Tarif |
+ Voraussichtlicher Betrag |
+
+
+
+ <% eligible.forEach(m => { %>
+
+ | <%= m.last_name %>, <%= m.first_name %> |
+ <%= m.tariff_name %> |
+ <%= Number(m.price_monthly).toFixed(2).replace('.', ',') %> € |
+
+ <% }) %>
+
+
+
+ <% } else { %>
+ Keine Mitglieder für diesen Zeitraum.
+ <% } %>
+
+
+ <% if (runs.length > 0) { %>
+
+
Letzte Abrechnungsläufe
+
+
+
+ | Periode |
+ Datum |
+ Rechnungen |
+ Gesamtbetrag |
+ Erstellt von |
+ Aktion |
+
+
+
+ <% runs.forEach(run => { %>
+
+ | <%= run.period %> |
+ <%= new Date(run.created_at).toLocaleDateString('de-DE') %> |
+ <%= run.invoice_count %> |
+ <%= Number(run.total_amount).toFixed(2).replace('.', ',') %> € |
+ <%= run.created_by || '–' %> |
+
+
+ Anzeigen
+
+ |
+
+ <% }) %>
+
+
+
+ <% } %>
+
+
+
+
+
diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs
index dfcc00c..9472751 100644
--- a/views/admin/dashboard.ejs
+++ b/views/admin/dashboard.ejs
@@ -16,6 +16,7 @@
📋 Tarife
🏷️ Kategorien
👥 Mitglieder
+ 💶 Abrechnung
⚙️ Einstellungen