Finanzen hinzugefügt.
This commit is contained in:
parent
cf732a1559
commit
95f4d9f271
2
app.js
2
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) => {
|
||||
|
||||
@ -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
243
routes/finance.js
Normal 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;
|
||||
@ -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
485
views/admin/finance.ejs
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user