This commit is contained in:
cay 2026-03-27 15:08:19 +00:00
parent 7cee8ac281
commit 6574b5f6d9
8 changed files with 393 additions and 38 deletions

4
app.js
View File

@ -35,7 +35,8 @@ const adminRouter = require('./routes/admin');
const apiRouter = require('./routes/api');
const billingRouter = require('./routes/billing');
const financeRouter = require('./routes/finance');
const renewalRouter = require('./routes/renewal');
const renewalRouter = require('./routes/renewal');
const contractsRouter = require('./routes/contracts');
const cron = require('node-cron');
app.use('/', indexRouter);
@ -44,6 +45,7 @@ app.use('/api', apiRouter);
app.use('/admin/billing', billingRouter);
app.use('/admin/finance', financeRouter);
app.use('/', renewalRouter);
app.use('/admin/contracts', contractsRouter);
// 404 Handler
app.use((req, res) => {

View File

@ -1407,3 +1407,12 @@ body:not(.admin-body) > * {
.member-detail-page .btn-sm { padding: 4px 9px; font-size: 0.76rem; }
.member-detail-page .neue-auszeit { padding: 10px 12px; }
.member-detail-page .neue-auszeit-title { font-size: 0.8rem; margin-bottom: 8px; }
/* Verträge Grid */
.contracts-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 900px) { .contracts-grid { grid-template-columns: 1fr; } }

78
routes/contracts.js Normal file
View File

@ -0,0 +1,78 @@
const express = require('express');
const router = express.Router();
const db = require('../config/database');
const { requireAdmin } = require('../middleware/auth');
// GET /admin/contracts
router.get('/', requireAdmin, async (req, res) => {
try {
// Verträge nach Kategorie
const [byCategory] = await db.query(`
SELECT
c.name as category_name,
COUNT(m.id) as total,
SUM(CASE WHEN m.status='active' THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN m.status='paused' THEN 1 ELSE 0 END) as paused,
SUM(CASE WHEN m.status='inactive' THEN 1 ELSE 0 END) as inactive,
SUM(COALESCE(m.agreed_price, t.price_monthly)) as monthly_revenue
FROM memberships m
JOIN tariffs t ON m.tariff_id = t.id
LEFT JOIN categories c ON t.category_id = c.id
GROUP BY c.id, c.name
ORDER BY total DESC
`);
// Verträge nach Tarif
const [byTariff] = await db.query(`
SELECT
t.name as tariff_name, t.price_monthly, t.duration_months, t.active as tariff_active,
COUNT(m.id) as total,
SUM(CASE WHEN m.status='active' THEN 1 ELSE 0 END) as active,
SUM(COALESCE(m.agreed_price, t.price_monthly)) as monthly_revenue
FROM memberships m
JOIN tariffs t ON m.tariff_id = t.id
GROUP BY t.id, t.name, t.price_monthly, t.duration_months, t.active
ORDER BY active DESC, total DESC
`);
// Gesamtübersicht
const [totals] = await db.query(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN status='paused' THEN 1 ELSE 0 END) as paused,
SUM(CASE WHEN status='inactive' THEN 1 ELSE 0 END) as inactive,
SUM(CASE WHEN is_minor=1 THEN 1 ELSE 0 END) as minors,
SUM(COALESCE(agreed_price, 0)) as total_monthly
FROM memberships
`);
// Auslaufende Verträge 3 Monate
const [expiring] = await db.query(`
SELECT m.*, t.name as tariff_name, t.price_monthly,
COALESCE(m.agreed_price, t.price_monthly) as agreed_price
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 aktiven Tarife für Dropdown
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE active=1 ORDER BY name ASC');
res.render('admin/contracts', {
admin: req.session.adminUser,
byCategory, byTariff,
totals: totals[0],
expiring, tariffs,
success: req.query.success || null,
error: req.query.error || null
});
} catch (err) {
console.error(err);
res.redirect('/admin?error=Fehler+in+Vertragsübersicht:+' + encodeURIComponent(err.message));
}
});
module.exports = router;

View File

@ -16,7 +16,8 @@
<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/contracts" class="nav-link">📑 Verträge</a>
<a href="/admin/billing" class="nav-link active">💶 Abrechnung</a>
<a href="/admin/finance" class="nav-link">📊 Finanzen</a>
<a href="/admin#einstellungen" class="nav-link">⚙️ Einstellungen</a>
</nav>

296
views/admin/contracts.ejs Normal file
View File

@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Verträge</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/contracts" class="nav-link active">📑 Verträge</a>
<a href="/admin/billing" class="nav-link">💶 Abrechnung</a>
<a href="/admin/finance" class="nav-link">📊 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">📑 Verträge</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">Gesamt Verträge</div>
<div class="kpi-value"><%= totals.total || 0 %></div>
</div>
<div class="kpi-card kpi-green">
<div class="kpi-label">Aktiv</div>
<div class="kpi-value"><%= totals.active || 0 %></div>
</div>
<div class="kpi-card kpi-orange">
<div class="kpi-label">Pausiert</div>
<div class="kpi-value"><%= totals.paused || 0 %></div>
</div>
<div class="kpi-card kpi-red">
<div class="kpi-label">Inaktiv</div>
<div class="kpi-value"><%= totals.inactive || 0 %></div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">Minderjährige</div>
<div class="kpi-value"><%= totals.minors || 0 %></div>
</div>
<div class="kpi-card kpi-yellow">
<div class="kpi-label">Monatl. Umsatz (aktiv)</div>
<div class="kpi-value" style="font-size:1.2rem"><%= Number(totals.total_monthly||0).toFixed(2).replace('.',',') %> €</div>
</div>
</div>
<!-- Tabs -->
<div class="finance-tabs">
<button class="ftab active" onclick="showTab('categories', this)">🏷️ Nach Kategorie</button>
<button class="ftab" onclick="showTab('tariffs', this)">📋 Nach Tarif</button>
<button class="ftab" onclick="showTab('expiring', this)">
⏳ Auslaufend
<% if (expiring.length > 0) { %>
<span class="expiry-badge expiry-<%= expiring.some(e => { const d=Math.ceil((new Date(e.effective_end)-new Date())/(864e5)); return d<=30; }) ? 'urgent' : 'warning' %>" style="margin-left:6px"><%= expiring.length %></span>
<% } %>
</button>
</div>
<!-- Tab: Nach Kategorie -->
<div class="ftab-content active" id="tab-categories">
<div class="contracts-grid">
<!-- Donut Chart -->
<div class="finance-card" style="display:flex;flex-direction:column;align-items:center">
<h3>Verteilung nach Kategorie</h3>
<canvas id="categoryChart" style="max-width:260px;max-height:260px;margin-top:12px"></canvas>
</div>
<!-- Tabelle -->
<div class="finance-card" style="flex:1">
<h3>Übersicht</h3>
<table class="admin-table" style="margin-top:12px">
<thead>
<tr>
<th>Kategorie</th>
<th>Gesamt</th>
<th>Aktiv</th>
<th>Pausiert</th>
<th>Inaktiv</th>
<th>Monatl. Umsatz</th>
</tr>
</thead>
<tbody>
<% byCategory.forEach(row => { %>
<tr>
<td><strong><%= row.category_name || ' Keine Kategorie ' %></strong></td>
<td><%= row.total %></td>
<td style="color:var(--success)"><strong><%= row.active %></strong></td>
<td style="color:var(--warning)"><%= row.paused %></td>
<td style="color:var(--error)"><%= row.inactive %></td>
<td><strong><%= Number(row.monthly_revenue||0).toFixed(2).replace('.',',') %> €</strong></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
<!-- Tab: Nach Tarif -->
<div class="ftab-content" id="tab-tariffs">
<div class="finance-card">
<h3>Verträge nach Tarif</h3>
<div class="table-wrap" style="margin-top:12px">
<table class="admin-table">
<thead>
<tr>
<th>Tarif</th>
<th>Laufzeit</th>
<th>Aktueller Preis</th>
<th>Mitglieder</th>
<th>Davon aktiv</th>
<th>Monatl. Umsatz</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<% byTariff.forEach(row => { %>
<tr>
<td><strong><%= row.tariff_name %></strong></td>
<td><%= row.duration_months %> Monate</td>
<td><%= Number(row.price_monthly).toFixed(2).replace('.',',') %> €/Monat</td>
<td><%= row.total %></td>
<td style="color:var(--success)"><strong><%= row.active %></strong></td>
<td><strong><%= Number(row.monthly_revenue||0).toFixed(2).replace('.',',') %> €</strong></td>
<td>
<span class="invoice-status <%= row.tariff_active ? 'paid' : 'cancelled' %>">
<%= row.tariff_active ? '✅ Aktiv' : '❌ Inaktiv' %>
</span>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
<!-- Tab: Auslaufende Verträge -->
<div class="ftab-content" id="tab-expiring">
<div class="finance-card">
<div class="section-header">
<h3>Auslaufende Verträge nächste 3 Monate (<%= expiring.length %>)</h3>
</div>
<% if (expiring.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>Vereinbarter Preis</th>
<th>Vertragsende</th>
<th>Restlaufzeit</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<% expiring.forEach(m => { %>
<%
const endDate = new Date(m.effective_end);
const diffDays = Math.ceil((endDate - new Date()) / (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><%= Number(m.agreed_price||m.price_monthly).toFixed(2).replace('.',',') %> €</strong></td>
<td><strong><%= endDate.toLocaleDateString('de-DE') %></strong></td>
<td>
<span class="expiry-badge expiry-<%= urgency %>">
noch <%= diffDays %> Tage
</span>
</td>
<td>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<a href="/admin/members/<%= m.id %>" class="btn btn-sm btn-outline">👤 Karteikarte</a>
<form method="POST" action="/admin/send-renewal/<%= m.id %>" style="display:inline">
<input type="hidden" name="_redirect" value="/admin/contracts">
<button type="submit" class="btn btn-sm btn-primary">📧 E-Mail</button>
</form>
<button type="button" class="btn btn-sm btn-success"
onclick="openRenewModal(<%= m.id %>, '<%= m.last_name %>, <%= m.first_name %>')">
🔄 Verlängern
</button>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
</main>
</div>
<!-- Modal: Manuell verlängern -->
<div class="modal-overlay hidden" id="renewModal">
<div class="modal">
<div class="modal-header">
<h3>Vertrag manuell verlängern</h3>
<button onclick="toggleModal('renewModal')" class="modal-close">✕</button>
</div>
<form method="POST" id="renewForm">
<div class="form-group">
<label>Mitglied</label>
<input type="text" id="renewMemberName" class="form-control" disabled>
</div>
<div class="form-group">
<label>Neuer Tarif *</label>
<select name="new_tariff_id" class="form-control" required>
<option value=""> Tarif wählen </option>
<% tariffs.forEach(t => { %>
<option value="<%= t.id %>"><%= t.name %> <%= Number(t.price_monthly).toFixed(2).replace('.',',') %>€/Monat (<%= t.duration_months %> Monate)</option>
<% }) %>
</select>
</div>
<div class="modal-footer">
<button type="button" onclick="toggleModal('renewModal')" class="btn btn-outline">Abbrechen</button>
<button type="submit" class="btn btn-success"
onclick="return confirm('Vertrag verlängern?')">🔄 Verlängern</button>
</div>
</form>
</div>
</div>
<script>
// Chart
const catData = <%- JSON.stringify(byCategory) %>;
new Chart(document.getElementById('categoryChart'), {
type: 'doughnut',
data: {
labels: catData.map(c => c.category_name || 'Sonstige'),
datasets: [{
data: catData.map(c => c.active),
backgroundColor: ['#2d2dcc','#16a34a','#d97706','#7c3aed','#dc2626','#0891b2'],
borderWidth: 2, borderColor: '#fff'
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { font: { family: 'Outfit' } } }
}
}
});
function showTab(name, el) {
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');
if (el) el.classList.add('active');
}
function toggleModal(id) {
document.getElementById(id).classList.toggle('hidden');
}
function openRenewModal(memberId, memberName) {
document.getElementById('renewMemberName').value = memberName;
document.getElementById('renewForm').action = '/admin/renew-manual/' + memberId;
toggleModal('renewModal');
}
// Hash on load
const hash = window.location.hash.replace('#','');
if (hash && document.getElementById('tab-' + hash)) showTab(hash);
</script>
</body>
</html>

View File

@ -16,6 +16,7 @@
<a href="#" class="nav-link active" onclick="showSection('tarife', this)">📋 Tarife</a>
<a href="#" class="nav-link" onclick="showSection('kategorien', this)">🏷️ Kategorien</a>
<a href="#" class="nav-link" onclick="showSection('mitglieder', this)">👥 Mitglieder</a>
<a href="/admin/contracts" class="nav-link">📑 Verträge</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>

View File

@ -17,7 +17,8 @@
<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/contracts" class="nav-link">📑 Verträge</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>

View File

@ -16,7 +16,8 @@
<a href="/admin" class="nav-link">📋 Tarife</a>
<a href="/admin#kategorien" class="nav-link">🏷️ Kategorien</a>
<a href="/admin#mitglieder" class="nav-link active">👥 Mitglieder</a>
<a href="/admin/billing" class="nav-link">💶 Abrechnung</a>
<a href="/admin/contracts" class="nav-link">📑 Verträge</a>
<a href="/admin/billing" class="nav-link">💶 Abrechnung</a>
<a href="/admin/finance" class="nav-link">📊 Finanzen</a>
<a href="/admin#einstellungen" class="nav-link">⚙️ Einstellungen</a>
</nav>
@ -456,40 +457,6 @@
</div>
<!-- ===== KARTE: Vertragsverlängerung ===== -->
<div class="karte karte-full">
<div class="karte-header">
<span class="karte-icon">🔄</span>
<h3>Vertragsverlängerung</h3>
</div>
<div class="karte-body">
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-end">
<!-- Manuell verlängern -->
<form method="POST" action="/admin/renew-manual/<%= member.id %>"
style="display:flex;gap:10px;align-items:flex-end;flex:1;min-width:300px"
onsubmit="return confirm('Vertrag manuell verlängern?')">
<div class="karte-field" style="flex:1">
<label>Neuer Tarif</label>
<select name="new_tariff_id" class="karte-input" style="border:1.5px solid var(--border);background:white">
<% tariffs.forEach(t => { %>
<option value="<%= t.id %>"><%= t.name %> <%= Number(t.price_monthly).toFixed(2) %>€/Monat</option>
<% }) %>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm" style="white-space:nowrap">
🔄 Manuell verlängern
</button>
</form>
<!-- E-Mail senden -->
<form method="POST" action="/admin/send-renewal/<%= member.id %>">
<button type="submit" class="btn btn-outline btn-sm" style="white-space:nowrap">
📧 Verlängerungs-E-Mail senden
</button>
</form>
</div>
</div>
</div>
</div><!-- end karteikarte-grid -->
</form>