Billing hinzugefügt

This commit is contained in:
cay 2026-03-27 12:12:28 +00:00
parent d9ee8f90a0
commit ce4577874b
6 changed files with 661 additions and 1 deletions

42
app.js
View File

@ -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}`);

View File

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

View File

@ -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;
}

332
routes/billing.js Normal file
View File

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

229
views/admin/billing.ejs Normal file
View File

@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Abrechnung</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="admin-body">
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="logo admin-logo">Plusfit<span>24</span></div>
<nav class="admin-nav">
<a href="/admin" class="nav-link">📋 Tarife</a>
<a href="/admin#kategorien" class="nav-link">🏷️ Kategorien</a>
<a href="/admin#mitglieder" class="nav-link">👥 Mitglieder</a>
<a href="/admin/billing" class="nav-link active">💶 Abrechnung</a>
<a href="/admin#einstellungen" class="nav-link">⚙️ Einstellungen</a>
</nav>
<div class="sidebar-footer">
<span>👤 <%= admin %></span>
<a href="/admin/logout" class="logout-link">Abmelden</a>
</div>
</aside>
<main class="admin-main">
<!-- Kopfzeile mit Perioden-Auswahl -->
<div class="billing-header">
<h1>💶 Abrechnung</h1>
<form method="GET" action="/admin/billing" class="period-form">
<input type="month" name="period" value="<%= period %>" class="form-control period-input">
<button type="submit" class="btn btn-outline">Anzeigen</button>
</form>
</div>
<% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<!-- Monatsüberschrift -->
<h2 class="billing-period-title"><%= periodLabel %></h2>
<!-- Stats -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-number"><%= summary.total || 0 %></div>
<div class="stat-label">Rechnungen gesamt</div>
</div>
<div class="stat-card">
<div class="stat-number" style="color:var(--error)"><%= summary.open_count || 0 %></div>
<div class="stat-label">Offen</div>
</div>
<div class="stat-card">
<div class="stat-number" style="color:var(--success)"><%= summary.paid_count || 0 %></div>
<div class="stat-label">Bezahlt</div>
</div>
<div class="stat-card">
<div class="stat-number"><%= summary.total_amount ? Number(summary.total_amount).toFixed(2).replace('.', ',') + ' €' : '0,00 €' %></div>
<div class="stat-label">Gesamtbetrag</div>
</div>
<div class="stat-card">
<div class="stat-number" style="color:var(--error)"><%= summary.open_amount ? Number(summary.open_amount).toFixed(2).replace('.', ',') + ' €' : '0,00 €' %></div>
<div class="stat-label">Noch offen</div>
</div>
<div class="stat-card">
<div class="stat-number" style="color:var(--success)"><%= summary.paid_amount ? Number(summary.paid_amount).toFixed(2).replace('.', ',') + ' €' : '0,00 €' %></div>
<div class="stat-label">Bereits bezahlt</div>
</div>
</div>
<!-- Aktionen -->
<div class="billing-actions">
<% if (invoices.length === 0) { %>
<!-- Noch kein Lauf → Vorschau + Lauf starten -->
<div class="billing-preview-box">
<div class="preview-info">
<strong>Bereit für Abrechnungslauf <%= periodLabel %></strong>
<span><%= eligible.length %> Mitglieder · Voraussichtlich <%= Number(preview_total).toFixed(2).replace('.', ',') %> €</span>
</div>
<form method="POST" action="/admin/billing/run"
onsubmit="return confirm('Abrechnungslauf für <%= periodLabel %> starten? Dieser Vorgang kann nicht rückgängig gemacht werden.')">
<input type="hidden" name="period" value="<%= period %>">
<button type="submit" class="btn btn-primary">▶ Abrechnungslauf starten</button>
</form>
</div>
<% } else { %>
<!-- Lauf bereits durchgeführt → Export & Aktionen -->
<div class="billing-run-done">
<div class="billing-action-btns">
<a href="/admin/billing/export/csv?period=<%= period %>" class="btn btn-outline">
📥 SEPA CSV exportieren
</a>
<% if (summary.open_count > 0) { %>
<form method="POST" action="/admin/billing/mark-all-paid"
onsubmit="return confirm('Alle offenen Rechnungen als bezahlt markieren?')">
<input type="hidden" name="period" value="<%= period %>">
<button type="submit" class="btn btn-success">✅ Alle als bezahlt markieren</button>
</form>
<% } %>
</div>
</div>
<% } %>
</div>
<!-- Rechnungsliste -->
<% if (invoices.length > 0) { %>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Nr.</th>
<th>Mitglied</th>
<th>Tarif</th>
<th>Betrag</th>
<th>IBAN</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<% invoices.forEach(inv => { %>
<tr>
<td class="invoice-nr">PF24-<%= String(inv.id).padStart(6, '0') %></td>
<td>
<strong><%= inv.last_name %>, <%= inv.first_name %></strong><br>
<small class="text-muted"><%= inv.email %></small>
</td>
<td><%= inv.tariff_name || '' %></td>
<td class="amount-cell">
<strong><%= Number(inv.amount).toFixed(2).replace('.', ',') %> €</strong>
</td>
<td class="iban-cell">
<%= inv.iban ? inv.iban.replace(/(.{4})/g, '$1 ').trim() : '' %>
</td>
<td>
<span class="invoice-status <%= inv.status %>">
<%= inv.status === 'paid' ? '✅ Bezahlt' : inv.status === 'open' ? '🔴 Offen' : '❌ Storniert' %>
</span>
<% if (inv.paid_at) { %>
<br><small class="text-muted"><%= new Date(inv.paid_at).toLocaleDateString('de-DE') %></small>
<% } %>
</td>
<td>
<div class="invoice-actions">
<a href="/admin/billing/export/pdf/<%= inv.id %>"
class="btn btn-sm btn-outline" target="_blank" title="PDF herunterladen">
📄 PDF
</a>
<% if (inv.status === 'open') { %>
<form method="POST" action="/admin/billing/invoices/<%= inv.id %>/paid" style="display:inline">
<input type="hidden" name="period" value="<%= period %>">
<button type="submit" class="btn btn-sm btn-success" title="Als bezahlt markieren">✅</button>
</form>
<% } %>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } else if (eligible.length > 0) { %>
<!-- Vorschau der Mitglieder -->
<div class="preview-table-wrap">
<h3 class="preview-title">Vorschau wird abgerechnet</h3>
<table class="admin-table">
<thead>
<tr>
<th>Mitglied</th>
<th>Tarif</th>
<th>Voraussichtlicher Betrag</th>
</tr>
</thead>
<tbody>
<% eligible.forEach(m => { %>
<tr>
<td><%= m.last_name %>, <%= m.first_name %></td>
<td><%= m.tariff_name %></td>
<td><strong><%= Number(m.price_monthly).toFixed(2).replace('.', ',') %> €</strong></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } else { %>
<div class="no-data-card">Keine Mitglieder für diesen Zeitraum.</div>
<% } %>
<!-- Letzte Abrechnungsläufe -->
<% if (runs.length > 0) { %>
<div class="runs-section">
<h3>Letzte Abrechnungsläufe</h3>
<table class="admin-table">
<thead>
<tr>
<th>Periode</th>
<th>Datum</th>
<th>Rechnungen</th>
<th>Gesamtbetrag</th>
<th>Erstellt von</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<% runs.forEach(run => { %>
<tr>
<td><strong><%= run.period %></strong></td>
<td><%= new Date(run.created_at).toLocaleDateString('de-DE') %></td>
<td><%= run.invoice_count %></td>
<td><%= Number(run.total_amount).toFixed(2).replace('.', ',') %> €</td>
<td><%= run.created_by || '' %></td>
<td>
<a href="/admin/billing?period=<%= run.period %>" class="btn btn-sm btn-outline">
Anzeigen
</a>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</main>
</div>
</body>
</html>

View File

@ -16,6 +16,7 @@
<a href="#" class="nav-link active" onclick="showSection('tarife', this)">📋 Tarife</a>
<a href="#" class="nav-link" onclick="showSection('kategorien', this)">🏷️ Kategorien</a>
<a href="#" class="nav-link" onclick="showSection('mitglieder', this)">👥 Mitglieder</a>
<a href="/admin/billing" class="nav-link">💶 Abrechnung</a>
<a href="#" class="nav-link" onclick="showSection('einstellungen', this)">⚙️ Einstellungen</a>
</nav>
<div class="sidebar-footer">