Billing hinzugefügt
This commit is contained in:
parent
d9ee8f90a0
commit
ce4577874b
42
app.js
42
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}`);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
332
routes/billing.js
Normal 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
229
views/admin/billing.ejs
Normal 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>
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user