Finanzen hinzugefügt.

This commit is contained in:
cay 2026-03-27 13:16:58 +00:00
parent cf732a1559
commit 95f4d9f271
5 changed files with 810 additions and 0 deletions

2
app.js
View File

@ -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) => {

View File

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

243
routes/finance.js Normal file
View File

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

View File

@ -17,6 +17,7 @@
<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="/admin/finance" class="nav-link">📊 Finanzen</a>
<a href="#" class="nav-link" onclick="showSection('einstellungen', this)">⚙️ Einstellungen</a>
</nav>
<div class="sidebar-footer">

485
views/admin/finance.ejs Normal file
View File

@ -0,0 +1,485 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Finanzübersicht</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">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
</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">💶 Abrechnung</a>
<a href="/admin/finance" class="nav-link active">📊 Finanzen</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">
<h1 class="finance-title">📊 Finanzübersicht</h1>
<% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<!-- ===== KPI KARTEN ===== -->
<div class="finance-kpi-grid">
<div class="kpi-card kpi-blue">
<div class="kpi-label">Gesamtumsatz (bezahlt)</div>
<div class="kpi-value"><%= Number(totalRevenue.paid_total).toFixed(2).replace('.', ',') %> €</div>
<div class="kpi-sub"><%= totalRevenue.invoice_count %> Rechnungen gesamt</div>
</div>
<div class="kpi-card kpi-red">
<div class="kpi-label">Offene Posten</div>
<div class="kpi-value"><%= Number(totalRevenue.open_total).toFixed(2).replace('.', ',') %> €</div>
<div class="kpi-sub"><%= openInvoices.length %> offene Rechnungen</div>
</div>
<div class="kpi-card kpi-orange">
<div class="kpi-label">Rückläufer (offen)</div>
<div class="kpi-value"><%= chargebackStats.open_count %></div>
<div class="kpi-sub"><%= Number(chargebackStats.total_amount).toFixed(2).replace('.', ',') %> € gesamt</div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">Mahngebühren (offen)</div>
<div class="kpi-value"><%= Number(dunningStats.open_total).toFixed(2).replace('.', ',') %> €</div>
<div class="kpi-sub"><%= dunningStats.open_count %> offene Mahnungen</div>
</div>
<div class="kpi-card kpi-green">
<div class="kpi-label">Mahngebühren (bezahlt)</div>
<div class="kpi-value"><%= Number(dunningStats.paid_total).toFixed(2).replace('.', ',') %> €</div>
<div class="kpi-sub">Aktueller Satz: <%= Number(dunningFee).toFixed(2).replace('.', ',') %> €</div>
</div>
<div class="kpi-card kpi-yellow">
<div class="kpi-label">Auslaufende Verträge</div>
<div class="kpi-value"><%= expiringContracts.length %></div>
<div class="kpi-sub">In den nächsten 3 Monaten</div>
</div>
</div>
<!-- ===== TABS ===== -->
<div class="finance-tabs">
<button class="ftab active" onclick="showTab('chart')">📈 Umsatzverlauf</button>
<button class="ftab" onclick="showTab('open')">🔴 Offene Posten</button>
<button class="ftab" onclick="showTab('chargebacks')">↩️ Rückläufer</button>
<button class="ftab" onclick="showTab('dunning')">📬 Mahngebühren</button>
<button class="ftab" onclick="showTab('expiring')">⏳ Auslaufende Verträge</button>
<button class="ftab" onclick="showTab('settings')">⚙️ Einstellungen</button>
</div>
<!-- ===== TAB: UMSATZVERLAUF ===== -->
<div class="ftab-content active" id="tab-chart">
<div class="finance-card">
<h3>Monatlicher Umsatz (letzte 12 Monate)</h3>
<canvas id="revenueChart" height="80"></canvas>
</div>
<div class="table-wrap" style="margin-top:16px">
<table class="admin-table">
<thead><tr><th>Periode</th><th>Rechnungen</th><th>Bezahlt</th><th>Offen</th><th>Gesamt</th></tr></thead>
<tbody>
<% monthlyRevenue.slice().reverse().forEach(m => { %>
<tr>
<td><strong><%= m.period %></strong></td>
<td><%= m.count %></td>
<td style="color:var(--success)"><%= Number(m.paid).toFixed(2).replace('.', ',') %> €</td>
<td style="color:var(--error)"><%= Number(m.open_amount).toFixed(2).replace('.', ',') %> €</td>
<td><strong><%= Number(m.total).toFixed(2).replace('.', ',') %> €</strong></td>
</tr>
<% }) %>
<% if (monthlyRevenue.length === 0) { %>
<tr><td colspan="5" class="no-data">Noch keine Abrechnungsdaten.</td></tr>
<% } %>
</tbody>
</table>
</div>
</div>
<!-- ===== TAB: OFFENE POSTEN ===== -->
<div class="ftab-content" id="tab-open">
<div class="finance-card">
<div class="section-header">
<h3>Offene Posten (<%= openInvoices.length %>)</h3>
</div>
<% if (openInvoices.length === 0) { %>
<p class="karte-empty">✅ Keine offenen Posten!</p>
<% } else { %>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Mitglied</th><th>Tarif</th><th>Periode</th><th>Betrag</th><th>Rechnung Nr.</th><th>Aktion</th></tr></thead>
<tbody>
<% openInvoices.forEach(inv => { %>
<tr>
<td>
<strong><%= inv.last_name %>, <%= inv.first_name %></strong><br>
<small class="text-muted"><%= inv.email %></small>
</td>
<td><%= inv.tariff_name || '' %></td>
<td><%= inv.period %></td>
<td style="color:var(--error)"><strong><%= Number(inv.amount).toFixed(2).replace('.', ',') %> €</strong></td>
<td class="invoice-nr">PF24-<%= String(inv.id).padStart(6,'0') %></td>
<td>
<a href="/admin/billing?period=<%= inv.period %>" class="btn btn-sm btn-outline">
Zur Abrechnung
</a>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<!-- ===== TAB: RÜCKLÄUFER ===== -->
<div class="ftab-content" id="tab-chargebacks">
<div class="finance-card">
<div class="section-header">
<h3>Rückläufer</h3>
<div style="display:flex;gap:8px">
<button class="btn btn-outline btn-sm" onclick="toggleModal('addChargebackModal')">+ Manuell eintragen</button>
<button class="btn btn-outline btn-sm" onclick="toggleModal('importChargebackModal')">📥 CSV Import</button>
</div>
</div>
<% if (chargebacks.length === 0) { %>
<p class="karte-empty">Keine Rückläufer vorhanden.</p>
<% } else { %>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Datum</th><th>Mitglied</th><th>Periode</th><th>Betrag</th><th>Grund</th><th>Status</th><th>Aktion</th></tr></thead>
<tbody>
<% chargebacks.forEach(c => { %>
<tr>
<td><%= new Date(c.chargeback_date).toLocaleDateString('de-DE') %></td>
<td><strong><%= c.last_name %>, <%= c.first_name %></strong></td>
<td><%= c.period %></td>
<td style="color:var(--error)"><strong><%= Number(c.amount).toFixed(2).replace('.', ',') %> €</strong></td>
<td><%= c.reason || '' %></td>
<td>
<span class="invoice-status <%= c.status === 'resolved' ? 'paid' : 'open' %>">
<%= c.status === 'resolved' ? '✅ Erledigt' : '🔴 Offen' %>
</span>
</td>
<td>
<% if (c.status === 'open') { %>
<form method="POST" action="/admin/finance/chargebacks/<%= c.id %>/resolve" style="display:inline">
<button type="submit" class="btn btn-sm btn-success">✅ Erledigt</button>
</form>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<!-- ===== TAB: MAHNGEBÜHREN ===== -->
<div class="ftab-content" id="tab-dunning">
<div class="finance-card">
<div class="section-header">
<h3>Mahngebühren</h3>
<button class="btn btn-primary btn-sm" onclick="toggleModal('addDunningModal')">+ Mahngebühr eintragen</button>
</div>
<% if (dunnings.length === 0) { %>
<p class="karte-empty">Keine Mahngebühren eingetragen.</p>
<% } else { %>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Datum</th><th>Mitglied</th><th>Betrag</th><th>Grund</th><th>Status</th><th>Aktion</th></tr></thead>
<tbody>
<% dunnings.forEach(d => { %>
<tr>
<td><%= new Date(d.issued_date).toLocaleDateString('de-DE') %></td>
<td><strong><%= d.last_name %>, <%= d.first_name %></strong></td>
<td><strong><%= Number(d.amount).toFixed(2).replace('.', ',') %> €</strong></td>
<td><%= d.reason %></td>
<td>
<span class="invoice-status <%= d.status === 'paid' ? 'paid' : d.status === 'cancelled' ? 'cancelled' : 'open' %>">
<%= d.status === 'paid' ? '✅ Bezahlt' : d.status === 'cancelled' ? '❌ Storniert' : '🔴 Offen' %>
</span>
<% if (d.paid_at) { %><br><small class="text-muted"><%= new Date(d.paid_at).toLocaleDateString('de-DE') %></small><% } %>
</td>
<td>
<div style="display:flex;gap:6px">
<% if (d.status === 'open') { %>
<form method="POST" action="/admin/finance/dunning/<%= d.id %>/paid" style="display:inline">
<button type="submit" class="btn btn-sm btn-success">✅</button>
</form>
<form method="POST" action="/admin/finance/dunning/<%= d.id %>/cancel" style="display:inline"
onsubmit="return confirm('Mahngebühr stornieren?')">
<button type="submit" class="btn btn-sm btn-danger">✕</button>
</form>
<% } %>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<!-- ===== TAB: AUSLAUFENDE VERTRÄGE ===== -->
<div class="ftab-content" id="tab-expiring">
<div class="finance-card">
<h3>Auslaufende Verträge nächste 3 Monate (<%= expiringContracts.length %>)</h3>
<% if (expiringContracts.length === 0) { %>
<p class="karte-empty">Keine auslaufenden Verträge in den nächsten 3 Monaten.</p>
<% } else { %>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Mitglied</th><th>Tarif</th><th>Vertragsende</th><th>Restlaufzeit</th><th>Monatsbeitrag</th><th>Aktion</th></tr></thead>
<tbody>
<% expiringContracts.forEach(m => { %>
<%
const endDate = new Date(m.effective_end);
const today = new Date();
const diffDays = Math.ceil((endDate - today) / (1000*60*60*24));
const urgency = diffDays <= 30 ? 'urgent' : diffDays <= 60 ? 'warning' : 'normal';
%>
<tr>
<td>
<strong><%= m.last_name %>, <%= m.first_name %></strong><br>
<small class="text-muted"><%= m.email %></small>
</td>
<td><%= m.tariff_name %></td>
<td><strong><%= endDate.toLocaleDateString('de-DE') %></strong></td>
<td>
<span class="expiry-badge expiry-<%= urgency %>">
noch <%= diffDays %> Tage
</span>
</td>
<td><%= Number(m.price_monthly).toFixed(2).replace('.', ',') %> €</td>
<td>
<a href="/admin/members/<%= m.id %>" class="btn btn-sm btn-outline">Zur Karteikarte</a>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<!-- ===== TAB: EINSTELLUNGEN ===== -->
<div class="ftab-content" id="tab-settings">
<div class="finance-card" style="max-width:400px">
<h3>Finanz-Einstellungen</h3>
<form method="POST" action="/admin/finance/settings">
<div class="form-group" style="margin-top:16px">
<label>Mahngebühr (€)</label>
<div class="input-wrap">
<input type="number" name="dunning_fee" step="0.01" min="0"
value="<%= Number(dunningFee).toFixed(2) %>" class="form-control">
</div>
</div>
<button type="submit" class="btn btn-primary" style="margin-top:12px">💾 Speichern</button>
</form>
</div>
</div>
</main>
</div>
<!-- Modal: Rückläufer manuell -->
<div class="modal-overlay hidden" id="addChargebackModal">
<div class="modal">
<div class="modal-header">
<h3>Rückläufer eintragen</h3>
<button onclick="toggleModal('addChargebackModal')" class="modal-close">✕</button>
</div>
<form method="POST" action="/admin/finance/chargebacks/add">
<div class="form-group">
<label>Mitglied *</label>
<select name="membership_id" class="form-control" required onchange="loadMemberInvoices(this.value)">
<option value=""> Mitglied wählen </option>
<% members.forEach(m => { %>
<option value="<%= m.id %>"><%= m.last_name %>, <%= m.first_name %></option>
<% }) %>
</select>
</div>
<div class="form-group">
<label>Rechnung (optional)</label>
<select name="invoice_id" class="form-control" id="invoiceSelect">
<option value=""> Rechnung wählen </option>
<% openInvoicesDropdown.forEach(i => { %>
<option value="<%= i.id %>" data-member="<%= i.membership_id %>">
<%= i.period %> <%= Number(i.amount).toFixed(2).replace('.', ',') %> € (<%= i.last_name %>, <%= i.first_name %>)
</option>
<% }) %>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>Periode *</label>
<input type="month" name="period" class="form-control" required>
</div>
<div class="form-group">
<label>Betrag (€) *</label>
<input type="number" name="amount" step="0.01" min="0" class="form-control" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Datum *</label>
<input type="date" name="chargeback_date" class="form-control" required
value="<%= new Date().toISOString().split('T')[0] %>">
</div>
<div class="form-group">
<label>Grund</label>
<input type="text" name="reason" class="form-control" value="SEPA Rücklastschrift">
</div>
</div>
<div class="form-group">
<label>Notizen</label>
<textarea name="notes" class="form-control" rows="2"></textarea>
</div>
<div class="modal-footer">
<button type="button" onclick="toggleModal('addChargebackModal')" class="btn btn-outline">Abbrechen</button>
<button type="submit" class="btn btn-primary">Eintragen</button>
</div>
</form>
</div>
</div>
<!-- Modal: CSV Import Rückläufer -->
<div class="modal-overlay hidden" id="importChargebackModal">
<div class="modal">
<div class="modal-header">
<h3>Rückläufer CSV Import</h3>
<button onclick="toggleModal('importChargebackModal')" class="modal-close">✕</button>
</div>
<form method="POST" action="/admin/finance/chargebacks/import">
<div class="form-group">
<label>CSV-Daten einfügen</label>
<p style="font-size:0.8rem;color:var(--text-muted);margin-bottom:8px">
Format pro Zeile: <code>IBAN;Betrag;Datum;Grund</code><br>
Beispiel: <code>DE89370400440532013000;29,95;2026-04-05;Rücklastschrift</code>
</p>
<textarea name="csv_data" class="form-control" rows="6"
placeholder="IBAN;Betrag;Datum;Grund"></textarea>
</div>
<div class="modal-footer">
<button type="button" onclick="toggleModal('importChargebackModal')" class="btn btn-outline">Abbrechen</button>
<button type="submit" class="btn btn-primary">📥 Importieren</button>
</div>
</form>
</div>
</div>
<!-- Modal: Mahngebühr eintragen -->
<div class="modal-overlay hidden" id="addDunningModal">
<div class="modal">
<div class="modal-header">
<h3>Mahngebühr eintragen</h3>
<button onclick="toggleModal('addDunningModal')" class="modal-close">✕</button>
</div>
<form method="POST" action="/admin/finance/dunning/add">
<div class="form-group">
<label>Mitglied *</label>
<select name="membership_id" class="form-control" required>
<option value=""> Mitglied wählen </option>
<% members.forEach(m => { %>
<option value="<%= m.id %>"><%= m.last_name %>, <%= m.first_name %></option>
<% }) %>
</select>
</div>
<div class="form-group">
<label>Rechnung (optional)</label>
<select name="invoice_id" class="form-control">
<option value=""> Rechnung wählen </option>
<% openInvoicesDropdown.forEach(i => { %>
<option value="<%= i.id %>">
<%= i.period %> <%= Number(i.amount).toFixed(2).replace('.', ',') %> € (<%= i.last_name %>, <%= i.first_name %>)
</option>
<% }) %>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>Betrag (€) *</label>
<input type="number" name="amount" step="0.01" min="0"
value="<%= Number(dunningFee).toFixed(2) %>" class="form-control" required>
</div>
<div class="form-group">
<label>Datum *</label>
<input type="date" name="issued_date" class="form-control" required
value="<%= new Date().toISOString().split('T')[0] %>">
</div>
</div>
<div class="form-group">
<label>Grund</label>
<input type="text" name="reason" class="form-control" value="Mahngebühr">
</div>
<div class="form-group">
<label>Notizen</label>
<textarea name="notes" class="form-control" rows="2"></textarea>
</div>
<div class="modal-footer">
<button type="button" onclick="toggleModal('addDunningModal')" class="btn btn-outline">Abbrechen</button>
<button type="submit" class="btn btn-primary">Eintragen</button>
</div>
</form>
</div>
</div>
<script>
// Chart
const ctx = document.getElementById('revenueChart').getContext('2d');
const chartData = <%- JSON.stringify(monthlyRevenue) %>;
new Chart(ctx, {
type: 'bar',
data: {
labels: chartData.map(m => m.period),
datasets: [
{
label: 'Bezahlt (€)',
data: chartData.map(m => parseFloat(m.paid) || 0),
backgroundColor: 'rgba(22, 163, 74, 0.7)',
borderRadius: 6
},
{
label: 'Offen (€)',
data: chartData.map(m => parseFloat(m.open_amount) || 0),
backgroundColor: 'rgba(220, 38, 38, 0.5)',
borderRadius: 6
}
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' } },
scales: { y: { beginAtZero: true, ticks: { callback: v => v + ' €' } } }
}
});
// Tabs
function showTab(name) {
document.querySelectorAll('.ftab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.ftab').forEach(t => t.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
event.target.classList.add('active');
}
function toggleModal(id) {
document.getElementById(id).classList.toggle('hidden');
}
</script>
</body>
</html>