Feature: E-Mail-Betreff und -Text pro Kunde statt global

- Felder mail_subject_template / mail_body_template in customers-Tabelle
- Kundenformular um E-Mail-Vorlage-Abschnitt erweitert
- EmailService verwendet Kundenvorlage mit Fallback auf Standardtext
- Globale E-Mail-Text-Einstellung in Einstellungen entfernt
- Migration 082: neue Spalten in customers
This commit is contained in:
fds2610-bot 2026-05-30 14:22:57 +02:00
parent 3fe5015027
commit af83ae8592
8 changed files with 49 additions and 102 deletions

View file

@ -0,0 +1,6 @@
-- 082_customer_mail_templates.sql
-- E-Mail-Betreff und -Text pro Kunde statt global in company_settings.
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS mail_subject_template VARCHAR(255) NULL DEFAULT NULL AFTER notes,
ADD COLUMN IF NOT EXISTS mail_body_template TEXT NULL DEFAULT NULL AFTER mail_subject_template;

View file

@ -161,7 +161,6 @@ $router->get('/tax/euer/{year}/anlageliste/pdf', fn($p) => (new EuerController()
// ── Einstellungen ──────────────────────────────────────────── // ── Einstellungen ────────────────────────────────────────────
$router->get('/settings', fn($p) => (new SettingsController())->show($p)); $router->get('/settings', fn($p) => (new SettingsController())->show($p));
$router->post('/settings', fn($p) => (new SettingsController())->update($p)); $router->post('/settings', fn($p) => (new SettingsController())->update($p));
$router->post('/settings/mail-template', fn($p) => (new SettingsController())->mailTemplate($p));
$router->post('/settings/mail-test', fn($p) => (new SettingsController())->mailTest($p)); $router->post('/settings/mail-test', fn($p) => (new SettingsController())->mailTest($p));
$router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p)); $router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p));
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p)); $router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));

View file

@ -258,7 +258,10 @@ class InvoiceController extends BaseController
try { try {
$lines = Invoice::lines($id); $lines = Invoice::lines($id);
$settings = Setting::get(); $settings = Setting::get();
(new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients); $customer = !empty($invoice['customer_id'])
? \Steuerkram\Models\Customer::find((int)$invoice['customer_id'])
: null;
(new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients, $customer ?: null);
if ($invoice['status'] === 'Finalisiert') { if ($invoice['status'] === 'Finalisiert') {
\Steuerkram\Database::execute( \Steuerkram\Database::execute(

View file

@ -95,25 +95,6 @@ class SettingsController extends BaseController
View::redirect('/settings', 'Logo hochgeladen.'); View::redirect('/settings', 'Logo hochgeladen.');
} }
public function mailTemplate(array $params): void
{
header('Content-Type: application/json');
$subject = trim($_POST['mail_subject_template'] ?? '');
$body = trim($_POST['mail_body_template'] ?? '');
if ($subject === '') {
echo json_encode(['error' => 'Betreff darf nicht leer sein.']);
return;
}
\Steuerkram\Database::execute(
'UPDATE company_settings SET mail_subject_template=?, mail_body_template=? WHERE id=1',
[$subject, $body ?: null]
);
echo json_encode(['ok' => true]);
}
public function mailTest(array $params): void public function mailTest(array $params): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');

View file

@ -39,8 +39,8 @@ class Customer
$nr = self::generateKundennummer(); $nr = self::generateKundennummer();
Database::execute( Database::execute(
'INSERT INTO customers (kundennummer, firma, anrede, vorname, nachname, strasse, plz, ort, land, 'INSERT INTO customers (kundennummer, firma, anrede, vorname, nachname, strasse, plz, ort, land,
email, telefon, ust_id, steuernummer, payment_days, notes) email, telefon, ust_id, steuernummer, payment_days, notes, mail_subject_template, mail_body_template)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
$data['kundennummer'] ?? $nr, $data['kundennummer'] ?? $nr,
$data['firma'] ?? null, $data['firma'] ?? null,
@ -57,6 +57,8 @@ class Customer
$data['steuernummer'] ?? null, $data['steuernummer'] ?? null,
(int)($data['payment_days'] ?? 14), (int)($data['payment_days'] ?? 14),
$data['notes'] ?? null, $data['notes'] ?? null,
($data['mail_subject_template'] ?? '') !== '' ? trim($data['mail_subject_template']) : null,
($data['mail_body_template'] ?? '') !== '' ? trim($data['mail_body_template']) : null,
] ]
); );
return (int)Database::lastInsertId(); return (int)Database::lastInsertId();
@ -66,7 +68,8 @@ class Customer
{ {
Database::execute( Database::execute(
'UPDATE customers SET firma=?, anrede=?, vorname=?, nachname=?, strasse=?, plz=?, ort=?, land=?, 'UPDATE customers SET firma=?, anrede=?, vorname=?, nachname=?, strasse=?, plz=?, ort=?, land=?,
email=?, telefon=?, ust_id=?, steuernummer=?, payment_days=?, notes=?, updated_at=NOW() email=?, telefon=?, ust_id=?, steuernummer=?, payment_days=?, notes=?,
mail_subject_template=?, mail_body_template=?, updated_at=NOW()
WHERE id=?', WHERE id=?',
[ [
$data['firma'] ?? null, $data['firma'] ?? null,
@ -83,6 +86,8 @@ class Customer
$data['steuernummer'] ?? null, $data['steuernummer'] ?? null,
(int)($data['payment_days'] ?? 14), (int)($data['payment_days'] ?? 14),
$data['notes'] ?? null, $data['notes'] ?? null,
($data['mail_subject_template'] ?? '') !== '' ? trim($data['mail_subject_template']) : null,
($data['mail_body_template'] ?? '') !== '' ? trim($data['mail_body_template']) : null,
$id, $id,
] ]
); );

View file

@ -9,10 +9,11 @@ class EmailService
/** /**
* Sendet eine Rechnung als PDF-Anhang an alle Empfänger. * Sendet eine Rechnung als PDF-Anhang an alle Empfänger.
* *
* @param string[] $recipients E-Mail-Adressen (1–3) * @param string[] $recipients E-Mail-Adressen (1–3)
* @param array|null $customer Kundendatensatz (für kundenspezifische E-Mail-Vorlage)
* @throws \RuntimeException * @throws \RuntimeException
*/ */
public function sendInvoice(array $invoice, array $lines, array $settings, array $recipients): void public function sendInvoice(array $invoice, array $lines, array $settings, array $recipients, ?array $customer = null): void
{ {
$mailer = $this->buildMailer($settings); $mailer = $this->buildMailer($settings);
@ -28,8 +29,8 @@ class EmailService
. "Mit freundlichen Grüßen\n" . "Mit freundlichen Grüßen\n"
. "%INHABER%"; . "%INHABER%";
$subjectTpl = ($settings['mail_subject_template'] ?? '') ?: 'Rechnung %RNR%'; $subjectTpl = ($customer['mail_subject_template'] ?? '') ?: 'Rechnung %RNR%';
$bodyTpl = ($settings['mail_body_template'] ?? '') ?: $defaultBody; $bodyTpl = ($customer['mail_body_template'] ?? '') ?: $defaultBody;
$subject = $this->replacePlaceholders($subjectTpl, $invoice, $settings); $subject = $this->replacePlaceholders($subjectTpl, $invoice, $settings);
$body = $this->replacePlaceholders($bodyTpl, $invoice, $settings); $body = $this->replacePlaceholders($bodyTpl, $invoice, $settings);

View file

@ -82,6 +82,32 @@
<textarea id="notes" name="notes"><?= $h($customer['notes'] ?? '') ?></textarea> <textarea id="notes" name="notes"><?= $h($customer['notes'] ?? '') ?></textarea>
</div> </div>
<div class="card" style="margin-top:1.25rem;border:1px solid var(--color-border)">
<div class="card-header" style="padding:.6rem 1rem">
<div class="card-title" style="font-size:.85rem">E-Mail-Vorlage für Rechnungsversand</div>
</div>
<div class="card-body" style="padding:.75rem 1rem">
<p style="font-size:.78rem;color:var(--color-text-muted);margin:0 0 .75rem">
Platzhalter: <code>%RNR%</code> = Rechnungsnummer &nbsp;·&nbsp;
<code>%FNAME%</code> = Firmenname &nbsp;·&nbsp;
<code>%INHABER%</code> = Inhaber.
Leer lassen = Standard-Vorlage verwenden.
</p>
<div class="form-group">
<label for="mail_subject_template">Betreff</label>
<input type="text" id="mail_subject_template" name="mail_subject_template"
value="<?= $h($customer['mail_subject_template'] ?? '') ?>"
placeholder="Rechnung %RNR%">
</div>
<div class="form-group" style="margin-bottom:0">
<label for="mail_body_template">Text</label>
<textarea id="mail_body_template" name="mail_body_template"
rows="6" style="resize:vertical;font-family:inherit;font-size:.85rem"
placeholder="Guten Tag,&#10;&#10;anbei erhalten Sie …"><?= $h($customer['mail_body_template'] ?? '') ?></textarea>
</div>
</div>
</div>
<div style="display:flex;gap:.75rem;margin-top:1rem"> <div style="display:flex;gap:.75rem;margin-top:1rem">
<button type="submit" class="btn btn-primary">Speichern</button> <button type="submit" class="btn btn-primary">Speichern</button>
<a href="/customers" class="btn btn-secondary">Abbrechen</a> <a href="/customers" class="btn btn-secondary">Abbrechen</a>

View file

@ -1,10 +1,6 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?> <?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<form id="settings-form" method="post" action="/settings"> <form id="settings-form" method="post" action="/settings">
<input type="hidden" id="hf-mail-subject" name="mail_subject_template"
value="<?= $h($settings['mail_subject_template'] ?? 'Rechnung %RNR%') ?>">
<input type="hidden" id="hf-mail-body" name="mail_body_template"
value="<?= $h($settings['mail_body_template'] ?? '') ?>">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
<!-- Firmendaten --> <!-- Firmendaten -->
@ -368,7 +364,6 @@
</div> </div>
</div> </div>
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap"> <div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" id="btn-mail-text">Text bearbeiten</button>
<button type="button" class="btn btn-secondary" id="btn-mail-test">Test-Mail senden</button> <button type="button" class="btn btn-secondary" id="btn-mail-test">Test-Mail senden</button>
<span id="mail-test-result" style="font-size:.8rem"></span> <span id="mail-test-result" style="font-size:.8rem"></span>
</div> </div>
@ -482,29 +477,6 @@
</div> </div>
</div> </div>
<!-- Modal: E-Mail-Text bearbeiten -->
<div id="mail-text-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;align-items:center;justify-content:center">
<div style="background:#fff;border-radius:8px;padding:1.5rem;width:520px;max-width:95vw;box-shadow:0 8px 32px rgba(0,0,0,.2)">
<h3 style="margin:0 0 1rem">E-Mail-Text bearbeiten</h3>
<p style="font-size:.78rem;color:#666;margin:0 0 1rem">
Platzhalter: <code>%RNR%</code> = Rechnungsnummer &nbsp;·&nbsp; <code>%FNAME%</code> = Firmenname &nbsp;·&nbsp; <code>%INHABER%</code> = Inhaber
</p>
<div class="form-group">
<label>Betreff</label>
<input type="text" id="mail-text-subject" style="width:100%"
value="<?= $h($settings['mail_subject_template'] ?? 'Rechnung %RNR%') ?>">
</div>
<div class="form-group">
<label>Text</label>
<textarea id="mail-text-body" rows="8" style="width:100%;resize:vertical;font-family:inherit;font-size:.85rem"><?= $h($settings['mail_body_template'] ?? '') ?></textarea>
</div>
<div style="display:flex;gap:.75rem;justify-content:flex-end;margin-top:1.25rem">
<button type="button" class="btn btn-secondary" id="btn-mail-text-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary" id="btn-mail-text-save">Speichern</button>
</div>
</div>
</div>
<script> <script>
document.getElementById('btn-mail-test')?.addEventListener('click', function () { document.getElementById('btn-mail-test')?.addEventListener('click', function () {
var btn = this; var btn = this;
@ -564,52 +536,6 @@ function logoPositionChanged() {
(pos === 'left' || pos === 'right') ? 'block' : 'none'; (pos === 'left' || pos === 'right') ? 'block' : 'none';
} }
// E-Mail-Text-Modal
document.getElementById('btn-mail-text')?.addEventListener('click', function () {
document.getElementById('mail-text-modal').style.display = 'flex';
});
document.getElementById('btn-mail-text-cancel')?.addEventListener('click', function () {
document.getElementById('mail-text-modal').style.display = 'none';
});
document.getElementById('btn-mail-text-save')?.addEventListener('click', function () {
var btn = this;
var subject = document.getElementById('mail-text-subject').value.trim();
var body = document.getElementById('mail-text-body').value;
var result = document.getElementById('mail-test-result');
if (!subject) {
document.getElementById('mail-text-subject').focus();
return;
}
btn.disabled = true;
var fd = new FormData();
fd.append('mail_subject_template', subject);
fd.append('mail_body_template', body);
fetch('/settings/mail-template', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.ok) {
// Versteckte Felder im Hauptformular synchron halten
document.getElementById('hf-mail-subject').value = subject;
document.getElementById('hf-mail-body').value = body;
document.getElementById('mail-text-modal').style.display = 'none';
result.textContent = '✓ E-Mail-Text gespeichert.';
result.style.color = 'green';
setTimeout(function () { result.textContent = ''; }, 3000);
} else {
result.textContent = '✗ ' + (data.error || 'Fehler beim Speichern.');
result.style.color = 'red';
}
})
.catch(function () {
result.textContent = '✗ Netzwerkfehler.';
result.style.color = 'red';
})
.finally(function () { btn.disabled = false; });
});
document.getElementById('btn-generate-app-key')?.addEventListener('click', function () { document.getElementById('btn-generate-app-key')?.addEventListener('click', function () {
const btn = this; const btn = this;
const result = document.getElementById('app-key-result'); const result = document.getElementById('app-key-result');