Compare commits
No commits in common. "main" and "master" have entirely different histories.
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.git
|
||||
deploy.log
|
||||
5
.env
Normal file
5
.env
Normal 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
5
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
deploy.log
|
||||
documents/
|
||||
npm-debug.log
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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
325
README.md
@ -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
197
app.js
@ -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");
|
||||
});
|
||||
|
||||
@ -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;
|
||||
@ -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
18
createAdmin.js
Normal 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');
|
||||
})();
|
||||
31
database/update_v5_vertragsarten.js
Normal file
31
database/update_v5_vertragsarten.js
Normal 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
5
debugUsers.js
Normal 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
11
docker-compose.yml
Normal file
@ -0,0 +1,11 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
plusfit24:
|
||||
build: .
|
||||
container_name: plusfit24
|
||||
restart: always
|
||||
ports:
|
||||
- "3005:3005"
|
||||
env_file:
|
||||
- .env
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -1,8 +0,0 @@
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.session && req.session.adminId) {
|
||||
return next();
|
||||
}
|
||||
res.redirect('/admin/login');
|
||||
}
|
||||
|
||||
module.exports = { requireAdmin };
|
||||
6
middleware/authMiddleware.js
Normal file
6
middleware/authMiddleware.js
Normal 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
2496
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@ -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
BIN
plusfit.db
Normal file
Binary file not shown.
0
plusfit.db.js
Normal file
0
plusfit.db.js
Normal file
BIN
plusfit.db.zip
Normal file
BIN
plusfit.db.zip
Normal file
Binary file not shown.
BIN
plusfit_backup.db
Normal file
BIN
plusfit_backup.db
Normal file
Binary file not shown.
83
plusfit_dump.sql
Normal file
83
plusfit_dump.sql
Normal 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
BIN
plusfit_dump.zip
Normal file
Binary file not shown.
0
plusfit_mysql.sqlcd
Normal file
0
plusfit_mysql.sqlcd
Normal file
BIN
public/AG_PlusFit24.pdf
Normal 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
6
public/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1581
public/css/style.css
1581
public/css/style.css
File diff suppressed because it is too large
Load Diff
Binary file not shown.
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/fonts/inter/Inter-Bold.woff2
Normal file
BIN
public/fonts/inter/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter/Inter-Regular.woff2
Normal file
BIN
public/fonts/inter/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter/Inter-SemiBold.woff2
Normal file
BIN
public/fonts/inter/Inter-SemiBold.woff2
Normal file
Binary file not shown.
BIN
public/images/logo.png
Normal file
BIN
public/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
366
routes/admin.js
366
routes/admin.js
@ -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;
|
||||
239
routes/api.js
239
routes/api.js
@ -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
32
routes/auth.js
Normal 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;
|
||||
@ -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
38
routes/company.js
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
296
routes/register.js
Normal 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;
|
||||
@ -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
67
routes/sepa.js
Normal 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
126
routes/sepaExport.js
Normal 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
173
routes/users.js
Normal 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
60
routes/widerruf.js
Normal 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;
|
||||
210
setup-kunde.sh
210
setup-kunde.sh
@ -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
204
utils/contractPdf.js
Normal 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
58
utils/crypto.js
Normal 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
50
utils/sendAdminMail.js
Normal 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
102
utils/sendContractMail.js
Normal 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
25
utils/sepaValidator.js
Normal 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
28
utils/vertragsnummer.js
Normal 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;
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
114
views/company.ejs
Normal 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') %>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
170
views/contracts.ejs
Normal 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
61
views/contractsSelect.ejs
Normal 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
44
views/createUser.ejs
Normal 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
38
views/dashboard.ejs
Normal 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
226
views/editUser.ejs
Normal 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>
|
||||
@ -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>
|
||||
@ -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
6
views/login.ejs
Normal 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>
|
||||
3
views/partials/footer.ejs
Normal file
3
views/partials/footer.ejs
Normal file
@ -0,0 +1,3 @@
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
12
views/partials/header.ejs
Normal file
12
views/partials/header.ejs
Normal 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
216
views/register.ejs
Normal 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
17
views/registerSuccess.ejs
Normal 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') %>
|
||||
@ -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>
|
||||
@ -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>
|
||||
481
views/signup.ejs
481
views/signup.ejs
@ -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>
|
||||
@ -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
183
views/userList.ejs
Normal 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
28
views/widerruf.ejs
Normal 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
15
views/widerrufErfolg.ejs
Normal 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') %>
|
||||
Loading…
Reference in New Issue
Block a user