diff --git a/app.js b/app.js index 502e039..c7c0d40 100644 --- a/app.js +++ b/app.js @@ -34,12 +34,14 @@ const indexRouter = require('./routes/index'); const adminRouter = require('./routes/admin'); const apiRouter = require('./routes/api'); const billingRouter = require('./routes/billing'); +const financeRouter = require('./routes/finance'); const cron = require('node-cron'); app.use('/', indexRouter); app.use('/admin', adminRouter); app.use('/api', apiRouter); app.use('/admin/billing', billingRouter); +app.use('/admin/finance', financeRouter); // 404 Handler app.use((req, res) => { diff --git a/public/css/style.css b/public/css/style.css index 4d6a27a..aaac667 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1169,3 +1169,82 @@ body:not(.admin-body) > * { top: 0; z-index: 1; } + +/* ================================================ + FINANZÜBERSICHT + ================================================ */ +.finance-title { font-size:1.8rem; font-weight:800; margin-bottom:20px; } + +.finance-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; + margin-bottom: 28px; +} +.kpi-card { + border-radius: 14px; + padding: 18px 20px; + border: 1.5px solid var(--border); + background: white; +} +.kpi-label { font-size:0.75rem; font-weight:700; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:8px; } +.kpi-value { font-size:1.7rem; font-weight:800; margin-bottom:4px; } +.kpi-sub { font-size:0.78rem; color:var(--text-muted); } +.kpi-blue .kpi-value { color: var(--primary); } +.kpi-red .kpi-value { color: var(--error); } +.kpi-orange .kpi-value { color: var(--warning); } +.kpi-purple .kpi-value { color: #7c3aed; } +.kpi-green .kpi-value { color: var(--success); } +.kpi-yellow .kpi-value { color: #b45309; } + +/* Tabs */ +.finance-tabs { + display: flex; + gap: 4px; + border-bottom: 2px solid var(--border); + margin-bottom: 20px; + flex-wrap: wrap; +} +.ftab { + padding: 10px 16px; + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + font-family: 'Outfit', sans-serif; + font-size: 0.88rem; + font-weight: 600; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.ftab:hover { color: var(--text); } +.ftab.active { color: var(--primary); border-bottom-color: var(--primary); } + +.ftab-content { display: none; } +.ftab-content.active { display: block; } + +.finance-card { + background: white; + border: 1.5px solid var(--border); + border-radius: 14px; + padding: 24px; + margin-bottom: 16px; +} +.finance-card h3 { + font-size: 1rem; + font-weight: 700; + margin-bottom: 16px; +} + +/* Auslaufende Verträge */ +.expiry-badge { + padding: 3px 10px; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 700; +} +.expiry-urgent { background:#fee2e2; color:var(--error); } +.expiry-warning { background:#fffbeb; color:var(--warning); } +.expiry-normal { background:#f0fdf4; color:var(--success); } diff --git a/routes/finance.js b/routes/finance.js new file mode 100644 index 0000000..db58ec6 --- /dev/null +++ b/routes/finance.js @@ -0,0 +1,243 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../config/database'); +const { requireAdmin } = require('../middleware/auth'); + +// ============================================ +// GET /admin/finance – Übersicht +// ============================================ +router.get('/', requireAdmin, async (req, res) => { + try { + // Mahngebühr aus Einstellungen + const [settingRows] = await db.query("SELECT value FROM settings WHERE key_name='dunning_fee'"); + const dunningFee = settingRows.length ? parseFloat(settingRows[0].value) : 7.50; + + // Gesamtumsatz + const [totalRevenue] = await db.query(` + SELECT + COALESCE(SUM(CASE WHEN status='paid' THEN amount ELSE 0 END), 0) as paid_total, + COALESCE(SUM(CASE WHEN status='open' THEN amount ELSE 0 END), 0) as open_total, + COALESCE(SUM(amount), 0) as gross_total, + COUNT(*) as invoice_count + FROM invoices + `); + + // Monatlicher Umsatz (letzte 12 Monate) + const [monthlyRevenue] = await db.query(` + SELECT + CONVERT(period USING utf8mb4) as period, + SUM(CASE WHEN status='paid' THEN amount ELSE 0 END) as paid, + SUM(CASE WHEN status='open' THEN amount ELSE 0 END) as open_amount, + SUM(amount) as total, + COUNT(*) as count + FROM invoices + GROUP BY CONVERT(period USING utf8mb4) + ORDER BY period DESC + LIMIT 12 + `); + + // Offene Posten + const [openInvoices] = await db.query(` + SELECT i.*, m.first_name, m.last_name, m.email, m.phone, + 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.status = 'open' + ORDER BY i.period ASC, m.last_name ASC + `); + + // Rückläufer + const [chargebacks] = await db.query(` + SELECT c.*, m.first_name, m.last_name, m.email + FROM chargebacks c + JOIN memberships m ON c.membership_id = m.id + ORDER BY c.chargeback_date DESC + `); + + // Mahngebühren + const [dunnings] = await db.query(` + SELECT d.*, m.first_name, m.last_name, m.email + FROM dunning_fees d + JOIN memberships m ON d.membership_id = m.id + ORDER BY d.issued_date DESC + `); + + // Mahngebühren Summen + const [dunningStats] = await db.query(` + SELECT + COALESCE(SUM(CASE WHEN status='open' THEN amount ELSE 0 END), 0) as open_total, + COALESCE(SUM(CASE WHEN status='paid' THEN amount ELSE 0 END), 0) as paid_total, + COUNT(CASE WHEN status='open' THEN 1 END) as open_count + FROM dunning_fees + `); + + // Rückläufer Summen + const [chargebackStats] = await db.query(` + SELECT + COUNT(*) as total, + COUNT(CASE WHEN status='open' THEN 1 END) as open_count, + COALESCE(SUM(amount), 0) as total_amount + FROM chargebacks + `); + + // Auslaufende Verträge (nächste 3 Monate) + const [expiringContracts] = await db.query(` + SELECT m.*, t.name as tariff_name, t.price_monthly + FROM memberships m + JOIN tariffs t ON m.tariff_id = t.id + WHERE m.status = 'active' + AND m.effective_end BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 3 MONTH) + ORDER BY m.effective_end ASC + `); + + // Alle Mitglieder für Dropdowns + const [members] = await db.query(` + SELECT m.id, m.first_name, m.last_name + FROM memberships m WHERE m.status IN ('active','paused','inactive') + ORDER BY m.last_name ASC + `); + + // Offene Rechnungen für Dropdown + const [openInvoicesDropdown] = await db.query(` + SELECT i.id, i.period, i.amount, m.first_name, m.last_name + FROM invoices i JOIN memberships m ON i.membership_id = m.id + WHERE i.status = 'open' + ORDER BY i.period DESC, m.last_name ASC + `); + + res.render('admin/finance', { + admin: req.session.adminUser, + dunningFee, + totalRevenue: totalRevenue[0], + monthlyRevenue: monthlyRevenue.reverse(), // aufsteigend für Chart + openInvoices, + chargebacks, + dunnings, + dunningStats: dunningStats[0], + chargebackStats: chargebackStats[0], + expiringContracts, + members, + openInvoicesDropdown, + success: req.query.success || null, + error: req.query.error || null + }); + } catch (err) { + console.error(err); + res.redirect('/admin?error=Fehler+in+der+Finanzübersicht:+' + encodeURIComponent(err.message)); + } +}); + +// ============================================ +// Einstellungen speichern +// ============================================ +router.post('/settings', requireAdmin, async (req, res) => { + const { dunning_fee } = req.body; + try { + await db.query( + "INSERT INTO settings (key_name, value, label) VALUES ('dunning_fee', ?, 'Mahngebühr (€)') ON DUPLICATE KEY UPDATE value = ?", + [dunning_fee, dunning_fee] + ); + res.redirect('/admin/finance?success=Einstellungen+gespeichert'); + } catch (err) { + res.redirect('/admin/finance?error=Fehler+beim+Speichern'); + } +}); + +// ============================================ +// Rückläufer eintragen +// ============================================ +router.post('/chargebacks/add', requireAdmin, async (req, res) => { + const { membership_id, invoice_id, period, amount, reason, chargeback_date, notes } = req.body; + try { + await db.query( + 'INSERT INTO chargebacks (membership_id, invoice_id, period, amount, reason, chargeback_date, notes) VALUES (?,?,?,?,?,?,?)', + [membership_id, invoice_id || null, period, amount, reason || 'SEPA Rücklastschrift', chargeback_date, notes || null] + ); + // Rechnung wieder auf offen setzen + if (invoice_id) { + await db.query("UPDATE invoices SET status='open', paid_at=NULL WHERE id=?", [invoice_id]); + } + res.redirect('/admin/finance?success=Rückläufer+eingetragen'); + } catch (err) { + console.error(err); + res.redirect('/admin/finance?error=Fehler+beim+Eintragen'); + } +}); + +router.post('/chargebacks/:id/resolve', requireAdmin, async (req, res) => { + try { + await db.query("UPDATE chargebacks SET status='resolved' WHERE id=?", [req.params.id]); + res.redirect('/admin/finance?success=Rückläufer+als+erledigt+markiert'); + } catch (err) { + res.redirect('/admin/finance?error=Fehler'); + } +}); + +// CSV Import Rückläufer +router.post('/chargebacks/import', requireAdmin, async (req, res) => { + const { csv_data } = req.body; + if (!csv_data || !csv_data.trim()) return res.redirect('/admin/finance?error=Keine+Daten+eingegeben'); + try { + const lines = csv_data.trim().split('\n').filter(l => l.trim()); + let imported = 0; + for (const line of lines) { + const cols = line.split(';').map(c => c.trim().replace(/"/g, '')); + if (cols.length < 3) continue; + const [iban, amount, date, reason] = cols; + const cleanIban = iban.replace(/\s/g, ''); + // Mitglied anhand IBAN suchen + const [members] = await db.query( + "SELECT id FROM memberships WHERE REPLACE(iban,' ','') = ?", [cleanIban] + ); + if (members.length === 0) continue; + const now = new Date(); + const period = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`; + await db.query( + 'INSERT INTO chargebacks (membership_id, period, amount, reason, chargeback_date) VALUES (?,?,?,?,?)', + [members[0].id, period, Math.abs(parseFloat(amount.replace(',','.'))), reason || 'SEPA Rücklastschrift', date || new Date().toISOString().split('T')[0]] + ); + imported++; + } + res.redirect(`/admin/finance?success=${imported}+Rückläufer+importiert`); + } catch (err) { + console.error(err); + res.redirect('/admin/finance?error=Import+Fehler:+' + encodeURIComponent(err.message)); + } +}); + +// ============================================ +// Mahngebühren +// ============================================ +router.post('/dunning/add', requireAdmin, async (req, res) => { + const { membership_id, invoice_id, amount, reason, issued_date, notes } = req.body; + try { + await db.query( + 'INSERT INTO dunning_fees (membership_id, invoice_id, amount, reason, issued_date, notes) VALUES (?,?,?,?,?,?)', + [membership_id, invoice_id || null, amount, reason || 'Mahngebühr', issued_date, notes || null] + ); + res.redirect('/admin/finance?success=Mahngebühr+eingetragen'); + } catch (err) { + res.redirect('/admin/finance?error=Fehler+beim+Eintragen'); + } +}); + +router.post('/dunning/:id/paid', requireAdmin, async (req, res) => { + try { + await db.query("UPDATE dunning_fees SET status='paid', paid_at=NOW() WHERE id=?", [req.params.id]); + res.redirect('/admin/finance?success=Mahngebühr+als+bezahlt+markiert'); + } catch (err) { + res.redirect('/admin/finance?error=Fehler'); + } +}); + +router.post('/dunning/:id/cancel', requireAdmin, async (req, res) => { + try { + await db.query("UPDATE dunning_fees SET status='cancelled' WHERE id=?", [req.params.id]); + res.redirect('/admin/finance?success=Mahngebühr+storniert'); + } catch (err) { + res.redirect('/admin/finance?error=Fehler'); + } +}); + +module.exports = router; diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs index 9472751..f7a2300 100644 --- a/views/admin/dashboard.ejs +++ b/views/admin/dashboard.ejs @@ -17,6 +17,7 @@ 🏷️ Kategorien 👥 Mitglieder 💶 Abrechnung + 📊 Finanzen ⚙️ Einstellungen