Compare commits

..

No commits in common. "main" and "master" have entirely different histories.
main ... master

88 changed files with 5282 additions and 7671 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
.git
deploy.log

5
.env Normal file
View File

@ -0,0 +1,5 @@
MAIL_HOST=smtp.ionos.de
MAIL_PORT=587
MAIL_USER=contract@plusfit24.de
MAIL_PASS=plusfit242026
ADMIN_MAIL=info@plusfit24.de

5
.gitignore vendored
View File

@ -1,4 +1,5 @@
node_modules/
.env
*.log
.DS_Store
deploy.log
documents/
npm-debug.log

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3005
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3005/ || exit 1
CMD ["node", "app.js"]

325
README.md
View File

@ -1,325 +0,0 @@
# PlusFit24 Mitgliedschafts-Anmeldesystem
## Vollständige Installationsanleitung
---
## 🏗️ Übersicht deiner Server-Infrastruktur
```
Internet
NGINX (192.168.0.157) ← SSL Zertifikat hier
│ plusfit24.software-joksch.com
│ Proxy → Port 3100
Node.js App Server (192.168.0.163:3100)
MariaDB (85.215.63.122:3306)
```
---
## SCHRITT 1: Datenbank einrichten (MariaDB 85.215.63.122)
Verbinde dich mit deinem MariaDB Server:
```bash
mysql -h 85.215.63.122 -u root -p
```
Führe das Schema aus:
```bash
mysql -h 85.215.63.122 -u root -p < database/schema.sql
```
Erstelle einen dedizierten DB-User (sicherer als root):
```sql
CREATE USER 'plusfit24'@'192.168.0.163' IDENTIFIED BY 'DEIN_SICHERES_PASSWORT';
GRANT ALL PRIVILEGES ON plusfit24.* TO 'plusfit24'@'192.168.0.163';
FLUSH PRIVILEGES;
```
⚠️ **Wichtig:** Ersetze `DEIN_SICHERES_PASSWORT` mit einem echten starken Passwort!
---
## SCHRITT 2: App auf Node.js Server einrichten (192.168.0.163)
### 2.1 Projekt auf den Server übertragen
**Option A via Gitea (empfohlen):**
```bash
# Auf dem Gitea-Server (192.168.0.221): Neues Repository anlegen
# Dann auf dem App-Server:
ssh user@192.168.0.163
cd /var/www
git clone http://192.168.0.221/dein-user/plusfit24.git
cd plusfit24
```
**Option B direkt per SCP:**
```bash
# Von deinem lokalen Rechner:
scp -r /pfad/zu/plusfit24 user@192.168.0.163:/var/www/plusfit24
```
### 2.2 Node.js installieren (falls noch nicht vorhanden)
```bash
# Node.js 20 LTS installieren
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# Version prüfen
node --version # sollte v20.x.x zeigen
npm --version
```
### 2.3 Abhängigkeiten installieren
```bash
cd /var/www/plusfit24
npm install
```
### 2.4 Umgebungsvariablen konfigurieren
```bash
cp .env.example .env # oder direkt:
nano .env
```
Fülle die `.env` aus:
```env
DB_HOST=85.215.63.122
DB_PORT=3306
DB_USER=plusfit24
DB_PASSWORD=DEIN_SICHERES_PASSWORT ← gleich wie in Schritt 1
DB_NAME=plusfit24
SESSION_SECRET=ein-sehr-langer-zufaelliger-string-hier-aendern
ADMIN_USER=admin
ADMIN_PASSWORD=DeinSicheresAdminPasswort123!
PORT=3100
```
🔐 **Tipp:** Session Secret generieren:
```bash
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
```
### 2.5 Test-Start (einmalig zum Prüfen)
```bash
node app.js
# Ausgabe: 🚀 PlusFit24 Server läuft auf Port 3100
# Ausgabe: ✅ Admin Account erstellt: admin
```
Mit Ctrl+C stoppen.
---
## SCHRITT 3: PM2 installieren (Prozess-Manager)
```bash
# PM2 global installieren
sudo npm install -g pm2
# App mit PM2 starten
cd /var/www/plusfit24
pm2 start ecosystem.config.js
# Status prüfen
pm2 status
# Logs ansehen
pm2 logs plusfit24
# PM2 beim Systemstart automatisch starten
pm2 startup
# Den angezeigten Befehl kopieren und ausführen, dann:
pm2 save
```
**Wichtige PM2 Befehle:**
```bash
pm2 restart plusfit24 # App neu starten
pm2 stop plusfit24 # App stoppen
pm2 logs plusfit24 # Live-Logs anzeigen
pm2 monit # Monitor-Ansicht
```
---
## SCHRITT 4: NGINX konfigurieren (192.168.0.157)
### 4.1 NGINX Konfiguration kopieren
```bash
# Auf dem NGINX Server einloggen
ssh user@192.168.0.157
# Konfigurationsdatei erstellen
sudo nano /etc/nginx/sites-available/plusfit24
```
Inhalt aus der Datei `nginx/plusfit24.conf` einfügen.
⚠️ **SSL Pfade anpassen!** Suche im File nach:
```
ssl_certificate /etc/ssl/certs/...
ssl_certificate_key /etc/ssl/private/...
```
Passe die Pfade auf deine tatsächlichen Zertifikat-Dateien an.
### 4.2 Site aktivieren
```bash
# Symlink erstellen
sudo ln -s /etc/nginx/sites-available/plusfit24 /etc/nginx/sites-enabled/
# Konfiguration prüfen
sudo nginx -t
# Bei "syntax is ok": NGINX neu laden
sudo systemctl reload nginx
```
### 4.3 Firewall prüfen (falls aktiv)
```bash
# Auf App-Server: Port 3100 für NGINX-Server freigeben
sudo ufw allow from 192.168.0.157 to any port 3100
```
---
## SCHRITT 5: DNS einrichten
Bei deinem DNS-Anbieter einen A-Record erstellen:
```
Typ: A
Name: plusfit24.software-joksch.com
Ziel: [Öffentliche IP deines NGINX-Servers]
TTL: 300
```
---
## SCHRITT 6: Testen
1. **Datenbank-Verbindung:** `node -e "require('./config/database').query('SELECT 1').then(()=>console.log('DB OK')).catch(console.error)"`
2. **App läuft:** `pm2 status` → Status sollte "online" sein
3. **Webseite:** https://plusfit24.software-joksch.com aufrufen
4. **Admin-Bereich:** https://plusfit24.software-joksch.com/admin/login
- Benutzername: `admin` (oder was du in .env gesetzt hast)
- Passwort: aus der `.env` Datei
---
## 📋 Admin-Bereich Anleitung
### Tarife verwalten
- **Neuer Tarif:** Button "+ Neuer Tarif" → Formular ausfüllen
- **Deaktivieren:** Tarif-Card → "⏸ Deaktivieren" (Tarif bleibt erhalten, erscheint nicht mehr auf der Webseite)
- **Preis ändern:** Alten Tarif deaktivieren → Neuen Tarif mit neuem Preis erstellen
- **Löschen:** Nur möglich wenn keine Mitglieder den Tarif haben
### Mitglieder ansehen
- Vollständige Liste aller angemeldeten Mitglieder
- Suchfunktion nach Name oder E-Mail
- ⚠️-Symbol zeigt Minderjährige
### Passwort ändern
- Admin → Einstellungen → Passwort ändern
---
## 🔧 Troubleshooting
**App startet nicht:**
```bash
pm2 logs plusfit24 --lines 50
# Häufige Ursachen: DB nicht erreichbar, .env fehlt, Port belegt
```
**DB-Verbindungsfehler:**
```bash
# Verbindung testen
mysql -h 85.215.63.122 -u plusfit24 -p plusfit24
```
**NGINX 502 Bad Gateway:**
```bash
# Prüfen ob App läuft
pm2 status
# Prüfen ob Port erreichbar
curl http://192.168.0.163:3100
```
**Nach Code-Updates:**
```bash
cd /var/www/plusfit24
git pull # falls Gitea genutzt
npm install # falls neue Dependencies
pm2 restart plusfit24
```
---
## 📁 Projektstruktur
```
plusfit24/
├── app.js # Haupt-Einstiegspunkt
├── package.json
├── .env # ⚠️ Nicht in Git einchecken!
├── ecosystem.config.js # PM2 Konfiguration
├── config/
│ └── database.js # DB-Verbindung
├── middleware/
│ └── auth.js # Admin-Authentifizierung
├── routes/
│ ├── index.js # Öffentliche Routen (Tarife, Anmeldung)
│ ├── admin.js # Admin-Routen
│ └── api.js # API (E-Mail-Prüfung, Formular-Submit)
├── views/
│ ├── index.ejs # Tarif-Auswahl
│ ├── signup.ejs # Anmelde-Formular (4 Schritte)
│ ├── success.ejs # Erfolgsseite
│ ├── error.ejs # Fehlerseite
│ └── admin/
│ ├── login.ejs # Admin Login
│ └── dashboard.ejs # Admin Dashboard
├── public/
│ ├── css/style.css # Alle Styles
│ └── pdfs/
│ └── Einverstaendniserklaerung.pdf
├── database/
│ └── schema.sql # Datenbank-Schema
└── nginx/
└── plusfit24.conf # NGINX Konfiguration
```
---
## 🔐 Sicherheitshinweise
1. `.env` niemals in Git einchecken (ist in `.gitignore`)
2. Starke Passwörter für DB und Admin verwenden
3. DB-User nur von App-Server-IP Zugriff erlauben
4. SESSION_SECRET muss ein langer, zufälliger String sein
5. Admin-URL ist `/admin/login` nicht öffentlich bewerben

197
app.js
View File

@ -1,175 +1,34 @@
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const path = require('path');
const bcrypt = require('bcryptjs');
const db = require('./config/database');
require("dotenv").config();
const express = require("express");
const session = require("express-session");
const bodyParser = require("body-parser");
const authRoutes = require("./routes/auth");
const userRoutes = require("./routes/users");
const widerrufRoutes = require("./routes/widerruf");
const app = express();
app.use(express.static("public"));
app.set("view engine", "ejs");
app.use(bodyParser.urlencoded({ extended: false }));
// View Engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(
session({
secret: "plusfit_secret_key",
resave: false,
saveUninitialized: false,
}),
);
// Static Files
app.use(express.static(path.join(__dirname, 'public')));
app.use("/", authRoutes);
app.use("/users", userRoutes);
app.use("/sepa", require("./routes/sepa"));
app.use("/sepa", require("./routes/sepaExport"));
app.use("/contracts", require("./routes/contracts"));
app.use("/register", require("./routes/register"));
app.use("/company", require("./routes/company"));
app.use("/widerruf", widerrufRoutes);
// Body Parser
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Session
app.use(session({
secret: process.env.SESSION_SECRET || 'plusfit24-secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // auf true setzen wenn HTTPS direkt (nicht via Proxy)
maxAge: 24 * 60 * 60 * 1000 // 24 Stunden
}
}));
// Routen
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 renewalRouter = require('./routes/renewal');
const contractsRouter = require('./routes/contracts');
const mailingRouter = require('./routes/mailing');
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);
app.use('/', renewalRouter);
app.use('/admin/contracts', contractsRouter);
app.use('/admin/mailing', mailingRouter);
// 404 Handler
app.use((req, res) => {
res.status(404).render('error', { message: 'Seite nicht gefunden' });
});
// Fehler Handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).render('error', { message: 'Ein Fehler ist aufgetreten' });
});
// Admin Account beim Start erstellen falls keiner existiert
async function initAdmin() {
try {
const [rows] = await db.query('SELECT COUNT(*) as count FROM admins');
if (rows[0].count === 0) {
const hash = await bcrypt.hash(process.env.ADMIN_PASSWORD || 'Admin1234!', 12);
await db.query(
'INSERT INTO admins (username, password_hash) VALUES (?, ?)',
[process.env.ADMIN_USER || 'admin', hash]
);
console.log('✅ Admin Account erstellt:', process.env.ADMIN_USER || 'admin');
}
} catch (err) {
console.error('❌ Fehler beim Erstellen des Admin Accounts:', err.message);
}
}
// Verlängerungs-E-Mails: täglich um 08:00 Uhr prüfen (60 Tage vorher)
cron.schedule('0 8 * * *', async () => {
console.log('⏰ Prüfe auslaufende Verträge (60 Tage)...');
try {
const [expiring] = await db.query(`
SELECT m.* FROM memberships m
WHERE m.status = 'active'
AND m.effective_end BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 61 DAY)
AND m.id NOT IN (
SELECT membership_id FROM renewal_requests
WHERE status IN ('pending','completed')
AND sent_at >= DATE_SUB(NOW(), INTERVAL 70 DAY)
)
`);
if (expiring.length > 0) {
const { renewalEmailHtml } = require('./routes/renewal');
const mailer = require('./config/mailer');
const crypto = require('crypto');
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE active = 1 ORDER BY price_monthly ASC');
for (const member of expiring) {
try {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.query(
'INSERT INTO renewal_requests (membership_id, token, expires_at) VALUES (?,?,?)',
[member.id, token, expiresAt]
);
const baseUrl = process.env.APP_URL || 'https://plusfit24.software-joksch.com';
const link = baseUrl + '/renew/' + token;
await mailer.sendMail({
from: process.env.MAIL_FROM,
to: member.email,
subject: `Deine PlusFit24 Mitgliedschaft läuft bald ab`,
html: renewalEmailHtml(member, tariffs, link, member.effective_end || member.contract_end)
});
await db.query(
'INSERT INTO email_log (membership_id, type, recipient, subject, status) VALUES (?,?,?,?,?)',
[member.id, 'renewal_auto', member.email, 'Mitgliedschaft läuft ab', 'sent']
);
console.log('📧 Verlängerungs-E-Mail gesendet an:', member.email);
} catch (err) {
console.error('❌ E-Mail Fehler für', member.email, ':', err.message);
}
}
} else {
console.log('✅ Keine auslaufenden Verträge heute.');
}
} catch (err) {
console.error('❌ Cron Fehler:', err.message);
}
});
// 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}`);
await initAdmin();
app.listen(3005, "0.0.0.0", () => {
console.log("Plusfit läuft auf Port 3005");
});

View File

@ -1,16 +0,0 @@
require('dotenv').config();
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: process.env.DB_PORT || 3306,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
charset: 'utf8mb4'
});
module.exports = pool;

View File

@ -1,20 +0,0 @@
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST || 'smtp.ionos.de',
port: parseInt(process.env.MAIL_PORT) || 587,
secure: process.env.MAIL_SECURE === 'true',
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD
},
tls: { rejectUnauthorized: false }
});
// Verbindung testen beim Start
transporter.verify((err) => {
if (err) console.error('❌ E-Mail Verbindung fehlgeschlagen:', err.message);
else console.log('✅ E-Mail Server verbunden:', process.env.MAIL_HOST);
});
module.exports = transporter;

18
createAdmin.js Normal file
View File

@ -0,0 +1,18 @@
const bcrypt = require('bcrypt');
const Database = require('better-sqlite3');
const db = new Database('plusfit.db');
(async () => {
const username = 'admin';
const password = 'admin123';
const hash = await bcrypt.hash(password, 10);
db.prepare(`
INSERT OR IGNORE INTO admins (username, password)
VALUES (?, ?)
`).run(username, hash);
console.log('✅ Admin angelegt: admin / admin123');
})();

View File

@ -0,0 +1,31 @@
const Database = require('better-sqlite3');
const db = new Database('plusfit.db');
console.log('🔧 Starte Datenbank-Update (Widerruf)...');
// 1. Widerrufsfrist
db.prepare(`
ALTER TABLE users
ADD COLUMN widerruf_moeglich_bis TEXT
`).run();
// 2. Widerrufen am
db.prepare(`
ALTER TABLE users
ADD COLUMN widerrufen_am TEXT
`).run();
// 3. Widerrufen von IP
db.prepare(`
ALTER TABLE users
ADD COLUMN widerrufen_von_ip TEXT
`).run();
// 4. Status
db.prepare(`
ALTER TABLE users
ADD COLUMN status TEXT DEFAULT 'aktiv'
`).run();
console.log('✅ Datenbank erfolgreich erweitert');
db.close();

5
debugUsers.js Normal file
View File

@ -0,0 +1,5 @@
const Database = require('better-sqlite3');
const db = new Database('plusfit.db');
const users = db.prepare('SELECT * FROM users').all();
console.log(users);

11
docker-compose.yml Normal file
View File

@ -0,0 +1,11 @@
version: "3.9"
services:
plusfit24:
build: .
container_name: plusfit24
restart: always
ports:
- "3005:3005"
env_file:
- .env

View File

@ -1,24 +0,0 @@
// PM2 Ecosystem Konfiguration
// Starten mit: pm2 start ecosystem.config.js
module.exports = {
apps: [
{
name: 'plusfit24',
script: 'app.js',
cwd: '/var/www/plusfit24',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '300M',
env: {
NODE_ENV: 'production',
PORT: 3100
},
error_file: '/var/log/pm2/plusfit24-error.log',
out_file: '/var/log/pm2/plusfit24-out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
time: true
}
]
};

View File

@ -1,8 +0,0 @@
function requireAdmin(req, res, next) {
if (req.session && req.session.adminId) {
return next();
}
res.redirect('/admin/login');
}
module.exports = { requireAdmin };

View File

@ -0,0 +1,6 @@
module.exports = (req, res, next) => {
if (!req.session.loggedIn) {
return res.redirect('/');
}
next();
};

2496
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +1,30 @@
{
"name": "plusfit24-signup",
"name": "plusfit",
"version": "1.0.0",
"description": "PlusFit24 Mitgliedschaft Anmeldesystem",
"description": "Plusfit Mitgliederverwaltung mit Login, Adressdaten und SEPA-Lastschrift (SQLite, Node.js)",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js"
"dev": "nodemon app.js",
"init-db": "node database/init.js"
},
"author": "Plusfit",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"express-session": "^1.17.3",
"ejs": "^3.1.9",
"mysql2": "^3.6.0",
"bcryptjs": "^2.4.3",
"dotenv": "^16.3.1",
"dns": "^0.2.2",
"pdfkit": "^0.14.0",
"node-cron": "^3.0.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.7"
"bcrypt": "^5.1.1",
"better-sqlite3": "^9.6.0",
"body-parser": "^1.20.4",
"bootstrap": "^5.3.8",
"dotenv": "^17.2.3",
"ejs": "^3.1.10",
"express": "^4.22.1",
"express-session": "^1.18.2",
"iban": "^0.0.14",
"nodemailer": "^7.0.12",
"pdfkit": "^0.17.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
"nodemon": "^3.0.3"
},
"keywords": []
}

BIN
plusfit.db Normal file

Binary file not shown.

0
plusfit.db.js Normal file
View File

BIN
plusfit.db.zip Normal file

Binary file not shown.

BIN
plusfit_backup.db Normal file

Binary file not shown.

83
plusfit_dump.sql Normal file
View File

@ -0,0 +1,83 @@
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
password TEXT
);
INSERT INTO admins VALUES(1,'admin','$2b$10$pBGE/HCOaqHzcvwowYtb7evVcxXTDjd6AAI03SSNqyjpmC/0VZ6Xa');
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vorname TEXT,
nachname TEXT,
strasse TEXT,
hausnummer TEXT,
plz TEXT,
ort TEXT,
land TEXT,
mobil TEXT,
telefon TEXT,
email TEXT,
iban TEXT,
bic TEXT,
kontoinhaber TEXT,
mandatsreferenz TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
, gesperrt INTEGER DEFAULT 0, vertragsnummer TEXT, vertragsvariante INTEGER, geburtsdatum TEXT, zustimmung_agb INTEGER DEFAULT 0, zustimmung_sepa INTEGER DEFAULT 0, zustimmung_einverstaendnis INTEGER DEFAULT 0, zustimmung_datum TEXT, zustimmung_ip TEXT, vertragsversion TEXT, widerruf_moeglich_bis TEXT, widerrufen_am TEXT, widerrufen_von_ip TEXT, status TEXT DEFAULT 'aktiv');
INSERT INTO users VALUES(11,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','87e2461b0c35c8d5ff345498edbb06bf:d2c26512d662c2cd075bf5ecf9bc97f645f77d58497b8a70a97231ce4ffc664e','64ef2f37012c7a65d9408bf92ab05eb3:798b946c92bf5cdd1e78b6c35fd6bfa5','Cay Josch','gfhmf','2026-01-01 14:12:33',0,'PF-2026-000001',2,NULL,1,1,1,'2026-01-01T14:12:33.508Z','::1','v1.0','2026-01-15T14:12:33.508Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(12,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','9b80b9ba378d5d56e0d86ab34e22a027:62ff75f664b1e03e65f483a9a13637a9f09180eacda25ee011175705bf491e0a','b451e90412e44d2bbc08e5713960ad2d:7b57fbfde3e822f181a16d8799f9d63b','Cay Josch','gfhmffgh','2026-01-01 14:15:20',0,'PF-2026-000002',2,NULL,1,1,1,'2026-01-01T14:15:20.564Z','::1','v1.0','2026-01-15T14:15:20.564Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(13,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','d0a0f4d51ea3eaabd8ef821027ff8705:9714ca174140e67693632a5550445a364cf761dc1bb0f8da81c50f63437e6269','44f98047304d75fa4f415b599faee4ee:0707cfddefa99cfc3d2eb1b7dbe1f21e','Cay Josch','Test5','2026-01-01 14:16:34',0,'PF-2026-000003',2,NULL,1,1,1,'2026-01-01T14:16:34.304Z','::1','v1.0','2026-01-15T14:16:34.304Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(14,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','d85c6adb3ae6929a2c006e0d91322243:b95a025c3beff69b7d5fd366d2098e6dc249b283331a62a48075ea8ffd078496','c452e42ebc9644f12afdeb3a6cad5417:81cb4ebc87eb58e4157d63bcb2ec208b','Cay Josch','Test5frg','2026-01-01 14:18:31',0,'PF-2026-000004',2,NULL,1,1,1,'2026-01-01T14:18:31.901Z','::1','v1.0','2026-01-15T14:18:31.901Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(15,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','cf0a9ebd22fd00aea1b0bd8f2d9421ba:b70bacd3c749c95e21b9c57a3ea3b52cc4305a775e3df8b54d7104b507e3eb9b','3cc0d07c371303ecc694dfb15776bde2:5c09d1960e9a5aedb62f459da43cd53f','Cay Josch','Test5frgatr','2026-01-01 14:40:18',0,'PF-2026-000005',2,NULL,1,1,1,'2026-01-01T14:40:18.359Z','::1','v1.0','2026-01-15T14:40:18.359Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(16,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','df01b1fa17ec2c455dda2cb98601f46a:eab3206f0302edcf6a6c2ad004a96914b93dc52b4376a76a20937ec1f34327af','4936be0722aba26f8700a215a1f8ea28:7665b8e08415ff9530913cdb2f8e0081','Cay Josch','Test5frgatreatrg','2026-01-01 14:49:06',0,'PF-2026-000006',2,NULL,1,1,1,'2026-01-01T14:49:06.163Z','::1','v1.0','2026-01-15T14:49:06.163Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(17,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','63efccca02c1ee8c36a11c19f9c979e9:544b48d591856c5804e63362c4283dbe43af59fc2c80d7d8cff1c9baf5162abf','60d353623c0cf566190cdc368313e464:33e894b1e3745697650789dccc2ea42a','Cay Josch','Test5fdrgatr','2026-01-01 14:56:37',0,'PF-2026-000007',2,NULL,1,1,1,'2026-01-01T14:56:37.161Z','::1','v1.0','2026-01-15T14:56:37.161Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(18,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','c840f0478d2bdaa61e506323a9fe3ccd:d767b360f7da53c0d2af975757700d1b612bcabc1a4f83996e04eb1d694b9083','05fdb262a838590d60ad2bfe741e1824:f092846733f47f7765b5e0d05ab5ec9e','Cay Josch','Test5fdrgtgdratr','2026-01-01 15:00:57',0,'PF-2026-000008',2,NULL,1,1,1,'2026-01-01T15:00:57.142Z','::1','v1.0','2026-01-15T15:00:57.143Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(19,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','2d08eec4d0f6c23a8250b7758f9983cd:c6da689ada651c112bd2b7696a7a655b39e2598dfe85b5e572547353f4ac5126','e41d283c5c6ede67d0b3efab5d86e801:7e7d7d01c02f3b66562a5202637e39d5','Cay Josch','Test5fdrgatrgu','2026-01-01 15:03:38',0,'PF-2026-000009',2,NULL,1,1,1,'2026-01-01T15:03:38.139Z','::1','v1.0','2026-01-15T15:03:38.139Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(20,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','6b09922ad92a750d538a22a212ce4fc3:e9ee0e5c6cc50955dc4c202964b0cce7b72692fc958e03ffbd0859a125489386','d250fbfec033d4452c4f6fa92d8461eb:2b7b441b16d917e3f64db6793a14139a','Cay Josch','Teshzt5fdrgatr','2026-01-01 15:05:18',0,'PF-2026-000010',2,NULL,1,1,1,'2026-01-01T15:05:18.719Z','::1','v1.0','2026-01-15T15:05:18.719Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(21,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','b397d96f81ffebf71719f29bc4f09432:2f7fa6910bdf6a4a7e7a60ffb77845eca1b1d93a5fae98c3071e955904b71f90','1d3bf3724e415f98794b7ef0c3aaa2df:ec87c540227a716c44572ba260a0f324','Cay Josch','Teshzt5sdefdrgatr','2026-01-01 15:08:47',0,'PF-2026-000011',2,NULL,1,1,1,'2026-01-01T15:08:47.171Z','::1','v1.0','2026-01-15T15:08:47.171Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(22,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','df3e334a94454bf1009a95b68e53a82f:6f4f08cd7faf653bb8b863511842650b419ab8cd893f1ad17e3d28578b6218ef','174d68e5d089d80e23bb9bdf6c9ea72a:8bdd73a85e2e8cf0ab5dd2cd999122b8','Cay Josch','Teshzt5sdtefdrgatr','2026-01-01 15:11:31',0,'PF-2026-000012',2,NULL,1,1,1,'2026-01-01T15:11:31.037Z','::1','v1.0','2026-01-15T15:11:31.037Z',NULL,NULL,'aktiv');
INSERT INTO users VALUES(23,'Cay','Joksch','Calle la Fuente','24','38628','San Miguel de Abina','Deutschland','01752547854','','info@joksch-it.ch','90784a704431acb0e6a712a951a3cb59:478996c21dd5f05ca7188d8e908536bd29de0c83dce16da29a87576b107275b3','e14aa36ceacc212c429764fc50021bef:4638c43c1ab2b694e00a0ea08a093229','Cay Josch','Teshzt5sgfhdtefdrgatr','2026-01-01 15:14:30',0,'PF-2026-000013',2,NULL,1,1,1,'2026-01-01T15:14:30.773Z','::1','v1.0','2026-01-15T15:14:30.773Z',NULL,NULL,'aktiv');
CREATE TABLE vertragsarten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
laufzeit INTEGER NOT NULL,
betrag REAL NOT NULL,
aktiv INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
, beschreibung TEXT);
INSERT INTO vertragsarten VALUES(1,'Test',12,79.0,1,'2025-12-30 17:43:51',' Das ist ein Test wie der Vertrag heissen soll und was er kann');
INSERT INTO vertragsarten VALUES(2,'test2',24,50.0,1,'2025-12-30 18:00:54','skifrwjüpigjürwbghA ');
CREATE TABLE company (
id INTEGER PRIMARY KEY CHECK (id = 1),
firmenname TEXT NOT NULL,
strasse TEXT,
hausnummer TEXT,
plz TEXT,
ort TEXT,
land TEXT,
telefon TEXT,
email TEXT,
web TEXT,
iban TEXT,
bic TEXT,
glaeubiger_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO company VALUES(1,'Plusfit24','Lohweg','33','85375','Neufahrn b. Freising','Deutschland','+49 174 9677163','info@fitplus24.de','https://plusfit24.de/','','','','2025-12-30 18:39:52');
PRAGMA writable_schema=ON;
CREATE TABLE IF NOT EXISTS sqlite_sequence(name,seq);
DELETE FROM sqlite_sequence;
INSERT INTO sqlite_sequence VALUES('admins',1);
INSERT INTO sqlite_sequence VALUES('users',23);
INSERT INTO sqlite_sequence VALUES('vertragsarten',2);
PRAGMA writable_schema=OFF;
COMMIT;

BIN
plusfit_dump.zip Normal file

Binary file not shown.

0
plusfit_mysql.sqlcd Normal file
View File

BIN
public/AG_PlusFit24.pdf Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

6
public/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Binary file not shown.

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -1,83 +0,0 @@
/**
* PlusFit24 IBAN Validierung
* Prüft: Format, Ländercode, Länge, Prüfziffer (Modulo 97)
*/
const IBAN_LENGTHS = {
AL:28, AD:24, AT:20, AZ:28, BH:22, BE:16, BA:20, BR:29, BG:22,
CR:22, HR:21, CY:28, CZ:24, DK:18, DO:28, EE:20, FO:18, FI:18,
FR:27, GE:22, DE:22, GI:23, GL:18, GT:28, HU:28, IS:26, IE:22,
IL:23, IT:27, JO:30, KZ:20, KW:30, LV:21, LB:28, LI:21, LT:20,
LU:20, MK:19, MT:31, MR:27, MU:30, MC:27, MD:24, ME:22, NL:18,
NO:15, PK:24, PS:29, PL:28, PT:25, QA:29, RO:24, SM:27, SA:24,
RS:22, SK:24, SI:19, ES:24, SE:24, CH:21, TN:24, TR:26, AE:23,
GB:22, VG:24
};
function formatIBAN(value) {
const clean = value.replace(/[^A-Z0-9]/gi, '').toUpperCase();
return clean.replace(/(.{4})/g, '$1 ').trim();
}
function validateIBAN(iban) {
const clean = iban.replace(/\s/g, '').toUpperCase();
if (clean.length < 5) return { valid: false, error: 'IBAN zu kurz' };
const country = clean.substring(0, 2);
if (!/^[A-Z]{2}$/.test(country)) return { valid: false, error: 'Ungültiger Ländercode' };
const checkDigits = clean.substring(2, 4);
if (!/^\d{2}$/.test(checkDigits)) return { valid: false, error: 'Prüfziffern ungültig' };
const expectedLength = IBAN_LENGTHS[country];
if (!expectedLength) return { valid: false, error: 'Ländercode "' + country + '" nicht unterstützt' };
if (clean.length !== expectedLength) {
return { valid: false, error: country + '-IBAN muss ' + expectedLength + ' Zeichen haben (aktuell: ' + clean.length + ')' };
}
if (!/^[A-Z0-9]+$/.test(clean.substring(4))) {
return { valid: false, error: 'IBAN enthält ungültige Zeichen' };
}
// Modulo-97 Prüfung
const rearranged = clean.substring(4) + clean.substring(0, 4);
const numeric = rearranged.split('').map(c => {
const code = c.charCodeAt(0);
return code >= 65 ? (code - 55).toString() : c;
}).join('');
let remainder = 0;
for (let i = 0; i < numeric.length; i++) {
remainder = (remainder * 10 + parseInt(numeric[i])) % 97;
}
if (remainder !== 1) return { valid: false, error: 'Prüfziffer falsch IBAN ungültig' };
return { valid: true, formatted: formatIBAN(clean) };
}
function attachIBANValidation(inputEl, statusEl, messageEl) {
inputEl.addEventListener('input', function () {
const rawClean = this.value.replace(/[^A-Z0-9]/gi, '').toUpperCase();
this.value = formatIBAN(rawClean);
if (rawClean.length < 5) {
if (statusEl) statusEl.textContent = '';
if (messageEl) { messageEl.textContent = ''; messageEl.className = 'iban-message'; }
this.classList.remove('input-error', 'input-valid');
return;
}
const result = validateIBAN(rawClean);
if (result.valid) {
if (statusEl) statusEl.textContent = '✅';
if (messageEl) { messageEl.textContent = 'IBAN gültig'; messageEl.className = 'iban-message success'; }
this.classList.remove('input-error'); this.classList.add('input-valid');
} else {
if (statusEl) statusEl.textContent = '❌';
if (messageEl) { messageEl.textContent = result.error; messageEl.className = 'iban-message error'; }
this.classList.remove('input-valid'); this.classList.add('input-error');
}
});
}

View File

@ -1,366 +0,0 @@
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const bcrypt = require('bcryptjs');
const db = require('../config/database');
const { requireAdmin } = require('../middleware/auth');
// Login
router.get('/login', (req, res) => {
if (req.session.adminId) return res.redirect('/admin');
res.render('admin/login', { error: null });
});
router.post('/login', async (req, res) => {
const { username, password } = req.body;
try {
const [admins] = await db.query('SELECT * FROM admins WHERE username = ?', [username]);
if (admins.length === 0) return res.render('admin/login', { error: 'Ungültige Anmeldedaten.' });
const valid = await bcrypt.compare(password, admins[0].password_hash);
if (!valid) return res.render('admin/login', { error: 'Ungültige Anmeldedaten.' });
req.session.adminId = admins[0].id;
req.session.adminUser = admins[0].username;
res.redirect('/admin');
} catch (err) {
res.render('admin/login', { error: 'Serverfehler.' });
}
});
router.get('/logout', (req, res) => {
req.session.destroy();
res.redirect('/admin/login');
});
// Dashboard
router.get('/', requireAdmin, async (req, res) => {
try {
const [tariffs] = await db.query(`
SELECT t.*, c.name as category_name
FROM tariffs t LEFT JOIN categories c ON t.category_id = c.id
ORDER BY t.active DESC, t.created_at DESC
`);
const [categories] = await db.query('SELECT * FROM categories ORDER BY name ASC');
const [memberships] = await db.query(`
SELECT m.*, t.name as tariff_name, t.price_monthly
FROM memberships m LEFT JOIN tariffs t ON m.tariff_id = t.id
ORDER BY m.created_at DESC
`);
const [stats] = await db.query(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active_count,
SUM(CASE WHEN is_minor = 1 THEN 1 ELSE 0 END) as minors,
SUM(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as last_30_days,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
SUM(CASE WHEN reviewed = 0 AND status = 'active' THEN 1 ELSE 0 END) as new_count
FROM memberships
`);
res.render('admin/dashboard', {
tariffs, categories, memberships, stats: stats[0],
admin: req.session.adminUser,
success: req.query.success || null,
error: req.query.error || null
});
} catch (err) {
console.error(err);
res.render('admin/dashboard', {
tariffs: [], categories: [], memberships: [], stats: {},
admin: req.session.adminUser,
success: null, error: 'Datenbankfehler: ' + err.message
});
}
});
// ===== KATEGORIEN =====
router.post('/categories', requireAdmin, async (req, res) => {
const { name } = req.body;
if (!name || !name.trim()) return res.redirect('/admin?error=Kategoriename+fehlt');
try {
await db.query('INSERT INTO categories (name) VALUES (?)', [name.trim()]);
res.redirect('/admin?success=Kategorie+erstellt#kategorien');
} catch (err) {
res.redirect('/admin?error=Fehler+beim+Erstellen');
}
});
router.post('/categories/:id/update', requireAdmin, async (req, res) => {
const { name } = req.body;
if (!name || !name.trim()) return res.redirect('/admin?error=Kategoriename+fehlt');
try {
await db.query('UPDATE categories SET name = ? WHERE id = ?', [name.trim(), req.params.id]);
res.redirect('/admin?success=Kategorie+aktualisiert#kategorien');
} catch (err) {
res.redirect('/admin?error=Fehler+beim+Aktualisieren');
}
});
router.post('/categories/:id/delete', requireAdmin, async (req, res) => {
try {
const [used] = await db.query('SELECT COUNT(*) as c FROM tariffs WHERE category_id = ?', [req.params.id]);
if (used[0].c > 0) {
return res.redirect('/admin?error=Kategorie+wird+von+' + used[0].c + '+Tarifen+verwendet++bitte+erst+Tarife+umziehen#kategorien');
}
await db.query('DELETE FROM categories WHERE id = ?', [req.params.id]);
res.redirect('/admin?success=Kategorie+gelöscht#kategorien');
} catch (err) {
res.redirect('/admin?error=Fehler+beim+Löschen');
}
});
// ===== TARIFE =====
router.post('/tariffs', requireAdmin, async (req, res) => {
const { name, category_id, duration_months, price_monthly, start_package_price, description } = req.body;
try {
await db.query(
'INSERT INTO tariffs (name, category_id, duration_months, price_monthly, start_package_price, description) VALUES (?, ?, ?, ?, ?, ?)',
[name, category_id || null, duration_months, price_monthly, start_package_price || 35.00, description || '']
);
res.redirect('/admin?success=Tarif+erstellt');
} catch (err) {
res.redirect('/admin?error=Fehler+beim+Erstellen+des+Tarifs');
}
});
router.post('/tariffs/:id/toggle', requireAdmin, async (req, res) => {
try {
await db.query('UPDATE tariffs SET active = NOT active WHERE id = ?', [req.params.id]);
res.redirect('/admin?success=Tarif+aktualisiert');
} catch (err) {
res.redirect('/admin?error=Fehler+beim+Aktualisieren');
}
});
router.post('/tariffs/:id/update', requireAdmin, async (req, res) => {
const { name, category_id, duration_months, price_monthly, start_package_price, description } = req.body;
try {
await db.query(
'UPDATE tariffs SET name=?, category_id=?, duration_months=?, price_monthly=?, start_package_price=?, description=? WHERE id=?',
[name, category_id || null, duration_months, price_monthly, start_package_price, description, req.params.id]
);
res.redirect('/admin?success=Tarif+aktualisiert');
} catch (err) {
res.redirect('/admin?error=Fehler+beim+Aktualisieren');
}
});
// Passwort ändern
router.post('/change-password', requireAdmin, async (req, res) => {
const { current_password, new_password, confirm_password } = req.body;
if (new_password !== confirm_password) return res.redirect('/admin?error=Passwörter+stimmen+nicht+überein');
try {
const [admins] = await db.query('SELECT * FROM admins WHERE id = ?', [req.session.adminId]);
const valid = await bcrypt.compare(current_password, admins[0].password_hash);
if (!valid) return res.redirect('/admin?error=Aktuelles+Passwort+falsch');
const hash = await bcrypt.hash(new_password, 12);
await db.query('UPDATE admins SET password_hash = ? WHERE id = ?', [hash, req.session.adminId]);
res.redirect('/admin?success=Passwort+geändert');
} catch (err) {
res.redirect('/admin?error=Fehler+beim+Ändern+des+Passworts');
}
});
// ===== MITGLIED DETAIL =====
router.get('/members/:id', requireAdmin, async (req, res) => {
try {
const [rows] = await db.query(`
SELECT m.*,
t.name as tariff_name, t.price_monthly, t.duration_months,
c.name as category_name
FROM memberships m
LEFT JOIN tariffs t ON m.tariff_id = t.id
LEFT JOIN categories c ON t.category_id = c.id
WHERE m.id = ?
`, [req.params.id]);
if (rows.length === 0) return res.redirect('/admin?error=Mitglied+nicht+gefunden');
const [pauses] = await db.query(
'SELECT * FROM membership_pauses WHERE membership_id = ? ORDER BY pause_start DESC',
[req.params.id]
);
const [invoices] = await db.query(`
SELECT * FROM invoices
WHERE membership_id = ?
ORDER BY period DESC, created_at DESC
`, [req.params.id]);
const [tariffs] = await db.query(
'SELECT * FROM tariffs WHERE active = 1 ORDER BY name ASC'
);
res.render('admin/member-detail', {
member: rows[0],
pauses,
tariffs,
invoices,
admin: req.session.adminUser,
success: req.query.success || null,
error: req.query.error || null
});
} catch (err) {
console.error(err);
res.redirect('/admin?error=Fehler+beim+Laden+des+Mitglieds');
}
});
router.post('/members/:id/update', requireAdmin, async (req, res) => {
const {
salutation, title, first_name, last_name, birth_date,
email, phone, street, address_addition, zip, city,
bank_name, account_holder, iban, tariff_id, status,
agreed_price, agreed_duration, start_package_price
} = req.body;
try {
await db.query(`
UPDATE memberships SET
salutation=?, title=?, first_name=?, last_name=?, birth_date=?,
email=?, phone=?, street=?, address_addition=?, zip=?, city=?,
bank_name=?, account_holder=?, iban=?, tariff_id=?, status=?
WHERE id=?
`, [
salutation, title || '', first_name, last_name, birth_date,
email, phone || '', street, address_addition || '', zip, city,
bank_name || '', account_holder || '', iban || '',
tariff_id, status, req.params.id
]);
res.redirect(`/admin/members/${req.params.id}?success=Daten+gespeichert`);
} catch (err) {
console.error(err);
res.redirect(`/admin/members/${req.params.id}?error=Fehler+beim+Speichern`);
}
});
// ===== AUSZEITEN =====
router.post('/members/:id/pauses/add', requireAdmin, async (req, res) => {
const { pause_start, pause_end, reason } = req.body;
const memberId = req.params.id;
try {
if (!pause_start || !pause_end) {
return res.redirect(`/admin/members/${memberId}?error=Bitte+Von+und+Bis+ausfüllen`);
}
const start = new Date(pause_start);
const end = new Date(pause_end);
if (end <= start) {
return res.redirect(`/admin/members/${memberId}?error=Enddatum+muss+nach+Startdatum+liegen`);
}
// Monate automatisch berechnen (aufgerundet)
const pause_months = Math.ceil(
(end.getFullYear() - start.getFullYear()) * 12 +
(end.getMonth() - start.getMonth()) +
(end.getDate() > start.getDate() ? 1 : 0)
);
// Auszeit eintragen
await db.query(
'INSERT INTO membership_pauses (membership_id, pause_start, pause_end, pause_months, reason) VALUES (?, ?, ?, ?, ?)',
[memberId, pause_start, pause_end, pause_months, reason || null]
);
// pause_months_total und effective_end aktualisieren
await db.query(`
UPDATE memberships
SET pause_months_total = (
SELECT COALESCE(SUM(pause_months), 0) FROM membership_pauses WHERE membership_id = ?
),
effective_end = DATE_ADD(contract_end, INTERVAL (
SELECT COALESCE(SUM(pause_months), 0) FROM membership_pauses WHERE membership_id = ?
) MONTH)
WHERE id = ?
`, [memberId, memberId, memberId]);
res.redirect(`/admin/members/${memberId}?success=Auszeit+eingetragen`);
} catch (err) {
console.error(err);
res.redirect(`/admin/members/${memberId}?error=Fehler+beim+Eintragen+der+Auszeit`);
}
});
router.post('/members/:id/pauses/:pauseId/delete', requireAdmin, async (req, res) => {
const { id: memberId, pauseId } = req.params;
try {
await db.query('DELETE FROM membership_pauses WHERE id = ? AND membership_id = ?', [pauseId, memberId]);
// Summe neu berechnen
await db.query(`
UPDATE memberships
SET pause_months_total = (
SELECT COALESCE(SUM(pause_months), 0) FROM membership_pauses WHERE membership_id = ?
),
effective_end = DATE_ADD(contract_end, INTERVAL (
SELECT COALESCE(SUM(pause_months), 0) FROM membership_pauses WHERE membership_id = ?
) MONTH)
WHERE id = ?
`, [memberId, memberId, memberId]);
res.redirect(`/admin/members/${memberId}?success=Auszeit+gelöscht`);
} catch (err) {
console.error(err);
res.redirect(`/admin/members/${memberId}?error=Fehler+beim+Löschen`);
}
});
// ===== NFC / ZUGANGSKARTE =====
router.post('/members/:id/regenerate-token', requireAdmin, async (req, res) => {
try {
const token = crypto.randomBytes(16).toString('hex');
await db.query(
'UPDATE memberships SET access_token = ? WHERE id = ?',
[token, req.params.id]
);
res.redirect(`/admin/members/${req.params.id}?success=Neuer+Token+generiert`);
} catch (err) {
res.redirect(`/admin/members/${req.params.id}?error=Fehler+beim+Generieren`);
}
});
router.post('/members/:id/update-nfc', requireAdmin, async (req, res) => {
const { nfc_uid } = req.body;
try {
await db.query(
'UPDATE memberships SET nfc_uid = ?, card_issued = 1, card_issued_at = NOW() WHERE id = ?',
[nfc_uid ? nfc_uid.trim().toUpperCase() : null, req.params.id]
);
res.redirect(`/admin/members/${req.params.id}?success=NFC+UID+gespeichert`);
} catch (err) {
res.redirect(`/admin/members/${req.params.id}?error=Fehler+beim+Speichern`);
}
});
// Mitglied manuell bestätigen
router.post('/members/:id/confirm', requireAdmin, async (req, res) => {
try {
await db.query(
"UPDATE memberships SET status='active', confirmed_at=NOW() WHERE id=?",
[req.params.id]
);
res.redirect(`/admin/members/${req.params.id}?success=Mitglied+manuell+bestätigt`);
} catch (err) {
res.redirect(`/admin/members/${req.params.id}?error=Fehler+bei+Bestätigung`);
}
});
// Mitglied als gesehen markieren
router.post('/members/:id/reviewed', requireAdmin, async (req, res) => {
try {
await db.query('UPDATE memberships SET reviewed = 1 WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) {
res.json({ success: false });
}
});
// GET /admin/api/badge-count für Live-Update
router.get('/api/badge-count', requireAdmin, async (req, res) => {
try {
const [rows] = await db.query(`
SELECT
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
SUM(CASE WHEN reviewed = 0 AND status = 'active' THEN 1 ELSE 0 END) as new_count
FROM memberships
`);
const total = (rows[0].pending_count || 0) + (rows[0].new_count || 0);
res.json({ total, pending: rows[0].pending_count || 0, new: rows[0].new_count || 0 });
} catch (err) {
res.json({ total: 0, pending: 0, new: 0 });
}
});
module.exports = router;

View File

@ -1,239 +0,0 @@
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const dns = require('dns').promises;
const db = require('../config/database');
const mailer = require('../config/mailer');
// ============================================
// E-Mail Validierung via DNS MX-Record
// ============================================
async function verifyEmailDomain(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) return { valid: false, reason: 'Ungültiges E-Mail-Format' };
const domain = email.split('@')[1];
try {
const records = await dns.resolveMx(domain);
if (records && records.length > 0) return { valid: true };
return { valid: false, reason: 'Domain hat keine E-Mail-Server (MX-Records fehlen)' };
} catch (err) {
return { valid: false, reason: 'E-Mail-Domain konnte nicht verifiziert werden' };
}
}
// ============================================
// IBAN Validierung (Modulo-97)
// ============================================
const IBAN_LENGTHS = {
AL:28,AD:24,AT:20,AZ:28,BH:22,BE:16,BA:20,BR:29,BG:22,CR:22,HR:21,
CY:28,CZ:24,DK:18,DO:28,EE:20,FO:18,FI:18,FR:27,GE:22,DE:22,GI:23,
GL:18,GT:28,HU:28,IS:26,IE:22,IL:23,IT:27,JO:30,KZ:20,KW:30,LV:21,
LB:28,LI:21,LT:20,LU:20,MK:19,MT:31,MR:27,MU:30,MC:27,MD:24,ME:22,
NL:18,NO:15,PK:24,PS:29,PL:28,PT:25,QA:29,RO:24,SM:27,SA:24,RS:22,
SK:24,SI:19,ES:24,SE:24,CH:21,TN:24,TR:26,AE:23,GB:22,VG:24
};
function validateIBANServer(iban) {
if (!iban) return { valid: true };
const clean = iban.replace(/\s/g, '').toUpperCase();
if (clean.length < 5) return { valid: false, error: 'IBAN zu kurz' };
const country = clean.substring(0, 2);
if (!/^[A-Z]{2}$/.test(country)) return { valid: false, error: 'Ungültiger Ländercode' };
const expectedLen = IBAN_LENGTHS[country];
if (!expectedLen) return { valid: false, error: 'Ländercode nicht unterstützt' };
if (clean.length !== expectedLen) return { valid: false, error: country + '-IBAN muss ' + expectedLen + ' Zeichen haben' };
const rearranged = clean.substring(4) + clean.substring(0, 4);
const numeric = rearranged.split('').map(c => {
const code = c.charCodeAt(0);
return code >= 65 ? (code - 55).toString() : c;
}).join('');
let remainder = 0;
for (let i = 0; i < numeric.length; i++) {
remainder = (remainder * 10 + parseInt(numeric[i])) % 97;
}
if (remainder !== 1) return { valid: false, error: 'IBAN Prüfziffer ungültig' };
return { valid: true };
}
// ============================================
// Bestätigungs-E-Mail Template
// ============================================
function confirmationEmailHtml(member, confirmLink) {
return `<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:Outfit,Arial,sans-serif;background:#f8f9ff;margin:0;padding:20px">
<div style="max-width:600px;margin:0 auto;background:white;border-radius:16px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.08)">
<div style="background:#2d2dcc;padding:32px;text-align:center">
<h1 style="color:white;margin:0;font-size:1.8rem">Plusfit<span style="color:#a5b4fc">24</span></h1>
<p style="color:#c7d2fe;margin:8px 0 0">Mitgliedschaft bestätigen</p>
</div>
<div style="padding:32px">
<p>Hallo ${member.first_name} ${member.last_name},</p>
<p>vielen Dank für deine Anmeldung bei PlusFit24! Um deine Mitgliedschaft zu aktivieren, bestätige bitte deine E-Mail-Adresse:</p>
<div style="text-align:center;margin:32px 0">
<a href="${confirmLink}"
style="display:inline-block;background:#2d2dcc;color:white;padding:16px 40px;border-radius:12px;text-decoration:none;font-weight:700;font-size:1.05rem">
Mitgliedschaft jetzt bestätigen
</a>
</div>
<div style="background:#f8f9ff;border-radius:10px;padding:16px;margin-bottom:20px">
<p style="margin:0 0 8px;font-weight:700">Deine Vertragsdaten:</p>
<p style="margin:4px 0;color:#374151">📋 Tarif: ${member.tariff_name}</p>
<p style="margin:4px 0;color:#374151">💰 Monatsbeitrag: ${Number(member.agreed_price).toFixed(2).replace('.', ',')} </p>
<p style="margin:4px 0;color:#374151">📦 Startpaket: ${Number(member.start_package_price).toFixed(2).replace('.', ',')} </p>
</div>
<p style="color:#6b7280;font-size:0.85rem">
Dieser Link ist <strong>24 Stunden</strong> gültig. Falls du diese Anmeldung nicht durchgeführt hast, ignoriere diese E-Mail.<br><br>
<strong>PlusFit24 UG</strong> · Moosleiten 12 · 84089 Aiglsbach
</p>
</div>
</div>
</body></html>`;
}
// ============================================
// POST /api/verify-email
// ============================================
router.post('/verify-email', async (req, res) => {
const { email } = req.body;
if (!email) return res.json({ valid: false, reason: 'Keine E-Mail angegeben' });
const result = await verifyEmailDomain(email);
res.json(result);
});
// ============================================
// POST /api/submit-membership
// ============================================
router.post('/submit-membership', async (req, res) => {
try {
const {
tariff_id, salutation, title, first_name, last_name, birth_date,
email, phone, street, address_addition, zip, city,
bank_name, account_holder, iban,
sepa_accepted, agb_accepted, datenschutz_accepted, data_correct,
guardian_consent
} = req.body;
// Pflichtfelder
if (!tariff_id || !first_name || !last_name || !birth_date || !email || !street || !zip || !city) {
return res.json({ success: false, error: 'Bitte alle Pflichtfelder ausfüllen.' });
}
if (!agb_accepted || !datenschutz_accepted || !data_correct) {
return res.json({ success: false, error: 'Bitte alle Einverständniserklärungen bestätigen.' });
}
// E-Mail validieren
const emailCheck = await verifyEmailDomain(email);
if (!emailCheck.valid) {
return res.json({ success: false, error: 'E-Mail-Adresse ist nicht erreichbar: ' + emailCheck.reason });
}
// IBAN prüfen
if (iban && iban.trim()) {
const ibanCheck = validateIBANServer(iban.trim());
if (!ibanCheck.valid) {
return res.json({ success: false, error: 'IBAN ungültig: ' + ibanCheck.error });
}
}
// Alter berechnen
const birthDateObj = new Date(birth_date);
const today = new Date();
let age = today.getFullYear() - birthDateObj.getFullYear();
const mo = today.getMonth() - birthDateObj.getMonth();
if (mo < 0 || (mo === 0 && today.getDate() < birthDateObj.getDate())) age--;
const is_minor = age < 18 ? 1 : 0;
if (is_minor && !guardian_consent) {
return res.json({ success: false, error: 'Bei Minderjährigen ist die Einverständniserklärung der Erziehungsberechtigten erforderlich.' });
}
if (age < 14) {
return res.json({ success: false, error: 'Das Mindestalter für eine Mitgliedschaft beträgt 14 Jahre.' });
}
// Tarif laden
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE id = ? AND active = 1', [tariff_id]);
if (tariffs.length === 0) {
return res.json({ success: false, error: 'Ungültiger oder inaktiver Tarif.' });
}
const tariff = tariffs[0];
// Berechnungen
const startPackagePrice = parseFloat(tariff.start_package_price) || 35.00;
const contractStart = new Date(today);
contractStart.setDate(contractStart.getDate() + 15);
const daysInMonth = new Date(contractStart.getFullYear(), contractStart.getMonth() + 1, 0).getDate();
const remainingDays = daysInMonth - contractStart.getDate() + 1;
const partialMonth = Math.round((parseFloat(tariff.price_monthly) / daysInMonth) * remainingDays * 100) / 100;
const firstPaymentAmt = Math.round((partialMonth + startPackagePrice) * 100) / 100;
const contractEnd = new Date(contractStart);
contractEnd.setMonth(contractEnd.getMonth() + tariff.duration_months);
contractEnd.setDate(contractEnd.getDate() - 1);
// Tokens generieren
const access_token = crypto.randomBytes(16).toString('hex');
const confirmation_token = crypto.randomBytes(32).toString('hex');
// In DB speichern — Status: pending
await db.query(`
INSERT INTO memberships
(tariff_id, salutation, title, first_name, last_name, birth_date,
email, phone, street, address_addition, zip, city,
bank_name, account_holder, iban,
sepa_accepted, agb_accepted, datenschutz_accepted, data_correct,
guardian_consent, is_minor,
access_token, confirmation_token,
agreed_price, agreed_duration,
start_package_price,
signup_date, contract_start, contract_end, effective_end,
first_payment_date, first_payment_amt,
status, reviewed)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`, [
tariff_id, salutation, title || '', first_name, last_name, birth_date,
email, phone || '', street, address_addition || '', zip, city,
bank_name || '', account_holder || '', iban || '',
sepa_accepted ? 1 : 0, agb_accepted ? 1 : 0,
datenschutz_accepted ? 1 : 0, data_correct ? 1 : 0,
guardian_consent ? 1 : 0, is_minor,
access_token, confirmation_token,
tariff.price_monthly, tariff.duration_months,
startPackagePrice,
today.toISOString().split('T')[0],
contractStart.toISOString().split('T')[0],
contractEnd.toISOString().split('T')[0],
contractEnd.toISOString().split('T')[0],
contractStart.toISOString().split('T')[0],
firstPaymentAmt,
'pending', 0
]);
// Bestätigungs-E-Mail senden
const baseUrl = process.env.APP_URL || 'https://plusfit24.software-joksch.com';
const confirmLink = `${baseUrl}/confirm/${confirmation_token}`;
const memberData = { first_name, last_name, agreed_price: tariff.price_monthly, start_package_price: startPackagePrice, tariff_name: tariff.name };
try {
await mailer.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject: 'PlusFit24 Bitte bestätige deine Mitgliedschaft',
html: confirmationEmailHtml(memberData, confirmLink)
});
} catch (mailErr) {
console.error('E-Mail Fehler:', mailErr.message);
// Trotzdem Erfolg zurückgeben — Admin kann manuell bestätigen
}
res.json({ success: true, pending: true });
} catch (err) {
console.error('Submit error:', err);
res.json({ success: false, error: 'Serverfehler. Bitte versuche es später erneut.' });
}
});
module.exports = router;

32
routes/auth.js Normal file
View File

@ -0,0 +1,32 @@
const express = require('express');
const bcrypt = require('bcrypt');
const Database = require('better-sqlite3');
const db = new Database('plusfit.db');
const router = express.Router();
router.get('/', (req, res) => {
res.render('login');
});
router.post('/login', async (req, res) => {
const { username, password } = req.body;
const admin = db
.prepare('SELECT * FROM admins WHERE username = ?')
.get(username);
if (!admin) return res.send('Login fehlgeschlagen');
const ok = await bcrypt.compare(password, admin.password);
if (!ok) return res.send('Login fehlgeschlagen');
req.session.loggedIn = true;
res.redirect('/users/dashboard');
});
router.get('/logout', (req, res) => {
req.session.destroy(() => res.redirect('/'));
});
module.exports = router;

View File

@ -1,471 +0,0 @@
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;
const price = parseFloat(member.agreed_price || member.price_monthly);
const firstPeriod = member.first_payment_date
? new Date(member.first_payment_date).toISOString().substring(0, 7)
: null;
if (firstPeriod === period) {
// Startpaket nur wenn nicht erlassen (start_package_price > 0)
const startPkg = parseFloat(member.start_package_price || 0);
const daysInMonth = new Date(
new Date(member.first_payment_date).getFullYear(),
new Date(member.first_payment_date).getMonth() + 1, 0
).getDate();
const day = new Date(member.first_payment_date).getDate();
const remaining = daysInMonth - day + 1;
const partial = Math.round((price / daysInMonth) * remaining * 100) / 100;
return Math.round((partial + startPkg) * 100) / 100;
}
return price;
}
// 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'
);
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 CONVERT(i.period USING utf8mb4) = CONVERT(? USING utf8mb4)
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 CONVERT(period USING utf8mb4) = CONVERT(? USING utf8mb4)
`, [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,
COALESCE(m.agreed_price, t.price_monthly) as agreed_price,
COALESCE(m.start_package_price, 0) as start_package_price
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 CONVERT(period USING utf8mb4) = CONVERT(? USING utf8mb4)
)
`, [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 CONVERT(period USING utf8mb4) = CONVERT(? USING utf8mb4)', [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,
COALESCE(m.agreed_price, t.price_monthly) as agreed_price,
COALESCE(m.start_package_price, 0) as start_package_price
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 CONVERT(period USING utf8mb4) = CONVERT(? USING utf8mb4) 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 CONVERT(i.period USING utf8mb4) = CONVERT(? USING utf8mb4) AND i.status = 'open' AND i.amount > 0
ORDER BY m.last_name ASC
`, [period]);
// Alle offenen Rechnungen dieser Periode als bezahlt markieren
// IDs zuerst laden um Collation-Probleme zu umgehen
const [openInvoices] = await db.query(
'SELECT id FROM invoices WHERE CONVERT(period USING utf8mb4) = CONVERT(? USING utf8mb4) AND status = ?',
[period, 'open']
);
if (openInvoices.length > 0) {
const ids = openInvoices.map(r => r.id);
await db.query(
`UPDATE invoices SET status='paid', paid_at=NOW() WHERE id IN (${ids.join(',')})`,
[]
);
}
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);
}
});
// POST Rechnung stornieren
router.post('/invoices/:id/cancel', requireAdmin, async (req, res) => {
const period = req.body.period || currentPeriod();
try {
await db.query("UPDATE invoices SET status='cancelled' WHERE id=?", [req.params.id]);
res.redirect(`/admin/billing?period=${period}&success=Rechnung+storniert`);
} catch (err) {
res.redirect(`/admin/billing?period=${period}&error=Fehler+beim+Stornieren`);
}
});
// GET Storno-PDF herunterladen
router.get('/export/storno-pdf/:invoiceId', requireAdmin, async (req, res) => {
try {
const [rows] = await db.query(`
SELECT i.*,
m.first_name, m.last_name, m.email, m.street, m.zip, m.city,
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.id = ?
`, [req.params.invoiceId]);
if (rows.length === 0) return res.status(404).send('Rechnung nicht gefunden');
const inv = rows[0];
const PDFDocument = require('pdfkit');
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="Storno_PF24-${String(inv.id).padStart(6,'0')}_${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}`);
// Roter STORNO Stempel
doc.fontSize(28).fillColor('#dc2626')
.text('STORNORECHNUNG', 60, 215);
doc.fontSize(10).fillColor('#333')
.text(`Storno-Nr.: STORNO-PF24-${String(inv.id).padStart(6,'0')}`, 60, 258)
.text(`Datum: ${new Date().toLocaleDateString('de-DE')}`)
.text(`Bezieht sich auf Rechnung: PF24-${String(inv.id).padStart(6,'0')} vom ${new Date(inv.created_at).toLocaleDateString('de-DE')}`)
.text(`Zeitraum: ${periodLabel(inv.period)}`);
doc.moveTo(60, 318).lineTo(535, 318).strokeColor('#ddd').stroke();
doc.fontSize(10).fillColor('#999')
.text('Beschreibung', 60, 330)
.text('Betrag', 460, 330, { align: 'right', width: 75 });
doc.moveTo(60, 345).lineTo(535, 345).strokeColor('#ddd').stroke();
doc.fontSize(11).fillColor('#dc2626')
.text(`Storno: ${inv.description || `Mitgliedsbeitrag ${periodLabel(inv.period)}`}`, 60, 355)
.text(`-${Number(inv.amount).toFixed(2).replace('.', ',')}`, 460, 355, { align: 'right', width: 75 });
doc.moveTo(60, 378).lineTo(535, 378).strokeColor('#ddd').stroke();
doc.fontSize(12).fillColor('#dc2626')
.text('Stornobetrag:', 360, 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);
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);
}
});
// POST Neue Rechnung aus stornierter erstellen
router.post('/invoices/:id/reissue', requireAdmin, async (req, res) => {
try {
const [rows] = await db.query('SELECT * FROM invoices WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.redirect('/admin/billing?error=Rechnung+nicht+gefunden');
const orig = rows[0];
// Neue Rechnung mit gleichen Daten aber neuer ID und Status open
const [result] = await db.query(`
INSERT INTO invoices (billing_run_id, membership_id, period, amount, description, iban, account_holder, bank_name, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open')
`, [
orig.billing_run_id, orig.membership_id,
orig.period,
orig.amount,
orig.description ? orig.description + ' (Neuausstellung)' : 'Neuausstellung',
orig.iban, orig.account_holder, orig.bank_name
]);
const newPeriod = orig.period;
res.redirect(`/admin/billing?period=${newPeriod}&success=Neue+Rechnung+PF24-${String(result.insertId).padStart(6,'0')}+erstellt`);
} catch (err) {
console.error(err);
res.redirect('/admin/billing?error=Fehler+bei+Neuausstellung');
}
});
module.exports = router;
module.exports.currentPeriod = currentPeriod;

38
routes/company.js Normal file
View File

@ -0,0 +1,38 @@
const express = require('express');
const Database = require('better-sqlite3');
const auth = require('../middleware/authMiddleware');
const router = express.Router();
const db = new Database('plusfit.db');
/* Formular anzeigen */
router.get('/', auth, (req, res) => {
const company = db.prepare(`
SELECT * FROM company WHERE id = 1
`).get();
res.render('company', { company });
});
/* Speichern */
router.post('/', auth, (req, res) => {
const c = req.body;
db.prepare(`
UPDATE company SET
firmenname = ?,
strasse = ?, hausnummer = ?, plz = ?, ort = ?, land = ?,
telefon = ?, email = ?, web = ?,
iban = ?, bic = ?, glaeubiger_id = ?
WHERE id = 1
`).run(
c.firmenname,
c.strasse, c.hausnummer, c.plz, c.ort, c.land,
c.telefon, c.email, c.web,
c.iban, c.bic, c.glaeubiger_id
);
res.redirect('/company');
});
module.exports = router;

View File

@ -1,78 +1,135 @@
const express = require('express');
const router = express.Router();
const db = require('../config/database');
const { requireAdmin } = require('../middleware/auth');
const Database = require('better-sqlite3');
const auth = require('../middleware/authMiddleware');
// 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
`);
const router = express.Router();
const db = new Database('plusfit.db');
// 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
`);
/* Übersicht */
router.get('/', auth, (req, res) => {
const vertragsarten = db
.prepare('SELECT * FROM vertragsarten ORDER BY id ASC')
.all();
// 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));
}
res.render('contracts', { vertragsarten });
});
/* Neu anlegen */
router.post('/create', auth, (req, res) => {
const { name, laufzeit, betrag, aktiv, beschreibung } = req.body;
db.prepare(`
INSERT INTO vertragsarten
(name, beschreibung, laufzeit, betrag, aktiv)
VALUES (?, ?, ?, ?, ?)
`).run(
name,
beschreibung,
laufzeit,
betrag,
aktiv ? 1 : 0
);
res.redirect('/contracts');
});
// Vertragsart aktiv / inaktiv setzen
router.post('/toggle/:id', auth, (req, res) => {
const { id } = req.params;
db.prepare(`
UPDATE vertragsarten
SET aktiv = CASE
WHEN aktiv = 1 THEN 0
ELSE 1
END
WHERE id = ?
`).run(id);
res.redirect('/contracts');
});
// Vertragsart deaktivieren + User migrieren
router.post('/deactivate/:id', auth, (req, res) => {
const oldId = req.params.id;
const { newContractId } = req.body;
if (!newContractId) {
return res.status(400).send('Neue Vertragsart fehlt');
}
const tx = db.transaction(() => {
// 1⃣ Alle User auf neue Vertragsart umstellen
db.prepare(`
UPDATE users
SET vertragsvariante = ?
WHERE vertragsvariante = ?
`).run(newContractId, oldId);
// 2⃣ Alte Vertragsart deaktivieren
db.prepare(`
UPDATE vertragsarten
SET aktiv = 0
WHERE id = ?
`).run(oldId);
});
tx();
res.redirect('/contracts');
});
// Öffentliche Vertragsauswahl für ALLE Besucher
router.get('/select', (req, res) => {
const vertragsarten = db.prepare(`
SELECT *
FROM vertragsarten
WHERE aktiv = 1
ORDER BY betrag ASC
`).all();
res.render('contractsSelect', { vertragsarten });
});
const PDFDocument = require('pdfkit');
router.get('/pdf/:id', (req, res) => {
const v = db.prepare(`
SELECT *
FROM vertragsarten
WHERE id = ? AND aktiv = 1
`).get(req.params.id);
if (!v) {
return res.status(404).send('Vertrag nicht gefunden');
}
const doc = new PDFDocument();
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`inline; filename=vertrag_${v.name}.pdf`
);
doc.pipe(res);
doc.fontSize(20).text(`Vertrag: ${v.name}`, { align: 'center' });
doc.moveDown();
doc.fontSize(12)
.text(`Laufzeit: ${v.laufzeit} Monate`)
.text(`Betrag: ${v.betrag.toFixed(2)} € / Monat`)
.moveDown();
doc.text(v.beschreibung || 'Keine weitere Beschreibung.');
doc.end();
});
module.exports = router;

View File

@ -1,361 +0,0 @@
const express = require('express');
const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 1024*1024 } });
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
`);
// Stornierte Rechnungen
const [cancelledInvoices] = 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.status = 'cancelled'
ORDER BY i.created_at DESC
`);
// 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,
cancelledInvoices,
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#chargebacks');
}
});
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, upload.single('csv_file'), async (req, res) => {
// Datei hat Vorrang, sonst Textfeld
let rawData = '';
if (req.file && req.file.buffer.length > 0) {
rawData = req.file.buffer.toString('utf-8').replace(/\r/g, '');
} else if (req.body.csv_data && req.body.csv_data.trim()) {
rawData = req.body.csv_data.trim();
}
if (!rawData) return res.redirect('/admin/finance?error=Keine+Daten+eingegeben');
try {
const lines = rawData.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%C3%BChr+eingetragen#dunning');
} catch (err) {
res.redirect('/admin/finance?error=Fehler+beim+Eintragen#chargebacks');
}
});
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%C3%BChr+bezahlt#dunning');
} 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%C3%BChr+storniert#dunning');
} catch (err) {
res.redirect('/admin/finance?error=Fehler');
}
});
// ============================================
// Alle offenen Rückläufer mahnen
// ============================================
router.post('/chargebacks/dunning-all', requireAdmin, async (req, res) => {
const { amount, issued_date, reason } = req.body;
try {
const [openCBs] = await db.query(
"SELECT * FROM chargebacks WHERE status = 'open'", []
);
if (openCBs.length === 0) {
return res.redirect('/admin/finance?error=Keine+offenen+Rückläufer+vorhanden');
}
let count = 0;
for (const cb of openCBs) {
await db.query(
'INSERT INTO dunning_fees (membership_id, amount, reason, issued_date, notes) VALUES (?,?,?,?,?)',
[cb.membership_id, amount, reason || 'Mahngebühr Rücklastschrift', issued_date,
`Automatisch aus Rückläufer vom ${new Date(cb.chargeback_date).toLocaleDateString('de-DE')}`]
);
count++;
}
res.redirect('/admin/finance?success=Mahngeb%C3%BChren+eingetragen#chargebacks');
} catch (err) {
console.error(err);
res.redirect('/admin/finance?error=Fehler+beim+Eintragen#chargebacks');
}
});
// Einzelne Mahngebühr über Rückläufer-ID (membership_id wird aus chargeback geholt)
router.post('/dunning/add-from-chargeback', requireAdmin, async (req, res) => {
const { chargeback_id, amount, issued_date, reason, notes } = req.body;
try {
const [cbs] = await db.query('SELECT * FROM chargebacks WHERE id = ?', [chargeback_id]);
if (cbs.length === 0) return res.redirect('/admin/finance?error=Rückläufer+nicht+gefunden');
await db.query(
'INSERT INTO dunning_fees (membership_id, amount, reason, issued_date, notes) VALUES (?,?,?,?,?)',
[cbs[0].membership_id, amount, reason || 'Mahngebühr Rücklastschrift', issued_date, notes || null]
);
res.redirect('/admin/finance?success=Mahngeb%C3%BChr+eingetragen#dunning');
} catch (err) {
res.redirect('/admin/finance?error=Fehler');
}
});
// ============================================
// SEPA Nachforderung: offene Rückläufer + Mahngebühren
// ============================================
router.get('/chargebacks/sepa-export', requireAdmin, async (req, res) => {
try {
// Offene Rückläufer
const [chargebacks] = await db.query(`
SELECT c.*, m.first_name, m.last_name, m.iban, m.account_holder
FROM chargebacks c
JOIN memberships m ON c.membership_id = m.id
WHERE c.status = 'open'
`);
// Offene Mahngebühren
const [dunnings] = await db.query(`
SELECT d.*, m.first_name, m.last_name, m.iban, m.account_holder
FROM dunning_fees d
JOIN memberships m ON d.membership_id = m.id
WHERE d.status = 'open'
`);
if (chargebacks.length === 0 && dunnings.length === 0) {
return res.redirect('/admin/finance?error=Keine+offenen+Rückläufer+oder+Mahngebühren');
}
const today = new Date().toISOString().split('T')[0];
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="SEPA_Nachforderung_${today}.csv"`);
res.write('\uFEFF');
res.write('Name;IBAN;BIC;Betrag;Verwendungszweck;Mandatsreferenz;Mandatsdatum\n');
for (const c of chargebacks) {
const name = `${c.last_name} ${c.first_name}`.replace(/;/g, ' ');
const iban = (c.iban || '').replace(/\s/g, '');
const amount = Number(c.amount).toFixed(2).replace('.', ',');
const ref = `PF24-RL-${String(c.id).padStart(5,'0')}`;
res.write(`${name};${iban};;${amount};Nachforderung Rücklastschrift ${c.period};${ref};${today}\n`);
}
for (const d of dunnings) {
const name = `${d.last_name} ${d.first_name}`.replace(/;/g, ' ');
const iban = (d.iban || '').replace(/\s/g, '');
const amount = Number(d.amount).toFixed(2).replace('.', ',');
const ref = `PF24-MG-${String(d.id).padStart(5,'0')}`;
res.write(`${name};${iban};;${amount};${d.reason};${ref};${today}\n`);
}
res.end();
} catch (err) {
console.error(err);
res.redirect('/admin/finance?error=SEPA+Export+Fehler');
}
});
module.exports = router;

View File

@ -1,74 +0,0 @@
const express = require('express');
const router = express.Router();
const db = require('../config/database');
// Startseite - Tarife anzeigen
router.get('/', async (req, res) => {
try {
const [tariffs] = await db.query(
'SELECT * FROM tariffs WHERE active = 1 ORDER BY price_monthly ASC'
);
res.render('index', { tariffs, error: null });
} catch (err) {
console.error(err);
res.render('index', { tariffs: [], error: 'Tarife konnten nicht geladen werden.' });
}
});
// Anmelde-Formular für gewählten Tarif
router.get('/anmelden/:tariffId', async (req, res) => {
try {
const [tariffs] = await db.query(
'SELECT * FROM tariffs WHERE id = ? AND active = 1',
[req.params.tariffId]
);
if (tariffs.length === 0) {
return res.redirect('/');
}
res.render('signup', { tariff: tariffs[0] });
} catch (err) {
console.error(err);
res.redirect('/');
}
});
// Bestätigung ausstehend
router.get('/bestaetigung-ausstehend', (req, res) => {
res.render('confirmation-pending', { email: req.query.email || '' });
});
// E-Mail Bestätigung
router.get('/confirm/:token', async (req, res) => {
try {
const [rows] = await db.query(
"SELECT * FROM memberships WHERE confirmation_token = ? AND status = 'pending'",
[req.params.token]
);
if (rows.length === 0) {
const [confirmed] = await db.query(
"SELECT * FROM memberships WHERE confirmation_token = ? AND status = 'active'",
[req.params.token]
);
if (confirmed.length > 0) return res.render('confirmation-success');
return res.render('confirmation-invalid');
}
const member = rows[0];
const hoursDiff = (new Date() - new Date(member.created_at)) / (1000 * 60 * 60);
if (hoursDiff > 24) return res.render('confirmation-invalid');
await db.query(
"UPDATE memberships SET status='active', confirmed_at=NOW() WHERE id=?",
[member.id]
);
res.render('confirmation-success');
} catch (err) {
console.error('Confirm error:', err);
res.render('error', { message: 'Fehler bei der Bestätigung.' });
}
});
// Erfolgsseite
router.get('/erfolg', (req, res) => {
res.render('success');
});
module.exports = router;

View File

@ -1,165 +0,0 @@
const express = require('express');
const router = express.Router();
const db = require('../config/database');
const mailer = require('../config/mailer');
const { requireAdmin } = require('../middleware/auth');
// ============================================
// GET /admin/mailing Übersicht
// ============================================
router.get('/', requireAdmin, async (req, res) => {
try {
const [members] = await db.query(`
SELECT m.id, m.first_name, m.last_name, m.email, m.status,
t.name as tariff_name
FROM memberships m
LEFT JOIN tariffs t ON m.tariff_id = t.id
WHERE m.status = 'active'
ORDER BY m.last_name ASC
`);
const [log] = await db.query(`
SELECT e.id, e.membership_id, e.type, e.recipient, e.subject, e.status, e.sent_at,
m.first_name, m.last_name
FROM email_log e
LEFT JOIN memberships m ON e.membership_id = m.id
ORDER BY e.sent_at DESC
LIMIT 50
`);
res.render('admin/mailing', {
admin: req.session.adminUser,
members,
log,
success: req.query.success || null,
error: req.query.error || null
});
} catch (err) {
console.error(err);
res.redirect('/admin?error=' + encodeURIComponent('Fehler im Mailing: ' + err.message));
}
});
// ============================================
// POST /admin/mailing/send-all An alle aktiven
// ============================================
router.post('/send-all', requireAdmin, async (req, res) => {
const { subject, body, include_name } = req.body;
if (!subject || !body) return res.redirect('/admin/mailing?error=Betreff+und+Text+erforderlich');
try {
const [members] = await db.query(
"SELECT * FROM memberships WHERE status = 'active'"
);
let sent = 0, failed = 0;
for (const member of members) {
const personalBody = include_name
? `Hallo ${member.first_name} ${member.last_name},\n\n${body}`
: body;
const html = bodyToHtml(personalBody, subject);
try {
await mailer.sendMail({
from: process.env.MAIL_FROM,
to: member.email,
subject: subject,
html: html
});
await db.query(
'INSERT INTO email_log (membership_id, type, recipient, subject, status) VALUES (?,?,?,?,?)',
[member.id, 'bulk', member.email, subject, 'sent']
);
sent++;
} catch (err) {
await db.query(
'INSERT INTO email_log (membership_id, type, recipient, subject, status) VALUES (?,?,?,?,?)',
[member.id, 'bulk', member.email, subject, 'failed']
);
failed++;
}
}
res.redirect(`/admin/mailing?success=${sent}+E-Mails+gesendet${failed > 0 ? '+(' + failed + '+Fehler)' : ''}`);
} catch (err) {
console.error(err);
res.redirect('/admin/mailing?error=' + encodeURIComponent(err.message));
}
});
// ============================================
// POST /admin/mailing/send-one An einzelnes Mitglied
// ============================================
router.post('/send-one', requireAdmin, async (req, res) => {
const { membership_id, subject, body } = req.body;
const backUrl = req.headers.referer || '/admin/mailing';
if (!membership_id || !subject || !body) {
return res.redirect(backUrl + '?error=Alle+Felder+erforderlich');
}
try {
const [rows] = await db.query(
'SELECT * FROM memberships WHERE id = ?', [membership_id]
);
if (rows.length === 0) return res.redirect(backUrl + '?error=Mitglied+nicht+gefunden');
const member = rows[0];
const html = bodyToHtml(body, subject);
await mailer.sendMail({
from: process.env.MAIL_FROM,
to: member.email,
subject: subject,
html: html
});
await db.query(
'INSERT INTO email_log (membership_id, type, recipient, subject, status) VALUES (?,?,?,?,?)',
[member.id, 'direct', member.email, subject, 'sent']
);
res.redirect(backUrl + '?success=E-Mail+an+' + encodeURIComponent(member.email) + '+gesendet');
} catch (err) {
console.error(err);
await db.query(
'INSERT INTO email_log (membership_id, type, recipient, subject, status) VALUES (?,?,?,?,?)',
[membership_id, 'direct', '', subject, 'failed']
).catch(() => {});
res.redirect(backUrl + '?error=E-Mail+Fehler:+' + encodeURIComponent(err.message));
}
});
// ============================================
// HTML Template
// ============================================
function bodyToHtml(text, subject) {
const paragraphs = text.split('\n')
.map(l => l.trim())
.map(l => l ? `<p style="margin:0 0 12px;color:#374151;line-height:1.6">${l}</p>` : '<br>')
.join('');
return `<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:Outfit,Arial,sans-serif;background:#f8f9ff;margin:0;padding:20px">
<div style="max-width:600px;margin:0 auto;background:white;border-radius:16px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.08)">
<div style="background:#2d2dcc;padding:28px 32px">
<h1 style="color:white;margin:0;font-size:1.6rem">Plusfit<span style="color:#a5b4fc">24</span></h1>
</div>
<div style="padding:32px">
<h2 style="margin:0 0 20px;color:#1a1a2e;font-size:1.2rem">${subject}</h2>
${paragraphs}
<hr style="border:none;border-top:1px solid #e2e4ed;margin:24px 0">
<p style="font-size:0.82rem;color:#9ca3af;margin:0">
PlusFit24 UG · Moosleiten 12 · 84089 Aiglsbach<br>
Diese E-Mail wurde über das PlusFit24 Verwaltungssystem gesendet.
</p>
</div>
</div>
</body></html>`;
}
module.exports = router;

296
routes/register.js Normal file
View File

@ -0,0 +1,296 @@
const express = require("express");
const Database = require("better-sqlite3");
const validateSepa = require("../utils/sepaValidator");
const { encrypt } = require("../utils/crypto");
const generateVertragsnummer = require("../utils/vertragsnummer");
const createContractPdf = require("../utils/contractPdf");
const sendContractMail = require("../utils/sendContractMail");
const sendAdminMail = require("../utils/sendAdminMail");
const router = express.Router();
const db = new Database("plusfit.db");
/* =========================
Helper
========================= */
function loadActiveContracts() {
return db
.prepare(
`
SELECT *
FROM vertragsarten
WHERE aktiv = 1
ORDER BY betrag ASC
`,
)
.all();
}
/* =========================
GET /register
========================= */
router.get("/", (req, res) => {
const vertragId = req.query.vertrag || null;
return res.render("register", {
vertragsarten: loadActiveContracts(),
selectedVertrag: vertragId,
formData: {},
});
});
/* =========================
POST /register/create
========================= */
router.post("/create", async (req, res) => {
const u = req.body;
const vertragsarten = loadActiveContracts();
/* =========================
Vertragsdaten laden
========================= */
const contractData = db
.prepare(
`
SELECT name, laufzeit, betrag
FROM vertragsarten
WHERE id = ? AND aktiv = 1
`,
)
.get(u.vertragsvariante);
if (!contractData) {
return res.render("register", {
vertragsarten,
selectedVertrag: u.vertragsvariante,
error: "Vertragsdaten konnten nicht geladen werden.",
formData: u,
});
}
/* =========================
Pflicht-Zustimmungen
========================= */
if (!u.agreeAgb || !u.agreeSepa || (age < 18 && !u.agreeConsent)) {
return res.render("register", {
vertragsarten,
selectedVertrag: u.vertragsvariante,
error: "Bitte bestätige alle erforderlichen Hinweise.",
formData: u,
});
}
/* =========================
Altersprüfung (mind. 18)
========================= */
if (!u.geburtsdatum) {
return res.render("register", {
vertragsarten,
selectedVertrag: u.vertragsvariante,
error: "Bitte gib dein Geburtsdatum an.",
formData: u,
});
}
const birthDate = new Date(u.geburtsdatum);
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < birthDate.getDate())
) {
age--;
}
if (age < 18) {
return res.render("register", {
vertragsarten,
selectedVertrag: u.vertragsvariante,
error: "Eine Mitgliedschaft ist erst ab 18 Jahren möglich.",
formData: u,
});
}
/* =========================
Formale SEPA-Prüfung
========================= */
const sepaError = validateSepa({
ibanValue: u.iban,
bic: u.bic,
mandatsreferenz: u.mandatsreferenz,
});
if (sepaError) {
return res.render("register", {
vertragsarten,
selectedVertrag: u.vertragsvariante,
error: sepaError,
formData: u,
});
}
/* =========================
Logische Prüfungen
========================= */
const mandatsExists = db
.prepare(
`
SELECT id FROM users
WHERE mandatsreferenz = ?
`,
)
.get(u.mandatsreferenz);
if (mandatsExists) {
return res.render("register", {
vertragsarten,
selectedVertrag: u.vertragsvariante,
error: "Diese Mandatsreferenz ist bereits vergeben.",
formData: u,
});
}
/* =========================
Vertrags- & Zustimmungsdaten
========================= */
const vertragsnummer = generateVertragsnummer();
const zustimmungsDatum = new Date().toISOString();
const zustimmungsIp = (
req.headers["x-forwarded-for"] ||
req.socket?.remoteAddress ||
""
)
.split(",")[0]
.trim();
const vertragsversion = "v1.0";
const widerrufBis = new Date();
widerrufBis.setDate(widerrufBis.getDate() + 14);
/* =========================
Verschlüsselung
========================= */
const ibanEncrypted = encrypt(u.iban);
const bicEncrypted = encrypt(u.bic);
/* =========================
SPEICHERN
========================= */
db.prepare(
`
INSERT INTO users (
vertragsnummer,
vertragsvariante,
vorname, nachname,
geburtstag,
strasse, hausnummer, plz, ort, land,
mobil, telefon, email,
kontoinhaber, iban, bic, mandatsreferenz,
zustimmung_agb,
zustimmung_sepa,
zustimmung_einverstaendnis,
zustimmung_datum,
zustimmung_ip,
vertragsversion,
widerruf_moeglich_bis,
status,
gesperrt
) VALUES (
?,?,
?,?,?,
?,?,?,?,?,
?,?,?,
?,?,?,?,
?,?,?,?,
?,?,
?,?,
0
)
`,
).run(
vertragsnummer,
u.vertragsvariante,
u.geburtsdatum,
u.vorname,
u.nachname,
u.strasse,
u.hausnummer,
u.plz,
u.ort,
u.land,
u.mobil,
u.telefon,
u.email,
u.kontoinhaber,
ibanEncrypted,
bicEncrypted,
u.mandatsreferenz,
u.agreeAgb ? 1 : 0,
u.agreeSepa ? 1 : 0,
u.agreeConsent ? 1 : 0,
zustimmungsDatum,
zustimmungsIp,
vertragsversion,
widerrufBis.toISOString(),
"aktiv",
);
/* =========================
Vertrags-PDF
========================= */
const pdfPath = await createContractPdf({
vertragsnummer,
vorname: u.vorname,
nachname: u.nachname,
vertragName: contractData.name,
laufzeit: contractData.laufzeit,
betrag: contractData.betrag,
datum: zustimmungsDatum,
ip: zustimmungsIp,
});
/* =========================
Vertragsmail
========================= */
await sendContractMail({
email: u.email,
vorname: u.vorname,
vertragsnummer,
vertragName: contractData.name,
betrag: contractData.betrag,
datum: zustimmungsDatum,
pdfPath,
});
await sendAdminMail({
vertragsnummer,
vorname: u.vorname,
nachname: u.nachname,
email: u.email,
vertragName: contractData.name,
betrag: contractData.betrag,
datum: zustimmungsDatum,
ip: zustimmungsIp,
});
/* =========================
ERFOLG
========================= */
return res.render("registerSuccess", { vertragsnummer });
});
module.exports = router;

View File

@ -1,248 +0,0 @@
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const db = require('../config/database');
const mailer = require('../config/mailer');
const { requireAdmin } = require('../middleware/auth');
// ============================================
// E-Mail Templates
// ============================================
function renewalEmailHtml(member, tariffs, renewalLink, expiryDate) {
const tarifList = tariffs.map(t => `
<tr>
<td style="padding:8px 12px;border-bottom:1px solid #eee">${t.name}</td>
<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right">
<strong>${Number(t.price_monthly).toFixed(2).replace('.', ',')} /Monat</strong>
</td>
<td style="padding:8px 12px;border-bottom:1px solid #eee">${t.duration_months} Monate</td>
</tr>`).join('');
return `<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:Outfit,Arial,sans-serif;background:#f8f9ff;margin:0;padding:20px">
<div style="max-width:600px;margin:0 auto;background:white;border-radius:16px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.08)">
<div style="background:#2d2dcc;padding:32px;text-align:center">
<h1 style="color:white;margin:0;font-size:1.8rem">Plusfit<span style="color:#a5b4fc">24</span></h1>
<p style="color:#c7d2fe;margin:8px 0 0">Deine Mitgliedschaft läuft bald ab</p>
</div>
<div style="padding:32px">
<p>Hallo ${member.first_name} ${member.last_name},</p>
<p>deine Mitgliedschaft bei PlusFit24 läuft am <strong>${new Date(expiryDate).toLocaleDateString('de-DE')}</strong> aus.</p>
<p>Wir würden uns freuen, dich weiterhin in unserem Studio begrüßen zu dürfen! Wähle jetzt deinen neuen Tarif:</p>
<table style="width:100%;border-collapse:collapse;margin:20px 0;border:1px solid #eee;border-radius:8px;overflow:hidden">
<thead>
<tr style="background:#f0f0ff">
<th style="padding:10px 12px;text-align:left;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.5px">Tarif</th>
<th style="padding:10px 12px;text-align:right;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.5px">Preis</th>
<th style="padding:10px 12px;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.5px">Laufzeit</th>
</tr>
</thead>
<tbody>${tarifList}</tbody>
</table>
<div style="text-align:center;margin:28px 0">
<a href="${renewalLink}" style="display:inline-block;background:#2d2dcc;color:white;padding:14px 32px;border-radius:10px;text-decoration:none;font-weight:700;font-size:1rem">
Jetzt Mitgliedschaft verlängern
</a>
</div>
<p style="color:#6b7280;font-size:0.85rem">
Dieser Link ist 30 Tage gültig. Falls du Fragen hast, melde dich gerne bei uns.<br>
<strong>PlusFit24 UG</strong> · Moosleiten 12 · 84089 Aiglsbach
</p>
</div>
</div>
</body></html>`;
}
// ============================================
// Öffentliche Verlängerungsseite (für Mitglieder)
// ============================================
router.get('/renew/:token', async (req, res) => {
try {
const [rows] = await db.query(`
SELECT r.*, m.first_name, m.last_name, m.email,
m.agreed_price, m.tariff_id
FROM renewal_requests r
JOIN memberships m ON r.membership_id = m.id
WHERE r.token = ? AND r.status = 'pending' AND r.expires_at > NOW()
`, [req.params.token]);
if (rows.length === 0) {
return res.render('renewal-expired');
}
const [tariffs] = await db.query(
'SELECT * FROM tariffs WHERE active = 1 ORDER BY price_monthly ASC'
);
res.render('renewal', {
request: rows[0],
tariffs,
error: null,
success: null
});
} catch (err) {
console.error(err);
res.render('error', { message: 'Fehler beim Laden der Seite.' });
}
});
router.post('/renew/:token', async (req, res) => {
const { tariff_id } = req.body;
try {
const [rows] = await db.query(`
SELECT r.*, m.first_name, m.last_name, m.email,
m.contract_end, m.effective_end
FROM renewal_requests r
JOIN memberships m ON r.membership_id = m.id
WHERE r.token = ? AND r.status = 'pending' AND r.expires_at > NOW()
`, [req.params.token]);
if (rows.length === 0) return res.render('renewal-expired');
const request = rows[0];
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE id = ? AND active = 1', [tariff_id]);
if (tariffs.length === 0) {
return res.render('renewal', { request, tariffs: [], error: 'Ungültiger Tarif.', success: null });
}
const tariff = tariffs[0];
// Neues Vertragsende berechnen
const startDate = new Date(request.effective_end || request.contract_end || new Date());
const newEnd = new Date(startDate);
newEnd.setMonth(newEnd.getMonth() + tariff.duration_months);
await db.query(`
UPDATE memberships SET
tariff_id = ?,
agreed_price = ?,
agreed_duration = ?,
contract_end = ?,
effective_end = ?,
status = 'active'
WHERE id = ?
`, [
tariff.id, tariff.price_monthly, tariff.duration_months,
newEnd, newEnd, request.membership_id
]);
await db.query(
"UPDATE renewal_requests SET status='completed', completed_at=NOW() WHERE id=?",
[request.id]
);
res.render('renewal', {
request, tariffs: [tariff],
error: null,
success: `Deine Mitgliedschaft wurde erfolgreich verlängert! Neuer Tarif: ${tariff.name} bis ${newEnd.toLocaleDateString('de-DE')}`
});
} catch (err) {
console.error(err);
res.render('error', { message: 'Fehler bei der Verlängerung.' });
}
});
// ============================================
// Admin: Verlängerungs-E-Mail manuell senden
// ============================================
router.post('/admin/send-renewal/:memberId', requireAdmin, async (req, res) => {
const memberId = req.params.memberId;
const backUrl = req.headers.referer || '/admin/finance';
try {
const [members] = await db.query(`
SELECT m.*, t.name as tariff_name
FROM memberships m
LEFT JOIN tariffs t ON m.tariff_id = t.id
WHERE m.id = ?
`, [memberId]);
if (members.length === 0) return res.redirect(backUrl + '?error=Mitglied+nicht+gefunden');
const member = members[0];
// Alten Pending-Request invalidieren
await db.query(
"UPDATE renewal_requests SET status='expired' WHERE membership_id=? AND status='pending'",
[memberId]
);
// Neuen Token generieren
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 Tage
await db.query(
'INSERT INTO renewal_requests (membership_id, token, expires_at) VALUES (?, ?, ?)',
[memberId, token, expiresAt]
);
// Aktive Tarife laden
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE active = 1 ORDER BY price_monthly ASC');
const baseUrl = `${req.protocol}://${req.get('host')}`;
const renewalLink = `${baseUrl}/renew/${token}`;
const expiryDate = member.effective_end || member.contract_end;
// E-Mail senden
await mailer.sendMail({
from: process.env.MAIL_FROM,
to: member.email,
subject: `Deine PlusFit24 Mitgliedschaft läuft am ${new Date(expiryDate).toLocaleDateString('de-DE')} ab`,
html: renewalEmailHtml(member, tariffs, renewalLink, expiryDate)
});
// Log
await db.query(
'INSERT INTO email_log (membership_id, type, recipient, subject, status) VALUES (?,?,?,?,?)',
[memberId, 'renewal', member.email, 'Mitgliedschaft läuft ab', 'sent']
);
res.redirect(backUrl + '?success=Verlängerungs-E-Mail+an+' + encodeURIComponent(member.email) + '+gesendet');
} catch (err) {
console.error(err);
// Log failure
await db.query(
'INSERT INTO email_log (membership_id, type, recipient, subject, status) VALUES (?,?,?,?,?)',
[memberId, 'renewal', '', 'Verlängerung', 'failed']
).catch(() => {});
res.redirect(backUrl + '?error=E-Mail+Fehler:+' + encodeURIComponent(err.message));
}
});
// Admin: Verlängerung manuell durchführen
router.post('/admin/renew-manual/:memberId', requireAdmin, async (req, res) => {
const { new_tariff_id } = req.body;
const memberId = req.params.memberId;
try {
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE id = ?', [new_tariff_id]);
if (tariffs.length === 0) return res.redirect(`/admin/members/${memberId}?error=Tarif+nicht+gefunden`);
const tariff = tariffs[0];
const [members] = await db.query('SELECT * FROM memberships WHERE id = ?', [memberId]);
const member = members[0];
const startDate = new Date(member.effective_end || member.contract_end || new Date());
const newEnd = new Date(startDate);
newEnd.setMonth(newEnd.getMonth() + tariff.duration_months);
await db.query(`
UPDATE memberships SET
tariff_id = ?,
agreed_price = ?,
agreed_duration = ?,
contract_end = ?,
effective_end = ?,
status = 'active'
WHERE id = ?
`, [tariff.id, tariff.price_monthly, tariff.duration_months, newEnd, newEnd, memberId]);
res.redirect(`/admin/members/${memberId}?success=Vertrag+verlängert+bis+${newEnd.toLocaleDateString('de-DE')}`);
} catch (err) {
console.error(err);
res.redirect(`/admin/members/${memberId}?error=Fehler+bei+Verlängerung`);
}
});
module.exports = router;
module.exports.renewalEmailHtml = renewalEmailHtml;

67
routes/sepa.js Normal file
View File

@ -0,0 +1,67 @@
const express = require('express');
const PDFDocument = require('pdfkit');
const Database = require('better-sqlite3');
const { decrypt } = require('../utils/crypto');
const auth = require('../middleware/authMiddleware');
const router = express.Router();
const db = new Database('plusfit.db');
router.get('/mandat/:id', auth, (req, res) => {
const u = db.prepare(`
SELECT *
FROM users
WHERE id = ?
AND status = 'aktiv'
AND gesperrt = 0
`).get(req.params.id);
if (!u) {
return res
.status(404)
.send('SEPA-Mandat nicht verfügbar (Vertrag nicht aktiv).');
}
if (!u.iban || !u.bic || !u.mandatsreferenz) {
return res
.status(400)
.send('SEPA-Daten unvollständig.');
}
const iban = decrypt(u.iban);
const bic = decrypt(u.bic);
const doc = new PDFDocument({ margin: 50 });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`inline; filename=sepa_mandat_${u.vertragsnummer}.pdf`
);
doc.pipe(res);
doc.fontSize(18).text('SEPA-Lastschriftmandat', { align: 'center' });
doc.moveDown(2);
doc.fontSize(12).text(`Name: ${u.vorname} ${u.nachname}`);
doc.text(`Vertragsnummer: ${u.vertragsnummer}`);
doc.text(`Adresse: ${u.strasse} ${u.hausnummer}, ${u.plz} ${u.ort}`);
doc.moveDown();
doc.text(`IBAN: ${iban}`);
doc.text(`BIC: ${bic}`);
doc.text(`Mandatsreferenz: ${u.mandatsreferenz}`);
doc.moveDown(2);
doc.text('Ich ermächtige Plusfit, Zahlungen von meinem Konto mittels Lastschrift einzuziehen.');
doc.moveDown(2);
doc.text('Unterschrift Kunde: ________________________________');
doc.text('Datum: ________________________________');
doc.end();
});
module.exports = router;

126
routes/sepaExport.js Normal file
View File

@ -0,0 +1,126 @@
const express = require('express');
const Database = require('better-sqlite3');
const { decrypt } = require('../utils/crypto');
const auth = require('../middleware/authMiddleware');
const router = express.Router();
const db = new Database('plusfit.db');
router.get('/export', auth, (req, res) => {
const users = db.prepare(`
SELECT
u.*,
v.betrag,
v.name AS vertragsname
FROM users u
JOIN vertragsarten v
ON u.vertragsvariante = v.id
WHERE
u.status = 'aktiv' -- 🔐 WICHTIGSTER FILTER
AND u.gesperrt = 0 -- optional, zusätzlich
AND v.aktiv = 1
AND u.iban IS NOT NULL
AND u.mandatsreferenz IS NOT NULL
`).all();
if (users.length === 0) {
return res.send('Keine aktiven SEPA-Lastschriften vorhanden');
}
const now = new Date();
const msgId = `PLUSFIT-${now.getTime()}`;
const date = now.toISOString().slice(0, 10);
const totalSum = users.reduce(
(sum, u) => sum + Number(u.betrag),
0
);
let xml = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.008.001.02">
<CstmrDrctDbtInitn>
<GrpHdr>
<MsgId>${msgId}</MsgId>
<CreDtTm>${now.toISOString()}</CreDtTm>
<NbOfTxs>${users.length}</NbOfTxs>
<CtrlSum>${totalSum.toFixed(2)}</CtrlSum>
<InitgPty>
<Nm>Plusfit</Nm>
</InitgPty>
</GrpHdr>
<PmtInf>
<PmtInfId>PMT-${date}</PmtInfId>
<PmtMtd>DD</PmtMtd>
<NbOfTxs>${users.length}</NbOfTxs>
<CtrlSum>${totalSum.toFixed(2)}</CtrlSum>
<PmtTpInf>
<SvcLvl><Cd>SEPA</Cd></SvcLvl>
<LclInstrm><Cd>CORE</Cd></LclInstrm>
<SeqTp>RCUR</SeqTp>
</PmtTpInf>
<ReqdColltnDt>${date}</ReqdColltnDt>
<Cdtr>
<Nm>Plusfit</Nm>
</Cdtr>
<CdtrAcct>
<Id><IBAN>DE12345678901234567890</IBAN></Id>
</CdtrAcct>
<CdtrAgt>
<FinInstnId><BIC>GENODEF1XXX</BIC></FinInstnId>
</CdtrAgt>
<ChrgBr>SLEV</ChrgBr>
`;
users.forEach(u => {
const iban = decrypt(u.iban);
xml += `
<DrctDbtTxInf>
<PmtId>
<EndToEndId>${u.vertragsnummer}</EndToEndId>
</PmtId>
<InstdAmt Ccy="EUR">${Number(u.betrag).toFixed(2)}</InstdAmt>
<DrctDbtTx>
<MndtRltdInf>
<MndtId>${u.mandatsreferenz}</MndtId>
<DtOfSgntr>${date}</DtOfSgntr>
</MndtRltdInf>
</DrctDbtTx>
<Dbtr>
<Nm>${u.kontoinhaber || `${u.vorname} ${u.nachname}`}</Nm>
</Dbtr>
<DbtrAcct>
<Id><IBAN>${iban}</IBAN></Id>
</DbtrAcct>
<RmtInf>
<Ustrd>Mitgliedsbeitrag ${u.vertragsname}</Ustrd>
</RmtInf>
</DrctDbtTxInf>
`;
});
xml += `
</PmtInf>
</CstmrDrctDbtInitn>
</Document>`;
res.setHeader('Content-Type', 'application/xml');
res.setHeader('Content-Disposition', 'attachment; filename=plusfit_sepa.xml');
res.send(xml);
});
module.exports = router;

173
routes/users.js Normal file
View File

@ -0,0 +1,173 @@
const express = require('express');
const Database = require('better-sqlite3');
const auth = require('../middleware/authMiddleware');
const { encrypt, decrypt } = require('../utils/crypto');
const generateVertragsnummer = require('../utils/vertragsnummer');
const db = new Database('plusfit.db');
const router = express.Router();
/* =========================
Dashboard
========================= */
router.get('/dashboard', auth, (req, res) => {
res.render('dashboard');
});
/* =========================
User anlegen Formular
========================= */
router.get('/create', auth, (req, res) => {
res.render('createUser');
});
/* =========================
User anlegen SPEICHERN
========================= */
router.post('/create', auth, (req, res) => {
const u = req.body;
const vertragsnummer = generateVertragsnummer();
const iban = encrypt(u.iban);
const bic = encrypt(u.bic);
const widerrufBis = new Date();
widerrufBis.setDate(widerrufBis.getDate() + 14);
db.prepare(`
INSERT INTO users (
vertragsnummer,
vertragsvariante,
vorname, nachname, geburtsdatum,
strasse, hausnummer, plz, ort, land,
mobil, telefon, email,
kontoinhaber, iban, bic, mandatsreferenz,
status,
widerruf_moeglich_bis,
gesperrt
) VALUES (
?,?,
?,?,?,
?,?,?,?,?,
?,?,?,
?,?,?,?,
?,?,
0
)
`).run(
vertragsnummer,
u.vertragsvariante,
u.vorname, u.nachname, u.geburtsdatum,
u.strasse, u.hausnummer, u.plz, u.ort, u.land,
u.mobil, u.telefon, u.email,
u.kontoinhaber,
iban,
bic,
u.mandatsreferenz,
'aktiv',
widerrufBis.toISOString()
);
res.redirect('/users/list');
});
/* =========================
Mitgliederübersicht (AKTIV)
========================= */
router.get('/list', auth, (req, res) => {
const search = req.query.q || '';
const users = db.prepare(`
SELECT *
FROM users
WHERE status = 'aktiv'
AND (
vorname LIKE ?
OR nachname LIKE ?
OR email LIKE ?
OR ort LIKE ?
OR vertragsnummer LIKE ?
)
ORDER BY created_at DESC
`).all(
`%${search}%`,
`%${search}%`,
`%${search}%`,
`%${search}%`,
`%${search}%`
);
res.render('userList', { users, search });
});
/* =========================
User bearbeiten FORMULAR
========================= */
router.get('/edit/:id', auth, (req, res) => {
const user = db.prepare(`
SELECT *
FROM users
WHERE id = ?
AND status = 'aktiv'
`).get(req.params.id);
if (!user) {
return res
.status(404)
.send('User nicht gefunden oder Vertrag nicht aktiv');
}
user.iban = decrypt(user.iban);
user.bic = decrypt(user.bic);
res.render('editUser', { user });
});
/* =========================
User bearbeiten SPEICHERN
========================= */
router.post('/edit/:id', auth, (req, res) => {
const u = req.body;
const iban = encrypt(u.iban);
const bic = encrypt(u.bic);
db.prepare(`
UPDATE users SET
vertragsvariante = ?,
vorname = ?, nachname = ?, geburtsdatum = ?,
strasse = ?, hausnummer = ?, plz = ?, ort = ?, land = ?,
mobil = ?, telefon = ?, email = ?,
kontoinhaber = ?, iban = ?, bic = ?, mandatsreferenz = ?,
gesperrt = ?
WHERE id = ?
AND status = 'aktiv'
`).run(
u.vertragsvariante,
u.vorname, u.nachname, u.geburtsdatum,
u.strasse, u.hausnummer, u.plz, u.ort, u.land,
u.mobil, u.telefon, u.email,
u.kontoinhaber,
iban,
bic,
u.mandatsreferenz,
u.gesperrt ? 1 : 0,
req.params.id
);
res.redirect('/users/list');
});
module.exports = router;

60
routes/widerruf.js Normal file
View File

@ -0,0 +1,60 @@
const express = require('express');
const Database = require('better-sqlite3');
const router = express.Router();
const db = new Database('plusfit.db');
/* =========================
GET /widerruf
========================= */
router.get('/', (req, res) => {
res.render('widerruf');
});
/* =========================
POST /widerruf
========================= */
router.post('/', (req, res) => {
const { vertragsnummer } = req.body;
const user = db.prepare(`
SELECT *
FROM users
WHERE vertragsnummer = ?
`).get(vertragsnummer);
if (!user) {
return res.render('widerruf', {
error: 'Vertrag nicht gefunden.'
});
}
if (user.status !== 'aktiv') {
return res.render('widerruf', {
error: 'Dieser Vertrag ist nicht widerrufbar.'
});
}
if (new Date() > new Date(user.widerruf_moeglich_bis)) {
return res.render('widerruf', {
error: 'Die Widerrufsfrist ist abgelaufen.'
});
}
db.prepare(`
UPDATE users
SET
status = 'widerrufen',
widerrufen_am = ?,
widerrufen_von_ip = ?
WHERE id = ?
`).run(
new Date().toISOString(),
req.ip,
user.id
);
res.render('widerrufErfolg', { vertragsnummer });
});
module.exports = router;

View File

@ -1,210 +0,0 @@
#!/bin/bash
# ============================================
# PlusFit24 Neuen Kunden einrichten
# Verwendung: ./setup-kunde.sh <kundenname>
# Beispiel: ./setup-kunde.sh fitnessstudio-berlin
# ============================================
set -e
# ---- Parameter prüfen ----
if [ -z "$1" ]; then
echo "❌ Verwendung: ./setup-kunde.sh <kundenname>"
echo " Beispiel: ./setup-kunde.sh fitnessstudio-berlin"
exit 1
fi
KUNDE=$1
APP_DIR="/opt/apps/$KUNDE"
DB_NAME=$(echo $KUNDE | tr '-' '_' | tr '.' '_')
SERVICE_NAME=$KUNDE
# ---- Nächsten freien Port finden ----
START_PORT=3100
PORT=$START_PORT
echo "🔍 Suche freien Port ab $START_PORT..."
while true; do
# Prüfen ob Port in einer .env Datei schon verwendet wird
PORT_IN_USE=$(grep -r "^PORT=$PORT$" /opt/apps/*/env 2>/dev/null || \
grep -r "^PORT=$PORT$" /opt/apps/*/.env 2>/dev/null || true)
# Prüfen ob Port wirklich offen ist
if [ -z "$PORT_IN_USE" ] && ! ss -tlnp | grep -q ":$PORT "; then
echo "✅ Freier Port gefunden: $PORT"
break
fi
PORT=$((PORT + 1))
if [ $PORT -gt 3200 ]; then
echo "❌ Kein freier Port zwischen 3100 und 3200 gefunden!"
exit 1
fi
done
echo ""
echo "================================================"
echo " PlusFit24 Kunde einrichten"
echo " Kunde: $KUNDE"
echo " Port: $PORT (automatisch gewählt)"
echo " Ordner: $APP_DIR"
echo " DB: $DB_NAME"
echo "================================================"
echo ""
# ---- Prüfen ob Kunde bereits existiert ----
if [ -d "$APP_DIR" ]; then
echo "❌ Ordner $APP_DIR existiert bereits!"
exit 1
fi
# ---- Zugangsdaten abfragen ----
read -p "DB Root Passwort (MariaDB): " DB_ROOT_PW
read -p "DB User Passwort (wird neu erstellt): " DB_USER_PW
read -p "Admin Benutzername: " ADMIN_USER
read -p "Admin Passwort: " ADMIN_PW
read -p "E-Mail Absender (Ionos): " MAIL_USER
read -p "E-Mail Passwort: " MAIL_PW
read -p "Domain (z.B. kunde.software-joksch.com): " DOMAIN
read -p "Studio-Name (für E-Mails): " STUDIO_NAME
echo ""
echo "▶ Starte Einrichtung für '$KUNDE' auf Port $PORT..."
echo ""
# ---- 1. App-Ordner kopieren ----
echo "[1/7] 📁 Kopiere App-Dateien..."
cp -r /opt/apps/Vertragsverwaltung_Plusfit24 $APP_DIR
rm -f $APP_DIR/.env
rm -f $APP_DIR/setup-kunde.sh
# ---- 2. Datenbank erstellen ----
echo "[2/7] 🗄️ Erstelle Datenbank und User..."
mysql -h 85.215.63.122 -u root -p"$DB_ROOT_PW" << SQL
CREATE DATABASE IF NOT EXISTS \`$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_NAME}'@'192.168.0.163' IDENTIFIED BY '${DB_USER_PW}';
GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${DB_NAME}'@'192.168.0.163';
FLUSH PRIVILEGES;
SQL
echo "[3/7] 📋 Erstelle Tabellen..."
mysql -h 85.215.63.122 -u root -p"$DB_ROOT_PW" $DB_NAME < $APP_DIR/database/schema.sql
for f in billing_migration finance_migration nfc_migration renewal_migration \
agreed_price_migration startpackage_migration confirmation_migration; do
mysql -h 85.215.63.122 -u root -p"$DB_ROOT_PW" $DB_NAME < $APP_DIR/database/${f}.sql 2>/dev/null || true
done
# ---- 3. .env erstellen ----
echo "[4/7] ⚙️ Erstelle Konfiguration..."
SESSION_SECRET=$(node -e "console.log(require('crypto').randomBytes(48).toString('hex'))")
cat > $APP_DIR/.env << ENV
# Datenbank
DB_HOST=85.215.63.122
DB_PORT=3306
DB_USER=${DB_NAME}
DB_PASSWORD=${DB_USER_PW}
DB_NAME=${DB_NAME}
# Session
SESSION_SECRET=${SESSION_SECRET}
# Admin
ADMIN_USER=${ADMIN_USER}
ADMIN_PASSWORD=${ADMIN_PW}
# App
PORT=${PORT}
APP_URL=https://${DOMAIN}
# E-Mail (Ionos)
MAIL_HOST=smtp.ionos.de
MAIL_PORT=587
MAIL_SECURE=false
MAIL_USER=${MAIL_USER}
MAIL_PASSWORD=${MAIL_PW}
MAIL_FROM=${STUDIO_NAME} <${MAIL_USER}>
ENV
# ---- 4. npm install ----
echo "[5/7] 📦 Installiere Abhängigkeiten..."
cd $APP_DIR && npm install --silent
# ---- 5. Systemd Service erstellen ----
echo "[6/7] 🔧 Erstelle und starte Service..."
cat > /etc/systemd/system/${SERVICE_NAME}.service << SERVICE
[Unit]
Description=${STUDIO_NAME} PlusFit24 App
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=${APP_DIR}
ExecStart=/usr/bin/node ${APP_DIR}/app.js
Restart=always
RestartSec=10
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
SERVICE
systemctl daemon-reload
systemctl enable $SERVICE_NAME
systemctl start $SERVICE_NAME
# ---- 6. NGINX Config erstellen ----
echo "[7/7] 🌐 Erstelle NGINX Konfiguration..."
NGINX_CONF="/etc/nginx/sites-available/$DOMAIN"
cat > $NGINX_CONF << NGINX
server {
listen 443 ssl http2;
server_name ${DOMAIN};
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/software-joksch.com.key;
client_max_body_size 10M;
location / {
proxy_pass http://192.168.0.163:${PORT};
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
proxy_read_timeout 3600;
}
}
NGINX
# ---- 7. Ergebnis ----
sleep 3
echo ""
if systemctl is-active --quiet $SERVICE_NAME; then
STATUS="✅ LÄUFT"
else
STATUS="❌ FEHLER"
fi
echo "================================================"
echo " $STATUS $STUDIO_NAME"
echo "================================================"
echo " 🌐 URL: https://$DOMAIN"
echo " 🔑 Admin: https://$DOMAIN/admin/login"
echo " 👤 Benutzer: $ADMIN_USER"
echo " 🔌 Port: $PORT"
echo " 🗄️ Datenbank: $DB_NAME"
echo " 📁 Ordner: $APP_DIR"
echo "================================================"
echo ""
echo "⚠️ Noch auf dem NGINX-Server ausführen:"
echo " ssh user@192.168.0.157"
echo " ln -s $NGINX_CONF /etc/nginx/sites-enabled/"
echo " nginx -t && systemctl reload nginx"
echo ""
# Port-Übersicht speichern
echo "$PORT $KUNDE $DOMAIN" >> /opt/apps/ports.txt
echo "📋 Port-Übersicht gespeichert in /opt/apps/ports.txt"

204
utils/contractPdf.js Normal file
View File

@ -0,0 +1,204 @@
const PDFDocument = require('pdfkit');
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
const db = new Database('plusfit.db', { readonly: true });
/**
* Footer (stabil, ohne Rekursion)
*/
function drawFooter(doc, text) {
const y = doc.page.height - doc.page.margins.bottom + 10;
doc.save();
doc
.fontSize(9)
.fillColor('gray')
.text(
text,
doc.page.margins.left,
y,
{
width: doc.page.width - doc.page.margins.left * 2,
align: 'center'
}
);
doc.restore();
}
module.exports = function createContractPdf(data) {
return new Promise((resolve, reject) => {
try {
/* =========================
Firmendaten laden
========================= */
const company = db.prepare('SELECT * FROM company LIMIT 1').get();
/* =========================
Zielordner sicherstellen
========================= */
const outputDir = path.join(__dirname, '..', 'documents', 'contracts');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const filePath = path.join(
outputDir,
`vertrag_${data.vertragsnummer}.pdf`
);
/* =========================
PDF initialisieren
========================= */
const doc = new PDFDocument({
size: 'A4',
margin: 50
});
const stream = fs.createWriteStream(filePath);
doc.pipe(stream);
const footerText =
`Plusfit · Vertrag ${data.vertragsnummer} · automatisch erstellt`;
/* =========================
HEADER MIT LOGO
========================= */
const logoPath = path.join(
__dirname,
'..',
'public',
'images',
'logo.png'
);
if (fs.existsSync(logoPath)) {
doc.image(logoPath, {
fit: [120, 60],
align: 'left',
valign: 'top'
});
}
doc
.moveDown(1.5)
.fontSize(20)
.text('Mitgliedsvertrag Plusfit', { align: 'center' })
.moveDown(2);
doc.fontSize(12);
/* =========================
VERTRAGSPARTNER (FIRMA)
========================= */
doc.fontSize(11).text('Vertragspartner:', { underline: true });
doc.moveDown(0.5);
if (company) {
doc.text(company.name || 'Plusfit');
if (company.inhaber) doc.text(`Inhaber: ${company.inhaber}`);
const addressLine = [
company.strasse,
company.hausnummer
].filter(Boolean).join(' ');
if (addressLine) doc.text(addressLine);
doc.text(`${company.plz || ''} ${company.ort || ''}`.trim());
if (company.email) doc.text(`E-Mail: ${company.email}`);
if (company.telefon) doc.text(`Telefon: ${company.telefon}`);
} else {
doc.fillColor('red')
.text('⚠️ Firmendaten nicht hinterlegt')
.fillColor('black');
}
doc.moveDown(2);
/* =========================
VERTRAGSDATEN
========================= */
doc.fontSize(12);
doc.text(`Vertragsnummer: ${data.vertragsnummer}`);
doc.text(`Name: ${data.vorname} ${data.nachname}`);
doc.text(`Vertragsart: ${data.vertragName}`);
doc.text(`Laufzeit: ${data.laufzeit} Monate`);
doc.text(`Mitgliedsbeitrag: ${Number(data.betrag).toFixed(2)} € / Monat`);
doc.moveDown();
/* =========================
VERTRAGSERKLÄRUNG
========================= */
doc.text('Vertragserklärung', { underline: true });
doc.moveDown(0.5);
doc.text(
'Der Kunde hat diesen Vertrag durch Anklicken des Buttons ' +
'„Kostenpflichtig verbindlich abschließen“ ausdrücklich angenommen.\n\n' +
'Der Vertrag kommt gemäß §§ 145 ff. BGB durch elektronische Annahme zustande. ' +
'Eine handschriftliche Unterschrift ist nicht erforderlich.'
);
doc.moveDown();
/* =========================
ZAHLUNG
========================= */
doc.text('Zahlungsmodalitäten', { underline: true });
doc.moveDown(0.5);
doc.text(
'Die Zahlung erfolgt monatlich im Voraus per SEPA-Lastschrift ' +
'von dem vom Kunden angegebenen Bankkonto.'
);
doc.moveDown();
/* =========================
WIDERRUFSBELEHRUNG
========================= */
doc.text('Widerrufsbelehrung', { underline: true });
doc.moveDown(0.5);
doc.text(
'Sie haben das Recht, diesen Vertrag binnen 14 Tagen ohne Angabe von Gründen zu widerrufen. ' +
'Die Frist beginnt mit dem Tag des Vertragsabschlusses.'
);
doc.moveDown(2);
/* =========================
ABSCHLUSSINFO
========================= */
const datum = new Date(data.datum).toLocaleDateString('de-DE');
doc.text(`Vertragsabschluss am: ${datum}`);
const ipText = data.ip && data.ip !== '::1' ? data.ip : 'nicht gespeichert (Datenschutz)';
doc.text(`IP-Adresse bei Abschluss: ${ipText}`);
doc.moveDown(2);
doc.text(
'Dieser Vertrag wurde elektronisch abgeschlossen. ' +
'Gemäß § 126a BGB ist keine handschriftliche Unterschrift erforderlich.',
{ italic: true }
);
/* =========================
FOOTER (nur einmal, stabil)
========================= */
drawFooter(doc, footerText);
doc.end();
stream.on('finish', () => resolve(filePath));
stream.on('error', reject);
} catch (err) {
reject(err);
}
});
};

58
utils/crypto.js Normal file
View File

@ -0,0 +1,58 @@
const crypto = require('crypto');
const ALGORITHM = 'aes-256-cbc';
const SECRET = crypto
.createHash('sha256')
.update('PLUSFIT_SUPER_SECRET_KEY')
.digest();
/**
* Verschlüsselt Text (neu)
*/
function encrypt(text) {
if (!text) return null;
// Bereits verschlüsselt? → nicht doppelt verschlüsseln
if (text.includes(':')) return text;
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, SECRET, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
/**
* Entschlüsselt Text (abwärtskompatibel!)
*/
function decrypt(text) {
if (!text) return null;
// ALTER KLARTEXT → einfach zurückgeben
if (!text.includes(':')) {
return text;
}
try {
const [ivHex, encrypted] = text.split(':');
const iv = Buffer.from(ivHex, 'hex');
if (iv.length !== 16) {
return text; // Sicherheitsfallback
}
const decipher = crypto.createDecipheriv(ALGORITHM, SECRET, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (err) {
console.error('Decrypt-Fehler:', err.message);
return text; // NIEMALS crashen
}
}
module.exports = { encrypt, decrypt };

50
utils/sendAdminMail.js Normal file
View File

@ -0,0 +1,50 @@
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: Number(process.env.MAIL_PORT),
secure: process.env.MAIL_PORT === '465',
requireTLS: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS
}
});
module.exports = async function sendAdminMail(data) {
try {
if (!process.env.ADMIN_MAIL) {
console.warn('⚠️ ADMIN_MAIL nicht gesetzt');
return;
}
await transporter.sendMail({
from: `"Plusfit System" <${process.env.MAIL_USER}>`,
to: process.env.ADMIN_MAIL,
subject: `🆕 Neuer Vertrag abgeschlossen ${data.vertragsnummer}`,
text: `
Neuer Vertragsabschluss bei Plusfit
----------------------------------
Vertragsnummer: ${data.vertragsnummer}
Name: ${data.vorname} ${data.nachname}
E-Mail: ${data.email}
Vertragsart: ${data.vertragName}
Beitrag: ${data.betrag.toFixed(2)} / Monat
Abgeschlossen am: ${new Date(data.datum).toLocaleString('de-DE')}
IP-Adresse: ${data.ip}
----------------------------------
Hinweis:
Diese Mail dient nur zur Information.
`.trim()
});
console.log(`📬 Admin-Mail gesendet (${process.env.ADMIN_MAIL})`);
} catch (err) {
console.error('❌ Fehler beim Versand der Admin-Mail:', err.message);
}
};

102
utils/sendContractMail.js Normal file
View File

@ -0,0 +1,102 @@
const nodemailer = require('nodemailer');
const fs = require('fs');
/* =========================
SMTP Transport
========================= */
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST, // smtp.ionos.de
port: Number(process.env.MAIL_PORT), // 587
secure: false, // ❗ MUSS false sein bei 587
requireTLS: true, // ✅ IONOS braucht das
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS
},
tls: {
rejectUnauthorized: false // ✅ verhindert TLS-Abbruch
}
});
console.log('MAIL_HOST:', process.env.MAIL_HOST);
console.log('MAIL_PORT:', process.env.MAIL_PORT);
/* =========================
Mail senden
========================= */
module.exports = async function sendContractMail(data) {
try {
if (!data || !data.email) {
console.warn('⚠️ Vertragsmail nicht gesendet keine E-Mail-Adresse');
return false;
}
if (!data.vertragsnummer) {
console.warn('⚠️ Vertragsmail nicht gesendet keine Vertragsnummer');
return false;
}
const pdfPath = data.pdfPath || null;
const hasPdf = pdfPath && fs.existsSync(pdfPath);
const datum = data.datum
? new Date(data.datum).toLocaleDateString('de-DE')
: new Date().toLocaleDateString('de-DE');
const vertragName = data.vertragName || 'Mitgliedsvertrag';
const betragText =
typeof data.betrag === 'number'
? `${data.betrag.toFixed(2)} € / Monat`
: '—';
await transporter.sendMail({
from: `"Plusfit Verträge" <${process.env.MAIL_USER}>`,
to: data.email,
subject: 'Dein Mitgliedsvertrag bei Plusfit',
text: `
Hallo ${data.vorname || ''},
vielen Dank für deine Anmeldung bei Plusfit.
Dein Vertrag wurde am ${datum} online abgeschlossen.
Gemäß § 126a BGB ist keine handschriftliche Unterschrift erforderlich.
----------------------------------
Vertragsdetails
----------------------------------
Vertragsnummer: ${data.vertragsnummer}
Vertragsart: ${vertragName}
Beitrag: ${betragText}
${hasPdf ? 'Im Anhang findest du deinen Vertrag als PDF.' : 'Der Vertrag ist in deinem Kundenbereich einsehbar.'}
----------------------------------
Widerrufsbelehrung
----------------------------------
Du hast das Recht, diesen Vertrag binnen 14 Tagen ohne Angabe von Gründen zu widerrufen.
Die Frist beginnt mit dem Tag des Vertragsabschlusses.
Der Widerruf kann schriftlich oder per E-Mail erfolgen.
Mit freundlichen Grüßen
Dein Plusfit-Team
`.trim(),
attachments: hasPdf
? [
{
filename: `Vertrag_${data.vertragsnummer}.pdf`,
path: pdfPath
}
]
: []
});
console.log(`📧 Vertragsmail gesendet an ${data.email}`);
return true;
} catch (err) {
console.error('❌ Fehler beim Versand der Vertragsmail:', err);
return false;
}
};

25
utils/sepaValidator.js Normal file
View File

@ -0,0 +1,25 @@
const iban = require('iban');
/**
* Stufe 1 Formale SEPA-Prüfung
* @returns {string|null} Fehlermeldung oder null
*/
module.exports = function validateSepa({ ibanValue, bic, mandatsreferenz }) {
// IBAN prüfen (Länge + Prüfziffer)
if (!ibanValue || !iban.isValid(ibanValue)) {
return 'Ungültige IBAN';
}
// BIC prüfen (8 oder 11 alphanumerische Zeichen)
if (!bic || !/^[A-Z0-9]{8}([A-Z0-9]{3})?$/.test(bic)) {
return 'Ungültiger BIC';
}
// Mandatsreferenz prüfen
if (!mandatsreferenz || mandatsreferenz.trim().length < 4) {
return 'Ungültige Mandatsreferenz';
}
return null; // ✅ alles ok
};

28
utils/vertragsnummer.js Normal file
View File

@ -0,0 +1,28 @@
const Database = require('better-sqlite3');
const db = new Database('plusfit.db');
function generateVertragsnummer() {
const year = new Date().getFullYear();
// Letzte Vertragsnummer dieses Jahres holen
const row = db.prepare(`
SELECT vertragsnummer
FROM users
WHERE vertragsnummer LIKE ?
ORDER BY vertragsnummer DESC
LIMIT 1
`).get(`PF-${year}-%`);
let nextNumber = 1;
if (row && row.vertragsnummer) {
const parts = row.vertragsnummer.split('-');
const lastNumber = parseInt(parts[2], 10);
nextNumber = lastNumber + 1;
}
const padded = String(nextNumber).padStart(6, '0');
return `PF-${year}-${padded}`;
}
module.exports = generateVertragsnummer;

View File

@ -1,266 +0,0 @@
<!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/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/mailing" class="nav-link">📧 Mailing</a>
<a href="/dokumentation/handbuch.docx" class="nav-link" target="_blank">❓ Hilfe</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">
<% if (inv.status !== 'cancelled') { %>
<!-- Normale Rechnung PDF -->
<a href="/admin/billing/export/pdf/<%= inv.id %>"
class="btn btn-sm btn-outline" target="_blank">
📄 Rechnung
</a>
<% } else { %>
<!-- Storno PDF -->
<a href="/admin/billing/export/storno-pdf/<%= inv.id %>"
class="btn btn-sm btn-storno" target="_blank">
🚫 Storno-PDF
</a>
<!-- Neue Rechnung ausstellen -->
<form method="POST" action="/admin/billing/invoices/<%= inv.id %>/reissue" style="display:inline"
onsubmit="return confirm('Neue Rechnung für diesen Posten ausstellen?')">
<button type="submit" class="btn btn-sm btn-primary">🔄 Neue Rechnung</button>
</form>
<% } %>
<% 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">✅ Bezahlt</button>
</form>
<% } %>
<% if (inv.status !== 'cancelled') { %>
<form method="POST" action="/admin/billing/invoices/<%= inv.id %>/cancel" style="display:inline"
onsubmit="return confirm('Rechnung PF24-<%= String(inv.id).padStart(6,'0') %> wirklich stornieren?')">
<input type="hidden" name="period" value="<%= period %>">
<button type="submit" class="btn btn-sm btn-danger">🚫 Stornieren</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.agreed_price || m.price_monthly).toFixed(2).replace('.', ',') %> €</strong>
<% if (m.agreed_price && Number(m.agreed_price) !== Number(m.price_monthly)) { %>
<br><small class="text-muted" title="Aktueller Tarif-Preis">Tarif: <%= Number(m.price_monthly).toFixed(2).replace('.', ',') %> €</small>
<% } %>
</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>
<div class="runs-scroll-wrap">
<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>
<div style="display:flex;gap:6px">
<a href="/admin/billing?period=<%= run.period %>" class="btn btn-sm btn-outline">
Anzeigen
</a>
<a href="/admin/billing/export/csv?period=<%= run.period %>" class="btn btn-sm btn-outline" title="SEPA CSV">
📥 CSV
</a>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<% } %>
</main>
</div>
</body>
</html>

View File

@ -1,298 +0,0 @@
<!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/mailing" class="nav-link">📧 Mailing</a>
<a href="/dokumentation/handbuch.docx" class="nav-link" target="_blank">❓ Hilfe</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

@ -1,460 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Admin Dashboard</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="#" 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
<% if ((stats.pending_count || 0) + (stats.new_count || 0) > 0) { %>
<span class="nav-badge"><%= (stats.pending_count || 0) + (stats.new_count || 0) %></span>
<% } %>
</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/mailing" class="nav-link">📧 Mailing</a>
<a href="/dokumentation/handbuch.docx" class="nav-link" target="_blank">❓ Hilfe</a>
<a href="#" class="nav-link" onclick="showSection('einstellungen', this)">⚙️ 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">
<div class="admin-header">
<h1 id="sectionTitle">Tarife</h1>
<div class="stats-row">
<div class="stat-card">
<div class="stat-number"><%= stats.total || 0 %></div>
<div class="stat-label">Gesamt Mitglieder</div>
</div>
<div class="stat-card">
<div class="stat-number"><%= stats.active_count || 0 %></div>
<div class="stat-label">Aktive Mitglieder</div>
</div>
<div class="stat-card">
<div class="stat-number"><%= stats.last_30_days || 0 %></div>
<div class="stat-label">Letzte 30 Tage</div>
</div>
<% if (stats.pending_count > 0) { %>
<div class="stat-card stat-card-pending" onclick="showSection('mitglieder', document.querySelector('[onclick*=mitglieder]'))" style="cursor:pointer" title="Ausstehende Bestätigungen">
<div class="stat-number" style="color:var(--error)"><%= stats.pending_count %></div>
<div class="stat-label">⏳ Ausstehend</div>
</div>
<% } %>
<% if (stats.new_count > 0) { %>
<div class="stat-card stat-card-new" onclick="showSection('mitglieder', document.querySelector('[onclick*=mitglieder]'))" style="cursor:pointer" title="Neue Mitglieder die noch nicht bearbeitet wurden">
<div class="stat-number" style="color:#0891b2"><%= stats.new_count %></div>
<div class="stat-label">🆕 Neu</div>
</div>
<% } %>
<div class="stat-card">
<div class="stat-number"><%= stats.minors || 0 %></div>
<div class="stat-label">Minderjährige</div>
</div>
</div>
</div>
<% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<!-- ===== TARIFE ===== -->
<section id="section-tarife" class="admin-section">
<div class="section-header">
<h2>Tarife verwalten</h2>
<button class="btn btn-primary" onclick="toggleModal('createTariffModal')">+ Neuer Tarif</button>
</div>
<div class="tariff-admin-grid">
<% tariffs.forEach(tariff => { %>
<div class="tariff-admin-card <%= tariff.active ? '' : 'inactive' %>">
<div class="tariff-admin-header">
<span class="tariff-status-badge <%= tariff.active ? 'active' : 'inactive' %>">
<%= tariff.active ? '✅ Aktiv' : '❌ Inaktiv' %>
</span>
<% if (tariff.category_name) { %>
<span class="category-pill"><%= tariff.category_name %></span>
<% } %>
</div>
<h3><%= tariff.name %></h3>
<div class="tariff-admin-details">
<span>💰 <%= Number(tariff.price_monthly).toFixed(2) %>€/Monat</span>
<span>📅 <%= tariff.duration_months %> Monate</span>
<span>📦 Startpaket: <%= Number(tariff.start_package_price).toFixed(2) %>€</span>
</div>
<div class="tariff-admin-actions">
<button class="btn btn-sm btn-outline" onclick="editTariff(<%= JSON.stringify(tariff) %>)">✏️ Bearbeiten</button>
<form method="POST" action="/admin/tariffs/<%= tariff.id %>/toggle" style="display:inline">
<button type="submit" class="btn btn-sm <%= tariff.active ? 'btn-warning' : 'btn-success' %>">
<%= tariff.active ? '⏸ Deaktivieren' : '▶ Aktivieren' %>
</button>
</form>
</div>
</div>
<% }) %>
<% if (tariffs.length === 0) { %>
<div class="no-data-card">Noch keine Tarife angelegt.</div>
<% } %>
</div>
</section>
<!-- ===== KATEGORIEN ===== -->
<section id="section-kategorien" class="admin-section hidden">
<div class="section-header">
<h2>Kategorien verwalten</h2>
<button class="btn btn-primary" onclick="toggleModal('createCategoryModal')">+ Neue Kategorie</button>
</div>
<div class="category-list">
<% if (categories.length === 0) { %>
<div class="no-data-card">Noch keine Kategorien angelegt.</div>
<% } %>
<% categories.forEach(cat => { %>
<div class="category-row">
<div class="category-info">
<span class="category-name">🏷️ <%= cat.name %></span>
<span class="category-meta">
<%= tariffs.filter(t => t.category_id === cat.id).length %> Tarif(e)
</span>
</div>
<div class="category-actions">
<button class="btn btn-sm btn-outline"
onclick="editCategory(<%= cat.id %>, '<%= cat.name.replace(/'/g, `\\'`) %>')">
✏️ Umbenennen
</button>
<form method="POST" action="/admin/categories/<%= cat.id %>/delete" style="display:inline"
onsubmit="return confirm('Kategorie \'<%= cat.name %>\' wirklich löschen?')">
<button type="submit" class="btn btn-sm btn-danger">🗑 Löschen</button>
</form>
</div>
</div>
<% }) %>
</div>
<div class="info-box">
<strong> Hinweis:</strong> Kategorien können nur gelöscht werden wenn keine Tarife zugeordnet sind.
Weise die Tarife zuerst einer anderen Kategorie zu.
</div>
</section>
<!-- ===== MITGLIEDER ===== -->
<section id="section-mitglieder" class="admin-section hidden">
<div class="section-header">
<h2>Mitglieder</h2>
<input type="text" id="memberSearch" placeholder="Suche nach Name, E-Mail..."
class="search-input" onkeyup="filterMembers()">
</div>
<div class="table-wrap">
<table class="admin-table" id="memberTable">
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Tarif</th>
<th>Geburtsdatum</th>
<th>Min.</th>
<th>IBAN</th>
<th>Angemeldet am</th>
</tr>
</thead>
<tbody>
<% memberships.forEach(m => { %>
<tr class="member-row <%= m.status === 'pending' ? 'member-row-pending' : (!m.reviewed ? 'member-row-new' : '') %>" onclick="openMember(<%= m.id %>, <%= m.reviewed ? 1 : 0 %>)" style="cursor:pointer">
<td><strong><%= m.salutation %> <%= m.first_name %> <%= m.last_name %></strong></td>
<td><%= m.email %></td>
<td><%= m.tariff_name || '' %></td>
<td><%= m.birth_date ? new Date(m.birth_date).toLocaleDateString('de-DE') : '' %></td>
<td><%= m.is_minor ? '⚠️ Ja' : 'Nein' %></td>
<td class="iban-cell"><%= m.iban ? m.iban.replace(/(.{4})/g, '$1 ').trim() : '' %></td>
<td><%= new Date(m.created_at).toLocaleDateString('de-DE') %></td>
</tr>
<% }) %>
<% if (memberships.length === 0) { %>
<tr><td colspan="7" class="no-data">Noch keine Mitglieder registriert.</td></tr>
<% } %>
</tbody>
</table>
</div>
</section>
<!-- ===== EINSTELLUNGEN ===== -->
<section id="section-einstellungen" class="admin-section hidden">
<h2>Passwort ändern</h2>
<div class="settings-card">
<form method="POST" action="/admin/change-password">
<div class="form-group">
<label>Aktuelles Passwort</label>
<div class="input-wrap"><input type="password" name="current_password" required></div>
</div>
<div class="form-group">
<label>Neues Passwort</label>
<div class="input-wrap"><input type="password" name="new_password" required minlength="8"></div>
</div>
<div class="form-group">
<label>Neues Passwort bestätigen</label>
<div class="input-wrap"><input type="password" name="confirm_password" required minlength="8"></div>
</div>
<button type="submit" class="btn btn-primary">Passwort ändern</button>
</form>
</div>
</section>
</main>
</div>
<!-- Modal: Neuer Tarif -->
<div class="modal-overlay hidden" id="createTariffModal">
<div class="modal">
<div class="modal-header">
<h3>Neuer Tarif</h3>
<button onclick="toggleModal('createTariffModal')" class="modal-close">✕</button>
</div>
<form method="POST" action="/admin/tariffs">
<div class="form-group">
<label>Name *</label>
<input type="text" name="name" required class="form-control" placeholder="z.B. Single 12 Monate">
</div>
<div class="form-group">
<label>Kategorie</label>
<select name="category_id" class="form-control">
<option value=""> Keine Kategorie </option>
<% categories.forEach(cat => { %>
<option value="<%= cat.id %>"><%= cat.name %></option>
<% }) %>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>Laufzeit (Monate) *</label>
<input type="number" name="duration_months" required class="form-control" min="1" value="12">
</div>
<div class="form-group">
<label>Preis/Monat (€) *</label>
<input type="number" name="price_monthly" required class="form-control" step="0.01" min="0">
</div>
</div>
<div class="form-group">
<label>Startpaket Preis (€)</label>
<input type="number" name="start_package_price" class="form-control" step="0.01" value="35.00">
</div>
<div class="form-group">
<label>Beschreibung</label>
<textarea name="description" class="form-control" rows="2"></textarea>
</div>
<div class="modal-footer">
<button type="button" onclick="toggleModal('createTariffModal')" class="btn btn-outline">Abbrechen</button>
<button type="submit" class="btn btn-primary">Tarif erstellen</button>
</div>
</form>
</div>
</div>
<!-- Modal: Tarif bearbeiten -->
<div class="modal-overlay hidden" id="editTariffModal">
<div class="modal">
<div class="modal-header">
<h3>Tarif bearbeiten</h3>
<button onclick="toggleModal('editTariffModal')" class="modal-close">✕</button>
</div>
<form method="POST" id="editTariffForm">
<div class="form-group">
<label>Name *</label>
<input type="text" name="name" id="edit_name" required class="form-control">
</div>
<div class="form-group">
<label>Kategorie</label>
<select name="category_id" id="edit_category" class="form-control">
<option value=""> Keine Kategorie </option>
<% categories.forEach(cat => { %>
<option value="<%= cat.id %>"><%= cat.name %></option>
<% }) %>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>Laufzeit (Monate) *</label>
<input type="number" name="duration_months" id="edit_duration" required class="form-control" min="1">
</div>
<div class="form-group">
<label>Preis/Monat (€) *</label>
<input type="number" name="price_monthly" id="edit_price" required class="form-control" step="0.01" min="0">
</div>
</div>
<div class="form-group">
<label>Startpaket Preis (€)</label>
<input type="number" name="start_package_price" id="edit_start_package" class="form-control" step="0.01">
</div>
<div class="form-group">
<label>Beschreibung</label>
<textarea name="description" id="edit_description" class="form-control" rows="2"></textarea>
</div>
<div class="modal-footer">
<button type="button" onclick="toggleModal('editTariffModal')" class="btn btn-outline">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- Modal: Neue Kategorie -->
<div class="modal-overlay hidden" id="createCategoryModal">
<div class="modal">
<div class="modal-header">
<h3>Neue Kategorie</h3>
<button onclick="toggleModal('createCategoryModal')" class="modal-close">✕</button>
</div>
<form method="POST" action="/admin/categories">
<div class="form-group">
<label>Kategoriename *</label>
<input type="text" name="name" required class="form-control" placeholder="z.B. Senioren">
</div>
<div class="modal-footer">
<button type="button" onclick="toggleModal('createCategoryModal')" class="btn btn-outline">Abbrechen</button>
<button type="submit" class="btn btn-primary">Kategorie erstellen</button>
</div>
</form>
</div>
</div>
<!-- Modal: Kategorie bearbeiten -->
<div class="modal-overlay hidden" id="editCategoryModal">
<div class="modal">
<div class="modal-header">
<h3>Kategorie umbenennen</h3>
<button onclick="toggleModal('editCategoryModal')" class="modal-close">✕</button>
</div>
<form method="POST" id="editCategoryForm">
<div class="form-group">
<label>Neuer Name *</label>
<input type="text" name="name" id="edit_cat_name" required class="form-control">
</div>
<div class="modal-footer">
<button type="button" onclick="toggleModal('editCategoryModal')" class="btn btn-outline">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<script>
const sectionTitles = {
tarife: 'Tarife', kategorien: 'Kategorien',
mitglieder: 'Mitglieder', einstellungen: 'Einstellungen'
};
function showSection(name, el) {
document.querySelectorAll('.admin-section').forEach(s => s.classList.add('hidden'));
document.getElementById('section-' + name).classList.remove('hidden');
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
if (el) el.classList.add('active');
document.getElementById('sectionTitle').textContent = sectionTitles[name] || name;
event && event.preventDefault();
}
function toggleModal(id) {
document.getElementById(id).classList.toggle('hidden');
}
function editTariff(tariff) {
document.getElementById('edit_name').value = tariff.name;
document.getElementById('edit_category').value = tariff.category_id || '';
document.getElementById('edit_duration').value = tariff.duration_months;
document.getElementById('edit_price').value = tariff.price_monthly;
document.getElementById('edit_start_package').value = tariff.start_package_price;
document.getElementById('edit_description').value = tariff.description || '';
document.getElementById('editTariffForm').action = '/admin/tariffs/' + tariff.id + '/update';
toggleModal('editTariffModal');
}
function editCategory(id, name) {
document.getElementById('edit_cat_name').value = name;
document.getElementById('editCategoryForm').action = '/admin/categories/' + id + '/update';
toggleModal('editCategoryModal');
}
function openMember(id, reviewed) {
if (!reviewed) {
fetch('/admin/members/' + id + '/reviewed', { method: 'POST' });
}
window.location = '/admin/members/' + id;
}
// Live Badge Update alle 30 Sekunden
function updateBadge() {
fetch('/admin/api/badge-count')
.then(r => r.json())
.then(data => {
const badge = document.querySelector('.nav-badge');
if (data.total > 0) {
if (badge) {
badge.textContent = data.total;
} else {
// Badge neu erstellen
const link = document.querySelector('[onclick*="mitglieder"]');
if (link) {
const span = document.createElement('span');
span.className = 'nav-badge';
span.textContent = data.total;
link.appendChild(span);
}
}
// Stat-Karten aktualisieren
if (data.new > 0) {
let newCard = document.getElementById('stat-new');
if (!newCard) {
const statsRow = document.querySelector('.stats-row');
newCard = document.createElement('div');
newCard.id = 'stat-new';
newCard.className = 'stat-card stat-card-new';
newCard.style.cursor = 'pointer';
newCard.onclick = () => showSection('mitglieder', document.querySelector('[onclick*="mitglieder"]'));
newCard.innerHTML = '<div class="stat-number" style="color:#0891b2">' + data.new + '</div><div class="stat-label">🆕 Neu</div>';
statsRow.appendChild(newCard);
} else {
newCard.querySelector('.stat-number').textContent = data.new;
}
} else {
const newCard = document.getElementById('stat-new');
if (newCard) newCard.remove();
}
} else {
if (badge) badge.remove();
const newCard = document.getElementById('stat-new');
if (newCard) newCard.remove();
}
}).catch(() => {});
}
// Sofort + alle 30 Sekunden
updateBadge();
setInterval(updateBadge, 30000);
function filterMembers() {
const q = document.getElementById('memberSearch').value.toLowerCase();
document.querySelectorAll('.member-row').forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
// URL-Hash auswerten (nach Redirect mit #kategorien etc.)
const hash = window.location.hash.replace('#', '');
if (hash && document.getElementById('section-' + hash)) {
const navLink = document.querySelector(`[onclick*="'${hash}'"]`);
showSection(hash, navLink);
}
</script>
</body>
</html>

View File

@ -1,677 +0,0 @@
<!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/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/mailing" class="nav-link">📧 Mailing</a>
<a href="/dokumentation/handbuch.docx" class="nav-link" target="_blank">❓ Hilfe</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', this)">📈 Umsatzverlauf</button>
<button class="ftab" onclick="showTab('open', this)">🔴 Offene Posten</button>
<button class="ftab" onclick="showTab('chargebacks', this)">↩️ Rückläufer</button>
<button class="ftab" onclick="showTab('dunning', this)">📬 Mahngebühren</button>
<button class="ftab" onclick="showTab('expiring', this)">⏳ Auslaufende Verträge</button>
<button class="ftab" onclick="showTab('cancelled', this)">🚫 Storniert</button>
<button class="ftab" onclick="showTab('settings', this)">⚙️ 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;flex-wrap:wrap">
<button class="btn btn-warning btn-sm" onclick="toggleModal('allDunningModal')">📬 Alle mahnen</button>
<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>
<a href="/admin/finance/chargebacks/sepa-export" class="btn btn-primary btn-sm">📥 SEPA Nachforderung</a>
</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>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<% if (c.status === 'open') { %>
<button type="button" class="btn btn-sm btn-warning"
onclick="openDunningModal(<%= c.id %>, '<%= c.last_name %>, <%= c.first_name %>', <%= c.amount %>, '<%= c.period %>');event.stopPropagation()">
📬 Mahnen
</button>
<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>
<% } %>
</div>
</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>
<div style="display:flex;gap:6px">
<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">
<button type="submit" class="btn btn-sm btn-primary" title="Verlängerungs-E-Mail senden">📧 E-Mail</button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<!-- ===== TAB: STORNIERT ===== -->
<div class="ftab-content" id="tab-cancelled">
<div class="finance-card">
<h3>Stornierte Rechnungen (<%= cancelledInvoices.length %>)</h3>
<% if (cancelledInvoices.length === 0) { %>
<p class="karte-empty">Keine stornierten Rechnungen vorhanden.</p>
<% } else { %>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Nr.</th>
<th>Mitglied</th>
<th>Tarif</th>
<th>Periode</th>
<th>Betrag</th>
<th>Storniert am</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<% cancelledInvoices.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><%= inv.period %></td>
<td><span style="text-decoration:line-through;color:var(--text-muted)">
<%= Number(inv.amount).toFixed(2).replace('.', ',') %> €
</span></td>
<td><%= new Date(inv.created_at).toLocaleDateString('de-DE') %></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: 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"
enctype="multipart/form-data">
<p style="font-size:0.8rem;color:var(--text-muted);margin-bottom:16px">
Format pro Zeile: <code>IBAN;Betrag;Datum;Grund</code><br>
Beispiel: <code>DE89370400440532013000;29,95;2026-04-05;Rücklastschrift</code>
</p>
<!-- Option 1: Datei -->
<div class="form-group">
<label>📁 CSV-Datei hochladen</label>
<div class="file-upload-wrap">
<input type="file" name="csv_file" id="csvFile" accept=".csv,.txt"
onchange="showFileName(this)">
<label for="csvFile" class="file-upload-label">
<span id="fileNameDisplay">Datei auswählen...</span>
</label>
</div>
</div>
<div class="import-divider"><span>oder</span></div>
<!-- Option 2: Textfeld -->
<div class="form-group">
<label>📋 Daten einfügen</label>
<textarea name="csv_data" class="form-control" rows="5"
placeholder="IBAN;Betrag;Datum;Grund&#10;DE89370400440532013000;29,95;2026-04-05;Rücklastschrift"></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>
<!-- Modal: Einzelne Mahngebühr für Rückläufer -->
<div class="modal-overlay hidden" id="singleDunningModal">
<div class="modal">
<div class="modal-header">
<h3>Mahngebühr zuweisen</h3>
<button onclick="toggleModal('singleDunningModal')" class="modal-close">✕</button>
</div>
<form method="POST" action="/admin/finance/dunning/add-from-chargeback">
<input type="hidden" name="chargeback_id" id="dunning_membership_id">
<input type="hidden" name="invoice_id" value="">
<div class="form-group">
<label>Mitglied</label>
<input type="text" id="dunning_member_name" class="form-control" disabled>
</div>
<div class="form-row">
<div class="form-group">
<label>Mahngebühr (€) *</label>
<input type="number" name="amount" id="dunning_amount" step="0.01" min="0"
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 Rücklastschrift">
</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('singleDunningModal')" class="btn btn-outline">Abbrechen</button>
<button type="submit" class="btn btn-primary">📬 Mahngebühr eintragen</button>
</div>
</form>
</div>
</div>
<!-- Modal: Alle offenen Rückläufer mahnen -->
<div class="modal-overlay hidden" id="allDunningModal">
<div class="modal">
<div class="modal-header">
<h3>Alle offenen Rückläufer mahnen</h3>
<button onclick="toggleModal('allDunningModal')" class="modal-close">✕</button>
</div>
<form method="POST" action="/admin/finance/chargebacks/dunning-all">
<p style="font-size:0.9rem;margin-bottom:16px;color:var(--text-muted)">
Trägt für alle offenen Rückläufer automatisch eine Mahngebühr ein.
</p>
<div class="form-row">
<div class="form-group">
<label>Mahngebühr pro Rückläufer (€) *</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 Rücklastschrift">
</div>
<div class="modal-footer">
<button type="button" onclick="toggleModal('allDunningModal')" class="btn btn-outline">Abbrechen</button>
<button type="submit" class="btn btn-warning"
onclick="return confirm('Für alle offenen Rückläufer eine Mahngebühr eintragen?')">
📬 Alle mahnen
</button>
</div>
</form>
</div>
</div>
<script>
const dunningDefaultFee = <%= Number(dunningFee).toFixed(2) %>;
// 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
const tabMap = {
chart: 0, open: 1, chargebacks: 2, dunning: 3, expiring: 4, cancelled: 5, settings: 6
};
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');
const btn = el || document.querySelectorAll('.ftab')[tabMap[name]];
if (btn) btn.classList.add('active');
history.replaceState(null, '', '#' + name);
}
// Beim Laden Hash auswerten
document.addEventListener('DOMContentLoaded', () => {
const hash = window.location.hash.replace('#', '');
if (hash && document.getElementById('tab-' + hash)) {
showTab(hash);
}
});
function toggleModal(id) {
document.getElementById(id).classList.toggle('hidden');
}
function showFileName(input) {
const display = document.getElementById('fileNameDisplay');
display.textContent = input.files.length > 0 ? input.files[0].name : 'Datei auswählen...';
}
function openDunningModal(chargebackId, memberName, amount, period) {
document.getElementById('dunning_member_name').value = memberName;
document.getElementById('dunning_amount').value = dunningDefaultFee;
document.getElementById('dunning_membership_id').value = chargebackId;
toggleModal('singleDunningModal');
}
</script>
</body>
</html>

View File

@ -1,40 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Admin Login</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-login-wrap">
<div class="admin-login-card">
<div class="logo admin-logo">Plusfit<span>24</span></div>
<h1>Admin Login</h1>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<form method="POST" action="/admin/login">
<div class="form-group">
<label>Benutzername</label>
<div class="input-wrap">
<span class="input-icon">👤</span>
<input type="text" name="username" required autofocus>
</div>
</div>
<div class="form-group">
<label>Passwort</label>
<div class="input-wrap">
<span class="input-icon">🔒</span>
<input type="password" name="password" required>
</div>
</div>
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form>
</div>
</div>
</body>
</html>

View File

@ -1,188 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Mailing</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/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/mailing" class="nav-link active">📧 Mailing</a>
<a href="/dokumentation/handbuch.docx" class="nav-link" target="_blank">❓ Hilfe</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">📧 Mailing</h1>
<% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<div class="finance-tabs">
<button class="ftab active" onclick="showTab('bulk', this)">📢 An alle aktiven Mitglieder</button>
<button class="ftab" onclick="showTab('single', this)">👤 An einzelnes Mitglied</button>
<button class="ftab" onclick="showTab('log', this)">
📋 Versandprotokoll
<% if (log.length > 0) { %><span style="margin-left:6px;background:#e0e7ff;color:var(--primary);padding:1px 7px;border-radius:10px;font-size:0.75rem"><%= log.length %></span><% } %>
</button>
</div>
<!-- ===== TAB: AN ALLE ===== -->
<div class="ftab-content active" id="tab-bulk">
<div class="mailing-layout">
<!-- Formular -->
<div class="finance-card" style="flex:1">
<h3>E-Mail an alle aktiven Mitglieder (<%= members.length %>)</h3>
<form method="POST" action="/admin/mailing/send-all"
onsubmit="return confirm('E-Mail an <%= members.length %> aktive Mitglieder senden?')">
<div class="form-group" style="margin-top:16px">
<label>Betreff *</label>
<input type="text" name="subject" class="form-control"
placeholder="z.B. Wichtige Information zu deiner Mitgliedschaft" required>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="include_name" value="1" checked style="margin-right:6px">
Persönliche Anrede ("Hallo Max Mustermann,") einfügen
</label>
</div>
<div class="form-group">
<label>Nachricht *</label>
<textarea name="body" class="form-control mail-textarea" rows="10"
placeholder="Schreibe hier deine Nachricht..." required></textarea>
</div>
<div class="mail-preview-hint">
💡 Zeilenumbrüche werden automatisch als Absätze formatiert.
</div>
<div style="display:flex;gap:10px;margin-top:16px">
<button type="submit" class="btn btn-primary">📢 Jetzt an alle senden</button>
</div>
</form>
</div>
<!-- Empfängerliste -->
<div class="finance-card mail-recipients">
<h3>Empfänger (<%= members.length %>)</h3>
<div class="recipients-list">
<% members.forEach(m => { %>
<div class="recipient-row">
<span class="recipient-name"><%= m.last_name %>, <%= m.first_name %></span>
<span class="recipient-email"><%= m.email %></span>
</div>
<% }) %>
<% if (members.length === 0) { %>
<p class="karte-empty">Keine aktiven Mitglieder.</p>
<% } %>
</div>
</div>
</div>
</div>
<!-- ===== TAB: EINZELN ===== -->
<div class="ftab-content" id="tab-single">
<div class="finance-card" style="max-width:680px">
<h3>E-Mail an einzelnes Mitglied</h3>
<form method="POST" action="/admin/mailing/send-one" style="margin-top:16px">
<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 %> · <%= m.email %></option>
<% }) %>
</select>
</div>
<div class="form-group">
<label>Betreff *</label>
<input type="text" name="subject" class="form-control"
placeholder="Betreff der E-Mail" required>
</div>
<div class="form-group">
<label>Nachricht *</label>
<textarea name="body" class="form-control mail-textarea" rows="10"
placeholder="Schreibe hier deine Nachricht..." required></textarea>
</div>
<button type="submit" class="btn btn-primary">📧 E-Mail senden</button>
</form>
</div>
</div>
<!-- ===== TAB: LOG ===== -->
<div class="ftab-content" id="tab-log">
<div class="finance-card">
<h3>Versandprotokoll (letzte 50)</h3>
<% if (log.length === 0) { %>
<p class="karte-empty">Noch keine E-Mails gesendet.</p>
<% } else { %>
<div class="table-wrap" style="margin-top:12px">
<table class="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Mitglied</th>
<th>Empfänger</th>
<th>Betreff</th>
<th>Typ</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<% log.forEach(entry => { %>
<tr>
<td><small><%= new Date(entry.sent_at).toLocaleString('de-DE') %></small></td>
<td><%= entry.first_name ? entry.last_name + ', ' + entry.first_name : '' %></td>
<td><small class="text-muted"><%= entry.recipient %></small></td>
<td><%= entry.subject %></td>
<td>
<span class="mail-type-badge mail-type-<%= entry.type %>">
<%= entry.type === 'bulk' ? '📢 Rundmail' : entry.type === 'direct' ? '👤 Direkt' : entry.type === 'renewal' ? '🔄 Verlängerung' : entry.type === 'renewal_auto' ? '⏰ Auto' : '📧 ' + entry.type %>
</span>
</td>
<td>
<span class="invoice-status <%= entry.status === 'sent' ? 'paid' : 'open' %>">
<%= entry.status === 'sent' ? '✅ Gesendet' : '❌ Fehler' %>
</span>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
</main>
</div>
<script>
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');
}
const hash = window.location.hash.replace('#','');
if (hash && document.getElementById('tab-' + hash)) showTab(hash);
</script>
</body>
</html>

View File

@ -1,544 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Mitglied #<%= member.id %></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 member-detail-page">
<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 active">👥 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="/admin/mailing" class="nav-link">📧 Mailing</a>
<a href="/dokumentation/handbuch.docx" class="nav-link" target="_blank">❓ Hilfe</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">
<div class="detail-header">
<a href="/admin#mitglieder" class="btn btn-outline btn-sm">← Zurück</a>
<div class="detail-header-title">
<h1><%= member.first_name %> <%= member.last_name %></h1>
<span class="member-id-badge">Mitglied #<%= member.id %></span>
<span class="status-badge <%= member.status === 'active' ? 'active' : member.status === 'paused' ? 'warning' : member.status === 'pending' ? 'warning' : 'inactive' %>">
<%= member.status === 'active' ? '✅ Aktiv' : member.status === 'paused' ? '⏸ Pausiert' : member.status === 'pending' ? '⏳ Ausstehend' : '❌ Inaktiv' %>
</span>
<% if (member.status === 'pending') { %>
<form method="POST" action="/admin/members/<%= member.id %>/confirm" style="display:inline">
<button type="submit" class="btn btn-sm btn-success">✅ Manuell bestätigen</button>
</form>
<% } %>
<% if (member.is_minor) { %>
<span class="status-badge warning">⚠️ Minderjährig</span>
<% } %>
</div>
<div class="detail-header-actions">
<button class="btn btn-outline btn-sm" onclick="toggleModal('directMailModal')">📧 E-Mail senden</button>
<button class="btn btn-primary" id="editBtn" onclick="enableEdit()">✏️ Bearbeiten</button>
<button class="btn btn-success hidden" id="saveBtn" form="memberForm">💾 Speichern</button>
<button class="btn btn-outline hidden" id="cancelBtn" onclick="cancelEdit()">✕ Abbrechen</button>
</div>
</div>
<% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<form method="POST" action="/admin/members/<%= member.id %>/update" id="memberForm">
<div class="karteikarte-grid">
<!-- ===== KARTE 1: Persönliche Daten ===== -->
<div class="karte">
<div class="karte-header"><span class="karte-icon">👤</span><h3>Persönliche Daten</h3></div>
<div class="karte-body">
<div class="karte-row">
<div class="karte-field">
<label>Anrede</label>
<select name="salutation" disabled class="karte-input">
<option value="Herr" <%= member.salutation === 'Herr' ? 'selected' : '' %>>Herr</option>
<option value="Frau" <%= member.salutation === 'Frau' ? 'selected' : '' %>>Frau</option>
<option value="Keine Angabe"<%= member.salutation === 'Keine Angabe'? 'selected' : '' %>>Keine Angabe</option>
</select>
</div>
<div class="karte-field">
<label>Titel</label>
<input type="text" name="title" value="<%= member.title || '' %>" disabled class="karte-input">
</div>
</div>
<div class="karte-row">
<div class="karte-field">
<label>Vorname</label>
<input type="text" name="first_name" value="<%= member.first_name %>" disabled class="karte-input">
</div>
<div class="karte-field">
<label>Nachname</label>
<input type="text" name="last_name" value="<%= member.last_name %>" disabled class="karte-input">
</div>
</div>
<div class="karte-row">
<div class="karte-field">
<label>Geburtsdatum</label>
<input type="date" name="birth_date" value="<%= member.birth_date ? new Date(member.birth_date).toISOString().split('T')[0] : '' %>" disabled class="karte-input">
</div>
<div class="karte-field">
<label>Telefon</label>
<input type="tel" name="phone" value="<%= member.phone || '' %>" disabled class="karte-input">
</div>
</div>
<div class="karte-row">
<div class="karte-field karte-field-full">
<label>E-Mail</label>
<input type="email" name="email" value="<%= member.email %>" disabled class="karte-input">
</div>
</div>
</div>
</div>
<!-- ===== KARTE 2: Adresse ===== -->
<div class="karte">
<div class="karte-header"><span class="karte-icon">📍</span><h3>Adresse</h3></div>
<div class="karte-body">
<div class="karte-row">
<div class="karte-field karte-field-full">
<label>Straße, Hausnummer</label>
<input type="text" name="street" value="<%= member.street %>" disabled class="karte-input">
</div>
</div>
<div class="karte-row">
<div class="karte-field karte-field-full">
<label>Adresszusatz</label>
<input type="text" name="address_addition" value="<%= member.address_addition || '' %>" disabled class="karte-input">
</div>
</div>
<div class="karte-row">
<div class="karte-field" style="max-width:140px">
<label>PLZ</label>
<input type="text" name="zip" value="<%= member.zip %>" disabled class="karte-input">
</div>
<div class="karte-field">
<label>Ort</label>
<input type="text" name="city" value="<%= member.city %>" disabled class="karte-input">
</div>
</div>
</div>
</div>
<!-- ===== KARTE 3: Vertrag ===== -->
<div class="karte">
<div class="karte-header"><span class="karte-icon">📄</span><h3>Vertrag</h3></div>
<div class="karte-body">
<div class="karte-row">
<div class="karte-field karte-field-full">
<label>Tarif</label>
<select name="tariff_id" disabled class="karte-input">
<% tariffs.forEach(t => { %>
<option value="<%= t.id %>" <%= member.tariff_id === t.id ? 'selected' : '' %>>
<%= t.name %> <%= Number(t.price_monthly).toFixed(2) %>€/Monat
</option>
<% }) %>
</select>
</div>
</div>
<div class="karte-row">
<div class="karte-field">
<label>Vereinbarter Preis/Monat <small>(vertraglich)</small></label>
<div class="input-wrap" style="border-radius:6px">
<input type="number" name="agreed_price" step="0.01" min="0"
value="<%= member.agreed_price ? Number(member.agreed_price).toFixed(2) : '' %>"
disabled class="karte-input" style="border:none;padding:5px 0">
<span style="font-size:0.8rem;color:var(--text-muted);padding-right:8px">€</span>
</div>
</div>
<div class="karte-field">
<label>Vereinbarte Laufzeit <small>(Monate)</small></label>
<input type="number" name="agreed_duration" min="1"
value="<%= member.agreed_duration || '' %>"
disabled class="karte-input">
</div>
</div>
<div class="karte-row">
<div class="karte-field">
<label>Startpaket (€) <small>(0 = erlassen)</small></label>
<input type="number" name="start_package_price" step="0.01" min="0"
value="<%= member.start_package_price != null ? Number(member.start_package_price).toFixed(2) : '35.00' %>"
disabled class="karte-input">
</div>
<div class="karte-field">
<label>Startpaket abgerechnet</label>
<input type="text"
value="<%= member.start_package_paid ? '✅ Ja' : '❌ Nein' %>"
disabled class="karte-input karte-readonly">
</div>
</div>
<div class="karte-row">
<div class="karte-field">
<label>Abschlussdatum</label>
<input type="text" value="<%= member.signup_date ? new Date(member.signup_date).toLocaleDateString('de-DE') : '' %>" disabled class="karte-input karte-readonly">
</div>
<div class="karte-field">
<label>Vertragsbeginn</label>
<input type="text" value="<%= member.contract_start ? new Date(member.contract_start).toLocaleDateString('de-DE') : '' %>" disabled class="karte-input karte-readonly">
</div>
</div>
<div class="karte-row">
<div class="karte-field">
<label>Vertragsende</label>
<input type="text" value="<%= member.contract_end ? new Date(member.contract_end).toLocaleDateString('de-DE') : '' %>" disabled class="karte-input karte-readonly">
</div>
<div class="karte-field">
<label>Eff. Ende <small>(inkl. Auszeit)</small></label>
<input type="text" value="<%= member.effective_end ? new Date(member.effective_end).toLocaleDateString('de-DE') : '' %>" disabled class="karte-input karte-readonly">
</div>
</div>
<div class="karte-row">
<div class="karte-field">
<label>Erste Zahlung am</label>
<input type="text" value="<%= member.first_payment_date ? new Date(member.first_payment_date).toLocaleDateString('de-DE') : '' %>" disabled class="karte-input karte-readonly">
</div>
<div class="karte-field">
<label>Erster Betrag</label>
<input type="text" value="<%= member.first_payment_amt ? Number(member.first_payment_amt).toFixed(2) + ' €' : '' %>" disabled class="karte-input karte-readonly">
</div>
</div>
<div class="karte-row">
<div class="karte-field">
<label>Status</label>
<select name="status" disabled class="karte-input">
<option value="active" <%= member.status === 'active' ? 'selected' : '' %>>✅ Aktiv</option>
<option value="inactive" <%= member.status === 'inactive' ? 'selected' : '' %>>❌ Inaktiv</option>
<option value="paused" <%= member.status === 'paused' ? 'selected' : '' %>>⏸ Pausiert</option>
</select>
</div>
<div class="karte-field">
<label>Auszeit gesamt</label>
<input type="text" value="<%= member.pause_months_total || 0 %> Monat(e)" disabled class="karte-input karte-readonly">
</div>
</div>
<!-- Auszeiten Tabelle -->
<div class="auszeit-section">
<div class="auszeit-header">
<span class="auszeit-title">⏸ Auszeiten</span>
</div>
<% if (pauses.length === 0) { %>
<p class="karte-empty" id="noPausesMsg">Keine Auszeiten eingetragen.</p>
<% } else { %>
<table class="pause-table">
<thead>
<tr>
<th>Von</th>
<th>Bis</th>
<th>Monate</th>
<th>Grund</th>
<th>Eingetragen am</th>
<th class="edit-only hidden">Aktion</th>
</tr>
</thead>
<tbody>
<% pauses.forEach(p => { %>
<tr>
<td><%= new Date(p.pause_start).toLocaleDateString('de-DE') %></td>
<td><%= new Date(p.pause_end).toLocaleDateString('de-DE') %></td>
<td><strong><%= p.pause_months %></strong></td>
<td><%= p.reason || '' %></td>
<td><%= new Date(p.created_at).toLocaleDateString('de-DE') %></td>
<td class="edit-only hidden">
<form method="POST" action="/admin/members/<%= member.id %>/pauses/<%= p.id %>/delete"
onsubmit="return confirm('Auszeit wirklich löschen?')">
<button type="submit" class="btn btn-sm btn-danger">🗑</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
<!-- Neue Auszeit nur im Bearbeiten-Modus sichtbar -->
<div class="neue-auszeit hidden" id="neueAuszeit">
<div class="neue-auszeit-title">+ Neue Auszeit eintragen</div>
<div class="auszeit-form-row">
<div class="karte-field">
<label>Von *</label>
<input type="date" name="pause_start" form="auszeitForm" required class="karte-input">
</div>
<div class="karte-field">
<label>Bis *</label>
<input type="date" name="pause_end" form="auszeitForm" required class="karte-input">
</div>
<div class="karte-field">
<label>Grund</label>
<input type="text" name="reason" form="auszeitForm" class="karte-input" placeholder="z.B. Urlaub, Verletzung...">
</div>
<div class="karte-field" style="max-width:120px; justify-content:flex-end; padding-top:22px">
<button type="submit" form="auszeitForm" class="btn btn-primary">Speichern</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ===== KARTE 4: Bankdaten ===== -->
<div class="karte">
<div class="karte-header"><span class="karte-icon">🏦</span><h3>Bankdaten / SEPA</h3></div>
<div class="karte-body">
<div class="karte-row">
<div class="karte-field">
<label>Geldinstitut</label>
<input type="text" name="bank_name" value="<%= member.bank_name || '' %>" disabled class="karte-input">
</div>
<div class="karte-field">
<label>Kontoinhaber</label>
<input type="text" name="account_holder" value="<%= member.account_holder || '' %>" disabled class="karte-input">
</div>
</div>
<div class="karte-row">
<div class="karte-field">
<label>SEPA akzeptiert</label>
<input type="text" value="<%= member.sepa_accepted ? '✅ Ja' : '❌ Nein' %>" disabled class="karte-input karte-readonly">
</div>
<div class="karte-field">
<label>AGB akzeptiert</label>
<input type="text" value="<%= member.agb_accepted ? '✅ Ja' : '❌ Nein' %>" disabled class="karte-input karte-readonly">
</div>
</div>
<div class="karte-row">
<div class="karte-field karte-field-full">
<label>IBAN</label>
<input type="text" name="iban" id="ibanInput"
value="<%= member.iban ? member.iban.replace(/(.{4})/g, '$1 ').trim() : '' %>"
disabled class="karte-input karte-iban" maxlength="34" autocomplete="off">
<div class="iban-message" id="ibanMessage"></div>
</div>
</div>
<% if (member.is_minor) { %>
<div class="karte-row">
<div class="karte-field karte-field-full">
<label>Einverständniserklärung (Erziehungsberechtigte)</label>
<input type="text" value="<%= member.guardian_consent ? '✅ Vorhanden' : '❌ Fehlt' %>" disabled class="karte-input karte-readonly">
</div>
</div>
<% } %>
</div>
</div>
<!-- ===== KARTE 5: Rechnungshistorie ===== -->
<div class="karte">
<div class="karte-header">
<span class="karte-icon">🧾</span>
<h3>Rechnungshistorie</h3>
<span class="karte-header-sub"><%= invoices.length %> Rechnung(en)</span>
</div>
<div class="karte-body" style="padding:0">
<% if (invoices.length === 0) { %>
<p class="karte-empty" style="padding:20px">Noch keine Rechnungen vorhanden.</p>
<% } else { %>
<div class="invoice-history-wrap">
<table class="invoice-history-table">
<thead>
<tr>
<th>Nr.</th>
<th>Periode</th>
<th>Betrag</th>
<th>Status</th>
<th>Datum</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<% invoices.forEach(inv => { %>
<tr class="invoice-history-row">
<td class="invoice-nr">PF24-<%= String(inv.id).padStart(6,'0') %></td>
<td><strong><%= inv.period %></strong></td>
<td>
<% if (inv.status === 'cancelled') { %>
<span style="text-decoration:line-through;color:var(--text-muted)">
<%= Number(inv.amount).toFixed(2).replace('.', ',') %> €
</span>
<% } else { %>
<strong><%= Number(inv.amount).toFixed(2).replace('.', ',') %> €</strong>
<% } %>
</td>
<td>
<span class="invoice-status <%= inv.status === 'paid' ? 'paid' : inv.status === 'cancelled' ? 'cancelled' : 'open' %>">
<%= inv.status === 'paid' ? '✅ Bezahlt' : inv.status === 'cancelled' ? '🚫 Storniert' : '🔴 Offen' %>
</span>
</td>
<td><small class="text-muted"><%= new Date(inv.created_at).toLocaleDateString('de-DE') %></small></td>
<td>
<% if (inv.status === 'cancelled') { %>
<a href="/admin/billing/export/storno-pdf/<%= inv.id %>"
class="btn btn-sm btn-storno" target="_blank">🚫 Storno-PDF</a>
<% } else { %>
<a href="/admin/billing/export/pdf/<%= inv.id %>"
class="btn btn-sm btn-outline" target="_blank">📄 Rechnung</a>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<!-- ===== KARTE 5: Zugangskarte / NFC ===== -->
<div class="karte">
<div class="karte-header">
<span class="karte-icon">🔑</span>
<h3>Zugangskarte / NFC</h3>
<% if (member.card_issued) { %>
<span class="status-badge active" style="margin-left:auto">✅ Ausgegeben</span>
<% } else { %>
<span class="status-badge inactive" style="margin-left:auto">⚠️ Nicht ausgegeben</span>
<% } %>
</div>
<div class="karte-body">
<!-- Access Token -->
<div class="karte-row">
<div class="karte-field karte-field-full">
<label>Access Token <small>(auf NFC-Karte schreiben)</small></label>
<div class="token-wrap">
<code class="token-display"><%= member.access_token || '' %></code>
<form method="POST" action="/admin/members/<%= member.id %>/regenerate-token"
onsubmit="return confirm('Token neu generieren? Die alte Karte funktioniert dann nicht mehr!')">
<button type="submit" class="btn btn-sm btn-warning token-regen-btn"
title="Neuen Token generieren (z.B. bei Kartenverlust)">
🔄 Neu
</button>
</form>
</div>
</div>
</div>
<!-- NFC UID -->
<div class="karte-row">
<div class="karte-field karte-field-full">
<label>NFC Karten-UID <small>(vom Lesegerät auslesen)</small></label>
<form method="POST" action="/admin/members/<%= member.id %>/update-nfc"
id="nfcForm" style="display:flex;gap:8px;align-items:flex-start">
<input type="text" name="nfc_uid"
value="<%= member.nfc_uid || '' %>"
placeholder="z.B. A1:B2:C3:D4"
class="karte-input karte-iban"
style="text-transform:uppercase;flex:1">
<button type="submit" class="btn btn-sm btn-primary" style="white-space:nowrap;margin-top:1px">
💾 Speichern
</button>
</form>
</div>
</div>
<% if (member.card_issued_at) { %>
<div class="karte-row">
<div class="karte-field karte-field-full">
<label>Karte ausgegeben am</label>
<input type="text"
value="<%= new Date(member.card_issued_at).toLocaleDateString('de-DE') %>"
disabled class="karte-input karte-readonly">
</div>
</div>
<% } %>
<!-- Info Box -->
<div class="nfc-info-box">
<strong> So wird die Karte eingerichtet:</strong>
<ol>
<li>Access Token mit NFC-Schreiber auf die Karte schreiben</li>
<li>Karten-UID mit dem Lesegerät auslesen und hier eintragen</li>
<li>Beim Zutritt: Lesegerät prüft UID + Token gegen die Datenbank</li>
</ol>
</div>
</div>
</div>
</div><!-- end karteikarte-grid -->
</form>
<!-- Auszeit Form (außerhalb von memberForm!) -->
<form id="auszeitForm" method="POST" action="/admin/members/<%= member.id %>/pauses/add"></form>
</main>
</div>
<!-- Modal: Direkte E-Mail -->
<div class="modal-overlay hidden" id="directMailModal">
<div class="modal">
<div class="modal-header">
<h3>E-Mail an <%= member.first_name %> <%= member.last_name %></h3>
<button onclick="toggleModal('directMailModal')" class="modal-close">✕</button>
</div>
<form method="POST" action="/admin/mailing/send-one">
<input type="hidden" name="membership_id" value="<%= member.id %>">
<div class="form-group">
<label>Empfänger</label>
<input type="text" value="<%= member.email %>" class="form-control" disabled>
</div>
<div class="form-group">
<label>Betreff *</label>
<input type="text" name="subject" class="form-control" required placeholder="Betreff">
</div>
<div class="form-group">
<label>Nachricht *</label>
<textarea name="body" class="form-control" rows="8" required
placeholder="Schreibe hier deine Nachricht..."></textarea>
</div>
<div class="modal-footer">
<button type="button" onclick="toggleModal('directMailModal')" class="btn btn-outline">Abbrechen</button>
<button type="submit" class="btn btn-primary">📧 Senden</button>
</div>
</form>
</div>
</div>
<script src="/js/iban.js"></script>
<script>
const editableFields = document.querySelectorAll('.karte-input:not(.karte-readonly)');
function enableEdit() {
editableFields.forEach(f => f.removeAttribute('disabled'));
document.getElementById('editBtn').classList.add('hidden');
document.getElementById('saveBtn').classList.remove('hidden');
document.getElementById('cancelBtn').classList.remove('hidden');
document.querySelectorAll('.karte').forEach(k => k.classList.add('edit-mode'));
// Auszeit-Bereich einblenden
document.getElementById('neueAuszeit').classList.remove('hidden');
document.querySelectorAll('.edit-only').forEach(el => el.classList.remove('hidden'));
// IBAN Validierung
const ibanInput = document.getElementById('ibanInput');
if (ibanInput) attachIBANValidation(ibanInput, null, document.getElementById('ibanMessage'));
}
function cancelEdit() {
window.location.reload();
}
</script>
</body>
</html>

114
views/company.ejs Normal file
View File

@ -0,0 +1,114 @@
<%- include('partials/header') %>
<h2>Firmendaten</h2>
<form method="POST" class="row g-3">
<!-- ================= -->
<!-- FIRMA -->
<!-- ================= -->
<h4>Unternehmen</h4>
<div class="col-md-6">
<input name="firmenname" class="form-control"
value="<%= company.firmenname %>"
placeholder="Firmenname" required>
</div>
<!-- ================= -->
<!-- ADRESSE -->
<!-- ================= -->
<h4 class="mt-4">Adresse</h4>
<div class="col-md-6">
<input name="strasse" class="form-control"
value="<%= company.strasse %>"
placeholder="Straße">
</div>
<div class="col-md-2">
<input name="hausnummer" class="form-control"
value="<%= company.hausnummer %>"
placeholder="Nr.">
</div>
<div class="col-md-2">
<input name="plz" class="form-control"
value="<%= company.plz %>"
placeholder="PLZ">
</div>
<div class="col-md-4">
<input name="ort" class="form-control"
value="<%= company.ort %>"
placeholder="Ort">
</div>
<div class="col-md-4">
<input name="land" class="form-control"
value="<%= company.land %>"
placeholder="Land">
</div>
<!-- ================= -->
<!-- KONTAKT -->
<!-- ================= -->
<h4 class="mt-4">Kontakt</h4>
<div class="col-md-4">
<input name="telefon" class="form-control"
value="<%= company.telefon %>"
placeholder="Telefon">
</div>
<div class="col-md-4">
<input name="email" class="form-control"
value="<%= company.email %>"
placeholder="E-Mail">
</div>
<div class="col-md-4">
<input name="web" class="form-control"
value="<%= company.web %>"
placeholder="Webseite">
</div>
<!-- ================= -->
<!-- SEPA -->
<!-- ================= -->
<h4 class="mt-4">SEPA / Bank</h4>
<div class="col-md-4">
<input name="iban" class="form-control"
value="<%= company.iban %>"
placeholder="IBAN">
</div>
<div class="col-md-4">
<input name="bic" class="form-control"
value="<%= company.bic %>"
placeholder="BIC">
</div>
<div class="col-md-4">
<input name="glaeubiger_id" class="form-control"
value="<%= company.glaeubiger_id %>"
placeholder="Gläubiger-ID">
</div>
<!-- ================= -->
<!-- SPEICHERN -->
<!-- ================= -->
<div class="col-12 mt-4">
<button class="btn btn-primary w-100 btn-lg">
💾 Firmendaten speichern
</button>
</div>
</form>
<a href="/users/dashboard" class="btn btn-link mt-3">
⬅ Zurück zum Dashboard
</a>
<%- include('partials/footer') %>

View File

@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Link ungültig</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>
<header class="site-header">
<div class="header-inner"><div class="logo">Plusfit<span>24</span></div></div>
</header>
<main class="success-main">
<div class="success-card">
<div class="success-icon">⚠️</div>
<h1>Link ungültig oder abgelaufen</h1>
<p>Dieser Bestätigungslink ist nicht mehr gültig.</p>
<p class="success-sub">Bitte kontaktiere uns direkt im Studio oder ruf uns an.</p>
<a href="/" class="btn btn-outline" style="margin-top:16px">Zur Startseite</a>
</div>
</main>
<footer class="site-footer">
<p>© 2024 PlusFit24 UG</p>
</footer>
</body>
</html>

View File

@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 E-Mail bestätigen</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>
<header class="site-header">
<div class="header-inner"><div class="logo">Plusfit<span>24</span></div></div>
</header>
<main class="success-main">
<div class="success-card">
<div class="success-icon">📧</div>
<h1>Fast geschafft!</h1>
<p>Wir haben eine Bestätigungs-E-Mail an</p>
<p><strong><%= email %></strong></p>
<p>gesendet. Bitte klicke auf den Link in der E-Mail um deine Mitgliedschaft zu aktivieren.</p>
<p class="success-sub">Der Link ist 24 Stunden gültig.</p>
<div class="info-box" style="margin-top:16px;text-align:left">
<strong> Keine E-Mail erhalten?</strong><br>
Bitte prüfe deinen Spam-Ordner oder kontaktiere uns direkt im Studio.
</div>
</div>
</main>
<footer class="site-footer">
<p>© 2024 PlusFit24 UG</p>
</footer>
</body>
</html>

View File

@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Bestätigt!</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>
<header class="site-header">
<div class="header-inner"><div class="logo">Plusfit<span>24</span></div></div>
</header>
<main class="success-main">
<div class="success-card">
<div class="success-icon">🎉</div>
<h1>Herzlich Willkommen!</h1>
<p>Deine Mitgliedschaft bei PlusFit24 wurde erfolgreich bestätigt und ist jetzt aktiv.</p>
<p class="success-sub">Wir freuen uns auf dich im Studio!</p>
<a href="/" class="btn btn-primary" style="margin-top:16px">Zurück zur Startseite</a>
</div>
</main>
<footer class="site-footer">
<p>© 2024 PlusFit24 UG</p>
</footer>
</body>
</html>

170
views/contracts.ejs Normal file
View File

@ -0,0 +1,170 @@
<%- include('partials/header') %>
<h2>Vertragsarten verwalten</h2>
<hr>
<!-- ========================= -->
<!-- NEUE VERTRAGSART ANLEGEN -->
<!-- ========================= -->
<h4>Neue Vertragsart anlegen</h4>
<form method="POST" action="/contracts/create" class="row g-3 mb-4">
<div class="col-md-3">
<input
name="name"
class="form-control"
placeholder="Name des Vertrages"
required>
</div>
<div class="col-md-2">
<input
type="number"
name="laufzeit"
class="form-control"
placeholder="Laufzeit (Monate)"
required>
</div>
<div class="col-md-2">
<input
type="number"
step="0.01"
name="betrag"
class="form-control"
placeholder="Betrag €"
required>
</div>
<div class="col-md-3">
<textarea
name="beschreibung"
class="form-control"
rows="1"
placeholder="Freitext / Beschreibung (optional)">
</textarea>
</div>
<div class="col-md-1 form-check mt-2">
<input
type="checkbox"
name="aktiv"
class="form-check-input"
checked>
<label class="form-check-label">Aktiv</label>
</div>
<div class="col-md-1">
<button class="btn btn-primary w-100">
</button>
</div>
</form>
<hr>
<!-- ========================= -->
<!-- VERTRAGSARTEN ÜBERSICHT -->
<!-- ========================= -->
<h4>Bestehende Vertragsarten</h4>
<%
const aktiveVertraege = vertragsarten.filter(v => v.aktiv);
%>
<table class="table table-bordered table-striped align-middle">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Name</th>
<th>Laufzeit</th>
<th>Betrag</th>
<th>Beschreibung</th>
<th>Status / Aktion</th>
</tr>
</thead>
<tbody>
<% if (vertragsarten.length === 0) { %>
<tr>
<td colspan="6" class="text-center text-muted">
Keine Vertragsarten vorhanden
</td>
</tr>
<% } %>
<% vertragsarten.forEach(v => { %>
<tr class="<%= v.aktiv ? '' : 'table-danger' %>">
<td><%= v.id %></td>
<td><%= v.name %></td>
<td><%= v.laufzeit %> Monate</td>
<td><%= v.betrag.toFixed(2) %> €</td>
<td class="<%= v.beschreibung ? '' : 'text-muted' %>">
<%= v.beschreibung || '—' %>
</td>
<!-- STATUS / AKTION -->
<td>
<% if (v.aktiv) { %>
<!-- DEAKTIVIEREN MIT ERSATZ -->
<form
method="POST"
action="/contracts/deactivate/<%= v.id %>">
<select
name="newContractId"
class="form-select form-select-sm mb-1"
required>
<option value="">
Ersatz-Vertrag wählen
</option>
<% aktiveVertraege.forEach(nv => { %>
<% if (nv.id !== v.id) { %>
<option value="<%= nv.id %>">
<%= nv.name %>
</option>
<% } %>
<% }) %>
</select>
<button
class="btn btn-sm btn-danger w-100"
onclick="return confirm(
'Alle User mit diesem Vertrag werden auf den neuen Vertrag umgestellt. Fortfahren?'
)">
🔴 Deaktivieren
</button>
</form>
<% } else { %>
<span class="text-muted">
🔒 Inaktiv
</span>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
<a href="/users/dashboard" class="btn btn-link mt-3">
⬅ Zurück zum Dashboard
</a>
<%- include('partials/footer') %>

61
views/contractsSelect.ejs Normal file
View File

@ -0,0 +1,61 @@
<%- include('partials/header') %>
<h2 class="text-center mb-4">Wähle deinen Vertrag</h2>
<p class="text-center text-muted mb-5">
Transparent, fair und flexibel
</p>
<div class="row g-4">
<% if (vertragsarten.length === 0) { %>
<div class="col-12 text-center text-muted">
Aktuell sind keine Verträge verfügbar.
</div>
<% } %>
<% vertragsarten.forEach(v => { %>
<div class="col-md-4">
<div class="card h-100 shadow-sm border-0">
<div class="card-body d-flex flex-column text-center">
<h4 class="card-title text-primary">
<%= v.name %>
</h4>
<p class="text-muted">
Laufzeit: <strong><%= v.laufzeit %> Monate</strong>
</p>
<p class="card-text">
<%= v.beschreibung || 'Keine Beschreibung vorhanden.' %>
</p>
<div class="my-4">
<span class="display-6 fw-bold">
<%= v.betrag.toFixed(2) %> €
</span>
<span class="text-muted"> / Monat</span>
</div>
<!-- BUTTONS -->
<div class="d-grid gap-2 mt-auto">
<!-- Vertrag auswählen -->
<a
href="/register?vertrag=<%= v.id %>"
class="btn btn-success">
✅ Vertrag auswählen
</a>
</div>
</div>
</div>
</div>
<% }) %>
</div>
<%- include('partials/footer') %>

44
views/createUser.ejs Normal file
View File

@ -0,0 +1,44 @@
<form method="POST">
<h3>Vertrag</h3>
<label>Vertragsnummer</label>
<input
type="text"
class="form-control mb-2 bg-light"
placeholder="Wird automatisch vergeben"
disabled>
<label>Vertragsvariante</label>
<input
type="number"
name="vertragsvariante"
class="form-control mb-2"
placeholder="z. B. 1, 2 oder 3"
required>
<h3>Persönliche Daten</h3>
<input name="vorname" placeholder="Vorname" required>
<input name="nachname" placeholder="Nachname" required>
<label>Geburtsdatum</label>
<input type="date" name="geburtsdatum" required>
<h3>Adresse</h3>
<input name="strasse" placeholder="Straße">
<input name="hausnummer" placeholder="Hausnummer">
<input name="plz" placeholder="PLZ">
<input name="ort" placeholder="Ort">
<input name="land" placeholder="Land">
<h3>Kontakt</h3>
<input name="mobil" placeholder="Mobilnummer">
<input name="telefon" placeholder="Telefonnummer">
<input name="email" type="email" placeholder="E-Mail">
<h3>SEPA Lastschrift</h3>
<input name="kontoinhaber" placeholder="Kontoinhaber">
<input name="iban" placeholder="IBAN">
<input name="bic" placeholder="BIC">
<input name="mandatsreferenz" placeholder="Mandatsreferenz">
<button>Speichern</button>
</form>

38
views/dashboard.ejs Normal file
View File

@ -0,0 +1,38 @@
<%- include('partials/header') %>
<h2>Plusfit Dashboard</h2>
<div class="list-group">
<a href="/company" class="list-group-item list-group-item-action">
🏢 Firmendaten
</a>
<a href="/users/create" class="list-group-item list-group-item-action">
Neues Mitglied anlegen
</a>
<a href="/users/list" class="list-group-item list-group-item-action">
📋 Mitgliederübersicht
</a>
<a href="/contracts"
class="list-group-item list-group-item-action">
📄 Vertragsarten verwalten
</a>
<a href="/sepa/export"
class="list-group-item list-group-item-action list-group-item-warning"
onclick="return confirm('SEPA-Datei für alle aktiven Mitglieder erstellen?')">
💳 SEPA-Export (aktive Mitglieder)
</a>
<a href="/logout" class="list-group-item list-group-item-action list-group-item-danger">
🚪 Logout
</a>
</div>
<%- include('partials/footer') %>

226
views/editUser.ejs Normal file
View File

@ -0,0 +1,226 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Plusfit Mitglied bearbeiten</title>
<style>
body {
font-family: Arial, sans-serif;
background: #f6f7f9;
}
.container {
max-width: 800px;
margin: 20px auto;
background: #fff;
padding: 20px;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
}
h2 {
margin-top: 0;
}
h3 {
margin-top: 25px;
border-bottom: 1px solid #ddd;
padding-bottom: 5px;
}
label {
display: block;
font-weight: bold;
margin-top: 10px;
}
input {
width: 100%;
padding: 8px;
margin-top: 3px;
border-radius: 4px;
border: 1px solid #ccc;
box-sizing: border-box;
}
/* 🔶 Leere Felder */
.empty-field {
background-color: #fff8e1;
border: 1px dashed #f0ad4e;
}
/* 🔥 Fokus-Effekt */
.empty-field:focus {
background-color: #ffffff;
border-color: #ff9800;
box-shadow: 0 0 5px rgba(255,152,0,0.7);
}
/* Pflichtfelder */
.required {
border-left: 4px solid #2e7d32;
}
button {
margin-top: 25px;
padding: 10px 18px;
background: #1976d2;
color: #fff;
border: none;
border-radius: 4px;
font-size: 15px;
cursor: pointer;
}
button:hover {
background: #1565c0;
}
.status {
margin-top: 10px;
}
</style>
</head>
<body>
<div class="container">
<%- include('partials/header') %>
<h2>Mitglied bearbeiten</h2>
<form method="POST">
<h3>Vertrag</h3>
<label>Vertragsnummer</label>
<input
value="<%= user.vertragsnummer %>"
readonly
class="form-control mb-2 bg-light">
<label>Vertragsvariante</label>
<input
type="number"
name="vertragsvariante"
value="<%= user.vertragsvariante || '' %>"
class="form-control mb-2 <%= user.vertragsvariante ? '' : 'empty-field' %>"
placeholder="z. B. 1, 2 oder 3">
<h3>Persönliche Daten</h3>
<label>Vorname</label>
<input name="vorname"
value="<%= user.vorname || '' %>"
required
class="required <%= user.vorname ? '' : 'empty-field' %>">
<label>Nachname</label>
<input name="nachname"
value="<%= user.nachname || '' %>"
required
class="required <%= user.nachname ? '' : 'empty-field' %>">
<label>Geburtsdatum</label>
<input type="date"
name="geburtsdatum"
value="<%= user.geburtsdatum || '' %>">
<h3>Adresse</h3>
<label>Straße</label>
<input name="strasse"
value="<%= user.strasse || '' %>"
placeholder="Straße fehlt"
class="<%= user.strasse ? '' : 'empty-field' %>">
<label>Hausnummer</label>
<input name="hausnummer"
value="<%= user.hausnummer || '' %>"
placeholder="Hausnummer fehlt"
class="<%= user.hausnummer ? '' : 'empty-field' %>">
<label>PLZ</label>
<input name="plz"
value="<%= user.plz || '' %>"
placeholder="PLZ fehlt"
class="<%= user.plz ? '' : 'empty-field' %>">
<label>Ort</label>
<input name="ort"
value="<%= user.ort || '' %>"
placeholder="Ort fehlt"
class="<%= user.ort ? '' : 'empty-field' %>">
<label>Land</label>
<input name="land"
value="<%= user.land || '' %>"
placeholder="Land fehlt"
class="<%= user.land ? '' : 'empty-field' %>">
<h3>Kontakt</h3>
<label>Mobilnummer</label>
<input name="mobil"
value="<%= user.mobil || '' %>"
placeholder="Mobilnummer fehlt"
class="<%= user.mobil ? '' : 'empty-field' %>">
<label>Telefonnummer</label>
<input name="telefon"
value="<%= user.telefon || '' %>"
placeholder="Telefonnummer fehlt"
class="<%= user.telefon ? '' : 'empty-field' %>">
<label>E-Mail</label>
<input name="email"
type="email"
value="<%= user.email || '' %>"
placeholder="E-Mail fehlt"
class="<%= user.email ? '' : 'empty-field' %>">
<h3>SEPA Lastschrift</h3>
<label>Kontoinhaber</label>
<input name="kontoinhaber"
value="<%= user.kontoinhaber || '' %>"
placeholder="Kontoinhaber fehlt"
class="<%= user.kontoinhaber ? '' : 'empty-field' %>">
<label>IBAN</label>
<input name="iban"
value="<%= user.iban || '' %>"
placeholder="IBAN fehlt"
class="<%= user.iban ? '' : 'empty-field' %>">
<label>BIC</label>
<input name="bic"
value="<%= user.bic || '' %>"
placeholder="BIC fehlt"
class="<%= user.bic ? '' : 'empty-field' %>">
<label>Mandatsreferenz</label>
<input name="mandatsreferenz"
value="<%= user.mandatsreferenz || '' %>"
placeholder="Mandatsreferenz fehlt"
class="<%= user.mandatsreferenz ? '' : 'empty-field' %>">
<h3>Status</h3>
<label class="status">
<input type="checkbox"
name="gesperrt"
<%= user.gesperrt ? 'checked' : '' %>>
Mitglied gesperrt
</label>
<button type="submit">💾 Änderungen speichern</button>
</form>
<br>
<a href="/users/list">⬅ Zurück zur Mitgliederübersicht</a>
<%- include('partials/footer') %>
</div>
</body>
</html>

View File

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>PlusFit24 Fehler</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="site-header"><div class="header-inner"><div class="logo">Plusfit<span>24</span></div></div></header>
<main class="success-main">
<div class="success-card">
<div class="success-icon">⚠️</div>
<h1>Fehler</h1>
<p><%= message %></p>
<a href="/" class="btn btn-primary">Zurück zur Startseite</a>
</div>
</main>
</body>
</html>

View File

@ -1,76 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Tarif Wählen</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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>
<header class="site-header">
<div class="header-inner">
<div class="logo">Plusfit<span>24</span></div>
</div>
</header>
<main>
<!-- Progress bar (step 1) -->
<nav class="progress-nav">
<span class="step active">TARIF</span>
<span class="step-dot active"></span>
<span class="step">4</span>
<span class="step-dot"></span>
<span class="step">ANSCHRIFT</span>
<span class="step-dot"></span>
<span class="step">LASTSCHRIFT</span>
<span class="step-dot"></span>
<span class="step">ABSCHLUSS</span>
</nav>
<section class="tarif-section">
<h1 class="page-title">Tarif Wählen</h1>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<div class="tarif-grid">
<% tariffs.forEach(tariff => { %>
<div class="tarif-card">
<div class="tarif-card-inner">
<div class="tarif-badge">
<span class="tarif-icon">◑</span>
<span><%= tariff.name %></span>
</div>
<h2 class="tarif-name"><%= tariff.name %></h2>
<div class="tarif-feature">
<span class="feature-icon">📦</span>
<span>Startpaket <%= Number(tariff.start_package_price).toFixed(2).replace('.', ',') %>€/einmalig</span>
</div>
<div class="tarif-price">
<span class="price-amount"><%= Number(tariff.price_monthly).toFixed(2).replace('.', ',') %>€</span>
<span class="price-period">/Monat</span>
</div>
<a href="/anmelden/<%= tariff.id %>" class="btn btn-primary btn-full">
Tarif auswählen
</a>
</div>
</div>
<% }) %>
<% if (tariffs.length === 0) { %>
<div class="no-tarifs">
<p>Aktuell sind keine Tarife verfügbar. Bitte versuche es später erneut.</p>
</div>
<% } %>
</div>
</section>
</main>
<footer class="site-footer">
<p>© 2024 PlusFit24 UG · <a href="https://plusfit24.de/datenschutz-2/" target="_blank">Datenschutz</a> · <a href="/public/pdfs/AG_PlusFit24.pdf" target="_blank">AGB</a></p>
</footer>
</body>
</html>

6
views/login.ejs Normal file
View File

@ -0,0 +1,6 @@
<h2>Plusfit Login</h2>
<form method="POST" action="/login">
<input name="username" placeholder="Username" required>
<input name="password" type="password" placeholder="Passwort" required>
<button>Login</button>
</form>

View File

@ -0,0 +1,3 @@
</div>
</body>
</html>

12
views/partials/header.ejs Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Plusfit</title>
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/css/bootstrap.min.css">
</head>
<body class="bg-light">
<div class="container mt-4">

216
views/register.ejs Normal file
View File

@ -0,0 +1,216 @@
<%- include('partials/header') %>
<% if (typeof error !== 'undefined') { %>
<div class="alert alert-danger">
⚠️ <%= error %>
</div>
<% } %>
<h2 class="text-center mb-4">Mitglied werden</h2>
<form method="POST" action="/register/create" class="row g-3">
<!-- ================= -->
<!-- PERSÖNLICHE DATEN -->
<!-- ================= -->
<h4>Persönliche Daten</h4>
<div class="col-md-6">
<input name="vorname" class="form-control" placeholder="Vorname" required>
</div>
<div class="col-md-6">
<input name="nachname" class="form-control" placeholder="Nachname" required>
</div>
<div class="col-md-6">
<input type="date"
name="geburtsdatum"
id="geburtsdatum"
class="form-control"
required>
</div>
<!-- ================= -->
<!-- ADRESSE -->
<!-- ================= -->
<h4 class="mt-4">Adresse</h4>
<div class="col-md-6">
<input name="strasse" class="form-control" placeholder="Straße" required>
</div>
<div class="col-md-2">
<input name="hausnummer" class="form-control" placeholder="Nr." required>
</div>
<div class="col-md-2">
<input name="plz" class="form-control" placeholder="PLZ" required>
</div>
<div class="col-md-4">
<input name="ort" class="form-control" placeholder="Ort" required>
</div>
<div class="col-md-4">
<input name="land" class="form-control" placeholder="Land" value="Deutschland">
</div>
<!-- ================= -->
<!-- KONTAKT -->
<!-- ================= -->
<h4 class="mt-4">Kontakt</h4>
<div class="col-md-4">
<input name="mobil" class="form-control" placeholder="Mobilnummer" required>
</div>
<div class="col-md-4">
<input name="telefon" class="form-control" placeholder="Telefon">
</div>
<div class="col-md-4">
<input type="email" name="email" class="form-control" placeholder="E-Mail" required>
</div>
<!-- ================= -->
<!-- VERTRAG -->
<!-- ================= -->
<h4 class="mt-4">Vertrag</h4>
<div class="col-md-12">
<select name="vertragsvariante" class="form-select" required>
<option value="">Bitte Vertrag auswählen</option>
<% vertragsarten.forEach(v => { %>
<option value="<%= v.id %>"
<%= selectedVertrag == v.id ? 'selected' : '' %>>
<%= v.name %> <%= v.betrag.toFixed(2) %> € / Monat
</option>
<% }) %>
</select>
</div>
<!-- ================= -->
<!-- SEPA -->
<!-- ================= -->
<h4 class="mt-4">SEPA-Lastschrift</h4>
<div class="col-md-6">
<input name="kontoinhaber" class="form-control"
placeholder="Kontoinhaber" required>
</div>
<div class="col-md-6">
<input name="mandatsreferenz" class="form-control"
placeholder="Mandatsreferenz" required>
</div>
<div class="col-md-6">
<input name="iban" class="form-control"
placeholder="IBAN" required>
</div>
<div class="col-md-6">
<input name="bic" class="form-control"
placeholder="BIC" required>
</div>
<!-- ✅ EINZIGE SEPA-ZUSTIMMUNG -->
<div class="col-md-12 form-check mt-2">
<input
type="checkbox"
class="form-check-input"
id="agreeSepa"
name="agreeSepa"
required>
<label class="form-check-label" for="agreeSepa">
Ich erteile ein SEPA-Lastschriftmandat und stimme der Abbuchung zu.
</label>
</div>
<!-- ================= -->
<!-- RECHTLICHES -->
<!-- ================= -->
<hr class="my-4">
<h5>Rechtliches</h5>
<div class="form-check mb-2" id="consentBlock" style="display:none;">
<input class="form-check-input"
type="checkbox"
id="agreeConsent"
name="agreeConsent"
required>
<label class="form-check-label" for="agreeConsent">
Ich habe die
<a href="/documents/Einverstaendniserklaerung.pdf" target="_blank">
Einverständniserklärung
</a>
gelesen.
</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input"
type="checkbox"
id="agreeAgb"
name="agreeAgb"
required>
<label class="form-check-label" for="agreeAgb">
Ich akzeptiere die <a href="https://plusfit24.de/wp-content/uploads/2022/11/AG_PlusFit24.pdf" target="_blank">AGB's</a>
</label>
</div>
<!-- ================= -->
<!-- ABSCHICKEN -->
<!-- ================= -->
<div class="col-12 mt-4">
<button type="submit" class="btn btn-success btn-lg w-100">
💳 Kostenpflichtig verbindlich abschließen
</button>
</div>
</form>
<script>
document.addEventListener("DOMContentLoaded", function() {
const birthInput = document.getElementById("geburtsdatum");
const consentBlock = document.getElementById("consentBlock");
const consentCheckbox = document.getElementById("agreeConsent");
function checkAge() {
if (!birthInput.value) {
consentBlock.style.display = "none";
consentCheckbox.required = false;
consentCheckbox.checked = false;
return;
}
const birthDate = new Date(birthInput.value);
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
if (age < 18) {
consentBlock.style.display = "block";
consentCheckbox.required = true;
} else {
consentBlock.style.display = "none";
consentCheckbox.required = false;
consentCheckbox.checked = false;
}
}
birthInput.addEventListener("change", checkAge);
// 🔥 WICHTIG: Beim Laden direkt prüfen
checkAge();
});
</script>
<%- include('partials/footer') %>

17
views/registerSuccess.ejs Normal file
View File

@ -0,0 +1,17 @@
<%- include('partials/header') %>
<div class="text-center">
<h2>🎉 Registrierung erfolgreich</h2>
<p class="mt-3">
Deine Vertragsnummer lautet:
</p>
<h4 class="text-primary">
<%= vertragsnummer %>
</h4>
<p class="mt-4 text-muted">
Wir melden uns zeitnah bei dir.
</p>
</div>
<%- include('partials/footer') %>

View File

@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="de"><head><meta charset="UTF-8">
<title>PlusFit24 Link abgelaufen</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="site-header"><div class="header-inner"><div class="logo">Plusfit<span>24</span></div></div></header>
<main class="success-main">
<div class="success-card">
<div class="success-icon">⏰</div>
<h1>Link abgelaufen</h1>
<p>Dieser Verlängerungslink ist nicht mehr gültig.</p>
<p class="success-sub">Bitte kontaktiere uns direkt im Studio oder ruf uns an.</p>
<a href="/" class="btn btn-primary">Zur Startseite</a>
</div>
</main>
</body></html>

View File

@ -1,70 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Mitgliedschaft verlängern</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>
<header class="site-header">
<div class="header-inner">
<div class="logo">Plusfit<span>24</span></div>
</div>
</header>
<main class="signup-main">
<div class="signup-container" style="max-width:680px">
<h2 class="step-title bold" style="margin-bottom:8px">Mitgliedschaft verlängern</h2>
<p class="step-subtitle">Hallo <%= request.first_name %>! Wähle deinen neuen Tarif.</p>
<% if (success) { %>
<div class="alert alert-success" style="margin-bottom:24px"><%= success %></div>
<a href="/" class="btn btn-primary">Zurück zur Startseite</a>
<% } else { %>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<form method="POST" action="/renew/<%= request.token %>">
<div class="tarif-grid" style="margin-bottom:24px">
<% tariffs.forEach(tariff => { %>
<label class="tarif-card renewal-tarif-card">
<input type="radio" name="tariff_id" value="<%= tariff.id %>" required
style="display:none" class="tarif-radio">
<div class="tarif-card-inner">
<div class="tarif-badge">
<span class="tarif-icon">◑</span>
<span><%= tariff.name %></span>
</div>
<h2 class="tarif-name"><%= tariff.name %></h2>
<div class="tarif-feature">
<span class="feature-icon">📦</span>
<span>Startpaket <%= Number(tariff.start_package_price).toFixed(2).replace('.', ',') %>€ einmalig</span>
</div>
<div class="tarif-price">
<span class="price-amount"><%= Number(tariff.price_monthly).toFixed(2).replace('.', ',') %>€</span>
<span class="price-period">/Monat</span>
</div>
</div>
</label>
<% }) %>
</div>
<button type="submit" class="btn btn-primary btn-full">Mitgliedschaft verlängern →</button>
</form>
<% } %>
</div>
</main>
<footer class="site-footer">
<p>© 2024 PlusFit24 UG</p>
</footer>
<script>
// Tarif-Karte anklicken → Radio aktivieren
document.querySelectorAll('.renewal-tarif-card').forEach(card => {
card.addEventListener('click', () => {
document.querySelectorAll('.renewal-tarif-card').forEach(c => c.style.borderColor = '');
card.style.borderColor = '#2d2dcc';
card.querySelector('input').checked = true;
});
});
</script>
</body>
</html>

View File

@ -1,481 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Mitgliedschaft abschließen</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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>
<header class="site-header">
<div class="header-inner">
<div class="logo">Plusfit<span>24</span></div>
</div>
</header>
<main class="signup-main">
<!-- Fortschrittsleiste -->
<nav class="progress-nav" id="progressNav">
<span class="step" data-step="0">TARIF</span>
<span class="step-dot"></span>
<span class="step active" data-step="1">4</span>
<span class="step-dot"></span>
<span class="step" data-step="2">ANSCHRIFT</span>
<span class="step-dot"></span>
<span class="step" data-step="3">LASTSCHRIFT</span>
<span class="step-dot"></span>
<span class="step" data-step="4">ABSCHLUSS</span>
</nav>
<div class="signup-container">
<!-- ===== SCHRITT 1: Persönliche Daten ===== -->
<div class="form-step active" id="step1">
<h2 class="step-title italic">Geburtsdatum</h2>
<div class="salutation-group">
<button type="button" class="sal-btn" data-value="Herr" onclick="selectSalutation(this)">Herr</button>
<button type="button" class="sal-btn" data-value="Frau" onclick="selectSalutation(this)">Frau</button>
<button type="button" class="sal-btn" data-value="Keine Angabe" onclick="selectSalutation(this)">Keine Angabe</button>
</div>
<div class="form-group">
<label>Titel</label>
<div class="input-wrap">
<span class="input-icon">👤</span>
<input type="text" id="title" placeholder="Dein Titel">
</div>
</div>
<div class="form-group">
<label>Vorname <span class="req">*</span></label>
<div class="input-wrap">
<span class="input-icon">👤</span>
<input type="text" id="first_name" placeholder="Dein Vorname" required>
</div>
</div>
<div class="form-group">
<label>Nachname <span class="req">*</span></label>
<div class="input-wrap">
<span class="input-icon">👤</span>
<input type="text" id="last_name" placeholder="Dein Nachname" required>
</div>
</div>
<div class="form-group">
<label>Geburtsdatum <span class="req">*</span></label>
<div class="input-wrap">
<span class="input-icon">📅</span>
<input type="date" id="birth_date" placeholder="TT.mm.jjjj" required>
</div>
</div>
<div class="form-group">
<label>Email <span class="req">*</span></label>
<div class="input-wrap">
<span class="input-icon">✉️</span>
<input type="email" id="email" placeholder="Deine Email" required>
<span class="email-status" id="emailStatus"></span>
</div>
<div class="email-message" id="emailMessage"></div>
</div>
<div class="form-group">
<label>Telefon</label>
<div class="input-wrap">
<span class="input-icon">📞</span>
<input type="tel" id="phone" placeholder="Deine Telefon-/ Handynummer">
</div>
</div>
<div class="minor-warning hidden" id="minorWarning">
<div class="warning-box">
<strong>⚠️ Hinweis für Minderjährige</strong>
<p>Da du noch nicht 18 Jahre alt bist, benötigst du die Einverständniserklärung deiner Erziehungsberechtigten. Du wirst am Ende aufgefordert, diese zu bestätigen.</p>
</div>
</div>
<div class="step-buttons">
<a href="/" class="btn btn-outline">Zurück</a>
<button type="button" class="btn btn-primary" onclick="validateStep1()">Weiter</button>
</div>
</div>
<!-- ===== SCHRITT 2: Anschrift ===== -->
<div class="form-step" id="step2">
<h2 class="step-title bold">Anschrift</h2>
<p class="step-subtitle italic">Wo wohnst du?</p>
<div class="form-group">
<label>Deine Adresse <span class="req">*</span></label>
<div class="input-wrap">
<span class="input-icon">📍</span>
<input type="text" id="street" placeholder="Straße, Hausnummer" required>
</div>
</div>
<div class="form-group">
<div class="input-wrap">
<span class="input-icon">📍</span>
<input type="text" id="address_addition" placeholder="Adress-Zusatz">
</div>
</div>
<div class="form-group">
<div class="input-wrap">
<span class="input-icon">📍</span>
<input type="text" id="zip" placeholder="PLZ" maxlength="5" required>
</div>
</div>
<div class="form-group">
<div class="input-wrap">
<span class="input-icon">📍</span>
<input type="text" id="city" placeholder="Ort" required>
</div>
</div>
<div class="step-buttons">
<button type="button" class="btn btn-outline" onclick="goToStep(1)">Zurück</button>
<button type="button" class="btn btn-primary" onclick="validateStep2()">Weiter</button>
</div>
</div>
<!-- ===== SCHRITT 3: Lastschrift ===== -->
<div class="form-step" id="step3">
<h2 class="step-title bold">Lastschrift</h2>
<p class="step-subtitle italic">Damit wir deinen Mitgliedsbeitrag abbuchen können</p>
<div class="sepa-mandate">
<label class="checkbox-label">
<input type="checkbox" id="sepa_accepted">
<span class="checkbox-custom"></span>
<span>Ich ermächtige PlusFit24 UG - NL: Moosleiten 12 84089 Aiglsbach, Zahlungen von meinem Konto mittels Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die von PlusFit24 UG - NL: Moosleiten 12 84089 Aiglsbach auf mein Konto gezogenen Lastschriften einzulösen. Hinweis: Ich kann innerhalb von acht Wochen, beginnend mit dem Belastungsdatum, die Erstattung des belasteten Betrages verlangen. Es gelten dabei die mit meinem Kreditinstitut vereinbarten Bedingungen. Gläubiger-Identifikationsnummer: DE1200100002549495</span>
</label>
</div>
<label class="form-label-section">Sepa Lastschriftmandat</label>
<div class="form-group">
<div class="input-wrap">
<span class="input-icon">🏦</span>
<input type="text" id="bank_name" placeholder="Geldinstitut">
</div>
</div>
<div class="form-group">
<div class="input-wrap">
<span class="input-icon">👤</span>
<input type="text" id="account_holder" placeholder="Name des Kontoinhabers">
</div>
</div>
<div class="form-group">
<div class="input-wrap">
<span class="input-icon">💳</span>
<input type="text" id="iban" placeholder="DE00 0000 0000 0000 0000 00" maxlength="34" autocomplete="off">
<span class="email-status" id="ibanStatus"></span>
</div>
<div class="email-message" id="ibanMessage"></div>
</div>
<div class="step-buttons">
<button type="button" class="btn btn-outline" onclick="goToStep(2)">Zurück</button>
<button type="button" class="btn btn-primary" onclick="validateStep3()">Weiter</button>
</div>
</div>
<!-- ===== SCHRITT 4: Abschluss ===== -->
<div class="form-step" id="step4">
<h2 class="step-title bold">Abschluss</h2>
<p class="step-subtitle italic">Alles auf einen Blick</p>
<div class="abschluss-grid">
<!-- Tarif Übersicht -->
<div class="tarif-summary-card">
<div class="summary-badge">◑ Wir freuen uns auf dich!</div>
<h3>Dein Wunsch-Tarif</h3>
<div class="summary-feature">
<span class="feature-icon">📦</span>
<span>Laufzeit (Monate): <%= tariff.duration_months %></span>
</div>
<div class="summary-feature">
<span class="feature-icon">📦</span>
<span>Tarif: <%= tariff.name %></span>
</div>
<div class="summary-feature">
<span class="feature-icon">📦</span>
<span>Zusätzlich: Startpaket <%= Number(tariff.start_package_price).toFixed(2).replace('.', ',') %>€ einmalig</span>
</div>
<div class="summary-price">
<span class="price-big"><%= Number(tariff.price_monthly).toFixed(2).replace('.', ',') %>€</span>
<span class="price-period">/Monat</span>
</div>
</div>
<!-- Letzter Schritt -->
<div class="final-checks">
<h3>Letzter Schritt</h3>
<div class="doc-buttons">
<a href="https://plusfit24.de/wp-content/uploads/2022/11/AG_PlusFit24.pdf" target="_blank" class="doc-btn">Widerrufsbelehrung</a>
<a href="https://plusfit24.de/datenschutz-2/" target="_blank" class="doc-btn">Datenschutz</a>
<a href="https://plusfit24.de/wp-content/uploads/2022/11/AG_PlusFit24.pdf" target="_blank" class="doc-btn">AGB</a>
</div>
<label class="checkbox-label">
<input type="checkbox" id="agb_accepted">
<span class="checkbox-custom"></span>
<span>Ich habe die AGBs und Widerrufsbelehrung gelesen und akzeptiert</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="datenschutz_accepted">
<span class="checkbox-custom"></span>
<span>Ich akzeptiere die Erhebung und Verarbeitung meiner eingegebenen Daten</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="data_correct">
<span class="checkbox-custom"></span>
<span>Ich bestätige die Richtigkeit meiner Angaben</span>
</label>
<!-- Einverständniserklärung für Minderjährige -->
<div id="guardianSection" class="hidden">
<div class="minor-info-box">
<strong>Einverständniserklärung der Erziehungsberechtigten</strong>
<p>Da du noch nicht 18 Jahre alt bist, muss ein Erziehungsberechtigter zustimmen.</p>
<a href="/pdfs/Einverstaendniserklaerung.pdf" target="_blank" class="doc-btn">📄 Einverständniserklärung öffnen</a>
</div>
<label class="checkbox-label">
<input type="checkbox" id="guardian_consent">
<span class="checkbox-custom"></span>
<span>Die Einverständniserklärung der Erziehungsberechtigten liegt vor und wurde unterschrieben.</span>
</label>
</div>
</div>
</div>
<div class="submit-section">
<p class="submit-notice"><strong>Schließe deinen Mitgliedschaftsvertrag mit einem Klick auf den untenstehenden Button verbindlich ab.</strong></p>
<div id="submitError" class="alert alert-error hidden"></div>
<button type="button" class="btn btn-submit" id="submitBtn" onclick="submitForm()">
Kostenpflichtige Mitgliedschaft abschließen
</button>
<p class="ssl-notice">🔒 Deine Daten werden ausschließlich verschlüsselt übermittelt.</p>
</div>
<div class="step-buttons">
<button type="button" class="btn btn-outline" onclick="goToStep(3)">Zurück</button>
</div>
</div>
</div>
</main>
<footer class="site-footer">
<p>© 2024 PlusFit24 UG · <a href="https://plusfit24.de/datenschutz-2/" target="_blank">Datenschutz</a> · <a href="https://plusfit24.de/wp-content/uploads/2022/11/AG_PlusFit24.pdf" target="_blank">AGB</a></p>
</footer>
<script src="/js/iban.js"></script>
<script>
const TARIFF_ID = '<%= tariff.id %>';
let currentStep = 1;
let salutation = '';
let emailVerified = false;
let emailVerifyTimer = null;
let isMinor = false;
// Anrede auswählen
function selectSalutation(btn) {
document.querySelectorAll('.sal-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
salutation = btn.dataset.value;
}
// Schritt wechseln
function goToStep(step) {
document.querySelectorAll('.form-step').forEach(s => s.classList.remove('active'));
document.getElementById('step' + step).classList.add('active');
// Progress nav aktualisieren
const steps = document.querySelectorAll('.progress-nav .step');
steps.forEach((s, i) => {
s.classList.toggle('active', i === step);
s.classList.toggle('done', i < step);
});
currentStep = step;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// E-Mail Verifizierung
document.getElementById('email').addEventListener('input', function() {
clearTimeout(emailVerifyTimer);
emailVerified = false;
document.getElementById('emailStatus').textContent = '';
document.getElementById('emailMessage').textContent = '';
const email = this.value.trim();
if (email.includes('@') && email.includes('.')) {
document.getElementById('emailStatus').textContent = '⏳';
emailVerifyTimer = setTimeout(() => verifyEmail(email), 800);
}
});
async function verifyEmail(email) {
try {
const res = await fetch('/api/verify-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const data = await res.json();
if (data.valid) {
emailVerified = true;
document.getElementById('emailStatus').textContent = '✅';
document.getElementById('emailMessage').textContent = '';
document.getElementById('emailMessage').className = 'email-message';
} else {
emailVerified = false;
document.getElementById('emailStatus').textContent = '❌';
document.getElementById('emailMessage').textContent = data.reason;
document.getElementById('emailMessage').className = 'email-message error';
}
} catch (e) {
document.getElementById('emailStatus').textContent = '⚠️';
}
}
// Geburtsdatum → Minderjähriger prüfen
document.getElementById('birth_date').addEventListener('change', function() {
const bd = new Date(this.value);
const today = new Date();
let age = today.getFullYear() - bd.getFullYear();
const m = today.getMonth() - bd.getMonth();
if (m < 0 || (m === 0 && today.getDate() < bd.getDate())) age--;
isMinor = age < 18;
document.getElementById('minorWarning').classList.toggle('hidden', !isMinor);
});
// Schritt 1 validieren
async function validateStep1() {
const firstName = document.getElementById('first_name').value.trim();
const lastName = document.getElementById('last_name').value.trim();
const birthDate = document.getElementById('birth_date').value;
const email = document.getElementById('email').value.trim();
if (!firstName || !lastName) return alert('Bitte Vor- und Nachname eingeben.');
if (!birthDate) return alert('Bitte Geburtsdatum eingeben.');
if (!email) return alert('Bitte E-Mail-Adresse eingeben.');
// Alter prüfen
const bd = new Date(birthDate);
const today = new Date();
let age = today.getFullYear() - bd.getFullYear();
const m = today.getMonth() - bd.getMonth();
if (m < 0 || (m === 0 && today.getDate() < bd.getDate())) age--;
if (age < 14) return alert('Das Mindestalter für eine Mitgliedschaft beträgt 14 Jahre.');
// E-Mail verifizieren falls noch nicht geschehen
if (!emailVerified) {
document.getElementById('emailStatus').textContent = '⏳';
await verifyEmail(email);
if (!emailVerified) return alert('Bitte eine gültige E-Mail-Adresse eingeben.');
}
goToStep(2);
}
// Schritt 2 validieren
function validateStep2() {
const street = document.getElementById('street').value.trim();
const zip = document.getElementById('zip').value.trim();
const city = document.getElementById('city').value.trim();
if (!street || !zip || !city) return alert('Bitte Straße, PLZ und Ort ausfüllen.');
goToStep(3);
}
// Schritt 3 validieren
function validateStep3() {
// Sepa ist optional aber Checkbox sollte gecheckt sein wenn Daten eingegeben
// Zeige Minderjährigen-Abschnitt im Abschluss
if (isMinor) {
document.getElementById('guardianSection').classList.remove('hidden');
}
goToStep(4);
}
// Formular absenden
async function submitForm() {
if (!document.getElementById('agb_accepted').checked) return alert('Bitte AGBs und Widerrufsbelehrung akzeptieren.');
if (!document.getElementById('datenschutz_accepted').checked) return alert('Bitte Datenschutzerklärung akzeptieren.');
if (!document.getElementById('data_correct').checked) return alert('Bitte die Richtigkeit Ihrer Angaben bestätigen.');
if (isMinor && !document.getElementById('guardian_consent').checked) {
return alert('Bei Minderjährigen ist die Einverständniserklärung der Erziehungsberechtigten erforderlich.');
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = 'Wird gesendet...';
const errorDiv = document.getElementById('submitError');
errorDiv.classList.add('hidden');
const payload = {
tariff_id: TARIFF_ID,
salutation: salutation,
title: document.getElementById('title').value.trim(),
first_name: document.getElementById('first_name').value.trim(),
last_name: document.getElementById('last_name').value.trim(),
birth_date: document.getElementById('birth_date').value,
email: document.getElementById('email').value.trim(),
phone: document.getElementById('phone').value.trim(),
street: document.getElementById('street').value.trim(),
address_addition: document.getElementById('address_addition').value.trim(),
zip: document.getElementById('zip').value.trim(),
city: document.getElementById('city').value.trim(),
bank_name: document.getElementById('bank_name').value.trim(),
account_holder: document.getElementById('account_holder').value.trim(),
iban: document.getElementById('iban').value.trim().replace(/\s/g, ''),
sepa_accepted: document.getElementById('sepa_accepted').checked,
agb_accepted: document.getElementById('agb_accepted').checked,
datenschutz_accepted: document.getElementById('datenschutz_accepted').checked,
data_correct: document.getElementById('data_correct').checked,
guardian_consent: isMinor ? document.getElementById('guardian_consent').checked : false
};
try {
const res = await fetch('/api/submit-membership', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success) {
window.location.href = data.pending ? '/bestaetigung-ausstehend?email=' + encodeURIComponent(document.getElementById('email').value) : '/erfolg';
} else {
errorDiv.textContent = data.error || 'Fehler beim Absenden.';
errorDiv.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Kostenpflichtige Mitgliedschaft abschließen';
}
} catch (e) {
errorDiv.textContent = 'Netzwerkfehler. Bitte versuche es erneut.';
errorDiv.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Kostenpflichtige Mitgliedschaft abschließen';
}
}
// IBAN Validierung (iban.js wird vor diesem Script geladen)
const ibanInput = document.getElementById('iban');
if (ibanInput) {
attachIBANValidation(ibanInput, document.getElementById('ibanStatus'), document.getElementById('ibanMessage'));
}
</script>
</html>

View File

@ -1,30 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Anmeldung erfolgreich</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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>
<header class="site-header">
<div class="header-inner">
<div class="logo">Plusfit<span>24</span></div>
</div>
</header>
<main class="success-main">
<div class="success-card">
<div class="success-icon">✅</div>
<h1>Herzlich Willkommen!</h1>
<p>Deine Mitgliedschaft wurde erfolgreich abgeschlossen. Wir freuen uns auf dich!</p>
<p class="success-sub">Du erhältst in Kürze eine Bestätigung.</p>
<a href="/" class="btn btn-primary">Zurück zur Startseite</a>
</div>
</main>
<footer class="site-footer">
<p>© 2024 PlusFit24 UG</p>
</footer>
</body>
</html>

183
views/userList.ejs Normal file
View File

@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Plusfit Mitgliederübersicht</title>
<style>
body {
font-family: Arial, sans-serif;
background: #f4f6f8;
}
.container {
max-width: 98%;
margin: 15px auto;
background: #fff;
padding: 15px;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
}
h2 {
margin-top: 0;
}
/* 🔍 Suche */
.search-box {
margin-bottom: 10px;
}
.search-box input {
padding: 6px;
width: 250px;
}
/* 📊 Tabelle */
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
border: 1px solid #ccc;
padding: 6px;
vertical-align: top;
}
th {
background: #e9ecef;
position: sticky;
top: 0;
z-index: 2;
}
/* 🔶 Leere Felder */
.empty {
background-color: #fff8e1;
color: #8a6d00;
font-style: italic;
}
/* 🔒 Gesperrte User */
.locked {
background-color: #ffe5e5;
}
/* Aktionen */
.actions a {
margin-right: 6px;
text-decoration: none;
}
.actions a:hover {
text-decoration: underline;
}
/* Scrollbar */
.table-wrapper {
overflow-x: auto;
}
</style>
</head>
<body>
<div class="container">
<h2>Mitgliederübersicht</h2>
<div class="search-box">
<form method="GET">
<input
type="text"
name="q"
placeholder="Suche Name, Ort, E-Mail"
value="<%= search || '' %>"
>
<button>Suchen</button>
<a href="/users/create"> Neu</a>
<a href="/users/dashboard">🏠 Dashboard</a>
</form>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>Vertragsnr.</th>
<th>Variante</th>
<th>Name</th>
<th>Geburtsdatum</th>
<th>Straße</th>
<th>Nr</th>
<th>PLZ</th>
<th>Ort</th>
<th>Land</th>
<th>Mobil</th>
<th>Telefon</th>
<th>E-Mail</th>
<th>Kontoinhaber</th>
<th>IBAN</th>
<th>BIC</th>
<th>Mandat</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<% if (users.length === 0) { %>
<tr>
<td colspan="16">Keine Mitglieder gefunden</td>
</tr>
<% } %>
<% users.forEach(u => { %>
<tr class="<%= u.gesperrt ? 'locked' : '' %>">
<td><%= u.id %></td>
<td><%= u.vertragsnummer || '—' %></td>
<td class="<%= u.vertragsvariante ? '' : 'empty' %>"><%= u.vertragsvariante || 'fehlt' %></td>
<td><%= u.vorname %> <%= u.nachname %></td>
<td class="<%= !u.geburtsdatum ? 'empty-field' : '' %>"><%= u.geburtsdatum || '— fehlt —' %></td>
<td class="<%= u.strasse ? '' : 'empty' %>"><%= u.strasse || 'fehlt' %></td>
<td class="<%= u.hausnummer ? '' : 'empty' %>"><%= u.hausnummer || 'fehlt' %></td>
<td class="<%= u.plz ? '' : 'empty' %>"><%= u.plz || 'fehlt' %></td>
<td class="<%= u.ort ? '' : 'empty' %>"><%= u.ort || 'fehlt' %></td>
<td class="<%= u.land ? '' : 'empty' %>"><%= u.land || 'fehlt' %></td>
<td class="<%= u.mobil ? '' : 'empty' %>"><%= u.mobil || 'fehlt' %></td>
<td class="<%= u.telefon ? '' : 'empty' %>"><%= u.telefon || 'fehlt' %></td>
<td class="<%= u.email ? '' : 'empty' %>"><%= u.email || 'fehlt' %></td>
<td class="<%= u.kontoinhaber ? '' : 'empty' %>"><%= u.kontoinhaber || 'fehlt' %></td>
<td class="<%= u.iban ? '' : 'empty' %>"><%= u.iban || 'fehlt' %></td>
<td class="<%= u.bic ? '' : 'empty' %>"><%= u.bic || 'fehlt' %></td>
<td class="<%= u.mandatsreferenz ? '' : 'empty' %>"><%= u.mandatsreferenz || 'fehlt' %></td>
<td>
<%= u.gesperrt ? '🔒 gesperrt' : '🟢 aktiv' %>
</td>
<td class="actions">
<a href="/users/edit/<%= u.id %>">✏️</a>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</body>
</html>

28
views/widerruf.ejs Normal file
View File

@ -0,0 +1,28 @@
<%- include('partials/header') %>
<h2>Vertrag widerrufen</h2>
<p class="text-muted">
Bitte gib deine Vertragsnummer ein, um den Vertrag zu widerrufen.
</p>
<% if (error) { %>
<div class="alert alert-danger">
<%= error %>
</div>
<% } %>
<form method="POST">
<input
type="text"
name="vertragsnummer"
class="form-control mb-3"
placeholder="Vertragsnummer"
required>
<button class="btn btn-danger">
Vertrag widerrufen
</button>
</form>
<%- include('partials/footer') %>

15
views/widerrufErfolg.ejs Normal file
View File

@ -0,0 +1,15 @@
<%- include('partials/header') %>
<h2>Widerruf bestätigt</h2>
<p>
Der Vertrag mit der Nummer
<strong><%= vertragsnummer %></strong>
wurde erfolgreich widerrufen.
</p>
<p class="text-muted">
Eine Bestätigung wurde dir per E-Mail zugesendet.
</p>
<%- include('partials/footer') %>