Merge pull request 'Feature: Buchungsvorlagen, E-Mail-Vorlagen pro Kunde, UI-Fixes' (#95) from fds2610-bot/Steuerkram:feature/buchungsvorlagen into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/95
This commit is contained in:
fds2610 2026-05-30 14:26:29 +02:00
commit cf2d2b6e3c
18 changed files with 137 additions and 152 deletions

View file

@ -0,0 +1,11 @@
-- 081_buchungsvorlage_standard_lieferant.sql
-- Standard-Lieferant pro Vorlage; wird im Buchungsdialog vorbelegt.
ALTER TABLE buchungsvorlagen
ADD COLUMN IF NOT EXISTS standard_lieferant VARCHAR(255) NULL DEFAULT NULL AFTER beschreibung;
-- Bestehende Vorlage "Essensbeleg" mit Standardlieferant "Lebensmittel" vorbelegen.
UPDATE buchungsvorlagen
SET standard_lieferant = 'Lebensmittel'
WHERE name = 'Essensbeleg n. abzugsfähig (nur USt.)'
AND (standard_lieferant IS NULL OR standard_lieferant = '');

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

@ -524,10 +524,11 @@ document.addEventListener('input', function(e) {
if (!inp.matches('#vorlage-modal .vorlage-netto, #vorlage-modal .vorlage-brutto')) return; if (!inp.matches('#vorlage-modal .vorlage-netto, #vorlage-modal .vorlage-brutto')) return;
const row = inp.closest('.vorlage-ust-row'); const row = inp.closest('.vorlage-ust-row');
if (!row) return; if (!row) return;
const satz = parseFloat(row.dataset.ustSatz) / 100; const satz = parseFloat(row.dataset.ustSatz) / 100;
const netto = row.querySelector('.vorlage-netto'); const netto = row.querySelector('.vorlage-netto');
const brutto = row.querySelector('.vorlage-brutto'); const brutto = row.querySelector('.vorlage-brutto');
const ust = row.querySelector('.vorlage-ust'); const ust = row.querySelector('.vorlage-ust');
const ustAnzeige = row.querySelector('.vorlage-ust-anzeige');
if (inp.classList.contains('vorlage-netto')) { if (inp.classList.contains('vorlage-netto')) {
const n = parseFloat(netto.value) || 0; const n = parseFloat(netto.value) || 0;
brutto.value = (n * (1 + satz)).toFixed(2); brutto.value = (n * (1 + satz)).toFixed(2);
@ -538,6 +539,7 @@ document.addEventListener('input', function(e) {
netto.value = n.toFixed(2); netto.value = n.toFixed(2);
ust.value = (b - n).toFixed(2); ust.value = (b - n).toFixed(2);
} }
if (ustAnzeige) ustAnzeige.textContent = parseFloat(ust.value).toFixed(2).replace('.', ',');
vorlageUpdateBruttoGesamt(); vorlageUpdateBruttoGesamt();
}); });
@ -548,19 +550,11 @@ function vorlageUpdateBruttoGesamt() {
const summe = Array.from(bruttoFelder).reduce((s, el) => s + (parseFloat(el.value) || 0), 0); const summe = Array.from(bruttoFelder).reduce((s, el) => s + (parseFloat(el.value) || 0), 0);
const anzeige = modal.querySelector('[data-feldname="brutto_gesamt"]'); const anzeige = modal.querySelector('[data-feldname="brutto_gesamt"]');
if (anzeige) anzeige.textContent = summe.toFixed(2).replace('.', ',') + ' €'; if (anzeige) anzeige.textContent = summe.toFixed(2).replace('.', ',') + ' €';
// Auch das buchungs_datum-Hidden synchronisieren mit belegdatum-Feld falls vorhanden
const datumFeld = modal.querySelector('input[name="belegdatum"]');
const datumHidden = modal.querySelector('input[name="buchungs_datum"]');
if (datumFeld && datumHidden && datumFeld.value) {
datumHidden.value = datumFeld.value;
}
} }
// Datum-Feld Änderung → buchungs_datum synchronisieren // Zahldatum-Feld Änderung → buchungs_datum-Hidden synchronisieren
document.addEventListener('change', function(e) { document.addEventListener('change', function(e) {
if (!e.target.matches('#vorlage-modal input[name="belegdatum"]')) return; if (!e.target.matches('#vorlage-modal #vorlage-zahldatum')) return;
const modal = document.getElementById('vorlage-modal'); const hidden = document.getElementById('vorlage-buchungs-datum');
if (!modal) return;
const hidden = modal.querySelector('input[name="buchungs_datum"]');
if (hidden) hidden.value = e.target.value; if (hidden) hidden.value = e.target.value;
}); });

View file

@ -209,10 +209,9 @@ class BookingController extends BaseController
if ($newAssetId !== null) { if ($newAssetId !== null) {
View::redirect("/assets/{$newAssetId}/edit", 'Buchung erfasst. Bitte Wirtschaftsgut-Details ergänzen.'); View::redirect("/assets/{$newAssetId}/edit", 'Buchung erfasst. Bitte Wirtschaftsgut-Details ergänzen.');
} elseif ($bankTxId > 0) {
View::redirect('/bank', 'Buchung erfasst und Bankbuchung verknüpft.');
} else { } else {
View::redirect("/belege/{$belegId}", 'Buchung erfasst.'); $msg = $bankTxId > 0 ? 'Buchung erfasst und Bankbuchung verknüpft.' : 'Buchung erfasst.';
View::redirect("/belege/{$belegId}", $msg);
} }
} catch (\PDOException $e) { } catch (\PDOException $e) {
error_log('[Steuerkram] PDO-Fehler in BookingController::store(): ' . $e->getMessage()); error_log('[Steuerkram] PDO-Fehler in BookingController::store(): ' . $e->getMessage());

View file

@ -141,14 +141,20 @@ class BuchungsvorlageController extends BaseController
$zahlungskonten = Account::all(['kontenart' => 'Zahlungskonto', 'aktiv' => 1]); $zahlungskonten = Account::all(['kontenart' => 'Zahlungskonto', 'aktiv' => 1]);
$zahlungskonten = array_filter($zahlungskonten, fn($a) => (int)$a['kontonummer'] >= 1200 && (int)$a['kontonummer'] <= 1299); $zahlungskonten = array_filter($zahlungskonten, fn($a) => (int)$a['kontonummer'] >= 1200 && (int)$a['kontonummer'] <= 1299);
$bankTxId = (int)($_GET['bank_tx'] ?? 0);
$bankTxDatum = '';
$hatBankbuchung = false; $hatBankbuchung = false;
if ($beleg) { if ($beleg) {
$linked = BankTransaction::findByBelegId((int)$beleg['id']); $linked = BankTransaction::findByBelegId((int)$beleg['id']);
$hatBankbuchung = !empty($linked); $hatBankbuchung = !empty($linked);
if (!$hatBankbuchung && !empty($_GET['bank_tx'])) { if (!$hatBankbuchung && $bankTxId > 0) {
$hatBankbuchung = true; $hatBankbuchung = true;
} }
} }
if ($bankTxId > 0) {
$bankTx = BankTransaction::find($bankTxId);
$bankTxDatum = $bankTx['wertstellung'] ?? '';
}
echo $this->renderView('buchungsvorlagen/modal', [ echo $this->renderView('buchungsvorlagen/modal', [
'vorlage' => $vorlage, 'vorlage' => $vorlage,
@ -157,6 +163,8 @@ class BuchungsvorlageController extends BaseController
'belegId' => $belegId, 'belegId' => $belegId,
'zahlungskonten' => array_values($zahlungskonten), 'zahlungskonten' => array_values($zahlungskonten),
'hatBankbuchung' => $hatBankbuchung, 'hatBankbuchung' => $hatBankbuchung,
'bankTxId' => $bankTxId,
'bankTxDatum' => $bankTxDatum,
]); ]);
} }
@ -170,17 +178,24 @@ class BuchungsvorlageController extends BaseController
$beleg = Beleg::find($belegId); $beleg = Beleg::find($belegId);
if (!$beleg) { http_response_code(404); return; } if (!$beleg) { http_response_code(404); return; }
$datum = !empty($_POST['buchungs_datum']) ? $_POST['buchungs_datum'] : date('Y-m-d'); $datum = !empty($_POST['buchungs_datum']) ? $_POST['buchungs_datum'] : date('Y-m-d');
$bankTxId = (int)($_POST['bank_tx_id'] ?? 0);
try { try {
$svc = new VorlageBuchungsService(); $svc = new VorlageBuchungsService();
$svc->buchen($belegId, $vorlageId, $_POST, $datum); $svc->buchen($belegId, $vorlageId, $_POST, $datum);
if ($bankTxId > 0) {
$totalBrutto = \Steuerkram\Models\Booking::sumBruttoForBeleg($belegId);
BankTransaction::linkBeleg($bankTxId, $belegId, $totalBrutto);
}
} catch (\RuntimeException $e) { } catch (\RuntimeException $e) {
View::redirect("/belege/{$belegId}", $e->getMessage(), 'error'); View::redirect("/belege/{$belegId}", $e->getMessage(), 'error');
return; return;
} }
View::redirect("/belege/{$belegId}", 'Buchungen aus Vorlage erfasst.'); $msg = $bankTxId > 0 ? 'Buchungen aus Vorlage erfasst und Bankbuchung verknüpft.' : 'Buchungen aus Vorlage erfasst.';
View::redirect("/belege/{$belegId}", $msg);
} }
// ── Hilfsmethoden ────────────────────────────────────────────────────── // ── Hilfsmethoden ──────────────────────────────────────────────────────

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

@ -54,11 +54,12 @@ class Buchungsvorlage
public static function create(array $data): int public static function create(array $data): int
{ {
Database::execute( Database::execute(
'INSERT INTO buchungsvorlagen (name, beschreibung, aktiv, sort_order) 'INSERT INTO buchungsvorlagen (name, beschreibung, standard_lieferant, aktiv, sort_order)
VALUES (?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?)',
[ [
trim($data['name']), trim($data['name']),
$data['beschreibung'] ?? null, $data['beschreibung'] ?? null,
$data['standard_lieferant'] !== '' ? trim($data['standard_lieferant'] ?? '') : null,
isset($data['aktiv']) ? (int)$data['aktiv'] : 1, isset($data['aktiv']) ? (int)$data['aktiv'] : 1,
(int)($data['sort_order'] ?? 0), (int)($data['sort_order'] ?? 0),
] ]
@ -70,11 +71,12 @@ class Buchungsvorlage
{ {
Database::execute( Database::execute(
'UPDATE buchungsvorlagen 'UPDATE buchungsvorlagen
SET name = ?, beschreibung = ?, aktiv = ?, sort_order = ? SET name = ?, beschreibung = ?, standard_lieferant = ?, aktiv = ?, sort_order = ?
WHERE id = ?', WHERE id = ?',
[ [
trim($data['name']), trim($data['name']),
$data['beschreibung'] ?? null, $data['beschreibung'] ?? null,
$data['standard_lieferant'] !== '' ? trim($data['standard_lieferant'] ?? '') : null,
isset($data['aktiv']) ? (int)$data['aktiv'] : 1, isset($data['aktiv']) ? (int)$data['aktiv'] : 1,
(int)($data['sort_order'] ?? 0), (int)($data['sort_order'] ?? 0),
$id, $id,

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

@ -224,9 +224,7 @@ $restFormatted = number_format($remaining, 2, ',', '.');
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Belegnr.</th> <th>Beleg</th>
<th>Datum</th>
<th>Typ</th>
<th>Lieferant</th> <th>Lieferant</th>
<th class="td-right">Brutto</th> <th class="td-right">Brutto</th>
<th></th> <th></th>
@ -239,20 +237,22 @@ $restFormatted = number_format($remaining, 2, ',', '.');
data-beleg-id="<?= (int)$b['id'] ?>" data-beleg-id="<?= (int)$b['id'] ?>"
style="cursor:<?= $hatBeleg ? 'pointer' : 'default' ?>" style="cursor:<?= $hatBeleg ? 'pointer' : 'default' ?>"
<?= $hatBeleg ? 'title="Klicken zum Vorschau anzeigen"' : '' ?>> <?= $hatBeleg ? 'title="Klicken zum Vorschau anzeigen"' : '' ?>>
<td class="td-mono" onclick="event.stopPropagation()"> <td onclick="event.stopPropagation()" style="white-space:nowrap">
<?php if ($hatBeleg): ?> <div class="td-mono" style="font-size:.8rem">
<a href="/belege/<?= (int)$b['id'] ?>" target="_blank"><?= $h($b['beleg_nr']) ?></a> <?php if ($hatBeleg): ?>
<?php else: ?> <a href="/belege/<?= (int)$b['id'] ?>" target="_blank"><?= $h($b['beleg_nr']) ?></a>
<a href="/invoices/<?= (int)$b['invoice_id'] ?>" target="_blank"><?= $h($b['beleg_nr']) ?></a> <?php else: ?>
<?php endif; ?> <a href="/invoices/<?= (int)$b['invoice_id'] ?>" target="_blank"><?= $h($b['beleg_nr']) ?></a>
<?php endif; ?>
</div>
<div class="td-mono text-muted" style="font-size:.78rem"><?= $h(View::formatDate($b['beleg_datum'])) ?></div>
<div style="margin-top:.15rem"><span class="badge badge-gray" style="font-size:.7rem"><?= $h($b['belegtyp']) ?></span></div>
</td> </td>
<td class="td-mono"><?= $h(View::formatDate($b['beleg_datum'])) ?></td>
<td><span class="badge badge-gray"><?= $h($b['belegtyp']) ?></span></td>
<td> <td>
<?= $b['supplier_name'] ? $h($b['supplier_name']) : '<span class="text-muted">–</span>' ?> <?= $b['supplier_name'] ? $h($b['supplier_name']) : '<span class="text-muted">–</span>' ?>
</td> </td>
<td class="td-right td-mono"> <td class="td-right td-mono" style="white-space:nowrap">
<?= $b['betrag_brutto'] ? $fmt($b['betrag_brutto']) . ' €' : '<span class="text-muted">–</span>' ?> <?= $b['betrag_brutto'] ? $fmt($b['betrag_brutto']) : '<span class="text-muted">–</span>' ?>
</td> </td>
<td onclick="event.stopPropagation()"> <td onclick="event.stopPropagation()">
<?php if ($hatBeleg): ?> <?php if ($hatBeleg): ?>

View file

@ -484,7 +484,7 @@ $suppliers = $suppliers ?? [];
<select name="steuerart" id="steuerart_select"> <select name="steuerart" id="steuerart_select">
<?php <?php
$steuerartOptionen = [ $steuerartOptionen = [
'normal' => 'Normal (Inland)', 'normal' => 'Normal (Inland/Drittland)',
'ig_lieferung' => 'Steuerfreie ig. Lieferung § 4 Nr. 1b (Kz 41)', 'ig_lieferung' => 'Steuerfreie ig. Lieferung § 4 Nr. 1b (Kz 41)',
'export' => 'Steuerfreie Ausfuhr § 4 Nr. 1a (Kz 44)', 'export' => 'Steuerfreie Ausfuhr § 4 Nr. 1a (Kz 44)',
'ig_erwerb_19' => 'Innergemeinschaftlicher Erwerb 19 % – ReverseCharge (Kz 89/91/67)', 'ig_erwerb_19' => 'Innergemeinschaftlicher Erwerb 19 % – ReverseCharge (Kz 89/91/67)',

View file

@ -27,6 +27,11 @@ $bedOptn = ['neq_zero' => 'wenn ≠ 0', 'always' => 'immer'];
<label>Beschreibung</label> <label>Beschreibung</label>
<textarea name="beschreibung" rows="2" class="form-control"><?= $h($vorlage['beschreibung'] ?? '') ?></textarea> <textarea name="beschreibung" rows="2" class="form-control"><?= $h($vorlage['beschreibung'] ?? '') ?></textarea>
</div> </div>
<div class="form-group">
<label>Standard-Lieferant</label>
<input type="text" name="standard_lieferant" value="<?= $h($vorlage['standard_lieferant'] ?? '') ?>"
class="form-control" placeholder="Wird im Buchungsdialog vorbelegt">
</div>
<div style="display:flex;gap:1.5rem"> <div style="display:flex;gap:1.5rem">
<div class="form-group" style="flex:0 0 auto"> <div class="form-group" style="flex:0 0 auto">
<label>Reihenfolge</label> <label>Reihenfolge</label>

View file

@ -7,13 +7,17 @@ $gruppe1 = array_filter($felder, fn($f) => $f['gruppe_nr'] == 1);
$gruppe2 = array_filter($felder, fn($f) => $f['gruppe_nr'] == 2); $gruppe2 = array_filter($felder, fn($f) => $f['gruppe_nr'] == 2);
$gruppe3 = array_filter($felder, fn($f) => $f['gruppe_nr'] == 3); $gruppe3 = array_filter($felder, fn($f) => $f['gruppe_nr'] == 3);
// Vorbelegen aus Beleg-Metadaten // Vorbelegen: Vorlage-Standard-Lieferant hat Vorrang vor Beleg-Lieferant
$standardLieferant = trim($vorlage['standard_lieferant'] ?? '');
$vorbelegt = [ $vorbelegt = [
'lieferant' => $beleg['supplier_name'] ?? '', 'lieferant' => $standardLieferant !== '' ? $standardLieferant : ($beleg['supplier_name'] ?? ''),
'rechnungsnr' => $beleg['rechnungs_nr'] ?? '', 'rechnungsnr' => $beleg['rechnungs_nr'] ?? '',
'belegdatum' => $beleg['beleg_datum'] ?? date('Y-m-d'), 'belegdatum' => $beleg['beleg_datum'] ?? date('Y-m-d'),
]; ];
// Zahldatum: Bankbuchungs-Wertstellung, Fallback heute
$zahldatum = ($bankTxDatum ?? '') ?: date('Y-m-d');
// Vorauswahl Zahlungskonto // Vorauswahl Zahlungskonto
$defaultKontonummer = $hatBankbuchung ? '1200' : '1210'; $defaultKontonummer = $hatBankbuchung ? '1200' : '1210';
$defaultKontoId = 0; $defaultKontoId = 0;
@ -35,7 +39,10 @@ if (!$defaultKontoId && !empty($zahlungskonten)) {
</div> </div>
<div class="modal-body" style="padding:1.25rem 1.5rem"> <div class="modal-body" style="padding:1.25rem 1.5rem">
<form method="post" action="/belege/<?= $h($belegId) ?>/vorlage/<?= $h($vorlage['id']) ?>/buchen" id="vorlage-buchen-form"> <form method="post" action="/belege/<?= $h($belegId) ?>/vorlage/<?= $h($vorlage['id']) ?>/buchen" id="vorlage-buchen-form">
<input type="hidden" name="buchungs_datum" value="<?= $h($vorbelegt['belegdatum'] ?: date('Y-m-d')) ?>"> <input type="hidden" name="buchungs_datum" id="vorlage-buchungs-datum" value="<?= $h($zahldatum) ?>">
<?php if (!empty($bankTxId)): ?>
<input type="hidden" name="bank_tx_id" value="<?= $h($bankTxId) ?>">
<?php endif; ?>
<?php if (!empty($gruppe1)): ?> <?php if (!empty($gruppe1)): ?>
<!-- Gruppe 1: Metadaten --> <!-- Gruppe 1: Metadaten -->
@ -50,11 +57,17 @@ if (!$defaultKontoId && !empty($zahlungskonten)) {
style="font-weight:600;font-size:.95rem">0,00 €</span> style="font-weight:600;font-size:.95rem">0,00 €</span>
</div> </div>
<?php elseif ($feld['typ'] === 'date'): ?> <?php elseif ($feld['typ'] === 'date'): ?>
<div class="form-group" style="display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem"> <div class="form-group" style="display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem;flex-wrap:wrap">
<label style="min-width:130px;margin:0;font-size:.875rem"><?= $h($feld['label']) ?></label> <label style="min-width:130px;margin:0;font-size:.875rem"><?= $h($feld['label']) ?></label>
<input type="date" name="<?= $h($feld['feldname']) ?>" <input type="date" name="<?= $h($feld['feldname']) ?>"
value="<?= $h($vorbelegt[$feld['feldname']] ?? '') ?>" value="<?= $h($vorbelegt[$feld['feldname']] ?? '') ?>"
style="flex:1;max-width:200px" class="form-control form-control-sm"> style="width:150px" class="form-control form-control-sm">
<?php if ($feld['feldname'] === 'belegdatum'): ?>
<label style="margin:0;font-size:.875rem;white-space:nowrap">Zahldatum</label>
<input type="date" id="vorlage-zahldatum"
value="<?= $h($zahldatum) ?>"
style="width:150px" class="form-control form-control-sm">
<?php endif; ?>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="form-group" style="display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem"> <div class="form-group" style="display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem">
@ -78,7 +91,7 @@ if (!$defaultKontoId && !empty($zahlungskonten)) {
<th style="text-align:left;padding:.3rem .5rem;font-weight:500">Steuer</th> <th style="text-align:left;padding:.3rem .5rem;font-weight:500">Steuer</th>
<th style="text-align:right;padding:.3rem .5rem;font-weight:500">Netto (€)</th> <th style="text-align:right;padding:.3rem .5rem;font-weight:500">Netto (€)</th>
<th style="text-align:right;padding:.3rem .5rem;font-weight:500">Brutto (€)</th> <th style="text-align:right;padding:.3rem .5rem;font-weight:500">Brutto (€)</th>
<th style="text-align:right;padding:.3rem .5rem;font-weight:500">USt (€)</th> <th style="text-align:right;padding:.3rem .5rem;font-weight:500;color:var(--color-text-muted)">USt (€)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -105,11 +118,10 @@ if (!$defaultKontoId && !empty($zahlungskonten)) {
style="text-align:right;width:110px" style="text-align:right;width:110px"
placeholder="0,00"> placeholder="0,00">
</td> </td>
<td style="padding:.3rem .5rem"> <td style="padding:.3rem .5rem;text-align:right">
<input type="number" name="<?= $h($uKey) ?>" step="0.01" value="" <span class="vorlage-ust-anzeige"
class="form-control form-control-sm vorlage-ust" style="display:inline-block;width:90px;text-align:right;color:var(--color-text-muted);font-size:.875rem">0,00</span>
style="text-align:right;width:90px;background:var(--color-bg-subtle)" <input type="hidden" name="<?= $h($uKey) ?>" class="vorlage-ust" value="">
readonly placeholder="0,00">
</td> </td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>

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');

View file

@ -1 +1 @@
1-260528 1-260530