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 ────────────────────────────────────────────
$router->get('/settings', fn($p) => (new SettingsController())->show($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/logo', fn($p) => (new SettingsController())->uploadLogo($p));
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));

View file

@ -258,7 +258,10 @@ class InvoiceController extends BaseController
try {
$lines = Invoice::lines($id);
$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') {
\Steuerkram\Database::execute(

View file

@ -95,25 +95,6 @@ class SettingsController extends BaseController
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
{
header('Content-Type: application/json');

View file

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

View file

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

View file

@ -82,6 +82,32 @@
<textarea id="notes" name="notes"><?= $h($customer['notes'] ?? '') ?></textarea>
</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">
<button type="submit" class="btn btn-primary">Speichern</button>
<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); ?>
<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">
<!-- Firmendaten -->
@ -368,7 +364,6 @@
</div>
</div>
<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>
<span id="mail-test-result" style="font-size:.8rem"></span>
</div>
@ -482,29 +477,6 @@
</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>
document.getElementById('btn-mail-test')?.addEventListener('click', function () {
var btn = this;
@ -564,52 +536,6 @@ function logoPositionChanged() {
(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 () {
const btn = this;
const result = document.getElementById('app-key-result');