Steuerkram/src/Views/belege/detail.php
fds2610-bot aa9ccd1207 Feature: Bank-Import mit konfigurierbaren Formaten, Buchungstext-Templates und Dublettenprüfung
Fix: Dublettenprüfung verwendet nur (buchungstag, wertstellung, wert) ohne buchungstext,
da Vorlagenänderungen sonst zu falschen Duplikaten führen.
2026-05-17 13:47:16 +02:00

1445 lines
71 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use Steuerkram\View;
use Steuerkram\Models\Setting;
$h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($v);
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
$testMode = Setting::isTestMode();
$suppliers = $suppliers ?? [];
?>
<!-- Beleg-Info-Bar -->
<div class="card mb-2"
id="beleg-info-bar"
data-beleg-id="<?= $h($beleg['id']) ?>"
data-belegtyp="<?= $h($beleg['belegtyp']) ?>"
data-beleg-datum="<?= $h($beleg['beleg_datum'] ?? '') ?>"
data-rechnungs-nr="<?= $h($beleg['rechnungs_nr'] ?? '') ?>"
data-betrag-brutto="<?= $h($beleg['betrag_brutto'] ?? '') ?>"
data-supplier-name="<?= $h($beleg['supplier_name'] ?? '') ?>">
<div class="card-body" style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;padding:.75rem 1.25rem">
<div>
<span class="text-muted text-sm">Belegnr.</span><br>
<strong class="font-mono"><?= $h($beleg['beleg_nr']) ?></strong>
</div>
<div>
<span class="text-muted text-sm">Typ</span><br>
<span class="badge badge-gray"><?= $h($beleg['belegtyp']) ?></span>
</div>
<div>
<span class="text-muted text-sm">Lieferant<?= (!$beleg['locked'] && $beleg['supplier_name']) ? ' <span style="opacity:.4;font-size:.7rem">✎</span>' : '' ?></span><br>
<?php if (!$beleg['locked'] && $beleg['supplier_name']): ?>
<span class="inline-edit-trigger"
id="supplier-inline-trigger"
data-field="supplier_name" data-type="text"
data-raw="<?= $h($beleg['supplier_name']) ?>"
title="Klicken zum Bearbeiten">
<?= $h($beleg['supplier_name']) ?>
</span>
<?php elseif (!$beleg['locked']): ?>
<input type="text" id="supplier-persistent-input"
value="" placeholder="Lieferant eingeben…"
list="supplier-datalist" autocomplete="off"
style="font-size:inherit;padding:2px 5px;border:1px solid var(--color-border);border-radius:var(--radius);background:#fff;min-width:180px">
<?php else: ?>
<?= $beleg['supplier_name'] ? $h($beleg['supplier_name']) : '<span class="text-muted">–</span>' ?>
<?php endif; ?>
</div>
<div>
<span class="text-muted text-sm">Belegdatum<?= !$beleg['locked'] ? ' <span style="opacity:.4;font-size:.7rem">✎</span>' : '' ?></span><br>
<?php if (!$beleg['locked']): ?>
<span class="inline-edit-trigger"
data-field="beleg_datum" data-type="date"
data-raw="<?= $h($beleg['beleg_datum'] ?? '') ?>"
title="Klicken zum Bearbeiten">
<?= $beleg['beleg_datum'] ? View::formatDate($beleg['beleg_datum']) : '<span class="text-muted">– setzen –</span>' ?>
</span>
<?php else: ?>
<?= View::formatDate($beleg['beleg_datum']) ?>
<?php endif; ?>
</div>
<div>
<span class="text-muted text-sm">Rechnungsnr.<?= !$beleg['locked'] ? ' <span style="opacity:.4;font-size:.7rem">✎</span>' : '' ?></span><br>
<?php if (!$beleg['locked']): ?>
<span class="inline-edit-trigger"
data-field="rechnungs_nr" data-type="text"
data-raw="<?= $h($beleg['rechnungs_nr'] ?? '') ?>"
title="Klicken zum Bearbeiten">
<?= $beleg['rechnungs_nr'] ? $h($beleg['rechnungs_nr']) : '<span class="text-muted">– setzen –</span>' ?>
</span>
<?php else: ?>
<?= $beleg['rechnungs_nr'] ? $h($beleg['rechnungs_nr']) : '<span class="text-muted">–</span>' ?>
<?php endif; ?>
</div>
<?php if (!empty($beleg['invoice_id'])): ?>
<div>
<span class="text-muted text-sm">Ausgangsrechnung</span><br>
<a href="/invoices/<?= (int)$beleg['invoice_id'] ?>">
<?= $h($beleg['rechnungs_nr'] ?? ('Rechnung #' . $beleg['invoice_id'])) ?>
</a>
</div>
<?php endif; ?>
<div>
<span class="text-muted text-sm">Brutto (Buchungen)</span><br>
<?= $beleg['betrag_brutto'] ? '<strong>' . $fmt($beleg['betrag_brutto']) . '</strong>' : '<span class="text-muted">–</span>' ?>
</div>
<div>
<span class="text-muted text-sm">GoBD-Integrität</span><br>
<?php if ($integrity): ?>
<span class="badge badge-green">✓ OK</span>
<?php else: ?>
<span class="badge badge-red">⚠ Prüfsumme abweichend</span>
<?php endif; ?>
</div>
<?php if (!empty($beleg['bewirtungsgrund']) || !empty($beleg['teilnehmerliste'])): ?>
<div>
<span class="text-muted text-sm">Bewirtung</span><br>
<?php if (!empty($beleg['bewirtungsgrund'])): ?>
<span><?= $h($beleg['bewirtungsgrund']) ?></span>
<?php endif; ?>
<?php if (!empty($beleg['teilnehmerliste'])): ?>
<span class="text-muted" style="font-size:.85em"> · <?= $h($beleg['teilnehmerliste']) ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<div style="margin-left:auto;display:flex;gap:.5rem">
<a href="/belege/<?= $h($beleg['id']) ?>/file" target="_blank" class="btn btn-secondary btn-sm">Datei öffnen</a>
<?php if (!$beleg['locked'] && in_array($beleg['mime_type'], ['image/jpeg', 'image/png'])): ?>
<button type="button" class="btn btn-secondary btn-sm" onclick="openCropModal()">Zuschneiden</button>
<?php endif; ?>
<?php if (!$beleg['locked']): ?>
<button type="button" class="btn btn-secondary btn-sm"
onclick="document.getElementById('beleg-meta-form').style.display = document.getElementById('beleg-meta-form').style.display === 'none' ? 'block' : 'none'">
Metadaten bearbeiten
</button>
<?php endif; ?>
<?php if (!$beleg['locked'] && empty($bookings)): ?>
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/delete"
data-confirm="Beleg wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.">
<button type="submit" class="btn btn-danger btn-sm">Löschen</button>
</form>
<?php endif; ?>
</div>
</div>
<?php if (!$beleg['locked']): ?>
<div id="beleg-meta-form" style="display:none;border-top:1px solid var(--color-border);padding:.75rem 1.25rem">
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/meta">
<div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:flex-end">
<div class="form-group" style="margin:0;min-width:140px">
<label style="font-size:.8rem">Rechnungsnr.</label>
<input type="text" name="rechnungs_nr" value="<?= $h($beleg['rechnungs_nr'] ?? '') ?>"
placeholder="z. B. RE-2024-001">
</div>
<div class="form-group" style="margin:0">
<label style="font-size:.8rem">Belegtyp</label>
<select name="belegtyp">
<?php foreach (['Eingangsrechnung','Ausgangsrechnung','Quittung','Kontoauszug','Sonstiges'] as $t): ?>
<option value="<?= $t ?>"<?= $beleg['belegtyp'] === $t ? ' selected' : '' ?>><?= $t ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0">
<label style="font-size:.8rem">Belegdatum</label>
<input type="date" name="beleg_datum" value="<?= $h($beleg['beleg_datum'] ?? '') ?>">
</div>
<div class="form-group" style="margin:0;min-width:180px">
<label style="font-size:.8rem">Lieferant</label>
<input type="text" name="supplier_name" id="supplier_name_input"
value="<?= $h($beleg['supplier_name'] ?? ($bankTxSupplier ?? '')) ?>"
placeholder="Name eingeben oder wählen…"
list="supplier-datalist"
autocomplete="off">
<datalist id="supplier-datalist">
<?php foreach ($suppliers as $sup): ?>
<option value="<?= $h($sup['name']) ?>">
<?php endforeach; ?>
</datalist>
</div>
<button type="submit" class="btn btn-primary btn-sm">Speichern</button>
<button type="button" class="btn btn-secondary btn-sm"
onclick="document.getElementById('beleg-meta-form').style.display='none'">Abbrechen</button>
</div>
</form>
</div>
<?php endif; ?>
</div>
<!-- Split-Pane: Vorschau + Buchungsformular -->
<div class="beleg-layout" id="beleg-split-layout">
<!-- Linke Seite: Dokumentvorschau -->
<div class="beleg-preview" id="beleg-preview-pane">
<?php if (in_array($beleg['mime_type'], ['application/pdf'])): ?>
<iframe src="/belege/<?= $h($beleg['id']) ?>/file" title="Belegvorschau"></iframe>
<?php elseif (str_starts_with($beleg['mime_type'], 'image/')): ?>
<div class="img-zoom-controls">
<button type="button" class="btn btn-secondary btn-sm" onclick="imgZoom(-1)" title="Verkleinern (Strg+–)">−</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="imgZoom(+1)" title="Vergrößern (Strg++)">+</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="imgZoomReset()" title="Zurücksetzen (Strg+0)">↺</button>
<span id="img-zoom-level">100 %</span>
</div>
<div id="img-zoom-wrap" class="img-zoom-wrap">
<img id="beleg-img" src="/belege/<?= $h($beleg['id']) ?>/file" alt="Belegvorschau">
</div>
<?php elseif (str_contains($beleg['mime_type'], 'xml')): ?>
<div style="padding:1.5rem">
<div class="badge badge-blue mb-2">XML / eRechnung</div>
<p class="text-muted text-sm">XML-Datei kann nicht direkt angezeigt werden.</p>
<a href="/belege/<?= $h($beleg['id']) ?>/file" class="btn btn-secondary mt-2" download>XML herunterladen</a>
</div>
<?php endif; ?>
</div>
<!-- Trennlinie (Drag-Handle) -->
<div class="split-handle" id="beleg-split-handle" title="Ziehen zum Anpassen der Breite"></div>
<!-- Rechte Seite: Buchungen -->
<div class="beleg-layout-right">
<!-- Vorhandene Buchungen -->
<div class="card mb-2">
<div class="card-header">
<div class="card-title">Buchungszeilen (<?= count($bookings) ?>)</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Buchungsnr.</th>
<th>Datum</th>
<th>Typ</th>
<th>Kategorie</th>
<th>Zahlungskonto</th>
<th class="td-right">Wert</th>
<th>Text</th>
<th>Anlage</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($bookings as $bk):
$isReversed = !empty($bk['reversed_by']);
$isReversal = (bool)$bk['is_reversal'];
?>
<tr style="<?= ($isReversed || $isReversal) ? 'opacity:.55;background:#fff5f5' : '' ?>">
<td class="td-mono text-sm"><?= $h($bk['buchungs_nr']) ?></td>
<td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td>
<td><span class="badge <?= $bk['buchungstyp'] === 'Einnahme' ? 'badge-green' : 'badge-red' ?>"><?= $h($bk['buchungstyp']) ?></span></td>
<td class="td-mono text-sm"><?= $h($bk['kategorie_nr']) ?><br><span class="td-muted"><?= $h($bk['kategorie_name']) ?></span></td>
<td class="td-mono text-sm"><?= $h($bk['zahlungs_nr']) ?><br><span class="td-muted"><?= $h($bk['zahlungs_name']) ?></span></td>
<td class="td-right td-mono"><?= $fmt($bk['betrag_netto']) ?></td>
<td class="text-sm">
<?= $h($bk['buchungstext']) ?>
<?php if ($isReversal): ?>
<span class="badge badge-red">Storno</span>
<?php elseif ($isReversed): ?>
<span class="badge badge-red">Storniert</span>
<?php endif; ?>
</td>
<td class="td-mono text-sm">
<?php if (!empty($bk['anlage_nr'])): ?>
<a href="/assets/<?= (int)$bk['asset_id'] ?>/edit" title="<?= $h($bk['anlage_bezeichnung']) ?>"><?= $h($bk['anlage_nr']) ?></a>
<?php elseif (!empty($bk['anlage_bezeichnung'])): ?>
<a href="/assets/<?= (int)$bk['asset_id'] ?>/edit"><?= $h($bk['anlage_bezeichnung']) ?></a>
<?php else: ?>
<span class="text-muted">–</span>
<?php endif; ?>
</td>
<td>
<?php if (!$bk['locked'] && !$isReversal && !$isReversed): ?>
<div style="display:flex;gap:.35rem">
<button type="button" class="btn btn-danger btn-sm"
data-modal-open="modal-storno-<?= $h($bk['id']) ?>">
Storno
</button>
<?php if ($testMode): ?>
<form method="post" action="/bookings/<?= $h($bk['id']) ?>/delete"
data-confirm="Buchung <?= $h($bk['buchungs_nr']) ?> wirklich löschen? (Nur im Testmodus – nicht GoBD-konform!)">
<input type="hidden" name="beleg_id" value="<?= $h($beleg['id']) ?>">
<button type="submit" class="btn btn-danger btn-sm" title="Löschen (Testmodus)">
Löschen
</button>
</form>
<?php endif; ?>
</div>
<!-- Storno-Modal -->
<div class="modal-overlay" id="modal-storno-<?= $h($bk['id']) ?>">
<div class="modal">
<div class="modal-title">Buchung stornieren</div>
<p class="text-sm text-muted mb-2">Die Buchung <?= $h($bk['buchungs_nr']) ?> wird durch eine Gegenbuchung storniert (GoBD-konform).</p>
<form method="post" action="/bookings/<?= $h($bk['id']) ?>/reverse">
<div class="form-group">
<label>Stornogrund</label>
<input type="text" name="reason" required placeholder="Grund für die Stornierung">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button type="submit" class="btn btn-danger">Stornieren</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($bookings)): ?>
<tr><td colspan="9" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- Gespeicherte Bankbuchungs-Verknüpfung -->
<?php if ($linkedBankTx ?? null): ?>
<?php $lbt = $linkedBankTx; $lbwert = (float)$lbt['wert']; ?>
<div class="card mb-2" style="border-left:4px solid var(--color-success)">
<div class="card-body" style="padding:.6rem 1.25rem">
<div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
<span class="badge badge-green" style="font-size:.8rem">Bankbuchung</span>
<a href="/bank/<?= (int)$lbt['id'] ?>" style="display:contents;text-decoration:none;color:inherit">
<span class="td-mono text-sm"><?= View::formatDate($lbt['wertstellung']) ?></span>
<?php if ($lbt['vorgang']): ?>
<span class="text-sm text-muted"><?= $h($lbt['vorgang']) ?></span>
<?php endif; ?>
<span class="text-sm" style="flex:1;min-width:0;display:inline-flex;align-items:baseline;gap:.35rem;overflow:hidden">
<?php if (!empty($lbt['kuerzel'])): ?>
<?php $kf = $lbt['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($lbt['kuerzel']) ?></span>
<?php endif; ?>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="<?= $h($lbt['buchungstext']) ?>"><?= $h($lbt['buchungstext']) ?></span>
</span>
<strong style="white-space:nowrap;color:<?= $lbwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>">
<?= $lbwert >= 0 ? '+' : '' ?><?= number_format($lbwert, 2, ',', '.') ?> €
<?php if (!empty($lbt['alloc_betrag']) && (float)$lbt['alloc_betrag'] !== abs($lbwert)): ?>
<span class="text-muted" style="font-weight:normal;font-size:.85em">
(zugeordnet: <?= number_format((float)$lbt['alloc_betrag'], 2, ',', '.') ?> €)
</span>
<?php endif; ?>
</strong>
</a>
<form method="post" action="/bank/<?= (int)$lbt['id'] ?>/unlink"
data-confirm="Verknüpfung mit dieser Bankbuchung wirklich aufheben?">
<input type="hidden" name="beleg_id" value="<?= (int)$beleg['id'] ?>">
<input type="hidden" name="redirect_to" value="/belege/<?= (int)$beleg['id'] ?>">
<button type="submit" class="btn btn-secondary btn-sm">Verknüpfung aufheben</button>
</form>
</div>
</div>
</div>
<?php endif; ?>
<!-- BankBuchung-Badge (ersetzt KI-Badge wenn aus Bank-Kontext) -->
<?php if (!$beleg['locked'] && ($bankTx ?? null)): ?>
<div class="card mb-2" style="border-left:4px solid var(--color-primary,#3b82f6)">
<div class="card-body" style="padding:.75rem 1.25rem">
<div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
<span class="badge badge-blue" style="font-size:.8rem">Bankbuchung</span>
<span class="td-mono text-sm"><?= View::formatDate($bankTx['wertstellung']) ?></span>
<span class="text-sm"><?= $h($bankTx['vorgang']) ?></span>
<span class="text-sm" style="flex:1;min-width:0;display:inline-flex;align-items:baseline;gap:.35rem;overflow:hidden">
<?php if (!empty($bankTx['kuerzel'])): ?>
<?php $kf = $bankTx['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($bankTx['kuerzel']) ?></span>
<?php endif; ?>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="<?= $h($bankTx['buchungstext']) ?>"><?= $h($bankTx['buchungstext']) ?></span>
</span>
<?php $bwert = (float)$bankTx['wert']; ?>
<strong style="color:<?= $bwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>;white-space:nowrap">
<?= $bwert >= 0 ? '+' : '' ?><?= number_format($bwert, 2, ',', '.') ?> €
</strong>
</div>
</div>
</div>
<?php elseif (!$beleg['locked'] && ($kiSektion ?? false)): ?>
<!-- KI-Kontierungsvorschlag -->
<div id="ki-badge" style="margin-bottom:1rem"></div>
<?php endif; ?>
<!-- Neue Buchungszeile erfassen -->
<?php if (!$beleg['locked']): ?>
<?php
$formErrors = $formErrors ?? [];
$formData = $formData ?? [];
// Default buchungstyp aus Belegtyp ableiten
$defaultTyp = $formData['buchungstyp'] ?? (
$beleg['belegtyp'] === 'Ausgangsrechnung' ? 'Einnahme' : 'Ausgabe'
);
// Konten nach Art gruppieren (Zahlungskonto nur 1200–1299)
$kontenGruppen = [];
foreach ($accounts as $acc) {
if ($acc['kontenart'] === 'Zahlungskonto') {
$nr = (int)$acc['kontonummer'];
if ($nr < 1200 || $nr > 1299) continue;
}
$kontenGruppen[$acc['kontenart']][] = $acc;
}
?>
<div class="card">
<div class="card-header">
<div class="card-title">Neue Buchungszeile</div>
</div>
<div class="card-body">
<?php if (!empty($formErrors)): ?>
<div class="flash flash-error" style="margin-bottom:1rem">
<?php if (isset($formErrors['_exception'])): ?>
Fehler: <?= $h($formErrors['_exception']) ?>
<?php else: ?>
<?= implode(' &nbsp;|&nbsp; ', array_map(fn($k,$v) => "<strong>$k:</strong> $v", array_keys($formErrors), $formErrors)) ?>
<?php endif; ?>
</div>
<?php endif; ?>
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings">
<?php
// bank_tx_id und alloc_betrag aus aktiver Session ODER aus POST-Daten beim Re-Render nach Validierungsfehler
$effectiveBankTxId = ($bankTx ?? null) ? (int)$bankTx['id'] : (int)($formData['bank_tx_id'] ?? 0);
$effectiveAllocBetrag = $formData['alloc_betrag'] ?? '';
?>
<?php if ($effectiveBankTxId > 0): ?>
<input type="hidden" name="bank_tx_id" value="<?= $effectiveBankTxId ?>">
<input type="hidden" name="alloc_betrag" value="<?= $h($effectiveAllocBetrag) ?>">
<?php endif; ?>
<!-- Buchungstyp -->
<div class="form-group">
<label>Buchungstyp *</label>
<div style="display:flex;gap:1rem">
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer">
<input type="radio" name="buchungstyp" value="Ausgabe"
<?= $defaultTyp === 'Ausgabe' ? 'checked' : '' ?>
onchange="filterKategorieKonten(this.value)">
<span class="badge badge-red">Betriebsausgabe</span>
</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer">
<input type="radio" name="buchungstyp" value="Einnahme"
<?= $defaultTyp === 'Einnahme' ? 'checked' : '' ?>
onchange="filterKategorieKonten(this.value)">
<span class="badge badge-green">Betriebseinnahme</span>
</label>
</div>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Zahldatum *</label>
<input type="date" name="buchungs_datum"
value="<?= $h($formData['buchungs_datum'] ?? date('Y-m-d')) ?>" required
class="<?= isset($formErrors['buchungs_datum']) ? 'input-error' : '' ?>">
</div>
<div class="form-group">
<label>USt-Satz (%)</label>
<input type="hidden" name="ust_satz" id="ust_satz_hidden" value="<?= $h($formData['ust_satz'] ?? '19') ?>">
<select id="ust_satz_select" onchange="updateSteuerartVisibility();updateNettoAnzeige();document.getElementById('ust_satz_hidden').value=this.value">
<?php foreach (['0','7','19'] as $s): ?>
<option value="<?= $s ?>"<?= ($formData['ust_satz'] ?? '19') === $s ? ' selected' : '' ?>><?= $s ?> %</option>
<?php endforeach; ?>
</select>
</div>
</div>
<!-- Steuerart (für Auslandsumsätze) -->
<div class="form-group" id="steuerart_group">
<label>Steuerart (Ausland / EU)</label>
<select name="steuerart" id="steuerart_select">
<?php
$steuerartOptionen = [
'normal' => 'Normal (Inland)',
'ig_lieferung' => 'Steuerfreie ig. Lieferung § 4 Nr. 1b (Kz 41)',
'export' => 'Steuerfreie Ausfuhr § 4 Nr. 1a (Kz 44)',
'ig_erwerb_19' => 'Innergemeinschaftlicher Erwerb 19 % – ReverseCharge (Kz 89/91/67)',
];
$currentSteuerart = $formData['steuerart'] ?? 'normal';
foreach ($steuerartOptionen as $val => $label): ?>
<option value="<?= $val ?>"<?= $currentSteuerart === $val ? ' selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select>
<p class="text-muted text-sm" style="margin-top:.25rem">
Nur relevant bei USt-Satz 0 % für Auslandsumsätze und EU-Erwerbe.
</p>
</div>
<!-- Kategorie-Konto -->
<div class="form-group">
<label for="kategorie_konto_id">Kategorie *
<span id="kategorie_konto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
</label>
<input type="search" name="kategorie_kontonummer" placeholder="Kontonr. oder Bezeichnung filtern – oder direkt eingeben" class="konto-filter"
data-target="kategorie_konto_id" autocomplete="off" style="margin-bottom:.3rem"
value="<?= $h($formData['kategorie_kontonummer'] ?? '') ?>">
<select id="kategorie_konto_id" name="kategorie_konto_id" size="6"
onchange="showKontoArt('kategorie_konto_id','kategorie_konto_art',this);updateEigenanteilState();updateBewirtungsState();updateAutoUst();checkAnlagevermoegen()">
<option value="">– Konto wählen –</option>
<?php foreach (['Betriebsausgabe', 'Betriebseinnahme', 'Anlagevermögen', 'Privat', 'Verbindlichkeit'] as $art): ?>
<?php if (empty($kontenGruppen[$art])) continue; ?>
<optgroup label="── <?= $h($art) ?> ──" data-kontenart="<?= $h($art) ?>">
<?php foreach ($kontenGruppen[$art] as $acc): ?>
<option value="<?= $h($acc['id']) ?>"
data-art="<?= $h($acc['kontenart']) ?>"
data-kontonummer="<?= $h($acc['kontonummer']) ?>"
data-steuercode="<?= $h($acc['steuercode'] ?? '') ?>"
data-search="<?= $h($acc['kontonummer'] . ' ' . $acc['bezeichnung'] . ' ' . $acc['kontenart']) ?>">
<?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
</select>
</div>
<!-- Zahlungskonto -->
<div class="form-group">
<label for="zahlungskonto_id">Zahlungskonto *
<span id="zahlungskonto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
</label>
<input type="search" name="zahlungs_kontonummer" placeholder="Kontonr. oder Bezeichnung filtern – oder direkt eingeben" class="konto-filter"
data-target="zahlungskonto_id" autocomplete="off" style="margin-bottom:.3rem"
value="<?= $h($formData['zahlungs_kontonummer'] ?? '') ?>">
<select id="zahlungskonto_id" name="zahlungskonto_id" size="4"
onchange="showKontoArt('zahlungskonto_id','zahlungskonto_art',this)">
<option value="">– Konto wählen –</option>
<?php foreach (['Zahlungskonto', 'Verbindlichkeit'] as $art): ?>
<?php if (empty($kontenGruppen[$art])) continue; ?>
<optgroup label="── <?= $h($art) ?> ──">
<?php foreach ($kontenGruppen[$art] as $acc): ?>
<option value="<?= $h($acc['id']) ?>"
data-art="<?= $h($acc['kontenart']) ?>"
data-search="<?= $h($acc['kontonummer'] . ' ' . $acc['bezeichnung'] . ' ' . $acc['kontenart']) ?>">
<?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
</select>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Nettobetrag (€) *</label>
<input type="number" name="betrag_netto" id="betrag_netto_input" step="0.01" required
placeholder="0.00"
value="<?= $h($formData['betrag_netto'] ?? '') ?>"
class="<?= isset($formErrors['betrag_netto']) ? 'input-error' : '' ?>"
oninput="updateNettoAnzeige()">
<p id="ust_brutto_anzeige" class="text-muted text-sm" style="margin-top:.25rem;line-height:1.6"></p>
</div>
<div class="form-group">
<label>Buchungstext *</label>
<?php
$defaultText = $formData['buchungstext']
?? ($beleg['rechnungs_nr']
? 'Rechnung ' . $beleg['rechnungs_nr']
: $beleg['belegtyp'] . ' ' . $beleg['beleg_nr']);
?>
<input type="text" name="buchungstext" required
value="<?= $h($defaultText) ?>"
class="<?= isset($formErrors['buchungstext']) ? 'input-error' : '' ?>">
</div>
</div>
<!-- Bewirtungsfelder (nur bei Konto 4650/4651) -->
<div id="bewirtungsfelder" style="display:none">
<div class="flash flash-info flash-persistent" style="margin-bottom:.75rem;padding:.5rem .75rem;font-size:.85rem">
<strong>Bewirtungsausgabe:</strong> 70 % werden auf das gewählte Konto gebucht, 30 % automatisch auf das Abzugskonto. Die Vorsteuer wird vollständig berücksichtigt.
</div>
<div class="form-group">
<label for="bewirtungsgrund_input">Bewirtungsgrund *</label>
<textarea name="bewirtungsgrund" id="bewirtungsgrund_input"
style="height:2.4em;resize:none;overflow:hidden"
oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px'"
placeholder="Anlass/Grund der Bewirtung"><?= $h($formData['bewirtungsgrund'] ?? $beleg['bewirtungsgrund'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label for="teilnehmerliste_input">Teilnehmerliste *</label>
<textarea name="teilnehmerliste" id="teilnehmerliste_input"
style="height:5.4em;resize:vertical"
placeholder="Namen der Teilnehmer"><?= $h($formData['teilnehmerliste'] ?? $beleg['teilnehmerliste'] ?? '') ?></textarea>
</div>
</div>
<div class="form-group" title="Nur bei Telefon/Internet 19% wählbar" style="margin-bottom:.75rem">
<label id="eigenanteil_group"
style="display:inline-flex;align-items:center;gap:.5rem;opacity:.35;pointer-events:none;cursor:not-allowed">
<input type="checkbox" id="eigenanteil_30_cb" name="eigenanteil_30" value="1" disabled>
<span>30% Eigenanteil buchen</span>
</label>
</div>
<!-- hidden: wird bei Auswahl eines bestehenden Anlageguts gesetzt -->
<input type="hidden" name="asset_id" id="asset_id_hidden" value="">
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
</form>
</div>
</div>
<?php if ($bankTx ?? null): ?>
<script>
(function () {
const brutto = <?= (float)($bankTxBrutto ?? 0) ?>;
let manualNetto = false;
const nettoInput = document.getElementById('betrag_netto_input');
const ustSelect = document.getElementById('ust_satz_select');
// Netto aus Brutto bei USt-Wechsel neu berechnen (solange nicht manuell geändert)
function recalcNetto() {
if (manualNetto || !brutto) return;
const ust = parseFloat(ustSelect.value) / 100;
const netto = Math.round(brutto / (1 + ust) * 100) / 100;
nettoInput.value = netto.toFixed(2);
if (typeof updateNettoAnzeige === 'function') updateNettoAnzeige();
}
nettoInput.addEventListener('input', function () { manualNetto = true; });
ustSelect.addEventListener('change', recalcNetto);
// updateAutoUst ist in einem späteren <script>-Block definiert und zum jetzigen
// Zeitpunkt noch nicht vorhanden – Wrapper daher erst in DOMContentLoaded setzen.
document.addEventListener('DOMContentLoaded', function () {
const _origAutoUst = window.updateAutoUst;
window.updateAutoUst = function () {
_origAutoUst.apply(this, arguments);
recalcNetto();
};
});
// Zahlungskonto 1200 vorauswählen
const zkSelect = document.getElementById('zahlungskonto_id');
if (zkSelect) {
for (const opt of zkSelect.options) {
if ((opt.dataset.search || '').startsWith('1200 ')) {
opt.selected = true;
zkSelect.dispatchEvent(new Event('change'));
break;
}
}
}
// Meta-Formular mit Lieferant aufklappen wenn noch nicht gespeichert
<?php if (!empty($bankTxSupplier) && empty($beleg['supplier_name'])): ?>
const metaForm = document.getElementById('beleg-meta-form');
if (metaForm) metaForm.style.display = 'block';
<?php endif; ?>
})();
</script>
<?php endif; ?>
<?php else: ?>
<div class="flash flash-info">Dieser Beleg ist gesperrt (GoBD). Keine weiteren Buchungen möglich.</div>
<?php endif; ?>
</div>
</div>
<!-- ── Modal: Anlagevermögen ──────────────────────────────────── -->
<div id="modal-anlagevermoegen" class="modal-overlay" role="dialog" aria-modal="true">
<div class="modal" style="max-width:640px;width:100%">
<div class="modal-header">
<h3 class="modal-title">Anlagevermögen buchen</h3>
</div>
<div class="modal-body">
<p style="margin-bottom:1rem">Das gewählte Konto gehört zum Anlagevermögen. Soll ein neues Anlagegut angelegt oder ein bestehendes ausgewählt werden?</p>
<div style="display:flex;flex-direction:column;gap:.6rem;margin-bottom:1.25rem">
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer">
<input type="radio" name="anlage_wahl" id="anlage_radio_neu" value="neu" checked>
<span>Neues Anlagegut anlegen</span>
</label>
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer">
<input type="radio" name="anlage_wahl" id="anlage_radio_vorhanden" value="vorhanden">
<span>Bestehendes Anlagegut auswählen</span>
</label>
</div>
<!-- Liste bestehender Anlagegüter (nur bei "vorhanden") -->
<div id="anlage_liste_wrap" style="display:none">
<p id="anlage_liste_leer" class="text-muted text-sm" style="display:none">
Für dieses Konto sind noch keine Anlagegüter vorhanden.
</p>
<div class="table-wrap" id="anlage_liste_table_wrap">
<table>
<thead>
<tr>
<th style="width:3rem">Nr.</th>
<th>Wirtschaftsgut</th>
<th>Anschaffungsdatum</th>
<th class="td-num">Anschaffungskosten</th>
<th style="width:2.5rem"></th>
</tr>
</thead>
<tbody id="anlage_liste_tbody"></tbody>
</table>
</div>
</div>
</div>
<div class="modal-footer" style="display:flex;justify-content:flex-end;gap:.75rem;padding:1rem 1.5rem;border-top:1px solid var(--color-border)">
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button type="button" class="btn btn-primary" id="anlage_modal_ok">Weiter</button>
</div>
</div>
</div>
<?php
// Assets als JSON für JS vorbereiten
$assetsJson = json_encode(array_map(fn($a) => [
'id' => (int)$a['id'],
'lfd_nr' => (int)$a['lfd_nr'],
'bezeichnung' => $a['bezeichnung'],
'anschaffungsdatum' => $a['anschaffungsdatum'],
'anschaffungskosten' => (float)$a['anschaffungskosten'],
'account_id' => $a['account_id'] !== null ? (int)$a['account_id'] : null,
], $assets ?? []), JSON_HEX_TAG);
?>
<script>
const _assets = <?= $assetsJson ?>;
// Formatierung Datum de-DE
function _fmtDate(str) {
if (!str) return '–';
const [y, m, d] = str.split('-');
return d + '.' + m + '.' + y;
}
function _fmtEur(n) {
return n.toLocaleString('de-DE', {minimumFractionDigits:2, maximumFractionDigits:2}) + ' €';
}
// Wird aufgerufen wenn Kategorie-Konto geändert wird
function checkAnlagevermoegen() {
const sel = document.getElementById('kategorie_konto_id');
if (!sel) return;
const opt = sel.options[sel.selectedIndex];
if (!opt || opt.value === '') return;
const art = opt.getAttribute('data-art') || '';
if (art !== 'Anlagevermögen') {
// Kein AV-Konto – Asset-Auswahl zurücksetzen
document.getElementById('asset_id_hidden').value = '';
return;
}
// AV-Konto gewählt → Modal öffnen
const accountId = parseInt(opt.value, 10);
_openAnlageModal(accountId);
}
function _openAnlageModal(accountId) {
// Radio auf "neu" zurücksetzen
document.getElementById('anlage_radio_neu').checked = true;
document.getElementById('anlage_liste_wrap').style.display = 'none';
document.getElementById('asset_id_hidden').value = '';
// Liste für dieses Konto befüllen
const passend = _assets.filter(a => a.account_id === accountId);
_renderAnlageListe(passend);
document.getElementById('modal-anlagevermoegen').classList.add('open');
}
function _renderAnlageListe(list) {
const tbody = document.getElementById('anlage_liste_tbody');
const leer = document.getElementById('anlage_liste_leer');
const table = document.getElementById('anlage_liste_table_wrap');
tbody.innerHTML = '';
if (list.length === 0) {
leer.style.display = '';
table.style.display = 'none';
return;
}
leer.style.display = 'none';
table.style.display = '';
list.forEach(a => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.dataset.assetId = a.id;
tr.innerHTML =
'<td class="td-mono text-sm">' + a.lfd_nr + '</td>' +
'<td>' + _esc(a.bezeichnung) + '</td>' +
'<td class="td-muted text-sm">' + _fmtDate(a.anschaffungsdatum) + '</td>' +
'<td class="td-num">' + _fmtEur(a.anschaffungskosten) + '</td>' +
'<td><input type="radio" name="anlage_auswahl" value="' + a.id + '"></td>';
tbody.appendChild(tr);
// Klick auf Zeile wählt Radio
tr.addEventListener('click', () => {
const radio = tr.querySelector('input[type="radio"]');
if (radio) radio.checked = true;
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('row-selected'));
tr.classList.add('row-selected');
});
});
}
function _esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Radio-Wahl: neu / vorhanden
document.addEventListener('change', e => {
if (e.target.name !== 'anlage_wahl') return;
const wrap = document.getElementById('anlage_liste_wrap');
wrap.style.display = e.target.value === 'vorhanden' ? '' : 'none';
if (e.target.value === 'neu') {
document.getElementById('asset_id_hidden').value = '';
document.querySelectorAll('input[name="anlage_auswahl"]').forEach(r => r.checked = false);
document.querySelectorAll('#anlage_liste_tbody tr').forEach(r => r.classList.remove('row-selected'));
}
});
// OK-Button
document.getElementById('anlage_modal_ok').addEventListener('click', () => {
const wahl = document.querySelector('input[name="anlage_wahl"]:checked')?.value;
if (wahl === 'vorhanden') {
const gewählt = document.querySelector('input[name="anlage_auswahl"]:checked');
if (!gewählt) {
alert('Bitte ein Anlagegut aus der Liste auswählen.');
return;
}
document.getElementById('asset_id_hidden').value = gewählt.value;
} else {
// "neu" – asset_id bleibt leer, Backend legt neues Gut an
document.getElementById('asset_id_hidden').value = '';
}
document.getElementById('modal-anlagevermoegen').classList.remove('open');
});
</script>
<script>
function filterKategorieKonten(buchungstyp) {
const select = document.getElementById('kategorie_konto_id');
const groups = select.querySelectorAll('optgroup');
const zielArt = buchungstyp === 'Einnahme' ? 'Betriebseinnahme' : 'Betriebsausgabe';
groups.forEach(function(g) {
const art = g.getAttribute('data-kontenart');
if (!art) return;
// Privat, Verbindlichkeit und Anlagevermögen immer anzeigen, Kategorie nach Typ filtern
g.style.display = (art === zielArt || art === 'Privat' || art === 'Verbindlichkeit' || art === 'Anlagevermögen') ? '' : 'none';
});
// Auswahl zurücksetzen wenn aktuell gewähltes Konto nicht mehr sichtbar
if (select.selectedIndex > 0) {
const selected = select.options[select.selectedIndex];
if (selected && selected.closest('optgroup') && selected.closest('optgroup').style.display === 'none') {
select.selectedIndex = 0;
}
}
updateEigenanteilState();
}
function updateAutoUst() {
const kategorieEl = document.getElementById('kategorie_konto_id');
const ustSatzEl = document.getElementById('ust_satz_select');
if (!kategorieEl || !ustSatzEl) return;
const selectedOpt = kategorieEl.options[kategorieEl.selectedIndex];
const steuercode = selectedOpt ? (selectedOpt.getAttribute('data-steuercode') || '') : '';
const kontonummer = selectedOpt ? (selectedOpt.getAttribute('data-kontonummer') || '') : '';
const ustMap = { 'VSt19': '19', 'USt19': '19', 'VSt7': '7', 'USt7': '7' };
// 4652/4657 immer 0 % (keine Vorsteuer bei pauschalen Bewirtungskosten ohne USt-Ausweis)
const autoUst = (['4652','4657'].includes(kontonummer)) ? '0' : (ustMap[steuercode] || null);
const hiddenEl = document.getElementById('ust_satz_hidden');
if (autoUst !== null) {
ustSatzEl.value = autoUst;
if (hiddenEl) hiddenEl.value = autoUst;
}
ustSatzEl.disabled = false;
ustSatzEl.title = '';
if (hiddenEl && autoUst === null) hiddenEl.value = ustSatzEl.value;
updateSteuerartVisibility();
updateNettoAnzeige();
}
function updateBewirtungsState() {
const kategorieEl = document.getElementById('kategorie_konto_id');
const felder = document.getElementById('bewirtungsfelder');
if (!kategorieEl || !felder) return;
const selectedOpt = kategorieEl.options[kategorieEl.selectedIndex];
const kontonummer = selectedOpt ? (selectedOpt.getAttribute('data-kontonummer') || '') : '';
const istBewirtung = ['4650','4651','4652'].includes(kontonummer);
felder.style.display = istBewirtung ? '' : 'none';
const grundEl = document.getElementById('bewirtungsgrund_input');
const teilnEl = document.getElementById('teilnehmerliste_input');
if (grundEl) grundEl.required = istBewirtung;
if (teilnEl) teilnEl.required = istBewirtung;
}
function updateEigenanteilState() {
const group = document.getElementById('eigenanteil_group');
const cb = document.getElementById('eigenanteil_30_cb');
if (!group || !cb) return;
const buchungstyp = document.querySelector('input[name="buchungstyp"]:checked');
const kategorieEl = document.getElementById('kategorie_konto_id');
const ustSatzEl = document.getElementById('ust_satz_select');
const istAusgabe = buchungstyp && buchungstyp.value === 'Ausgabe';
const selectedOpt = kategorieEl && kategorieEl.options[kategorieEl.selectedIndex];
const kontonummer = selectedOpt ? (selectedOpt.getAttribute('data-search') || '').split(' ')[0] : '';
const istTelInternet = kontonummer === '4920' || kontonummer === '4925';
const ist19 = ustSatzEl && ustSatzEl.value === '19';
const aktiv = istAusgabe && istTelInternet && ist19;
group.style.opacity = aktiv ? '1' : '.35';
group.style.pointerEvents = aktiv ? '' : 'none';
group.style.cursor = aktiv ? 'pointer' : 'not-allowed';
cb.disabled = !aktiv;
if (!aktiv) cb.checked = false;
}
// Initial filtern
document.addEventListener('DOMContentLoaded', function() {
const checked = document.querySelector('input[name="buchungstyp"]:checked');
if (checked) filterKategorieKonten(checked.value);
updateSteuerartVisibility();
updateEigenanteilState();
updateBewirtungsState();
updateAutoUst();
updateNettoAnzeige();
// Leeres Lieferantenfeld fokussieren wenn kein KI-Block aktiv
const sup = document.getElementById('supplier-persistent-input');
if (sup && !document.getElementById('ki-badge')) sup.focus();
});
function updateSteuerartVisibility() {
const ustSatz = document.getElementById('ust_satz_select');
const steuerartGroup = document.getElementById('steuerart_group');
const steuerartSelect = document.getElementById('steuerart_select');
if (!ustSatz || !steuerartGroup) return;
if (ustSatz.value === '0') {
steuerartGroup.style.display = '';
} else {
steuerartGroup.style.display = 'none';
if (steuerartSelect) steuerartSelect.value = 'normal';
}
updateEigenanteilState();
}
function updateNettoAnzeige() {
const nettoEl = document.getElementById('betrag_netto_input');
const ustEl = document.getElementById('ust_satz_select');
const anzeige = document.getElementById('ust_brutto_anzeige');
if (!nettoEl || !ustEl || !anzeige) return;
const netto = parseFloat(nettoEl.value);
if (isNaN(netto) || nettoEl.value === '') {
anzeige.textContent = '';
return;
}
const ustSatz = parseFloat(ustEl.value) || 0;
const ust = Math.round(netto * ustSatz / 100 * 100) / 100;
const brutto = Math.round((netto + ust) * 100) / 100;
const fmt = v => v.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' €';
anzeige.innerHTML = 'USt. ' + fmt(ust) + ' &nbsp;·&nbsp; Brutto ' + fmt(brutto);
}
</script>
<?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
<script>
const _belegId = <?= (int)$beleg['id'] ?>;
const _kiAktiv = <?= json_encode((bool)($kiAktiv ?? false)) ?>;
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
function _selectById(selectEl, id) {
if (!selectEl) return;
const str = String(id);
for (const opt of selectEl.options) {
opt.selected = (opt.value === str);
}
}
function _applyKiVorschlag(v) {
const btRadio = document.querySelector('input[name="buchungstyp"][value="' + v.buchungstyp + '"]');
if (btRadio) { btRadio.checked = true; filterKategorieKonten(v.buchungstyp); }
const ustEl = document.getElementById('ust_satz_select');
if (ustEl) { ustEl.value = String(v.ust_satz); updateSteuerartVisibility(); }
const katEl2 = document.getElementById('kategorie_konto_id');
_selectById(katEl2, v.kategorie_konto_id);
if (katEl2) transferKontoNr(katEl2);
updateEigenanteilState();
const zhlEl2 = document.getElementById('zahlungskonto_id');
_selectById(zhlEl2, v.zahlungskonto_id);
if (zhlEl2) transferKontoNr(zhlEl2);
const netEl = document.querySelector('input[name="betrag_netto"]');
if (netEl && v.betrag_netto) { netEl.value = v.betrag_netto; updateNettoAnzeige(); }
const txtEl = document.querySelector('input[name="buchungstext"]');
if (txtEl && v.buchungstext) txtEl.value = v.buchungstext;
// Lieferant aus KI-Vorschlag übernehmen
if (v.supplier_name) {
const bar = document.querySelector('[data-beleg-id]');
const persistentInput = document.getElementById('supplier-persistent-input');
const trigger = document.getElementById('supplier-inline-trigger');
if (persistentInput) {
persistentInput.value = v.supplier_name;
if (bar) bar.dataset.supplierName = v.supplier_name;
if (v.supplier_found === false) persistentInput.focus();
} else if (trigger) {
trigger.dataset.raw = v.supplier_name;
if (bar) bar.dataset.supplierName = v.supplier_name;
trigger.textContent = v.supplier_name;
if (v.supplier_found === false && !trigger.querySelector('input')) {
trigger.click();
}
}
}
}
function _showKiBadgeDone(arr) {
// arr ist immer ein Array (bereits normalisiert)
const v = arr[0];
const katEl = document.getElementById('kategorie_konto_id');
const zhlEl = document.getElementById('zahlungskonto_id');
const katOpt = katEl ? katEl.querySelector('option[value="' + v.kategorie_konto_id + '"]') : null;
const zhlOpt = zhlEl ? zhlEl.querySelector('option[value="' + v.zahlungskonto_id + '"]') : null;
const katTxt = katOpt ? katOpt.text : v.kategorie_konto_id;
const zhlTxt = zhlOpt ? zhlOpt.text : v.zahlungskonto_id;
const supTxt = v.supplier_name
? ' · Lieferant: ' + v.supplier_name + (v.supplier_found === false ? ' <span style="color:var(--color-warning);font-weight:600">(neu – bitte prüfen)</span>' : '')
: '';
// Erste Zeile (bereits ins Formular übertragen)
let details =
(v.buchungstyp ? v.buchungstyp + ' · ' : '') +
'Kategorie: ' + katTxt + ' · ' +
'Zahlung: ' + zhlTxt + supTxt;
// Alle Buchungszeilen mit Beträgen anzeigen
const fmt = n => parseFloat(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
let zeilen = '';
arr.forEach((z, i) => {
const netto = parseFloat(z.betrag_netto) || 0;
const ust = Math.round(netto * z.ust_satz / 100 * 100) / 100;
const brutto = Math.round((netto + ust) * 100) / 100;
const hinweis = (i > 0) ? ' <span style="color:var(--color-muted);font-style:italic">(bitte als weitere Buchung erfassen)</span>' : '';
zeilen += '<br><span style="font-size:.85em">Zeile ' + (i + 1) + ': '
+ 'Netto ' + fmt(netto) + ' € | USt ' + z.ust_satz + '% ' + fmt(ust) + ' € | Brutto ' + fmt(brutto) + ' €'
+ hinweis + '</span>';
});
const neuBtn = _kiAktiv
? '<button class="btn btn-secondary btn-sm" style="flex-shrink:0;margin-top:.1rem" onclick="_startKiAnalyse()">Neu generieren</button>'
: '';
document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem">' +
'<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span>' + zeilen + '</div>' +
neuBtn +
'</div>';
}
function _showKiBadgeNoResult() {
const neuBtn = _kiAktiv
? ' <button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button>'
: '';
document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-warning">Kein Kontierungsvorschlag generiert.' + neuBtn + '</div>';
}
function _showKiBadgeLoading() {
document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-info" style="opacity:.65">Analysiere Beleg … bitte warten.</div>';
}
function _showKiBadgeError() {
document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-warning">Fehler bei der KI-Analyse. ' +
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button></div>';
}
// Normalisiert _kiVorschlag zu einem Array (Objekt oder Array aus DB möglich).
// Gibt null zurück wenn kein Ergebnis vorhanden.
// Gibt leeres Array [] zurück wenn verarbeitet aber kein Ergebnis erzeugt.
function _normalizeKiArr(raw) {
if (raw === null || raw === undefined) return null;
const arr = Array.isArray(raw) ? raw : [raw];
// Leeres Array = "verarbeitet, kein Ergebnis"
if (arr.length === 0) return [];
// Mindestens erstes Element muss gültig sein
return (arr[0] && arr[0].kategorie_konto_id) ? arr : [];
}
async function _startKiAnalyse() {
await fetch('/belege/' + _belegId + '/vorschlag/reset', {method: 'POST'});
_showKiBadgeLoading();
try {
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
const raw = await r.json();
const arr = _normalizeKiArr(raw);
if (arr && arr.length > 0) { _applyKiVorschlag(arr[0]); _showKiBadgeDone(arr); }
else _showKiBadgeNoResult();
} catch (e) {
_showKiBadgeError();
}
}
document.addEventListener('DOMContentLoaded', function () {
const arr = _normalizeKiArr(_kiVorschlag);
if (arr === null) {
if (_kiAktiv) { _startKiAnalyse(); }
} else if (arr.length === 0) {
// Verarbeitet, aber kein Ergebnis
_showKiBadgeNoResult();
} else {
// Vorschlag vorhanden → ins Formular übernehmen und anzeigen
_applyKiVorschlag(arr[0]);
_showKiBadgeDone(arr);
}
});
</script>
<?php endif; ?>
<?php if (!$beleg['locked'] && in_array($beleg['mime_type'], ['image/jpeg', 'image/png'])): ?>
<!-- Crop-Modal -->
<div class="modal-overlay" id="crop-modal-overlay" style="display:none;align-items:center;justify-content:center">
<div class="modal" style="max-width:95vw;width:auto;padding:1.25rem">
<div class="modal-title" style="margin-bottom:.75rem">Beleg zuschneiden</div>
<p class="text-sm text-muted" style="margin-bottom:.75rem">
Ziehe einen Rahmen über den gewünschten Ausschnitt. Das Original wird unwiderruflich ersetzt.
</p>
<div id="crop-canvas-wrap" style="position:relative;display:inline-block;cursor:crosshair;line-height:0;border:1px solid var(--color-border);border-radius:var(--radius)">
<canvas id="crop-canvas"></canvas>
<div id="crop-spinner" style="display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:rgba(0,0,0,.35);border-radius:var(--radius)">
<svg width="40" height="40" viewBox="0 0 40 40" style="animation:crop-spin 0.8s linear infinite">
<circle cx="20" cy="20" r="16" fill="none" stroke="rgba(255,255,255,.25)" stroke-width="4"/>
<path d="M20 4 A16 16 0 0 1 36 20" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
</svg>
</div>
</div>
<style>@keyframes crop-spin{to{transform:rotate(360deg)}}</style>
<div style="margin-top:.75rem;display:flex;gap:.5rem;align-items:center">
<button type="button" class="btn btn-primary btn-sm" id="crop-confirm-btn" disabled onclick="confirmCrop()">Zuschnitt übernehmen</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="closeCropModal()">Abbrechen</button>
<span id="crop-size-hint" class="text-muted text-sm"></span>
</div>
</div>
</div>
<script>
(function() {
const BELEG_ID = <?= (int)$beleg['id'] ?>;
const FILE_URL = '/belege/<?= (int)$beleg['id'] ?>/file';
const overlay = document.getElementById('crop-modal-overlay');
const canvas = document.getElementById('crop-canvas');
const confirmBtn = document.getElementById('crop-confirm-btn');
const sizeHint = document.getElementById('crop-size-hint');
const ctx = canvas.getContext('2d');
let img = null;
let scaleX = 1, scaleY = 1;
let sel = null; // {x,y,w,h} in Canvas-Koordinaten
let dragging = false;
let startX = 0, startY = 0;
const spinner = document.getElementById('crop-spinner');
window.openCropModal = function() {
overlay.style.display = 'flex';
if (img) { drawCanvas(); return; }
spinner.style.display = 'flex';
img = new Image();
img.onload = function() {
// Canvas auf max. 90vw / 72vh skalieren
const maxW = Math.min(window.innerWidth * 0.90, 1200);
const maxH = Math.min(window.innerHeight * 0.72, 900);
const ratio = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1);
canvas.width = Math.round(img.naturalWidth * ratio);
canvas.height = Math.round(img.naturalHeight * ratio);
scaleX = img.naturalWidth / canvas.width;
scaleY = img.naturalHeight / canvas.height;
sel = null;
spinner.style.display = 'none';
drawCanvas();
};
img.src = FILE_URL + '?nocache=' + Date.now();
};
window.closeCropModal = function() {
overlay.style.display = 'none';
};
function drawCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
if (!sel || sel.w === 0 || sel.h === 0) return;
// Abdunkeln außerhalb der Auswahl
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Ausschnitt freistellen
ctx.drawImage(img,
sel.x * scaleX, sel.y * scaleY, sel.w * scaleX, sel.h * scaleY,
sel.x, sel.y, sel.w, sel.h);
// Rahmen
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(sel.x + 0.5, sel.y + 0.5, sel.w, sel.h);
ctx.setLineDash([]);
const realW = Math.round(sel.w * scaleX);
const realH = Math.round(sel.h * scaleY);
sizeHint.textContent = realW + ' × ' + realH + ' px';
confirmBtn.disabled = (realW < 10 || realH < 10);
}
function canvasPos(e) {
const r = canvas.getBoundingClientRect();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
return {
x: Math.max(0, Math.min(canvas.width, Math.round(cx - r.left))),
y: Math.max(0, Math.min(canvas.height, Math.round(cy - r.top))),
};
}
canvas.addEventListener('mousedown', startDrag);
canvas.addEventListener('touchstart', startDrag, {passive: true});
function startDrag(e) {
const p = canvasPos(e);
startX = p.x; startY = p.y;
sel = {x: p.x, y: p.y, w: 0, h: 0};
dragging = true;
confirmBtn.disabled = true;
sizeHint.textContent = '';
}
window.addEventListener('mousemove', moveDrag);
window.addEventListener('touchmove', moveDrag, {passive: true});
function moveDrag(e) {
if (!dragging) return;
const p = canvasPos(e);
sel = {
x: Math.min(startX, p.x),
y: Math.min(startY, p.y),
w: Math.abs(p.x - startX),
h: Math.abs(p.y - startY),
};
drawCanvas();
}
window.addEventListener('mouseup', endDrag);
window.addEventListener('touchend', endDrag);
function endDrag() { dragging = false; }
window.confirmCrop = async function() {
if (!sel || sel.w < 1 || sel.h < 1) return;
confirmBtn.disabled = true;
confirmBtn.textContent = 'Wird gespeichert…';
const payload = {
x: Math.round(sel.x * scaleX),
y: Math.round(sel.y * scaleY),
w: Math.round(sel.w * scaleX),
h: Math.round(sel.h * scaleY),
};
try {
const res = await fetch('/belege/' + BELEG_ID + '/crop', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Unbekannter Fehler');
location.reload();
} catch (err) {
alert('Fehler beim Zuschneiden: ' + err.message);
confirmBtn.disabled = false;
confirmBtn.textContent = 'Zuschnitt übernehmen';
}
};
// ESC schließt Modal
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && overlay.style.display !== 'none') closeCropModal();
});
// Klick auf Overlay-Hintergrund schließt
overlay.addEventListener('click', function(e) {
if (e.target === overlay) closeCropModal();
});
}());
</script>
<?php endif; ?>
<?php if (!$beleg['locked']): ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
const _sup = document.getElementById('supplier-persistent-input');
if (!_sup) return;
const _bar = _sup.closest('[data-beleg-id]');
async function _saveSupplier() {
const newValue = _sup.value.trim();
if (!_bar) return;
const body = new URLSearchParams({
rechnungs_nr: _bar.dataset.rechnungsNr || '',
belegtyp: _bar.dataset.belegtyp || '',
beleg_datum: _bar.dataset.belegDatum || '',
betrag_brutto: _bar.dataset.betragBrutto || '',
supplier_name: newValue,
});
try {
const res = await fetch('/belege/<?= (int)$beleg['id'] ?>/meta', {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded'},
body: body.toString(),
});
if (!res.ok) throw new Error();
if (newValue) {
_bar.dataset.supplierName = newValue;
// Hidden-Metaformular synchron halten
const hiddenInput = document.getElementById('supplier_name_input');
if (hiddenInput) hiddenInput.value = newValue;
// Persistentes Input durch Inline-Trigger ersetzen (= visuelle Bestätigung)
const span = document.createElement('span');
span.className = 'inline-edit-trigger';
span.id = 'supplier-inline-trigger';
span.dataset.field = 'supplier_name';
span.dataset.type = 'text';
span.dataset.raw = newValue;
span.title = 'Klicken zum Bearbeiten';
span.textContent = newValue;
_sup.replaceWith(span);
}
} catch (_) {
// Speichern fehlgeschlagen – Feld bleibt editierbar
}
}
_sup.addEventListener('blur', _saveSupplier);
_sup.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); _sup.blur(); }
});
});
</script>
<?php endif; ?>
<script>
(function () {
const STORAGE_KEY = 'beleg_split_width';
const layout = document.getElementById('beleg-split-layout');
const preview = document.getElementById('beleg-preview-pane');
const handle = document.getElementById('beleg-split-handle');
if (!layout || !preview || !handle) return;
function applyWidth(px) {
preview.style.width = px + 'px';
preview.style.setProperty('--beleg-preview-width', px + 'px');
}
function savedWidth() {
const stored = parseInt(localStorage.getItem(STORAGE_KEY), 10);
if (!isNaN(stored) && stored > 0) return stored;
return Math.round(layout.offsetWidth * 0.55);
}
applyWidth(savedWidth());
let dragging = false;
let startX = 0;
let startW = 0;
handle.addEventListener('mousedown', function (e) {
e.preventDefault();
dragging = true;
startX = e.clientX;
startW = preview.offsetWidth;
handle.classList.add('dragging');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
const delta = e.clientX - startX;
const total = layout.offsetWidth;
const minR = 320;
const maxW = total - minR - handle.offsetWidth;
const newW = Math.max(200, Math.min(maxW, startW + delta));
applyWidth(newW);
});
document.addEventListener('mouseup', function () {
if (!dragging) return;
dragging = false;
handle.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
localStorage.setItem(STORAGE_KEY, preview.offsetWidth);
});
}());
</script>
<?php if (str_starts_with($beleg['mime_type'] ?? '', 'image/')): ?>
<script>
(function () {
const ZOOM_KEY = 'beleg_img_zoom';
const STEP = 1.25;
const MIN = 0.25;
const MAX = 5;
const img = document.getElementById('beleg-img');
const wrap = document.getElementById('img-zoom-wrap');
const lvl = document.getElementById('img-zoom-level');
if (!img || !wrap) return;
let scale = 1;
function applyScale(s) {
scale = Math.max(MIN, Math.min(MAX, s));
img.style.width = Math.round(scale * 100) + '%';
if (lvl) lvl.textContent = Math.round(scale * 100) + ' %';
wrap.style.cursor = scale > 1 ? 'grab' : 'zoom-in';
localStorage.setItem(ZOOM_KEY, scale);
}
window.imgZoom = function (dir) { applyScale(dir > 0 ? scale * STEP : scale / STEP); };
window.imgZoomReset = function () { applyScale(1); };
const saved = parseFloat(localStorage.getItem(ZOOM_KEY));
if (!isNaN(saved) && saved > 0) applyScale(saved);
wrap.addEventListener('wheel', function (e) {
if (!e.ctrlKey) return;
e.preventDefault();
applyScale(e.deltaY < 0 ? scale * STEP : scale / STEP);
}, { passive: false });
document.addEventListener('keydown', function (e) {
if (!e.ctrlKey || !document.getElementById('beleg-img')) return;
if (e.key === '+' || e.key === '=') { e.preventDefault(); imgZoom(+1); }
if (e.key === '-') { e.preventDefault(); imgZoom(-1); }
if (e.key === '0') { e.preventDefault(); imgZoomReset(); }
});
}());
</script>
<?php endif; ?>