Fix #157: belege/detail.php in Teil-Views und externe JS-Dateien aufgeteilt

This commit is contained in:
fds2610-bot 2026-08-11 06:03:55 +02:00
parent 3acd0cea5c
commit d0a8a6dfb7
15 changed files with 1681 additions and 1545 deletions

View file

@ -2,6 +2,17 @@
--- ---
## 2026-08-11 – belege/detail.php in Teil-Views und externe JS-Dateien aufgeteilt (#157)
- `src/Views/belege/detail.php` war mit 1.548 Zeilen die mit Abstand größte Datei der Anwendung, davon 827 Zeilen JavaScript in neun `<script>`-Blöcken – teils mit einem `DOMContentLoaded`-Workaround, weil `updateAutoUst()` erst in einem späteren Block definiert wurde. Reine Strukturänderung, kein Verhaltensunterschied.
- **Teil-Views** (`src/Views/belege/`, per `include __DIR__ . '/_name.php'` eingebunden, Variablen stehen durch `extract()` in `BaseController::renderView()` bereits im Scope): `_infobar.php` (Beleg-Info-Bar), `_vorschau.php` (Split-Pane, Dokumentvorschau, Buchungsliste), `_buchungsform.php` (Buchungsformular inkl. Kontengruppierung), `_anlage_modal.php` (Anlagevermögen-Modal), `_crop_modal.php` (Zuschnitt-Modal, nur bei `image/jpeg`/`image/png` eingebunden). `_infobar.php` und `_vorschau.php` benötigen ein eigenes `use Steuerkram\View;`, da `use`-Deklarationen anders als Variablen nicht über `include` vererbt werden.
- **JavaScript** (`public/static/js/`, mit `?v=<?= ASSET_VERSION ?>` eingebunden wie `app.css`): `beleg-form.js` (Netto/Brutto-Umschalter, USt-Auto-Vorbelegung, Bewirtung/Eigenanteil, Netto-Neuberechnung bei verknüpfter Bankbuchung), `beleg-anlage.js` (Anlagevermögen-Auswahl), `beleg-ki.js` (KI-Kontierungsvorschlag, nur wenn `$kiSektion` und nicht gesperrt), `beleg-crop.js` (Bildzuschnitt, nur bei Bild-MIME-Typ), `beleg-ui.js` (Lieferanten-Autospeicherung, verschiebbarer Trenner, Bildzoom). `beleg-form.js` wird als erstes eingebunden, da es `updateAutoUst()` definiert – der bisherige `DOMContentLoaded`-Workaround (Zeile 645–673 der alten Datei) entfällt, weil Funktionsdeklarationen innerhalb derselben Datei bereits beim Parsen gehoben werden.
- Alle PHP-Interpolationen in JavaScript sind durch `data-*`-Attribute ersetzt: Beleg-ID (`data-beleg-id` auf `#crop-modal-overlay`, bereits vorhandenes `data-beleg-id` auf `#beleg-info-bar` für die Lieferanten-Autospeicherung), Anlagevermögen-Liste (`data-assets` auf `#modal-anlagevermoegen`, JSON via `$h()` HTML-escaped statt `JSON_HEX_TAG`), KI-Vorschlag (`data-ki-aktiv`/`data-ki-vorschlag` auf `#ki-badge`), Bankbuchungs-Brutto und Meta-Formular-Autoaufklappung (`data-bank-tx-brutto`/`data-open-meta-form` auf `#buchung_form`).
- `showKontoArt()`, `setupAccountSearch()` und `transferKontoNr()` existierten bereits in `app.js` und wurden nicht dupliziert.
- **`tests/86_BelegDetailStrukturTest.php`** (neu): Prüft, dass `detail.php` unter 200 Zeilen bleibt, keine inline `<script>`-Blöcke mehr enthält, alle per `include`/`<script src>` referenzierten Dateien existieren, `beleg-form.js` vor `beleg-ki.js` eingebunden wird und keine der ausgelagerten JS-Dateien PHP-Interpolationsreste enthält.
- **`tests/14_UstBruttoAnzeigeTest.php`** und **`tests/61_BetragModusToggleTest.php`**: lasen bisher den Inhalt von `detail.php` direkt; geprüfte Markup- und JS-Fundstellen liegen jetzt in `_buchungsform.php` bzw. `beleg-form.js`, die Tests wurden entsprechend umgestellt.
- Manuelle Abnahme: Alle vier Beleg-Varianten (PDF, Bild, gesperrt, gesperrt+Bild) über HTTP gerendert und geprüft, dass keine PHP-Reste im HTML auftauchen, die `<script src>`-Tags je nach Sperrstatus/MIME-Typ korrekt bedingt eingebunden werden, `beleg-form.js` vor `beleg-ki.js` referenziert wird und `data-assets` bei Sonderzeichen (Anführungszeichen, `&`, Umlaute) korrekt escaped/parsebar ist. Ein echter Browser-Durchlauf der in #157 geforderten Checkliste (Formular-Interaktionen, Zuschnitt, Zoom, Trenner) war mangels verfügbarem `e2e-runner`-Projekt in dieser Umgebung nicht möglich (`tests/e2e/run.sh` übersprang mit der dokumentierten Warnung); die JS-Dateien wurden bis auf die beschriebenen Interpolations-Ersetzungen unverändert aus den ursprünglichen `<script>`-Blöcken übernommen.
## 2026-08-10 – Direkte SQL-Aufrufe aus Controllern in die Modellschicht verschoben (#156) ## 2026-08-10 – Direkte SQL-Aufrufe aus Controllern in die Modellschicht verschoben (#156)
- 34 direkte `Database::`-Aufrufe in `AccountController`, `InvoiceController`, `BookingController`, `UstvaController`, `BankController`, `BelegController` und `CustomerController` verletzten die MVC-Schichtgrenze (`CLAUDE.md`: SQL ausschließlich über `Database::` *innerhalb* der Modelle) und waren dadurch nur über einen vollständigen HTTP-Request testbar. `AssetController.php` bleibt unverändert – die dort verbleibenden vier Aufrufe liegen außerhalb des Umfangs von #155 und sind laut Issue #156 nicht Teil dieses Tickets. - 34 direkte `Database::`-Aufrufe in `AccountController`, `InvoiceController`, `BookingController`, `UstvaController`, `BankController`, `BelegController` und `CustomerController` verletzten die MVC-Schichtgrenze (`CLAUDE.md`: SQL ausschließlich über `Database::` *innerhalb* der Modelle) und waren dadurch nur über einen vollständigen HTTP-Request testbar. `AssetController.php` bleibt unverändert – die dort verbleibenden vier Aufrufe liegen außerhalb des Umfangs von #155 und sind laut Issue #156 nicht Teil dieses Tickets.

View file

@ -0,0 +1,117 @@
'use strict';
const _assets = JSON.parse(document.getElementById('modal-anlagevermoegen')?.dataset.assets || '[]');
// 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');
});

View file

@ -0,0 +1,152 @@
'use strict';
(function() {
const overlay = document.getElementById('crop-modal-overlay');
const BELEG_ID = overlay.dataset.belegId;
const FILE_URL = '/belege/' + BELEG_ID + '/file';
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();
});
}());

View file

@ -0,0 +1,251 @@
'use strict';
// ── Netto-Neuberechnung bei verknüpfter Bankbuchung ─────────────
(function () {
const form = document.getElementById('buchung_form');
if (!form || form.dataset.bankTxBrutto === undefined) return;
const brutto = parseFloat(form.dataset.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;
if (typeof getBetragModus === 'function' && getBetragModus() !== 'netto') 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);
const _origAutoUst = 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
if (form.dataset.openMetaForm) {
const metaForm = document.getElementById('beleg-meta-form');
if (metaForm) metaForm.style.display = 'block';
}
})();
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();
}
// ── Netto/Brutto-Eingabemodus ──────────────────────────────────
// Solange Modus "netto" ist, entspricht der Feldwert dem Nettobetrag (wie bisher).
// Im Modus "brutto" wird der eingegebene Wert als Bruttobetrag interpretiert;
// vor dem Absenden wird er in den tatsächlichen Nettobetrag umgerechnet (siehe Submit-Handler unten).
let _betragModus = 'netto';
function getBetragModus() { return _betragModus; }
function setBetragModus(modus) {
if (modus === _betragModus) return;
const input = document.getElementById('betrag_netto_input');
const ustEl = document.getElementById('ust_satz_select');
const ustSatz = ustEl ? (parseFloat(ustEl.value) || 0) : 0;
const alt = parseFloat(input.value);
if (!isNaN(alt)) {
const neu = (modus === 'brutto')
? Math.round(alt * (1 + ustSatz / 100) * 100) / 100 // Netto → Brutto
: Math.round(alt / (1 + ustSatz / 100) * 100) / 100; // Brutto → Netto
input.value = neu.toFixed(2);
}
_betragModus = modus;
const nettoBtn = document.getElementById('betrag_modus_netto_btn');
const bruttoBtn = document.getElementById('betrag_modus_brutto_btn');
nettoBtn.classList.toggle('active', modus === 'netto');
nettoBtn.setAttribute('aria-pressed', modus === 'netto' ? 'true' : 'false');
bruttoBtn.classList.toggle('active', modus === 'brutto');
bruttoBtn.setAttribute('aria-pressed', modus === 'brutto' ? 'true' : 'false');
updateNettoAnzeige();
}
// Übernimmt einen Netto-Betrag (z.B. aus KI-Vorschlag) und schaltet dafür in den Netto-Modus zurück.
function setBetragNettoWert(wert) {
if (_betragModus !== 'netto') setBetragModus('netto');
const input = document.getElementById('betrag_netto_input');
input.value = wert;
updateNettoAnzeige();
}
function updateNettoAnzeige() {
const eingabeEl = document.getElementById('betrag_netto_input');
const ustEl = document.getElementById('ust_satz_select');
const anzeige = document.getElementById('ust_brutto_anzeige');
if (!eingabeEl || !ustEl || !anzeige) return;
const eingabe = parseFloat(eingabeEl.value);
if (isNaN(eingabe) || eingabeEl.value === '') {
anzeige.textContent = '';
return;
}
const ustSatz = parseFloat(ustEl.value) || 0;
const fmt = v => v.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' €';
if (_betragModus === 'brutto') {
const netto = Math.round(eingabe / (1 + ustSatz / 100) * 100) / 100;
const ust = Math.round((eingabe - netto) * 100) / 100;
anzeige.innerHTML = 'Netto ' + fmt(netto) + ' &nbsp;·&nbsp; USt. ' + fmt(ust);
} else {
const ust = Math.round(eingabe * ustSatz / 100 * 100) / 100;
const brutto = Math.round((eingabe + ust) * 100) / 100;
anzeige.innerHTML = 'USt. ' + fmt(ust) + ' &nbsp;·&nbsp; Brutto ' + fmt(brutto);
}
}
// Vor dem Absenden: Im Brutto-Modus enthält das Feld einen Bruttobetrag,
// das Backend erwartet aber immer den Nettobetrag (Feldname betrag_netto).
const _buchungFormEl = document.getElementById('buchung_form');
if (_buchungFormEl) {
_buchungFormEl.addEventListener('submit', function () {
if (_betragModus !== 'brutto') return;
const input = document.getElementById('betrag_netto_input');
const ustEl = document.getElementById('ust_satz_select');
const ustSatz = ustEl ? (parseFloat(ustEl.value) || 0) : 0;
const brutto = parseFloat(input.value);
if (!isNaN(brutto)) {
input.value = (Math.round(brutto / (1 + ustSatz / 100) * 100) / 100).toFixed(2);
}
});
}

View file

@ -0,0 +1,162 @@
'use strict';
const _kiBadgeEl = document.getElementById('ki-badge');
const _belegId = parseInt(_kiBadgeEl.dataset.belegId, 10);
const _kiAktiv = JSON.parse(_kiBadgeEl.dataset.kiAktiv);
const _kiVorschlag = JSON.parse(_kiBadgeEl.dataset.kiVorschlag);
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);
if (v.betrag_netto) setBetragNettoWert(v.betrag_netto);
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: ' + _esc(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>';
}
function _showKiBadgeConnectionError() {
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">Ollama nicht erreichbar.' + neuBtn +
' <a href="/settings" style="color:inherit;text-decoration:underline">Einstellungen / Verbindung testen</a>' +
'</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();
if (raw && raw.connection_error) { _showKiBadgeConnectionError(); return; }
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);
}
});

View file

@ -0,0 +1,146 @@
'use strict';
// ── Lieferanten-Autospeicherung ─────────────────────────────────
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/' + _bar.dataset.belegId + '/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(); }
});
});
// ── Verschiebbarer Trenner (Split-Pane) ──────────────────────────
(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);
});
}());
// ── Bildzoom ──────────────────────────────────────────────────
(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(); }
});
}());

View file

@ -0,0 +1,59 @@
<?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 ?? []));
?>
<!-- ── Modal: Anlagevermögen ──────────────────────────────────── -->
<div id="modal-anlagevermoegen" class="modal-overlay" role="dialog" aria-modal="true" data-assets="<?= $h($assetsJson) ?>">
<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>

View file

@ -0,0 +1,235 @@
<!-- 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>'.$h($k).':</strong> '.$h($v), array_keys($formErrors), $formErrors)) ?>
<?php endif; ?>
</div>
<?php endif; ?>
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings" id="buchung_form"
<?php if ($bankTx ?? null): ?>
data-bank-tx-brutto="<?= (float)($bankTxBrutto ?? 0) ?>"
<?php if (!empty($bankTxSupplier) && empty($beleg['supplier_name'])): ?>data-open-meta-form="1"<?php endif; ?>
<?php endif; ?>>
<?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/Drittland)',
'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">
<div class="betrag-modus-toggle" role="group" aria-label="Eingabemodus Betrag" style="display:flex;align-items:center;gap:.4rem;min-height:2.1rem;margin-bottom:.4rem">
<button type="button" class="betrag-modus-btn active" id="betrag_modus_netto_btn"
aria-pressed="true" onclick="setBetragModus('netto')">NETTOBETRAG (€)</button>
<button type="button" class="betrag-modus-btn" id="betrag_modus_brutto_btn"
aria-pressed="false" onclick="setBetragModus('brutto')">BRUTTOBETRAG (€)</button>
</div>
<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">
<div style="display:flex;align-items:center;min-height:2.1rem;margin-bottom:.4rem">
<label style="margin-bottom:0">Buchungstext *</label>
</div>
<?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 else: ?>
<div class="flash flash-info">Dieser Beleg ist gesperrt (GoBD). Keine weiteren Buchungen möglich.</div>
<?php endif; ?>
</div>
</div>

View file

@ -0,0 +1,25 @@
<!-- Crop-Modal -->
<div class="modal-overlay" id="crop-modal-overlay" style="display:none;align-items:center;justify-content:center"
data-beleg-id="<?= $h($beleg['id']) ?>">
<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>

View file

@ -0,0 +1,169 @@
<?php use Steuerkram\View; ?>
<!-- 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"
style="background:none;border:none;cursor:pointer;color:var(--color-danger);padding:.2rem;line-height:1;display:inline-flex;align-items:center"
title="Löschen">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</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>

View file

@ -0,0 +1,212 @@
<?php use Steuerkram\View; ?>
<!-- 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.<br>Datum<br>Typ</th>
<th>Kategorie</th>
<th>Zahlungskonto</th>
<th class="td-right">Wert</th>
<th>Text</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']) ?><br>
<span class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></span><br>
<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>
<?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="6" 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"
data-beleg-id="<?= $h($beleg['id']) ?>"
data-ki-aktiv="<?= $h(json_encode((bool)($kiAktiv ?? false))) ?>"
data-ki-vorschlag="<?= $h(json_encode($kiVorschlag ?? null)) ?>"></div>
<?php endif; ?>
<!-- Buchungsvorlagen-Badge -->
<?php if (!$beleg['locked'] && !empty($vorlagen)): ?>
<div class="card mb-2" style="border-left:4px solid var(--color-warning,#f59e0b);margin-bottom:.75rem">
<div class="card-body" style="padding:.65rem 1.25rem">
<div style="display:flex;align-items:center;gap:.75rem">
<span class="badge badge-yellow" style="font-size:.8rem;white-space:nowrap">Vorlage</span>
<select id="vorlage-select"
data-beleg-id="<?= $h($beleg['id']) ?>"
<?php if (!empty($bankTx)): ?>data-bank-tx="<?= $h($bankTx['id']) ?>"<?php endif; ?>
class="form-control form-control-sm" style="flex:1;max-width:340px">
<option value="">— Vorlage auswählen —</option>
<?php foreach ($vorlagen as $v): ?>
<option value="<?= $h($v['id']) ?>"><?= $h($v['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div id="vorlage-modal-container"></div>
<?php endif; ?>

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,9 @@ declare(strict_types=1);
/** /**
* Test 14: USt- und Brutto-Anzeige im Buchungsformular * Test 14: USt- und Brutto-Anzeige im Buchungsformular
* *
* Prüft, dass die notwendigen HTML-Elemente und die JS-Funktion * Prüft, dass die notwendigen HTML-Elemente (Teil-View _buchungsform.php)
* updateNettoAnzeige() im Beleg-Detailview vorhanden sind. * und die JS-Funktion updateNettoAnzeige() (static/js/beleg-form.js)
* vorhanden sind.
*/ */
class UstBruttoAnzeigeTest extends TestCase class UstBruttoAnzeigeTest extends TestCase
{ {
@ -14,12 +15,20 @@ class UstBruttoAnzeigeTest extends TestCase
public function run(): void public function run(): void
{ {
$viewFile = __DIR__ . '/../src/Views/belege/detail.php'; $viewFile = __DIR__ . '/../src/Views/belege/_buchungsform.php';
$content = file_get_contents($viewFile); $jsFile = __DIR__ . '/../public/static/js/beleg-form.js';
$viewContent = file_get_contents($viewFile);
$jsContent = file_get_contents($jsFile);
$content = $viewContent . "\n" . $jsContent;
$this->assert( $this->assert(
$content !== false, $viewContent !== false,
'View-Datei belege/detail.php ist lesbar' 'Teil-View belege/_buchungsform.php ist lesbar'
);
$this->assert(
$jsContent !== false,
'JS-Datei static/js/beleg-form.js ist lesbar'
); );
$this->assert( $this->assert(

View file

@ -5,9 +5,10 @@ declare(strict_types=1);
/** /**
* Test 61: Netto/Brutto-Umschalter im Buchungsformular * Test 61: Netto/Brutto-Umschalter im Buchungsformular
* *
* Prüft, dass im Beleg-Detailview die beiden Umschalt-Buttons für * Prüft, dass in der Teil-View _buchungsform.php die beiden Umschalt-Buttons
* Netto- bzw. Bruttobetrag vorhanden sind und die zugehörige JS-Logik * für Netto- bzw. Bruttobetrag vorhanden sind und die zugehörige JS-Logik
* (Moduswechsel, Umrechnung, Konvertierung vor dem Absenden) existiert. * (Moduswechsel, Umrechnung, Konvertierung vor dem Absenden) in
* static/js/beleg-form.js existiert.
*/ */
class BetragModusToggleTest extends TestCase class BetragModusToggleTest extends TestCase
{ {
@ -15,12 +16,20 @@ class BetragModusToggleTest extends TestCase
public function run(): void public function run(): void
{ {
$viewFile = __DIR__ . '/../src/Views/belege/detail.php'; $viewFile = __DIR__ . '/../src/Views/belege/_buchungsform.php';
$content = file_get_contents($viewFile); $jsFile = __DIR__ . '/../public/static/js/beleg-form.js';
$viewContent = file_get_contents($viewFile);
$jsContent = file_get_contents($jsFile);
$content = $viewContent . "\n" . $jsContent;
$this->assert( $this->assert(
$content !== false, $viewContent !== false,
'View-Datei belege/detail.php ist lesbar' 'Teil-View belege/_buchungsform.php ist lesbar'
);
$this->assert(
$jsContent !== false,
'JS-Datei static/js/beleg-form.js ist lesbar'
); );
$this->assert( $this->assert(

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/**
* Test 86: Struktur von belege/detail.php (Fix #157)
*
* detail.php war mit 1.548 Zeilen (davon 827 JavaScript in neun
* <script>-Blöcken) die mit Abstand größte View der Anwendung. Dieser Test
* hält die im Zuge der Aufteilung entstandene Struktur fest: detail.php ist
* nur noch ein Gerüst aus Includes und <script src>-Tags, das Markup steckt
* in Teil-Views, die Logik in eigenständigen, cachefähigen JS-Dateien.
*/
class BelegDetailStrukturTest extends TestCase
{
protected string $testName = 'Test 86: Struktur von belege/detail.php';
public function run(): void
{
$viewDir = __DIR__ . '/../src/Views/belege';
$jsDir = __DIR__ . '/../public/static/js';
$viewFile = $viewDir . '/detail.php';
$content = file_get_contents($viewFile);
$this->assert($content !== false, 'belege/detail.php ist lesbar');
$lines = substr_count($content, "\n") + 1;
$this->assert(
$lines < 200,
"belege/detail.php bleibt unter 200 Zeilen (erhalten: $lines)"
);
// Kein <script>-Block mit Logik mehr – nur noch <script src>-Tags
$this->assert(
preg_match('/<script>/', $content) === 0,
'Keine inline <script>-Blöcke mehr in detail.php'
);
preg_match_all('/<script\b[^>]*>/', $content, $scriptTags);
foreach ($scriptTags[0] as $tag) {
$this->assert(
str_contains($tag, 'src='),
"Script-Tag lädt eine externe Datei statt Inline-JS: $tag"
);
}
// Alle per include __DIR__ . '/_xyz.php' eingebundenen Teil-Views existieren
preg_match_all('/include\s+__DIR__\s*\.\s*\'\/([^\']+)\'/', $content, $includes);
$this->assertGreaterThan(0, count($includes[1]), 'detail.php bindet Teil-Views per include ein');
foreach ($includes[1] as $partial) {
$this->assert(
is_file($viewDir . '/' . $partial),
"Teil-View $partial existiert in src/Views/belege/"
);
}
// Alle referenzierten JS-Dateien existieren
preg_match_all('/<script\s+src="\/static\/js\/([^"?]+)/', $content, $scripts);
$this->assertGreaterThan(0, count($scripts[1]), 'detail.php bindet JS-Dateien per <script src> ein');
foreach ($scripts[1] as $js) {
$this->assert(
is_file($jsDir . '/' . $js),
"JS-Datei $js existiert in public/static/js/"
);
}
// Erwartete Teil-Views laut Fix #157 sind tatsächlich vorhanden
foreach (['_infobar.php', '_vorschau.php', '_buchungsform.php', '_anlage_modal.php', '_crop_modal.php'] as $partial) {
$this->assert(is_file($viewDir . '/' . $partial), "Teil-View $partial existiert");
}
// Erwartete JS-Dateien laut Fix #157 sind tatsächlich vorhanden
foreach (['beleg-form.js', 'beleg-anlage.js', 'beleg-ki.js', 'beleg-crop.js', 'beleg-ui.js'] as $js) {
$this->assert(is_file($jsDir . '/' . $js), "JS-Datei $js existiert");
}
// beleg-form.js muss vor beleg-ki.js eingebunden werden: beleg-form.js
// definiert updateAutoUst() und initialisiert Formular-Defaults per
// DOMContentLoaded, bevor der KI-Vorschlag diese ggf. überschreibt.
$posForm = strpos($content, 'beleg-form.js');
$posKi = strpos($content, 'beleg-ki.js');
if ($posKi !== false) {
$this->assert(
$posForm !== false && $posForm < $posKi,
'beleg-form.js wird vor beleg-ki.js eingebunden'
);
}
// Kein PHP-Interpolations-Marker mehr innerhalb der ausgelagerten JS-Dateien
foreach (['beleg-form.js', 'beleg-anlage.js', 'beleg-ki.js', 'beleg-crop.js', 'beleg-ui.js'] as $js) {
$jsContent = file_get_contents($jsDir . '/' . $js);
$this->assert(
$jsContent !== false && !str_contains($jsContent, '<?php') && !str_contains($jsContent, '<?='),
"$js enthält keine PHP-Interpolation"
);
}
}
}