Email versnad

This commit is contained in:
cay 2026-03-28 09:00:44 +00:00
parent 8c38fd2b5d
commit 5fd8f549bb
8 changed files with 440 additions and 4 deletions

2
app.js
View File

@ -37,6 +37,7 @@ 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);
@ -46,6 +47,7 @@ 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) => {

View File

@ -1450,3 +1450,51 @@ body:not(.admin-body) > * {
background: #f0f0ff;
border-color: var(--primary);
}
/* ================================================
MAILING
================================================ */
.mailing-layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: 16px;
align-items: start;
}
@media (max-width: 900px) { .mailing-layout { grid-template-columns: 1fr; } }
.mail-textarea {
width: 100%;
font-family: 'Outfit', sans-serif;
font-size: 0.92rem;
resize: vertical;
min-height: 200px;
}
.mail-preview-hint {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 6px;
}
.mail-recipients { max-height: 500px; overflow-y: auto; }
.recipients-list { margin-top: 12px; }
.recipient-row {
display: flex;
flex-direction: column;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.recipient-row:last-child { border-bottom: none; }
.recipient-name { font-weight: 600; }
.recipient-email { color: var(--text-muted); font-size: 0.8rem; }
.mail-type-badge {
font-size: 0.78rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
background: var(--bg);
}
.mail-type-bulk { background: #ede9fe; color: #5b21b6; }
.mail-type-direct { background: #dbeafe; color: #1e40af; }
.mail-type-renewal { background: #dcfce7; color: var(--success); }
.mail-type-renewal_auto { background: #fef3c7; color: var(--warning); }

164
routes/mailing.js Normal file
View File

@ -0,0 +1,164 @@
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 id, first_name, last_name, email, 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.*, 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;

View File

@ -19,7 +19,8 @@
<a href="/admin/contracts" class="nav-link">📑 Verträge</a>
<a href="/admin/billing" class="nav-link active">💶 Abrechnung</a>
<a href="/admin/finance" class="nav-link">📊 Finanzen</a>
<a href="/admin#einstellungen" class="nav-link">⚙️ Einstellungen</a>
<a href="/admin/mailing" class="nav-link">📧 Mailing</a>
<a href="/admin#einstellungen" class="nav-link">⚙️ Einstellungen</a>
</nav>
<div class="sidebar-footer">
<span>👤 <%= admin %></span>

View File

@ -20,7 +20,8 @@
<a href="/admin/contracts" class="nav-link active">📑 Verträge</a>
<a href="/admin/billing" class="nav-link">💶 Abrechnung</a>
<a href="/admin/finance" class="nav-link">📊 Finanzen</a>
<a href="/admin#einstellungen" class="nav-link">⚙️ Einstellungen</a>
<a href="/admin/mailing" class="nav-link">📧 Mailing</a>
<a href="/admin#einstellungen" class="nav-link">⚙️ Einstellungen</a>
</nav>
<div class="sidebar-footer">
<span>👤 <%= admin %></span>

View File

@ -20,7 +20,8 @@
<a href="/admin/contracts" class="nav-link">📑 Verträge</a>
<a href="/admin/billing" class="nav-link">💶 Abrechnung</a>
<a href="/admin/finance" class="nav-link active">📊 Finanzen</a>
<a href="/admin#einstellungen" class="nav-link">⚙️ Einstellungen</a>
<a href="/admin/mailing" class="nav-link">📧 Mailing</a>
<a href="/admin#einstellungen" class="nav-link">⚙️ Einstellungen</a>
</nav>
<div class="sidebar-footer">
<span>👤 <%= admin %></span>

187
views/admin/mailing.ejs Normal file
View File

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

View File

@ -19,7 +19,8 @@
<a href="/admin/contracts" class="nav-link">📑 Verträge</a>
<a href="/admin/billing" class="nav-link">💶 Abrechnung</a>
<a href="/admin/finance" class="nav-link">📊 Finanzen</a>
<a href="/admin#einstellungen" class="nav-link">⚙️ Einstellungen</a>
<a href="/admin/mailing" class="nav-link">📧 Mailing</a>
<a href="/admin#einstellungen" class="nav-link">⚙️ Einstellungen</a>
</nav>
<div class="sidebar-footer">
<span>👤 <%= admin %></span>
@ -47,6 +48,7 @@
<% } %>
</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>
@ -485,6 +487,36 @@
</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)');