Merge pull request 'Fix #156 + #157 + #158: SQL-Aufrufe, belege/detail.php aufgeteilt, View-Duplikate gebündelt' (#181) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/181
This commit is contained in:
commit
7a8f1ad8c3
70 changed files with 2621 additions and 1793 deletions
32
Changes.md
32
Changes.md
|
|
@ -2,6 +2,38 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-11 – Duplizierte View-Logik gebündelt: Kontengruppierung, Kontrastfarbe, Helper-Boilerplate (#158)
|
||||||
|
|
||||||
|
- **Kontengruppierung** (`invoices/book.php`, `invoices/show.php`, `belege/_buchungsform.php`) war dreimal kopiert und bereits divergiert: Nur `belege/_buchungsform.php` (per `include` in `detail.php`) filterte Zahlungskonten auf den Nummernbereich 1200–1299, die beiden Rechnungs-Views nicht. Neue Methode `View::gruppiereKonten(array $accounts, bool $nurZahlungskonten1200 = false)` bündelt beide Varianten; alle drei Views wenden jetzt den Filter an (Parameter `true`).
|
||||||
|
- Entscheidung für „Filter überall anwenden“ statt „nirgends“ oder „Unterschied beibehalten“: `BuchungsvorlageController::modal()` filtert unabhängig davon bereits identisch auf 1200–1299 – das ist die etablierte Konvention für „welches Bankkonto hat die Zahlung erhalten“-Auswahlen im Projekt, nicht nur ein Sonderfall der Belegerfassung. Produktiv gab es zum Zeitpunkt der Änderung noch keine einzige Rechnungs-Zahlungsbuchung (0 Zeilen in `bookings` mit verknüpfter `invoices`), das Risiko eines bestehenden Workflows, der auf Kasse/Verrechnungskonto/USt-Konten als Zahlungskonto einer Rechnung angewiesen ist, bestand also nicht.
|
||||||
|
- Sichtbare Auswirkung: Im Zahlungs-Modal der Rechnungsdetailansicht und in `invoices/book.php` stehen jetzt nur noch die fünf Bankkonten (1200–1240) zur Auswahl, nicht mehr Kasse, Verrechnungskonto oder die Umsatzsteuerkonten. Siehe `docs/funktionen.md`.
|
||||||
|
- **Kontrastfarbe**: Die Formel zur Wahl einer lesbaren Textfarbe (`0.299*hexdec(...)+0.587*hexdec(...)+0.114*hexdec(...)`) stand identisch in fünf Views (`belege/detail.php`, `bank/index.php`, `bank/show.php`, `bank/fints.php`, `settings/bankimport.php`). Neue Methode `View::kontrastfarbe(string $hex, string $dunkel = '#1e293b', string $hell = '#ffffff')` validiert die Eingabe (fehlt `#`, ist der Wert zu kurz oder kein gültiges Hex, liefert sie die Standardfarbe statt der bisherigen stillen Rechnung auf 0, die bei leerem/kurzem Wert immer weiße Schrift ergab); dreistellige Kurzform (`#abc`) wird expandiert. Alle fünf Views delegieren jetzt an `View::kontrastfarbe()`.
|
||||||
|
- **Helper-Boilerplate**: `$h = fn($v) => View::h($v);` (36 Views) und `$fmt = fn($v) => View::formatMoney($v);` (8 exakte Duplikate; abweichende `$fmt`-Definitionen mit `abs()` bzw. ohne geschütztes Leerzeichen blieben unverändert) sind entfernt. `BaseController::renderView()` und `View::render()` stellen `$h`/`$fmt` jetzt per `$data['h'] ??= …` zentral bereit, bevor `extract()` läuft – Views, die die Variablen weiterhin selbst definieren, bleiben dadurch unverändert lauffähig.
|
||||||
|
- **`tests/87_ViewHelperTest.php`** (neu): Deckt `View::kontrastfarbe()` (bekannte Farben, ungültige/leere Werte → Standardfarbe, Kurzform-Expansion, eigene Standardfarben) und `View::gruppiereKonten()` (mit/ohne Filter) ab.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- **`src/Models/Booking.php`:** Neue Methoden `dashboardKennzahlen()` (offene Rechnungen, ungebuchte Belege – bislang ungetestete Dashboard-Kennzahlen), `recent(int $limit)`, `journalForAccount(int $accountId, int $year)`, `deleteHard(int $id)` sowie `bucheBewirtungssplit()` (aus `BookingController` verschoben, vgl. Q3 im Issue: Split-Fachlogik gehört zu `Booking`, dort existiert bereits der verwandte Helfer `upsertKorrekturbuchung()`).
|
||||||
|
- **`src/Models/FiscalYear.php`:** Neue Methode `registeredYears()` (Geschäftsjahre aus `fiscal_years`, für das Konten-Journal – die bestehende `availableYears()` liest stattdessen `DISTINCT YEAR()` aus `bookings` und passte inhaltlich nicht).
|
||||||
|
- **`src/Models/Invoice.php`:** Neue Methoden `setBelegId()`, `findStorno()`, `findReference()`, `markSent()`, `deleteLine()`, `forCustomer()` sowie `finalizeWithBeleg()` (bündelt die bisher im Controller offene Transaktion aus Finalisierung + Beleg-Erzeugung; die eigentliche PDF-Erstellung bleibt als Callback beim Aufrufer, da sie Datei-I/O und keine reine Datenbank-Operation ist).
|
||||||
|
- **`src/Models/Beleg.php`:** Neue Methoden `updateBewirtung()`, `updateEigenanteil30()`, `updateBetragBrutto()`, `updateStorage()` sowie `setSupplierId()` (identisch zu `updateSupplier()`, aber bewusst ohne `locked`-Prüfung – wird beim automatischen Vorbelegen des Lieferanten aus einer Bankbuchung verwendet).
|
||||||
|
- **`src/Models/BankTransaction.php`:** Neue Methode `importBatch()` bündelt Batch-Anlage, Zeilen-Import und Transaktionsklammer (vorher offen in `BankController::doImport()`).
|
||||||
|
- **`src/Models/UstvaSubmission.php`** (neu): `all()`, `find()`, `upsert()` für `ustva_submissions`, bisher nur als Inline-SQL in `UstvaController`.
|
||||||
|
- Alle verschobenen SQL-Statements wurden unverändert übernommen; keine Verhaltensänderung außer der bewusst parametrisierten `LIMIT` in `Booking::recent()` (vorher fest `LIMIT 10`, jetzt Parameter mit Default 10) und der Zusammenfassung der Transaktionsklammern in die jeweiligen Modellmethoden.
|
||||||
|
- **`tests/85_ModellAbfragenTest.php`** (neu): Deckt alle oben genannten Modellmethoden anhand bekannter Testdaten ab, Schwerpunkt auf `Booking::dashboardKennzahlen()`. Regressionsschutz am Ende: `grep`-Äquivalent, das außerhalb der dokumentierten `AssetController`-Ausnahme keinen `Database::`-Aufruf mehr in `src/Controllers/` toleriert.
|
||||||
|
|
||||||
## 2026-08-09 – Regressionstest: Reflektiertes XSS über Kontonummer-Freitextfeld (#149)
|
## 2026-08-09 – Regressionstest: Reflektiertes XSS über Kontonummer-Freitextfeld (#149)
|
||||||
|
|
||||||
- `BookingController::store()` schreibt eine nicht auflösbare Freitext-Kontonummer (`kategorie_kontonummer`/`zahlungs_kontonummer`) ungefiltert in `$errors[...]`. Die Ausgabestelle in `src/Views/belege/detail.php:419` escaped Schlüssel und Wert bereits seit Fix #132 (Commit 6489819) konsequent mit `$h()` – der in #149 gemeldete Bug war zum Zeitpunkt der Meldung durch die dortige Änderung bereits geschlossen, nur ohne eigene Testabdeckung für diesen speziellen Eingabepfad.
|
- `BookingController::store()` schreibt eine nicht auflösbare Freitext-Kontonummer (`kategorie_kontonummer`/`zahlungs_kontonummer`) ungefiltert in `$errors[...]`. Die Ausgabestelle in `src/Views/belege/detail.php:419` escaped Schlüssel und Wert bereits seit Fix #132 (Commit 6489819) konsequent mit `$h()` – der in #149 gemeldete Bug war zum Zeitpunkt der Meldung durch die dortige Änderung bereits geschlossen, nur ohne eigene Testabdeckung für diesen speziellen Eingabepfad.
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ CRUD für Kundenstammdaten. Kunden werden beim Erstellen von Ausgangsrechnungen
|
||||||
- Status-Workflow: Entwurf → Finalisiert (gesperrt für Bearbeitung) → Gesendet → Teilzahlung / Bezahlt / Überzahlt / Storniert
|
- Status-Workflow: Entwurf → Finalisiert (gesperrt für Bearbeitung) → Gesendet → Teilzahlung / Bezahlt / Überzahlt / Storniert
|
||||||
- **Zahlungsstatus automatisch**: Sobald dem Beleg einer Rechnung Zahlungsbuchungen zugeordnet werden, ergibt sich der Status aus der Summe der aktiven Einnahme-Buchungen – weniger als der Rechnungsbetrag → `Teilzahlung`, exakt → `Bezahlt`, mehr → `Überzahlt`. Stornierte Buchungen zählen nicht mit; ohne aktive Buchung fällt die Rechnung auf `Gesendet` (falls bereits versendet, Zeitpunkt in `sent_at`) bzw. `Finalisiert` zurück. Entwürfe und stornierte Rechnungen bleiben unberührt. Der Button „Rechnung als bezahlt markieren" bleibt als manuelle Übersteuerung erhalten (z. B. bei Skonto), wird aber von der nächsten Buchungsänderung wieder überschrieben.
|
- **Zahlungsstatus automatisch**: Sobald dem Beleg einer Rechnung Zahlungsbuchungen zugeordnet werden, ergibt sich der Status aus der Summe der aktiven Einnahme-Buchungen – weniger als der Rechnungsbetrag → `Teilzahlung`, exakt → `Bezahlt`, mehr → `Überzahlt`. Stornierte Buchungen zählen nicht mit; ohne aktive Buchung fällt die Rechnung auf `Gesendet` (falls bereits versendet, Zeitpunkt in `sent_at`) bzw. `Finalisiert` zurück. Entwürfe und stornierte Rechnungen bleiben unberührt. Der Button „Rechnung als bezahlt markieren" bleibt als manuelle Übersteuerung erhalten (z. B. bei Skonto), wird aber von der nächsten Buchungsänderung wieder überschrieben.
|
||||||
- Das Zahlungs-Modal der Detailansicht und die Buchungsansicht zeigen Rechnungsbetrag, gezahlten Betrag und offenen Rest; der Betragsvorschlag entspricht dem offenen Restbetrag.
|
- Das Zahlungs-Modal der Detailansicht und die Buchungsansicht zeigen Rechnungsbetrag, gezahlten Betrag und offenen Rest; der Betragsvorschlag entspricht dem offenen Restbetrag.
|
||||||
|
- **Zahlungskonto-Auswahl auf Bankkonten (1200–1299) beschränkt**: Analog zur Belegerfassung und den Buchungsvorlagen listet die Auswahl nur noch echte Bankkonten, keine Kasse, kein Verrechnungskonto und keine Umsatzsteuerkonten mehr (Fix #158).
|
||||||
- Buchungsworkflow: Finalisierung erzeugt automatisch einen Beleg und eine Buchung in der Belegverwaltung
|
- Buchungsworkflow: Finalisierung erzeugt automatisch einen Beleg und eine Buchung in der Belegverwaltung
|
||||||
- **ZUGFeRD EN16931 (E-Rechnung)**: Jede generierte PDF enthält ein eingebettetes `factur-x.xml` nach EU-Norm EN 16931; Pflichtangaben laut § 14 UStG vollständig abgedeckt; Validierung möglich unter [portal.e-rechnung-bund.de](https://portal.e-rechnung-bund.de/validator); Bibliothek: `horstoeko/zugferd` v1.x
|
- **ZUGFeRD EN16931 (E-Rechnung)**: Jede generierte PDF enthält ein eingebettetes `factur-x.xml` nach EU-Norm EN 16931; Pflichtangaben laut § 14 UStG vollständig abgedeckt; Validierung möglich unter [portal.e-rechnung-bund.de](https://portal.e-rechnung-bund.de/validator); Bibliothek: `horstoeko/zugferd` v1.x
|
||||||
- **XML-Rechnung als Einzeldatei**: In der Detailansicht liefert das XML-Symbol neben dem PDF-Symbol die reine XML-Rechnung (`Rechnung-<Nr>.xml`, Route `/invoices/{id}/xml`). Verwendet wird das Profil **XRechnung 3.0** – eine CIUS von EN 16931, die zusätzlich die deutschen BR-DE-Regeln erfüllt und daher von Prüfdiensten wie dem [E-Rechnungsvalidator](https://erechnungsvalidator.service-bw.de) ohne Beanstandung akzeptiert wird. Fehlen Stamm- oder Rechnungsdaten, wird die Datei trotzdem ausgeliefert; die Detailansicht listet die fehlenden Angaben mit ihrer BT-Nummer auf und färbt das Symbol als Warnung ein.
|
- **XML-Rechnung als Einzeldatei**: In der Detailansicht liefert das XML-Symbol neben dem PDF-Symbol die reine XML-Rechnung (`Rechnung-<Nr>.xml`, Route `/invoices/{id}/xml`). Verwendet wird das Profil **XRechnung 3.0** – eine CIUS von EN 16931, die zusätzlich die deutschen BR-DE-Regeln erfüllt und daher von Prüfdiensten wie dem [E-Rechnungsvalidator](https://erechnungsvalidator.service-bw.de) ohne Beanstandung akzeptiert wird. Fehlen Stamm- oder Rechnungsdaten, wird die Datei trotzdem ausgeliefert; die Detailansicht listet die fehlenden Angaben mit ihrer BT-Nummer auf und färbt das Symbol als Warnung ein.
|
||||||
|
|
|
||||||
117
public/static/js/beleg-anlage.js
Normal file
117
public/static/js/beleg-anlage.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
152
public/static/js/beleg-crop.js
Normal file
152
public/static/js/beleg-crop.js
Normal 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();
|
||||||
|
});
|
||||||
|
}());
|
||||||
251
public/static/js/beleg-form.js
Normal file
251
public/static/js/beleg-form.js
Normal 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) + ' · 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) + ' · 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
162
public/static/js/beleg-ki.js
Normal file
162
public/static/js/beleg-ki.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
146
public/static/js/beleg-ui.js
Normal file
146
public/static/js/beleg-ui.js
Normal 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(); }
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
|
@ -7,6 +7,7 @@ namespace Steuerkram\Controllers;
|
||||||
use Steuerkram\Models\Account;
|
use Steuerkram\Models\Account;
|
||||||
use Steuerkram\Models\Booking;
|
use Steuerkram\Models\Booking;
|
||||||
use Steuerkram\Models\Beleg;
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Models\FiscalYear;
|
||||||
use Steuerkram\Services\EuerCalculator;
|
use Steuerkram\Services\EuerCalculator;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
|
@ -50,37 +51,14 @@ class AccountController extends BaseController
|
||||||
$stats['month_label'] = $monthNames[$month - 1];
|
$stats['month_label'] = $monthNames[$month - 1];
|
||||||
$stats['vormonat_label'] = $monthNames[$prevMonth - 1];
|
$stats['vormonat_label'] = $monthNames[$prevMonth - 1];
|
||||||
|
|
||||||
// Offene Rechnungen
|
// Offene Rechnungen, ungebuchte Belege
|
||||||
// Bei Teilzahlungen zählt nur der noch offene Restbetrag
|
$kennzahlen = Booking::dashboardKennzahlen();
|
||||||
$offen = \Steuerkram\Database::fetchOne(
|
$stats['offene_rechnungen'] = $kennzahlen['offene_rechnungen'];
|
||||||
'SELECT COUNT(*) as cnt,
|
$stats['offene_rechnungen_sum'] = $kennzahlen['offene_rechnungen_sum'];
|
||||||
COALESCE(SUM(i.betrag_brutto - COALESCE((
|
$stats['ungebuchte_belege'] = $kennzahlen['ungebuchte_belege'];
|
||||||
SELECT SUM(bk.betrag_brutto) FROM bookings bk
|
|
||||||
WHERE bk.beleg_id = i.beleg_id AND bk.buchungstyp = \'Einnahme\'
|
|
||||||
AND bk.is_reversal = 0 AND bk.parent_booking_id IS NULL AND bk.reversed_by IS NULL
|
|
||||||
), 0)), 0) as total
|
|
||||||
FROM invoices i WHERE i.status IN (\'Gesendet\', \'Teilzahlung\')'
|
|
||||||
);
|
|
||||||
$stats['offene_rechnungen'] = (int)($offen['cnt'] ?? 0);
|
|
||||||
$stats['offene_rechnungen_sum'] = (float)($offen['total'] ?? 0);
|
|
||||||
|
|
||||||
// Ungebuchte Belege
|
|
||||||
$ungebucht = \Steuerkram\Database::fetchOne(
|
|
||||||
'SELECT COUNT(*) as cnt FROM belege WHERE NOT EXISTS (SELECT 1 FROM bookings WHERE beleg_id = belege.id)'
|
|
||||||
);
|
|
||||||
$stats['ungebuchte_belege'] = (int)($ungebucht['cnt'] ?? 0);
|
|
||||||
|
|
||||||
// Letzte Buchungen
|
// Letzte Buchungen
|
||||||
$recentBookings = \Steuerkram\Database::fetchAll(
|
$recentBookings = Booking::recent(10);
|
||||||
'SELECT bk.*, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name,
|
|
||||||
z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name,
|
|
||||||
bl.beleg_nr
|
|
||||||
FROM bookings bk
|
|
||||||
JOIN accounts k ON bk.kategorie_konto_id = k.id
|
|
||||||
JOIN accounts z ON bk.zahlungskonto_id = z.id
|
|
||||||
JOIN belege bl ON bk.beleg_id = bl.id
|
|
||||||
ORDER BY bk.created_at DESC LIMIT 10'
|
|
||||||
);
|
|
||||||
|
|
||||||
$view = new View();
|
$view = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
|
|
@ -268,9 +246,7 @@ class AccountController extends BaseController
|
||||||
$account = Account::find($id);
|
$account = Account::find($id);
|
||||||
if (!$account) { http_response_code(404); return; }
|
if (!$account) { http_response_code(404); return; }
|
||||||
|
|
||||||
$availableYears = \Steuerkram\Database::fetchAll(
|
$availableYears = FiscalYear::registeredYears();
|
||||||
'SELECT year FROM fiscal_years ORDER BY year DESC'
|
|
||||||
);
|
|
||||||
$years = array_column($availableYears, 'year');
|
$years = array_column($availableYears, 'year');
|
||||||
if (empty($years)) {
|
if (empty($years)) {
|
||||||
$years = [(int)date('Y')];
|
$years = [(int)date('Y')];
|
||||||
|
|
@ -283,25 +259,7 @@ class AccountController extends BaseController
|
||||||
|
|
||||||
// Buchungen wo dieses Konto Kategorie-Konto ist (alle, auch Kindzeilen)
|
// Buchungen wo dieses Konto Kategorie-Konto ist (alle, auch Kindzeilen)
|
||||||
// ODER Zahlungs-Konto ist (nur Hauptbuchungen, Kindzeilen gehören nicht ans Zahlungskonto)
|
// ODER Zahlungs-Konto ist (nur Hauptbuchungen, Kindzeilen gehören nicht ans Zahlungskonto)
|
||||||
$bookings = \Steuerkram\Database::fetchAll(
|
$bookings = Booking::journalForAccount($id, $year);
|
||||||
'SELECT bk.*,
|
|
||||||
k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name,
|
|
||||||
z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name,
|
|
||||||
bl.beleg_nr
|
|
||||||
FROM bookings bk
|
|
||||||
JOIN accounts k ON bk.kategorie_konto_id = k.id
|
|
||||||
JOIN accounts z ON bk.zahlungskonto_id = z.id
|
|
||||||
LEFT JOIN belege bl ON bk.beleg_id = bl.id
|
|
||||||
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
|
||||||
WHERE fy.year = ?
|
|
||||||
AND (
|
|
||||||
bk.kategorie_konto_id = ?
|
|
||||||
OR (bk.zahlungskonto_id = ? AND bk.parent_booking_id IS NULL
|
|
||||||
AND bk.kategorie_konto_id != ?)
|
|
||||||
)
|
|
||||||
ORDER BY bk.buchungs_datum, bk.id',
|
|
||||||
[$year, $id, $id, $id]
|
|
||||||
);
|
|
||||||
|
|
||||||
$view = new View();
|
$view = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ use Steuerkram\Models\BankProvider;
|
||||||
use Steuerkram\Models\BankSource;
|
use Steuerkram\Models\BankSource;
|
||||||
use Steuerkram\Models\BankTransaction;
|
use Steuerkram\Models\BankTransaction;
|
||||||
use Steuerkram\Controllers\InvoiceController;
|
use Steuerkram\Controllers\InvoiceController;
|
||||||
use Steuerkram\Database;
|
|
||||||
use Steuerkram\Models\Beleg;
|
use Steuerkram\Models\Beleg;
|
||||||
use Steuerkram\Models\Invoice;
|
use Steuerkram\Models\Invoice;
|
||||||
use Steuerkram\Services\BankImportParser;
|
use Steuerkram\Services\BankImportParser;
|
||||||
|
|
@ -202,15 +201,9 @@ class BankController extends BaseController
|
||||||
|
|
||||||
private function doImport(int $sourceId, string $filename, array $rows): void
|
private function doImport(int $sourceId, string $filename, array $rows): void
|
||||||
{
|
{
|
||||||
Database::beginTransaction();
|
|
||||||
try {
|
try {
|
||||||
$batchId = BankTransaction::createBatch($sourceId, $filename, count($rows));
|
$batchId = BankTransaction::importBatch($sourceId, $filename, $rows);
|
||||||
foreach ($rows as $row) {
|
|
||||||
BankTransaction::insertRow($batchId, $row);
|
|
||||||
}
|
|
||||||
Database::commit();
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Database::rollBack();
|
|
||||||
View::redirect('/bank/upload', 'Import fehlgeschlagen: ' . $e->getMessage(), 'error');
|
View::redirect('/bank/upload', 'Import fehlgeschlagen: ' . $e->getMessage(), 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,15 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Steuerkram\Controllers;
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
|
use Steuerkram\View;
|
||||||
|
|
||||||
class BaseController
|
class BaseController
|
||||||
{
|
{
|
||||||
protected function renderView(string $template, array $data): string
|
protected function renderView(string $template, array $data): string
|
||||||
{
|
{
|
||||||
|
$data['h'] ??= fn($v) => View::h($v);
|
||||||
|
$data['fmt'] ??= fn($v) => View::formatMoney($v);
|
||||||
|
|
||||||
extract($data, EXTR_SKIP);
|
extract($data, EXTR_SKIP);
|
||||||
ob_start();
|
ob_start();
|
||||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||||
|
|
|
||||||
|
|
@ -291,10 +291,7 @@ class BelegController extends BaseController
|
||||||
// Lieferant am Beleg setzen, wenn noch keiner hinterlegt ist
|
// Lieferant am Beleg setzen, wenn noch keiner hinterlegt ist
|
||||||
if (empty($beleg['supplier_name']) && $bankTxSupplier !== '') {
|
if (empty($beleg['supplier_name']) && $bankTxSupplier !== '') {
|
||||||
$supplierId = \Steuerkram\Models\Supplier::findOrCreate($bankTxSupplier);
|
$supplierId = \Steuerkram\Models\Supplier::findOrCreate($bankTxSupplier);
|
||||||
\Steuerkram\Database::execute(
|
Beleg::setSupplierId((int)$params['id'], $supplierId);
|
||||||
'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=?',
|
|
||||||
[$supplierId, (int)$params['id']]
|
|
||||||
);
|
|
||||||
$beleg['supplier_name'] = $bankTxSupplier;
|
$beleg['supplier_name'] = $bankTxSupplier;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ class BookingController extends BaseController
|
||||||
if (!empty($data[$idField])) continue; // Select-Auswahl hat Vorrang
|
if (!empty($data[$idField])) continue; // Select-Auswahl hat Vorrang
|
||||||
$nr = trim($data[$field] ?? '');
|
$nr = trim($data[$field] ?? '');
|
||||||
if ($nr !== '') {
|
if ($nr !== '') {
|
||||||
$acc = \Steuerkram\Database::fetchOne('SELECT id FROM accounts WHERE kontonummer = ?', [$nr]);
|
$acc = Account::findByKontonummer($nr);
|
||||||
if (!$acc) {
|
if (!$acc) {
|
||||||
$errors[$idField] = "Kontonummer '$nr' nicht gefunden.";
|
$errors[$idField] = "Kontonummer '$nr' nicht gefunden.";
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -131,20 +131,14 @@ class BookingController extends BaseController
|
||||||
$data['beleg_id'] = $belegId;
|
$data['beleg_id'] = $belegId;
|
||||||
|
|
||||||
// Bewirtungsfelder am Beleg speichern (gehören zum Beleg, nicht zur Buchung)
|
// Bewirtungsfelder am Beleg speichern (gehören zum Beleg, nicht zur Buchung)
|
||||||
\Steuerkram\Database::execute(
|
Beleg::updateBewirtung(
|
||||||
'UPDATE belege SET bewirtungsgrund=?, teilnehmerliste=?, updated_at=NOW() WHERE id=?',
|
$belegId,
|
||||||
[
|
!empty($data['bewirtungsgrund']) ? $data['bewirtungsgrund'] : null,
|
||||||
!empty($data['bewirtungsgrund']) ? $data['bewirtungsgrund'] : null,
|
!empty($data['teilnehmerliste']) ? $data['teilnehmerliste'] : null
|
||||||
!empty($data['teilnehmerliste']) ? $data['teilnehmerliste'] : null,
|
|
||||||
$belegId,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Anlagevermögen: neues Asset anlegen oder bestehendes verknüpfen
|
// Anlagevermögen: neues Asset anlegen oder bestehendes verknüpfen
|
||||||
$konto = \Steuerkram\Database::fetchOne(
|
$konto = Account::find((int)$data['kategorie_konto_id']);
|
||||||
'SELECT id, kontonummer, kontenart FROM accounts WHERE id = ?',
|
|
||||||
[(int)$data['kategorie_konto_id']]
|
|
||||||
);
|
|
||||||
$kontonummer = $konto['kontonummer'] ?? '';
|
$kontonummer = $konto['kontonummer'] ?? '';
|
||||||
|
|
||||||
$newAssetId = null;
|
$newAssetId = null;
|
||||||
|
|
@ -165,15 +159,12 @@ class BookingController extends BaseController
|
||||||
|
|
||||||
// Bewirtungskonto-Split: 4650→4654, 4651→4656, 4652→4657
|
// Bewirtungskonto-Split: 4650→4654, 4651→4656, 4652→4657
|
||||||
if (in_array($kontonummer, ['4650', '4651', '4652'], true)) {
|
if (in_array($kontonummer, ['4650', '4651', '4652'], true)) {
|
||||||
$this->bucheBewirtungssplit($data, $kontonummer);
|
Booking::bucheBewirtungssplit($data, $kontonummer);
|
||||||
} else {
|
} else {
|
||||||
Booking::create($data);
|
Booking::create($data);
|
||||||
|
|
||||||
$eigenanteil = isset($data['eigenanteil_30']) ? 1 : 0;
|
$eigenanteil = isset($data['eigenanteil_30']) ? 1 : 0;
|
||||||
\Steuerkram\Database::execute(
|
Beleg::updateEigenanteil30($belegId, $eigenanteil);
|
||||||
'UPDATE belege SET eigenanteil_30 = ?, updated_at = NOW() WHERE id = ?',
|
|
||||||
[$eigenanteil, $belegId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($eigenanteil === 1) {
|
if ($eigenanteil === 1) {
|
||||||
Booking::upsertEigenanteil30(
|
Booking::upsertEigenanteil30(
|
||||||
|
|
@ -187,10 +178,7 @@ class BookingController extends BaseController
|
||||||
|
|
||||||
// Belegbetrag in Metadaten aktualisieren
|
// Belegbetrag in Metadaten aktualisieren
|
||||||
$totalBrutto = Booking::sumBruttoForBeleg($belegId);
|
$totalBrutto = Booking::sumBruttoForBeleg($belegId);
|
||||||
\Steuerkram\Database::execute(
|
Beleg::updateBetragBrutto($belegId, $totalBrutto);
|
||||||
'UPDATE belege SET betrag_brutto = ?, updated_at = NOW() WHERE id = ?',
|
|
||||||
[$totalBrutto, $belegId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bankbuchung verknüpfen wenn aus Bank-Kontext
|
// Bankbuchung verknüpfen wenn aus Bank-Kontext
|
||||||
$bankTxId = (int)($data['bank_tx_id'] ?? 0);
|
$bankTxId = (int)($data['bank_tx_id'] ?? 0);
|
||||||
|
|
@ -255,36 +243,6 @@ class BookingController extends BaseController
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function bucheBewirtungssplit(array $data, string $kontonummer): void
|
|
||||||
{
|
|
||||||
// Abzugskonto ermitteln
|
|
||||||
$abzugskontonummer = match($kontonummer) { '4650' => '4654', '4652' => '4657', default => '4656' };
|
|
||||||
$abzugskonto = \Steuerkram\Database::fetchOne(
|
|
||||||
'SELECT id FROM accounts WHERE kontonummer = ?',
|
|
||||||
[$abzugskontonummer]
|
|
||||||
);
|
|
||||||
if (!$abzugskonto) {
|
|
||||||
throw new \RuntimeException("Abzugskonto {$abzugskontonummer} nicht gefunden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$nettoGesamt = round((float)$data['betrag_netto'], 2);
|
|
||||||
$ustSatz = (float)($data['ust_satz'] ?? 0);
|
|
||||||
|
|
||||||
$netto70 = round($nettoGesamt * 0.7, 2);
|
|
||||||
$netto30 = round($nettoGesamt - $netto70, 2);
|
|
||||||
|
|
||||||
// Buchung 1: 70 % auf Bewirtungskonto (abzugsfähig)
|
|
||||||
Booking::create(array_merge($data, [
|
|
||||||
'betrag_netto' => $netto70,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Buchung 2: 30 % auf Abzugskonto (nicht abzugsfähig, § 4 Abs. 5 EStG)
|
|
||||||
Booking::create(array_merge($data, [
|
|
||||||
'kategorie_konto_id' => $abzugskonto['id'],
|
|
||||||
'betrag_netto' => $netto30,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(array $params): void
|
public function destroy(array $params): void
|
||||||
{
|
{
|
||||||
if (!Setting::isTestMode()) {
|
if (!Setting::isTestMode()) {
|
||||||
|
|
@ -299,8 +257,7 @@ class BookingController extends BaseController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
\Steuerkram\Database::execute('DELETE FROM bookings WHERE parent_booking_id = ?', [$id]);
|
Booking::deleteHard($id);
|
||||||
\Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$id]);
|
|
||||||
if (!empty($booking['beleg_id'])) {
|
if (!empty($booking['beleg_id'])) {
|
||||||
Invoice::recalcPaymentStatusForBeleg((int)$booking['beleg_id']);
|
Invoice::recalcPaymentStatusForBeleg((int)$booking['beleg_id']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Steuerkram\Controllers;
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
use Steuerkram\Models\Customer;
|
use Steuerkram\Models\Customer;
|
||||||
|
use Steuerkram\Models\Invoice;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
||||||
class CustomerController extends BaseController
|
class CustomerController extends BaseController
|
||||||
|
|
@ -69,10 +70,7 @@ class CustomerController extends BaseController
|
||||||
$customer = Customer::find((int)$params['id']);
|
$customer = Customer::find((int)$params['id']);
|
||||||
if (!$customer) { http_response_code(404); return; }
|
if (!$customer) { http_response_code(404); return; }
|
||||||
|
|
||||||
$invoices = \Steuerkram\Database::fetchAll(
|
$invoices = Invoice::forCustomer((int)$customer['id']);
|
||||||
'SELECT * FROM invoices WHERE customer_id=? ORDER BY rechnungs_datum DESC LIMIT 20',
|
|
||||||
[$customer['id']]
|
|
||||||
);
|
|
||||||
|
|
||||||
$view = new View();
|
$view = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
|
|
|
||||||
|
|
@ -111,18 +111,12 @@ class InvoiceController extends BaseController
|
||||||
$settings = Setting::get();
|
$settings = Setting::get();
|
||||||
|
|
||||||
// Querverweis: Gibt es eine Stornorechnung zu dieser Rechnung?
|
// Querverweis: Gibt es eine Stornorechnung zu dieser Rechnung?
|
||||||
$stornorechnung = \Steuerkram\Database::fetchOne(
|
$stornorechnung = Invoice::findStorno((int)$params['id']);
|
||||||
'SELECT id, rechnungs_nr FROM invoices WHERE storno_of_id = ? LIMIT 1',
|
|
||||||
[(int)$params['id']]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Querverweis: Ist dies eine Stornorechnung → Originalrechnung laden
|
// Querverweis: Ist dies eine Stornorechnung → Originalrechnung laden
|
||||||
$originalrechnung = null;
|
$originalrechnung = null;
|
||||||
if (!empty($invoice['storno_of_id'])) {
|
if (!empty($invoice['storno_of_id'])) {
|
||||||
$originalrechnung = \Steuerkram\Database::fetchOne(
|
$originalrechnung = Invoice::findReference((int)$invoice['storno_of_id']);
|
||||||
'SELECT id, rechnungs_nr, rechnungs_datum FROM invoices WHERE id = ?',
|
|
||||||
[(int)$invoice['storno_of_id']]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buchungen + Konten für das Zahlungs-Modal laden (nur wenn Beleg vorhanden)
|
// Buchungen + Konten für das Zahlungs-Modal laden (nur wenn Beleg vorhanden)
|
||||||
|
|
@ -225,18 +219,12 @@ class InvoiceController extends BaseController
|
||||||
{
|
{
|
||||||
$id = (int)$params['id'];
|
$id = (int)$params['id'];
|
||||||
try {
|
try {
|
||||||
\Steuerkram\Database::beginTransaction();
|
[$nr, $belegNr] = Invoice::finalizeWithBeleg($id, fn(array $invoice): string => self::createBeleg($invoice));
|
||||||
$nr = Invoice::finalize($id);
|
|
||||||
$invoice = Invoice::find($id);
|
|
||||||
$belegNr = self::createBeleg($invoice);
|
|
||||||
\Steuerkram\Database::commit();
|
|
||||||
View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert (Beleg {$belegNr}). Bitte Zahlungseingang erfassen.");
|
View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert (Beleg {$belegNr}). Bitte Zahlungseingang erfassen.");
|
||||||
} catch (\PDOException $e) {
|
} catch (\PDOException $e) {
|
||||||
\Steuerkram\Database::rollBack();
|
|
||||||
error_log('[Steuerkram] PDO-Fehler in InvoiceController::finalize(): ' . $e->getMessage());
|
error_log('[Steuerkram] PDO-Fehler in InvoiceController::finalize(): ' . $e->getMessage());
|
||||||
View::redirect("/invoices/{$id}", 'Datenbankfehler beim Finalisieren der Rechnung.', 'error');
|
View::redirect("/invoices/{$id}", 'Datenbankfehler beim Finalisieren der Rechnung.', 'error');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Steuerkram\Database::rollBack();
|
|
||||||
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
|
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -275,17 +263,7 @@ class InvoiceController extends BaseController
|
||||||
: null;
|
: null;
|
||||||
(new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients, $customer ?: null);
|
(new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients, $customer ?: null);
|
||||||
|
|
||||||
if ($invoice['status'] === 'Finalisiert') {
|
Invoice::markSent($id, $invoice['status']);
|
||||||
\Steuerkram\Database::execute(
|
|
||||||
"UPDATE invoices SET status='Gesendet', sent_at=NOW(), updated_at=NOW() WHERE id=?",
|
|
||||||
[$id]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
\Steuerkram\Database::execute(
|
|
||||||
'UPDATE invoices SET sent_at=NOW(), updated_at=NOW() WHERE id=? AND sent_at IS NULL',
|
|
||||||
[$id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gesendet.');
|
View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gesendet.');
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
|
|
@ -345,9 +323,11 @@ class InvoiceController extends BaseController
|
||||||
}
|
}
|
||||||
$absFilePath = Beleg::absolutePath(['storage_path' => $relPath]);
|
$absFilePath = Beleg::absolutePath(['storage_path' => $relPath]);
|
||||||
file_put_contents($absFilePath, $pdf);
|
file_put_contents($absFilePath, $pdf);
|
||||||
\Steuerkram\Database::execute(
|
Beleg::updateStorage(
|
||||||
'UPDATE belege SET storage_path=?, filename=?, file_hash=?, updated_at=NOW() WHERE id=?',
|
(int)$invoice['beleg_id'],
|
||||||
[$relPath, ($invoice['rechnungs_nr'] ?? 'Rechnung') . '.pdf', hash('sha256', $pdf), (int)$invoice['beleg_id']]
|
$relPath,
|
||||||
|
($invoice['rechnungs_nr'] ?? 'Rechnung') . '.pdf',
|
||||||
|
hash('sha256', $pdf)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -571,9 +551,7 @@ class InvoiceController extends BaseController
|
||||||
'notes' => (!empty($invoice['storno_of_id']) ? 'Stornorechnung zu Rechnung ' : 'Automatisch erstellt aus Rechnung ') . $invoice['rechnungs_nr'],
|
'notes' => (!empty($invoice['storno_of_id']) ? 'Stornorechnung zu Rechnung ' : 'Automatisch erstellt aus Rechnung ') . $invoice['rechnungs_nr'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
\Steuerkram\Database::execute(
|
Invoice::setBelegId($id, $belegId);
|
||||||
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id]
|
|
||||||
);
|
|
||||||
|
|
||||||
return $belegNr;
|
return $belegNr;
|
||||||
}
|
}
|
||||||
|
|
@ -613,10 +591,7 @@ class InvoiceController extends BaseController
|
||||||
|
|
||||||
public function deleteLine(array $params): void
|
public function deleteLine(array $params): void
|
||||||
{
|
{
|
||||||
\Steuerkram\Database::execute(
|
Invoice::deleteLine((int)$params['id'], (int)$params['line_id']);
|
||||||
'DELETE FROM invoice_lines WHERE id=? AND invoice_id=?',
|
|
||||||
[(int)$params['line_id'], (int)$params['id']]
|
|
||||||
);
|
|
||||||
View::redirect('/invoices/' . $params['id'] . '/edit', 'Position gelöscht.');
|
View::redirect('/invoices/' . $params['id'] . '/edit', 'Position gelöscht.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Steuerkram\Controllers;
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
use Steuerkram\Models\Setting;
|
use Steuerkram\Models\Setting;
|
||||||
use Steuerkram\Database;
|
use Steuerkram\Models\UstvaSubmission;
|
||||||
use Steuerkram\Services\UstvaCalculator;
|
use Steuerkram\Services\UstvaCalculator;
|
||||||
use Steuerkram\Services\ElsterXmlService;
|
use Steuerkram\Services\ElsterXmlService;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
@ -14,9 +14,7 @@ class UstvaController extends BaseController
|
||||||
{
|
{
|
||||||
public function index(array $params): void
|
public function index(array $params): void
|
||||||
{
|
{
|
||||||
$submissions = Database::fetchAll(
|
$submissions = UstvaSubmission::all();
|
||||||
'SELECT * FROM ustva_submissions ORDER BY year DESC, month DESC'
|
|
||||||
);
|
|
||||||
|
|
||||||
$years = [];
|
$years = [];
|
||||||
for ($y = (int)date('Y'); $y >= 2020; $y--) $years[] = $y;
|
for ($y = (int)date('Y'); $y >= 2020; $y--) $years[] = $y;
|
||||||
|
|
@ -45,9 +43,7 @@ class UstvaController extends BaseController
|
||||||
$kz = $calc->calculate($year, $month);
|
$kz = $calc->calculate($year, $month);
|
||||||
|
|
||||||
// Load saved submission if exists
|
// Load saved submission if exists
|
||||||
$saved = Database::fetchOne(
|
$saved = UstvaSubmission::find($year, $month);
|
||||||
'SELECT * FROM ustva_submissions WHERE year=? AND month=?', [$year, $month]
|
|
||||||
);
|
|
||||||
|
|
||||||
$settings = Setting::get();
|
$settings = Setting::get();
|
||||||
$months = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
$months = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||||
|
|
@ -78,22 +74,7 @@ class UstvaController extends BaseController
|
||||||
$xmlSvc = new ElsterXmlService();
|
$xmlSvc = new ElsterXmlService();
|
||||||
$xml = $xmlSvc->generate($year, $month, $kz);
|
$xml = $xmlSvc->generate($year, $month, $kz);
|
||||||
|
|
||||||
// Upsert
|
UstvaSubmission::upsert($year, $month, $xml, $kz);
|
||||||
$existing = Database::fetchOne(
|
|
||||||
'SELECT id FROM ustva_submissions WHERE year=? AND month=?', [$year, $month]
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
Database::execute(
|
|
||||||
'UPDATE ustva_submissions SET xml_content=?, field_values=? WHERE year=? AND month=?',
|
|
||||||
[$xml, json_encode($kz, JSON_UNESCAPED_UNICODE), $year, $month]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Database::execute(
|
|
||||||
'INSERT INTO ustva_submissions (year, month, xml_content, field_values) VALUES (?,?,?,?)',
|
|
||||||
[$year, $month, $xml, json_encode($kz, JSON_UNESCAPED_UNICODE)]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
View::redirect("/tax/ustva/{$year}/{$month}", 'UStVA gespeichert.');
|
View::redirect("/tax/ustva/{$year}/{$month}", 'UStVA gespeichert.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,23 @@ class BankTransaction
|
||||||
return ['new' => $new, 'dup' => $dup];
|
return ['new' => $new, 'dup' => $dup];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Importiert einen Batch Bank-Zeilen in einer Transaktion; bei Fehlern wird alles zurückgerollt. */
|
||||||
|
public static function importBatch(int $sourceId, string $filename, array $rows): int
|
||||||
|
{
|
||||||
|
Database::beginTransaction();
|
||||||
|
try {
|
||||||
|
$batchId = self::createBatch($sourceId, $filename, count($rows));
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
self::insertRow($batchId, $row);
|
||||||
|
}
|
||||||
|
Database::commit();
|
||||||
|
return $batchId;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Database::rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function createBatch(int $sourceId, string $filename, int $rowCount): int
|
public static function createBatch(int $sourceId, string $filename, int $rowCount): int
|
||||||
{
|
{
|
||||||
Database::execute(
|
Database::execute(
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,47 @@ class Beleg
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Setzt den Lieferanten ohne locked-Prüfung, z.B. beim automatischen Vorbelegen aus einer Bankbuchung. */
|
||||||
|
public static function setSupplierId(int $id, int $supplierId): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=?',
|
||||||
|
[$supplierId, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateBewirtung(int $id, ?string $bewirtungsgrund, ?string $teilnehmerliste): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET bewirtungsgrund=?, teilnehmerliste=?, updated_at=NOW() WHERE id=?',
|
||||||
|
[$bewirtungsgrund, $teilnehmerliste, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateEigenanteil30(int $id, int $eigenanteil): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET eigenanteil_30 = ?, updated_at = NOW() WHERE id = ?',
|
||||||
|
[$eigenanteil, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateBetragBrutto(int $id, float $betrag): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET betrag_brutto = ?, updated_at = NOW() WHERE id = ?',
|
||||||
|
[$betrag, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateStorage(int $id, string $storagePath, string $filename, string $hash): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET storage_path=?, filename=?, file_hash=?, updated_at=NOW() WHERE id=?',
|
||||||
|
[$storagePath, $filename, $hash, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function delete(int $id): bool
|
public static function delete(int $id): bool
|
||||||
{
|
{
|
||||||
$beleg = self::find($id);
|
$beleg = self::find($id);
|
||||||
|
|
|
||||||
|
|
@ -608,4 +608,103 @@ class Booking
|
||||||
[$year]
|
[$year]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kennzahlen für das Dashboard: offene Rechnungen und ungebuchte Belege. */
|
||||||
|
public static function dashboardKennzahlen(): array
|
||||||
|
{
|
||||||
|
$offen = Database::fetchOne(
|
||||||
|
'SELECT COUNT(*) as cnt,
|
||||||
|
COALESCE(SUM(i.betrag_brutto - COALESCE((
|
||||||
|
SELECT SUM(bk.betrag_brutto) FROM bookings bk
|
||||||
|
WHERE bk.beleg_id = i.beleg_id AND bk.buchungstyp = \'Einnahme\'
|
||||||
|
AND bk.is_reversal = 0 AND bk.parent_booking_id IS NULL AND bk.reversed_by IS NULL
|
||||||
|
), 0)), 0) as total
|
||||||
|
FROM invoices i WHERE i.status IN (\'Gesendet\', \'Teilzahlung\')'
|
||||||
|
);
|
||||||
|
|
||||||
|
$ungebucht = Database::fetchOne(
|
||||||
|
'SELECT COUNT(*) as cnt FROM belege WHERE NOT EXISTS (SELECT 1 FROM bookings WHERE beleg_id = belege.id)'
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'offene_rechnungen' => (int)($offen['cnt'] ?? 0),
|
||||||
|
'offene_rechnungen_sum' => (float)($offen['total'] ?? 0),
|
||||||
|
'ungebuchte_belege' => (int)($ungebucht['cnt'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zuletzt angelegte Buchungen für das Dashboard. */
|
||||||
|
public static function recent(int $limit = 10): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT bk.*, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name,
|
||||||
|
z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name,
|
||||||
|
bl.beleg_nr
|
||||||
|
FROM bookings bk
|
||||||
|
JOIN accounts k ON bk.kategorie_konto_id = k.id
|
||||||
|
JOIN accounts z ON bk.zahlungskonto_id = z.id
|
||||||
|
JOIN belege bl ON bk.beleg_id = bl.id
|
||||||
|
ORDER BY bk.created_at DESC LIMIT ?',
|
||||||
|
[$limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Journal-Buchungen eines Kontos (als Kategorie- oder Zahlungskonto) in einem Geschäftsjahr. */
|
||||||
|
public static function journalForAccount(int $accountId, int $year): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT bk.*,
|
||||||
|
k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name,
|
||||||
|
z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name,
|
||||||
|
bl.beleg_nr
|
||||||
|
FROM bookings bk
|
||||||
|
JOIN accounts k ON bk.kategorie_konto_id = k.id
|
||||||
|
JOIN accounts z ON bk.zahlungskonto_id = z.id
|
||||||
|
LEFT JOIN belege bl ON bk.beleg_id = bl.id
|
||||||
|
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||||
|
WHERE fy.year = ?
|
||||||
|
AND (
|
||||||
|
bk.kategorie_konto_id = ?
|
||||||
|
OR (bk.zahlungskonto_id = ? AND bk.parent_booking_id IS NULL
|
||||||
|
AND bk.kategorie_konto_id != ?)
|
||||||
|
)
|
||||||
|
ORDER BY bk.buchungs_datum, bk.id',
|
||||||
|
[$year, $accountId, $accountId, $accountId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht eine Buchung samt Kindbuchungen unwiderruflich (nur Testmodus). */
|
||||||
|
public static function deleteHard(int $id): void
|
||||||
|
{
|
||||||
|
Database::execute('DELETE FROM bookings WHERE parent_booking_id = ?', [$id]);
|
||||||
|
Database::execute('DELETE FROM bookings WHERE id = ?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewirtungskonto-Split nach § 4 Abs. 5 EStG: 70 % abzugsfähig auf dem
|
||||||
|
* Bewirtungskonto, 30 % nicht abzugsfähig auf dem zugehörigen Abzugskonto.
|
||||||
|
*/
|
||||||
|
public static function bucheBewirtungssplit(array $data, string $kontonummer): void
|
||||||
|
{
|
||||||
|
$abzugskontonummer = match($kontonummer) { '4650' => '4654', '4652' => '4657', default => '4656' };
|
||||||
|
$abzugskonto = Account::findByKontonummer($abzugskontonummer);
|
||||||
|
if (!$abzugskonto) {
|
||||||
|
throw new \RuntimeException("Abzugskonto {$abzugskontonummer} nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$nettoGesamt = round((float)$data['betrag_netto'], 2);
|
||||||
|
$netto70 = round($nettoGesamt * 0.7, 2);
|
||||||
|
$netto30 = round($nettoGesamt - $netto70, 2);
|
||||||
|
|
||||||
|
// Buchung 1: 70 % auf Bewirtungskonto (abzugsfähig)
|
||||||
|
self::create(array_merge($data, [
|
||||||
|
'betrag_netto' => $netto70,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Buchung 2: 30 % auf Abzugskonto (nicht abzugsfähig, § 4 Abs. 5 EStG)
|
||||||
|
self::create(array_merge($data, [
|
||||||
|
'kategorie_konto_id' => $abzugskonto['id'],
|
||||||
|
'betrag_netto' => $netto30,
|
||||||
|
]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,10 @@ class FiscalYear
|
||||||
);
|
);
|
||||||
return array_column($years, 'year');
|
return array_column($years, 'year');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Angelegte Geschäftsjahre (fiscal_years-Tabelle), nicht aus Buchungen abgeleitet. */
|
||||||
|
public static function registeredYears(): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll('SELECT year FROM fiscal_years ORDER BY year DESC');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,83 @@ class Invoice
|
||||||
Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]);
|
Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function setBelegId(int $id, int $belegId): void
|
||||||
|
{
|
||||||
|
Database::execute('UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stornorechnung zu einer (Original-)Rechnung, falls vorhanden. */
|
||||||
|
public static function findStorno(int $invoiceId): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne(
|
||||||
|
'SELECT id, rechnungs_nr FROM invoices WHERE storno_of_id = ? LIMIT 1',
|
||||||
|
[$invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kurzreferenz (Nr. + Datum) auf eine andere Rechnung, z.B. die Originalrechnung einer Stornorechnung. */
|
||||||
|
public static function findReference(int $invoiceId): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne(
|
||||||
|
'SELECT id, rechnungs_nr, rechnungs_datum FROM invoices WHERE id = ?',
|
||||||
|
[$invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt den Versandstatus; wiederholter Versand überschreibt sent_at nicht mehr. */
|
||||||
|
public static function markSent(int $id, string $currentStatus): void
|
||||||
|
{
|
||||||
|
if ($currentStatus === 'Finalisiert') {
|
||||||
|
Database::execute(
|
||||||
|
"UPDATE invoices SET status='Gesendet', sent_at=NOW(), updated_at=NOW() WHERE id=?",
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE invoices SET sent_at=NOW(), updated_at=NOW() WHERE id=? AND sent_at IS NULL',
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deleteLine(int $invoiceId, int $lineId): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'DELETE FROM invoice_lines WHERE id=? AND invoice_id=?',
|
||||||
|
[$lineId, $invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forCustomer(int $customerId, int $limit = 20): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT * FROM invoices WHERE customer_id=? ORDER BY rechnungs_datum DESC LIMIT ?',
|
||||||
|
[$customerId, $limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalisiert eine Rechnung und erzeugt in derselben Transaktion den
|
||||||
|
* zugehörigen Beleg über den übergebenen Callback (PDF-Erstellung ist
|
||||||
|
* Sache des Aufrufers, nicht der Modellschicht).
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: string} [Rechnungsnummer, Rückgabewert des Callbacks]
|
||||||
|
*/
|
||||||
|
public static function finalizeWithBeleg(int $id, callable $belegCallback): array
|
||||||
|
{
|
||||||
|
Database::beginTransaction();
|
||||||
|
try {
|
||||||
|
$nr = self::finalize($id);
|
||||||
|
$invoice = self::find($id);
|
||||||
|
$belegNr = $belegCallback($invoice);
|
||||||
|
Database::commit();
|
||||||
|
return [$nr, $belegNr];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Database::rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Status, die aus den zugeordneten Buchungen automatisch abgeleitet werden. */
|
/** Status, die aus den zugeordneten Buchungen automatisch abgeleitet werden. */
|
||||||
public const ZAHL_STATUS = ['Finalisiert', 'Gesendet', 'Teilzahlung', 'Bezahlt', 'Überzahlt'];
|
public const ZAHL_STATUS = ['Finalisiert', 'Gesendet', 'Teilzahlung', 'Bezahlt', 'Überzahlt'];
|
||||||
|
|
||||||
|
|
|
||||||
44
src/Models/UstvaSubmission.php
Normal file
44
src/Models/UstvaSubmission.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Models;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
class UstvaSubmission
|
||||||
|
{
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll('SELECT * FROM ustva_submissions ORDER BY year DESC, month DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find(int $year, int $month): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne(
|
||||||
|
'SELECT * FROM ustva_submissions WHERE year=? AND month=?',
|
||||||
|
[$year, $month]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function upsert(int $year, int $month, string $xml, array $kz): void
|
||||||
|
{
|
||||||
|
$existing = Database::fetchOne(
|
||||||
|
'SELECT id FROM ustva_submissions WHERE year=? AND month=?',
|
||||||
|
[$year, $month]
|
||||||
|
);
|
||||||
|
$fieldValues = json_encode($kz, JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE ustva_submissions SET xml_content=?, field_values=? WHERE year=? AND month=?',
|
||||||
|
[$xml, $fieldValues, $year, $month]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO ustva_submissions (year, month, xml_content, field_values) VALUES (?,?,?,?)',
|
||||||
|
[$year, $month, $xml, $fieldValues]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/View.php
45
src/View.php
|
|
@ -28,6 +28,8 @@ class View
|
||||||
{
|
{
|
||||||
$data['flash'] = $this->flash;
|
$data['flash'] = $this->flash;
|
||||||
$data['currentPath'] = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
$data['currentPath'] = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
$data['h'] ??= fn($v) => self::h($v);
|
||||||
|
$data['fmt'] ??= fn($v) => self::formatMoney($v);
|
||||||
|
|
||||||
extract($data, EXTR_SKIP);
|
extract($data, EXTR_SKIP);
|
||||||
include $this->viewDir . '/' . $template;
|
include $this->viewDir . '/' . $template;
|
||||||
|
|
@ -96,4 +98,47 @@ class View
|
||||||
}
|
}
|
||||||
return http_build_query($safe);
|
return http_build_query($safe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesbare Textfarbe (dunkel/hell) für einen Hex-Hintergrund ermitteln.
|
||||||
|
* Ungültige oder zu kurze Werte liefern die Standardfarbe statt einer
|
||||||
|
* stillen Rechnung auf 0 (siehe Issue #158).
|
||||||
|
*/
|
||||||
|
public static function kontrastfarbe(string $hex, string $dunkel = '#1e293b', string $hell = '#ffffff'): string
|
||||||
|
{
|
||||||
|
if (preg_match('/^#([0-9a-fA-F]{3})$/', $hex, $m)) {
|
||||||
|
$hex = '#' . $m[1][0] . $m[1][0] . $m[1][1] . $m[1][1] . $m[1][2] . $m[1][2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $hex)) {
|
||||||
|
return $dunkel;
|
||||||
|
}
|
||||||
|
|
||||||
|
$helligkeit = 0.299*hexdec(substr($hex,1,2)) + 0.587*hexdec(substr($hex,3,2)) + 0.114*hexdec(substr($hex,5,2));
|
||||||
|
|
||||||
|
return $helligkeit > 128 ? $dunkel : $hell;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konten nach kontenart gruppieren (für <optgroup>).
|
||||||
|
* Bei $nurZahlungskonten1200 werden Zahlungskonten außerhalb 1200–1299
|
||||||
|
* (Kasse, Verrechnungskonto, USt-Konten) ausgeschlossen – das ist die
|
||||||
|
* Konvention für Buchungsformulare (siehe BuchungsvorlageController,
|
||||||
|
* belege/_buchungsform.php): dort sind nur reine Bankkonten als
|
||||||
|
* Zahlungskonto wählbar.
|
||||||
|
*/
|
||||||
|
public static function gruppiereKonten(array $accounts, bool $nurZahlungskonten1200 = false): array
|
||||||
|
{
|
||||||
|
$gruppen = [];
|
||||||
|
foreach ($accounts as $acc) {
|
||||||
|
if ($nurZahlungskonten1200 && $acc['kontenart'] === 'Zahlungskonto') {
|
||||||
|
$nr = (int)$acc['kontonummer'];
|
||||||
|
if ($nr < 1200 || $nr > 1299) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$gruppen[$acc['kontenart']][] = $acc;
|
||||||
|
}
|
||||||
|
return $gruppen;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<div class="card" style="max-width:700px">
|
<div class="card" style="max-width:700px">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="<?= $h($action) ?>">
|
<form method="post" action="<?= $h($action) ?>">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
$fmt = fn($v) => number_format(abs((float)$v), 2, ',', '.');
|
$fmt = fn($v) => number_format(abs((float)$v), 2, ',', '.');
|
||||||
|
|
||||||
// Jahr-Navigation
|
// Jahr-Navigation
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<form method="get" style="display:flex;gap:.5rem;align-items:center">
|
<form method="get" style="display:flex;gap:.5rem;align-items:center">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
|
|
||||||
$assetId = (int)($asset['id'] ?? 0);
|
$assetId = (int)($asset['id'] ?? 0);
|
||||||
$isEdit = $assetId > 0;
|
$isEdit = $assetId > 0;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
|
|
||||||
$assetsJson = json_encode(array_map(fn($a) => [
|
$assetsJson = json_encode(array_map(fn($a) => [
|
||||||
'id' => (int)$a['id'],
|
'id' => (int)$a['id'],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
$kTextClr = fn(string $hex): string => View::kontrastfarbe($hex);
|
||||||
$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';
|
|
||||||
|
|
||||||
// Verbindungsdaten als JSON für JS bereitstellen.
|
// Verbindungsdaten als JSON für JS bereitstellen.
|
||||||
// PINs und API-Secrets werden bewusst NICHT mitgeschickt – im Formular blanko, leer = unverändert.
|
// PINs und API-Secrets werden bewusst NICHT mitgeschickt – im Formular blanko, leer = unverändert.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
$kTextClr = fn(string $hex): string => View::kontrastfarbe($hex);
|
||||||
$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';
|
|
||||||
|
|
||||||
$months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
|
$months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
|
||||||
?>
|
?>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
use Steuerkram\Models\Setting;
|
use Steuerkram\Models\Setting;
|
||||||
$h = fn($v) => View::h($v);
|
$kTextClr = fn(string $hex): string => View::kontrastfarbe($hex);
|
||||||
$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';
|
|
||||||
|
|
||||||
$wert = (float)$transaction['wert'];
|
$wert = (float)$transaction['wert'];
|
||||||
$isPlus = $wert >= 0;
|
$isPlus = $wert >= 0;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="/bank/upload" enctype="multipart/form-data" id="bank-upload-form">
|
<form method="post" action="/bank/upload" enctype="multipart/form-data" id="bank-upload-form">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
<?php
|
<?php
|
||||||
$fmtDate = fn($d) => $d ? date('d.m.Y', strtotime($d)) : '–';
|
$fmtDate = fn($d) => $d ? date('d.m.Y', strtotime($d)) : '–';
|
||||||
$fmtMoney = function($v) {
|
$fmtMoney = function($v) {
|
||||||
|
|
|
||||||
59
src/Views/belege/_anlage_modal.php
Normal file
59
src/Views/belege/_anlage_modal.php
Normal 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>
|
||||||
228
src/Views/belege/_buchungsform.php
Normal file
228
src/Views/belege/_buchungsform.php
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
<!-- 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 = \Steuerkram\View::gruppiereKonten($accounts, true);
|
||||||
|
?>
|
||||||
|
<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(' | ', 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>
|
||||||
25
src/Views/belege/_crop_modal.php
Normal file
25
src/Views/belege/_crop_modal.php
Normal 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>
|
||||||
169
src/Views/belege/_infobar.php
Normal file
169
src/Views/belege/_infobar.php
Normal 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>
|
||||||
212
src/Views/belege/_vorschau.php
Normal file
212
src/Views/belege/_vorschau.php
Normal 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
|
|
@ -1,4 +1,4 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); $suppliers = $suppliers ?? []; ?>
|
<?php use Steuerkram\View; $suppliers = $suppliers ?? []; ?>
|
||||||
|
|
||||||
<!-- Drag & Drop Upload -->
|
<!-- Drag & Drop Upload -->
|
||||||
<div id="bl-dnd-zone" style="
|
<div id="bl-dnd-zone" style="
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<div class="card" style="max-width:700px">
|
<div class="card" style="max-width:700px">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="/belege/upload" enctype="multipart/form-data">
|
<form method="post" action="/belege/upload" enctype="multipart/form-data">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
<?php use Steuerkram\View; ?>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
|
|
||||||
$isEdit = !empty($vorlage['id']);
|
$isEdit = !empty($vorlage['id']);
|
||||||
$action = $isEdit ? "/einstellungen/buchungsvorlagen/{$vorlage['id']}" : '/einstellungen/buchungsvorlagen';
|
$action = $isEdit ? "/einstellungen/buchungsvorlagen/{$vorlage['id']}" : '/einstellungen/buchungsvorlagen';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
|
|
||||||
|
|
||||||
// Felder nach Gruppe_nr sortiert gruppieren (dynamisch, unabhängig von festen Nummern)
|
// Felder nach Gruppe_nr sortiert gruppieren (dynamisch, unabhängig von festen Nummern)
|
||||||
$gruppenFelder = [];
|
$gruppenFelder = [];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<div class="card" style="max-width:800px">
|
<div class="card" style="max-width:800px">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="<?= $h($action) ?>">
|
<form method="post" action="<?= $h($action) ?>">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<form method="get" style="display:flex;gap:.5rem;align-items:center">
|
<form method="get" style="display:flex;gap:.5rem;align-items:center">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
<?php use Steuerkram\View; ?>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:340px 1fr;gap:1.5rem;align-items:start">
|
<div style="display:grid;grid-template-columns:340px 1fr;gap:1.5rem;align-items:start">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
$fmt = fn($v) => View::formatMoney($v);
|
|
||||||
|
|
||||||
// Jede Kachelzahl führt auf die Einzelbuchungen, aus denen sie besteht (#167).
|
// Jede Kachelzahl führt auf die Einzelbuchungen, aus denen sie besteht (#167).
|
||||||
$detail = function (string $bucket, ?int $month = null) use ($stats): string {
|
$detail = function (string $bucket, ?int $month = null) use ($stats): string {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
|
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
|
||||||
|
|
||||||
// Beitrag der Buchung zur Kachelsumme. In der Ergebnis-Ansicht mindern
|
// Beitrag der Buchung zur Kachelsumme. In der Ergebnis-Ansicht mindern
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
use Steuerkram\Models\EventLog;
|
use Steuerkram\Models\EventLog;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
|
|
||||||
$levelStyles = [
|
$levelStyles = [
|
||||||
EventLog::LEVEL_ERROR => ['Fehler', 'var(--color-danger,#dc2626)'],
|
EventLog::LEVEL_ERROR => ['Fehler', 'var(--color-danger,#dc2626)'],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
use Steuerkram\Models\EventLog;
|
use Steuerkram\Models\EventLog;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
|
|
||||||
$aktiverKanal = $filter['channel'] ?? '';
|
$aktiverKanal = $filter['channel'] ?? '';
|
||||||
$aktivesLevel = $filter['level'] ?? '';
|
$aktivesLevel = $filter['level'] ?? '';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
$fmt = fn($v) => View::formatMoney($v);
|
|
||||||
|
|
||||||
$customerName = !empty($invoice['firma'])
|
$customerName = !empty($invoice['firma'])
|
||||||
? $invoice['firma']
|
? $invoice['firma']
|
||||||
|
|
@ -10,10 +8,7 @@ $customerName = !empty($invoice['firma'])
|
||||||
$formErrors = $formErrors ?? [];
|
$formErrors = $formErrors ?? [];
|
||||||
$formData = $formData ?? [];
|
$formData = $formData ?? [];
|
||||||
|
|
||||||
$kontenGruppen = [];
|
$kontenGruppen = View::gruppiereKonten($accounts, true);
|
||||||
foreach ($accounts as $acc) {
|
|
||||||
$kontenGruppen[$acc['kontenart']][] = $acc;
|
|
||||||
}
|
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<!-- Rechnungs-Header -->
|
<!-- Rechnungs-Header -->
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
$isEdit = !empty($invoice['id']);
|
$isEdit = !empty($invoice['id']);
|
||||||
// Pre-select customer if passed via GET
|
// Pre-select customer if passed via GET
|
||||||
$preCustomer = (int)($_GET['customer_id'] ?? 0);
|
$preCustomer = (int)($_GET['customer_id'] ?? 0);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
<?php use Steuerkram\View; ?>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
$fmt = fn($v) => View::formatMoney($v);
|
|
||||||
|
|
||||||
$customerName = !empty($invoice['firma'])
|
$customerName = !empty($invoice['firma'])
|
||||||
? $invoice['firma']
|
? $invoice['firma']
|
||||||
|
|
@ -429,8 +427,7 @@ $badgeClass = match($invoice['status']) {
|
||||||
<!-- Zahlungs-Modal -->
|
<!-- Zahlungs-Modal -->
|
||||||
<?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert']) && !empty($invoice['beleg_id'])): ?>
|
<?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert']) && !empty($invoice['beleg_id'])): ?>
|
||||||
<?php
|
<?php
|
||||||
$kontenGruppen = [];
|
$kontenGruppen = \Steuerkram\View::gruppiereKonten($accounts, true);
|
||||||
foreach ($accounts as $acc) { $kontenGruppen[$acc['kontenart']][] = $acc; }
|
|
||||||
?>
|
?>
|
||||||
<div id="buchungs-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;align-items:flex-start;justify-content:center;padding:2rem 1rem;overflow-y:auto">
|
<div id="buchungs-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;align-items:flex-start;justify-content:center;padding:2rem 1rem;overflow-y:auto">
|
||||||
<div style="background:#fff;border-radius:8px;width:100%;max-width:720px;box-shadow:0 8px 32px rgba(0,0,0,.2)">
|
<div style="background:#fff;border-radius:8px;width:100%;max-width:720px;box-shadow:0 8px 32px rgba(0,0,0,.2)">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
$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';
|
use Steuerkram\View;
|
||||||
|
$kTextClr = fn(string $hex): string => View::kontrastfarbe($hex);
|
||||||
|
|
||||||
$dbLabels = [
|
$dbLabels = [
|
||||||
'buchungstag' => 'Buchungstag',
|
'buchungstag' => 'Buchungstag',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<form id="settings-form" method="post" action="/settings">
|
<form id="settings-form" method="post" action="/settings">
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<div class="card" style="max-width:480px">
|
<div class="card" style="max-width:480px">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="<?= $h($action) ?>">
|
<form method="post" action="<?= $h($action) ?>">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<div class="card" style="max-width:400px">
|
<div class="card" style="max-width:400px">
|
||||||
<div class="card-header"><div class="card-title">Geschäftsjahr wählen</div></div>
|
<div class="card-header"><div class="card-title">Geschäftsjahr wählen</div></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
|
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
$fmt = fn($v) => View::formatMoney($v);
|
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:300px 1fr;gap:1.5rem;align-items:start">
|
<div style="display:grid;grid-template-columns:300px 1fr;gap:1.5rem;align-items:start">
|
||||||
|
|
||||||
<!-- Formular: Monat wählen -->
|
<!-- Formular: Monat wählen -->
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
$fmtKz = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
|
$fmtKz = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
|
||||||
|
|
||||||
// Für Kz 69: vorzeichenbehafteter USt-Beitrag zur Zahllast.
|
// Für Kz 69: vorzeichenbehafteter USt-Beitrag zur Zahllast.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
$h = fn($v) => View::h($v);
|
|
||||||
$fmt = fn($v) => View::formatMoney($v);
|
|
||||||
$fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' : '–';
|
$fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' : '–';
|
||||||
$fmtKzGanz = fn($v) => $v !== null ? number_format((float)$v, 0, ',', '.') . ',- €' : '–';
|
$fmtKzGanz = fn($v) => $v !== null ? number_format((float)$v, 0, ',', '.') . ',- €' : '–';
|
||||||
?>
|
?>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v);
|
<?php
|
||||||
|
use Steuerkram\View;
|
||||||
$editId = $formData['edit_id'] ?? null;
|
$editId = $formData['edit_id'] ?? null;
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
478
tests/85_ModellAbfragenTest.php
Normal file
478
tests/85_ModellAbfragenTest.php
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
use Steuerkram\Models\Account;
|
||||||
|
use Steuerkram\Models\BankSource;
|
||||||
|
use Steuerkram\Models\BankTransaction;
|
||||||
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Models\Booking;
|
||||||
|
use Steuerkram\Models\Customer;
|
||||||
|
use Steuerkram\Models\FiscalYear;
|
||||||
|
use Steuerkram\Models\Invoice;
|
||||||
|
use Steuerkram\Models\Supplier;
|
||||||
|
use Steuerkram\Models\UstvaSubmission;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 85: Aus Controllern verschobene Modell-Abfragen (Fix #156).
|
||||||
|
*
|
||||||
|
* 54 direkte Database::-Aufrufe in Controllern verletzten die Modellschicht
|
||||||
|
* und waren nur über einen vollständigen HTTP-Request testbar. Dieser Test
|
||||||
|
* deckt die dabei neu entstandenen bzw. konsolidierten Modellmethoden ab –
|
||||||
|
* mit Schwerpunkt auf der bislang völlig ungetesteten Kennzahlenberechnung
|
||||||
|
* Booking::dashboardKennzahlen(). Ein Regressionsschutz am Ende stellt
|
||||||
|
* sicher, dass kein Controller wieder direkt auf Database:: zugreift.
|
||||||
|
*/
|
||||||
|
class ModellAbfragenTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 85: Aus Controllern verschobene Modell-Abfragen (Fix #156)';
|
||||||
|
|
||||||
|
private int $zahlungsId;
|
||||||
|
private int $kategorieId;
|
||||||
|
private int $belegSeq = 0;
|
||||||
|
|
||||||
|
private ?int $offenerBelegId = null;
|
||||||
|
private ?int $recentBookingId = null;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->zahlungsId = (int)$this->findAccountId('1200');
|
||||||
|
$this->kategorieId = (int)$this->findAccountId('8400');
|
||||||
|
|
||||||
|
if (!$this->zahlungsId || !$this->kategorieId) {
|
||||||
|
$this->assert(false, 'Konten 1200/8400 nicht gefunden – SKR03-Seed vorhanden?');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->testDashboardKennzahlen();
|
||||||
|
$this->testRecent();
|
||||||
|
$this->testJournalForAccount();
|
||||||
|
$this->testFiscalYearRegisteredYears();
|
||||||
|
$this->testBewirtungssplit();
|
||||||
|
$this->testDeleteHard();
|
||||||
|
$this->testInvoiceQuerverweise();
|
||||||
|
$this->testInvoiceFinalizeWithBeleg();
|
||||||
|
$this->testBelegUpdates();
|
||||||
|
$this->testUstvaSubmission();
|
||||||
|
$this->testBankImportBatch();
|
||||||
|
$this->testKeineDirektenDatenbankaufrufeInControllern();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testDashboardKennzahlen(): void
|
||||||
|
{
|
||||||
|
$vorher = Booking::dashboardKennzahlen();
|
||||||
|
|
||||||
|
$customerId = Customer::create([
|
||||||
|
'nachname' => 'Kennzahlen',
|
||||||
|
'firma' => 'Kennzahlen GmbH',
|
||||||
|
'strasse' => 'Teststraße 1',
|
||||||
|
'plz' => '10115',
|
||||||
|
'ort' => 'Berlin',
|
||||||
|
'payment_days' => 14,
|
||||||
|
]);
|
||||||
|
$invoiceId = Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Testrechnung Dashboard-Kennzahlen',
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'lines' => [[
|
||||||
|
'bezeichnung' => 'Beratung',
|
||||||
|
'menge' => 1,
|
||||||
|
'einheit' => 'Std.',
|
||||||
|
'einzelpreis' => 500.00,
|
||||||
|
'ust_satz' => 19.0,
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
Invoice::finalize($invoiceId);
|
||||||
|
$invoice = Invoice::find($invoiceId);
|
||||||
|
|
||||||
|
$belegId = $this->newBeleg('OFFEN', $invoice['rechnungs_datum'], $invoice['rechnungs_nr'], (float)$invoice['betrag_brutto']);
|
||||||
|
Invoice::setBelegId($invoiceId, $belegId);
|
||||||
|
Invoice::markSent($invoiceId, 'Finalisiert');
|
||||||
|
|
||||||
|
// Zweiter, völlig unabhängiger Beleg ohne jede Buchung.
|
||||||
|
$ungebuchterBelegId = $this->newBeleg('UNGEBUCHT', date('Y-m-d'), null, 42.00);
|
||||||
|
|
||||||
|
$nachher = Booking::dashboardKennzahlen();
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorher['offene_rechnungen'] + 1,
|
||||||
|
$nachher['offene_rechnungen'],
|
||||||
|
'dashboardKennzahlen(): offene_rechnungen zählt die neue unbezahlte Rechnung'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
round($vorher['offene_rechnungen_sum'] + (float)$invoice['betrag_brutto'], 2),
|
||||||
|
round($nachher['offene_rechnungen_sum'], 2),
|
||||||
|
'dashboardKennzahlen(): offene_rechnungen_sum enthält den vollen Rechnungsbetrag (keine Zahlung erfasst)'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorher['ungebuchte_belege'] + 2,
|
||||||
|
$nachher['ungebuchte_belege'],
|
||||||
|
'dashboardKennzahlen(): ungebuchte_belege zählt Rechnungs- und Einzelbeleg (beide ohne Buchung)'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Für nachfolgende Tests weiterreichen.
|
||||||
|
$this->offenerBelegId = $belegId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testRecent(): void
|
||||||
|
{
|
||||||
|
// ust_satz 0, damit Booking::create() keine zusätzliche USt-Kindbuchung
|
||||||
|
// anlegt – sonst wäre nicht eindeutig, welche der beiden Zeilen jünger ist.
|
||||||
|
$bookingId = Booking::create([
|
||||||
|
'beleg_id' => $this->offenerBelegId,
|
||||||
|
'buchungs_datum' => date('Y-m-d'),
|
||||||
|
'buchungstyp' => 'Einnahme',
|
||||||
|
'kategorie_konto_id' => $this->kategorieId,
|
||||||
|
'zahlungskonto_id' => $this->zahlungsId,
|
||||||
|
'betrag_netto' => 100.00,
|
||||||
|
'ust_satz' => 0,
|
||||||
|
'buchungstext' => 'Test recent() Buchung',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recentOne = Booking::recent(1);
|
||||||
|
$this->assertEquals(1, count($recentOne), 'recent(1) liefert genau einen Datensatz');
|
||||||
|
$this->assertEquals($bookingId, (int)$recentOne[0]['id'], 'recent(1) liefert die zuletzt angelegte Buchung');
|
||||||
|
$this->assertNotEmpty($recentOne[0]['kategorie_nr'] ?? null, 'recent() liefert die Kategorie-Kontonummer mit');
|
||||||
|
$this->assertNotEmpty($recentOne[0]['beleg_nr'] ?? null, 'recent() liefert die Belegnummer mit');
|
||||||
|
|
||||||
|
$recentMany = Booking::recent(50);
|
||||||
|
$ids = array_column($recentMany, 'id');
|
||||||
|
$this->assert(in_array($bookingId, $ids, true), 'recent(50) enthält die soeben angelegte Buchung');
|
||||||
|
|
||||||
|
$this->recentBookingId = $bookingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testJournalForAccount(): void
|
||||||
|
{
|
||||||
|
$year = (int)date('Y');
|
||||||
|
|
||||||
|
$rows = Booking::journalForAccount($this->kategorieId, $year);
|
||||||
|
$ids = array_column($rows, 'id');
|
||||||
|
$this->assert(
|
||||||
|
in_array($this->recentBookingId, $ids, true),
|
||||||
|
'journalForAccount() findet die Buchung über das Kategorie-Konto'
|
||||||
|
);
|
||||||
|
|
||||||
|
$rowsZahlung = Booking::journalForAccount($this->zahlungsId, $year);
|
||||||
|
$idsZahlung = array_column($rowsZahlung, 'id');
|
||||||
|
$this->assert(
|
||||||
|
in_array($this->recentBookingId, $idsZahlung, true),
|
||||||
|
'journalForAccount() findet dieselbe Buchung auch über das Zahlungskonto'
|
||||||
|
);
|
||||||
|
|
||||||
|
$rowsAndererJahr = Booking::journalForAccount($this->kategorieId, $year - 50);
|
||||||
|
$idsAndererJahr = array_column($rowsAndererJahr, 'id');
|
||||||
|
$this->assert(
|
||||||
|
!in_array($this->recentBookingId, $idsAndererJahr, true),
|
||||||
|
'journalForAccount() filtert korrekt nach Geschäftsjahr'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testFiscalYearRegisteredYears(): void
|
||||||
|
{
|
||||||
|
$exotischesJahr = 2091;
|
||||||
|
FiscalYear::findOrCreateByYear($exotischesJahr);
|
||||||
|
|
||||||
|
$jahre = array_column(FiscalYear::registeredYears(), 'year');
|
||||||
|
$this->assert(
|
||||||
|
in_array($exotischesJahr, $jahre, true),
|
||||||
|
'registeredYears() liefert ein frisch angelegtes Geschäftsjahr'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testBewirtungssplit(): void
|
||||||
|
{
|
||||||
|
$belegId = $this->newBeleg('BEWIRTUNG', date('Y-m-d'), null, 119.00);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'beleg_id' => $belegId,
|
||||||
|
'buchungs_datum' => date('Y-m-d'),
|
||||||
|
'buchungstyp' => 'Ausgabe',
|
||||||
|
'kategorie_konto_id' => (int)$this->findAccountId('4650'),
|
||||||
|
'zahlungskonto_id' => $this->zahlungsId,
|
||||||
|
'betrag_netto' => 100.00,
|
||||||
|
'ust_satz' => 19.0,
|
||||||
|
'buchungstext' => 'Testessen Bewirtung',
|
||||||
|
];
|
||||||
|
|
||||||
|
Booking::bucheBewirtungssplit($data, '4650');
|
||||||
|
|
||||||
|
$hauptbuchungen = array_values(array_filter(
|
||||||
|
Booking::forBeleg($belegId),
|
||||||
|
fn(array $b): bool => $b['parent_booking_id'] === null
|
||||||
|
));
|
||||||
|
$this->assertEquals(2, count($hauptbuchungen), 'bucheBewirtungssplit() legt zwei Hauptbuchungen an');
|
||||||
|
|
||||||
|
$konto4650Id = (int)$this->findAccountId('4650');
|
||||||
|
$konto4654Id = (int)$this->findAccountId('4654');
|
||||||
|
|
||||||
|
$b70 = null;
|
||||||
|
$b30 = null;
|
||||||
|
foreach ($hauptbuchungen as $b) {
|
||||||
|
if ((int)$b['kategorie_konto_id'] === $konto4650Id) $b70 = $b;
|
||||||
|
if ((int)$b['kategorie_konto_id'] === $konto4654Id) $b30 = $b;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertNotEmpty($b70, '70 %-Buchung liegt auf dem Bewirtungskonto 4650');
|
||||||
|
$this->assertNotEmpty($b30, '30 %-Buchung liegt auf dem Abzugskonto 4654');
|
||||||
|
$this->assertEquals(70.00, (float)($b70['betrag_netto'] ?? 0), '70 % Netto auf dem Bewirtungskonto');
|
||||||
|
$this->assertEquals(30.00, (float)($b30['betrag_netto'] ?? 0), '30 % Netto auf dem Abzugskonto');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testDeleteHard(): void
|
||||||
|
{
|
||||||
|
$belegId = $this->newBeleg('DELETEHARD', date('Y-m-d'), null, 119.00);
|
||||||
|
|
||||||
|
$bookingId = Booking::create([
|
||||||
|
'beleg_id' => $belegId,
|
||||||
|
'buchungs_datum' => date('Y-m-d'),
|
||||||
|
'buchungstyp' => 'Einnahme',
|
||||||
|
'kategorie_konto_id' => $this->kategorieId,
|
||||||
|
'zahlungskonto_id' => $this->zahlungsId,
|
||||||
|
'betrag_netto' => 100.00,
|
||||||
|
'ust_satz' => 19.0,
|
||||||
|
'buchungstext' => 'Test deleteHard() Buchung',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 19 % USt erzeugt automatisch eine Kindbuchung (USt-Zeile).
|
||||||
|
$kinder = Database::fetchAll('SELECT id FROM bookings WHERE parent_booking_id = ?', [$bookingId]);
|
||||||
|
$this->assertGreaterThan(0, count($kinder), 'Voraussetzung: Hauptbuchung hat eine Kindbuchung');
|
||||||
|
|
||||||
|
Booking::deleteHard($bookingId);
|
||||||
|
|
||||||
|
$this->assert(Booking::find($bookingId) === false, 'deleteHard() entfernt die Hauptbuchung');
|
||||||
|
$verbleibendeKinder = Database::fetchAll('SELECT id FROM bookings WHERE parent_booking_id = ?', [$bookingId]);
|
||||||
|
$this->assertEquals(0, count($verbleibendeKinder), 'deleteHard() entfernt auch die Kindbuchung');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testInvoiceQuerverweise(): void
|
||||||
|
{
|
||||||
|
$customerId = Customer::create([
|
||||||
|
'nachname' => 'Querverweis',
|
||||||
|
'firma' => 'Querverweis GmbH',
|
||||||
|
'strasse' => 'Teststraße 2',
|
||||||
|
'plz' => '10115',
|
||||||
|
'ort' => 'Berlin',
|
||||||
|
'payment_days' => 14,
|
||||||
|
]);
|
||||||
|
$invoiceId = Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Testrechnung Querverweis',
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'lines' => [[
|
||||||
|
'bezeichnung' => 'Beratung',
|
||||||
|
'menge' => 1,
|
||||||
|
'einheit' => 'Std.',
|
||||||
|
'einzelpreis' => 200.00,
|
||||||
|
'ust_satz' => 19.0,
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
Invoice::finalize($invoiceId);
|
||||||
|
$invoice = Invoice::find($invoiceId);
|
||||||
|
$belegId = $this->newBeleg('QUERVERWEIS', $invoice['rechnungs_datum'], $invoice['rechnungs_nr'], (float)$invoice['betrag_brutto']);
|
||||||
|
Invoice::setBelegId($invoiceId, $belegId);
|
||||||
|
|
||||||
|
$stornoId = Invoice::createCancellation($invoiceId);
|
||||||
|
|
||||||
|
$storno = Invoice::findStorno($invoiceId);
|
||||||
|
$this->assertEquals($stornoId, (int)($storno['id'] ?? 0), 'findStorno() liefert die Stornorechnung zur Originalrechnung');
|
||||||
|
|
||||||
|
$ref = Invoice::findReference($invoiceId);
|
||||||
|
$this->assertEquals($invoice['rechnungs_nr'], $ref['rechnungs_nr'] ?? null, 'findReference() liefert die Rechnungsnummer');
|
||||||
|
$this->assertEquals($invoice['rechnungs_datum'], $ref['rechnungs_datum'] ?? null, 'findReference() liefert das Rechnungsdatum');
|
||||||
|
|
||||||
|
// deleteLine()
|
||||||
|
$lineInvoiceId = Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Testrechnung mit zwei Positionen',
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'lines' => [
|
||||||
|
['bezeichnung' => 'Position A', 'menge' => 1, 'einheit' => 'Stk.', 'einzelpreis' => 10.00, 'ust_satz' => 19.0],
|
||||||
|
['bezeichnung' => 'Position B', 'menge' => 1, 'einheit' => 'Stk.', 'einzelpreis' => 20.00, 'ust_satz' => 19.0],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$lines = Invoice::lines($lineInvoiceId);
|
||||||
|
$this->assertEquals(2, count($lines), 'Voraussetzung: Rechnung hat zwei Positionen');
|
||||||
|
Invoice::deleteLine($lineInvoiceId, (int)$lines[0]['id']);
|
||||||
|
$this->assertEquals(1, count(Invoice::lines($lineInvoiceId)), 'deleteLine() entfernt genau eine Position');
|
||||||
|
|
||||||
|
// forCustomer()
|
||||||
|
$kundenRechnungen = array_column(Invoice::forCustomer($customerId), 'id');
|
||||||
|
$this->assert(in_array($invoiceId, $kundenRechnungen, true), 'forCustomer() liefert die Rechnungen des Kunden');
|
||||||
|
|
||||||
|
// markSent()
|
||||||
|
$markInvoiceId = Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Testrechnung markSent',
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'lines' => [['bezeichnung' => 'Beratung', 'menge' => 1, 'einheit' => 'Std.', 'einzelpreis' => 50.00, 'ust_satz' => 19.0]],
|
||||||
|
]);
|
||||||
|
Invoice::finalize($markInvoiceId);
|
||||||
|
Invoice::markSent($markInvoiceId, 'Finalisiert');
|
||||||
|
$markInvoice = Invoice::find($markInvoiceId);
|
||||||
|
$this->assertEquals('Gesendet', $markInvoice['status'], 'markSent() setzt den Status bei der ersten Sendung auf Gesendet');
|
||||||
|
$this->assertNotEmpty($markInvoice['sent_at'], 'markSent() setzt sent_at');
|
||||||
|
|
||||||
|
$sentAtErsteSendung = $markInvoice['sent_at'];
|
||||||
|
Invoice::markSent($markInvoiceId, 'Gesendet');
|
||||||
|
$markInvoiceErneut = Invoice::find($markInvoiceId);
|
||||||
|
$this->assertEquals('Gesendet', $markInvoiceErneut['status'], 'markSent() ändert den Status beim erneuten Versand nicht');
|
||||||
|
$this->assertEquals($sentAtErsteSendung, $markInvoiceErneut['sent_at'], 'markSent() überschreibt sent_at beim erneuten Versand nicht');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testInvoiceFinalizeWithBeleg(): void
|
||||||
|
{
|
||||||
|
$customerId = Customer::create([
|
||||||
|
'nachname' => 'Finalisierung',
|
||||||
|
'firma' => 'Finalisierung GmbH',
|
||||||
|
'strasse' => 'Teststraße 3',
|
||||||
|
'plz' => '10115',
|
||||||
|
'ort' => 'Berlin',
|
||||||
|
'payment_days' => 14,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invoiceId = Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Testrechnung finalizeWithBeleg',
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'lines' => [['bezeichnung' => 'Beratung', 'menge' => 1, 'einheit' => 'Std.', 'einzelpreis' => 80.00, 'ust_satz' => 19.0]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$nr, $belegNr] = Invoice::finalizeWithBeleg($invoiceId, function (array $invoice): string {
|
||||||
|
return 'TESTBELEG-' . $invoice['id'];
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertNotEmpty($nr, 'finalizeWithBeleg() liefert die vergebene Rechnungsnummer');
|
||||||
|
$this->assertEquals('TESTBELEG-' . $invoiceId, $belegNr, 'finalizeWithBeleg() liefert den Rückgabewert des Beleg-Callbacks');
|
||||||
|
$this->assertEquals('Finalisiert', Invoice::find($invoiceId)['status'], 'Rechnung ist nach finalizeWithBeleg() finalisiert');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testBelegUpdates(): void
|
||||||
|
{
|
||||||
|
$belegId = $this->newBeleg('UPDATES', date('Y-m-d'), null, 50.00);
|
||||||
|
|
||||||
|
Beleg::updateBewirtung($belegId, 'Geschäftsessen', 'Person A, Person B');
|
||||||
|
$beleg = Beleg::find($belegId);
|
||||||
|
$this->assertEquals('Geschäftsessen', $beleg['bewirtungsgrund'], 'updateBewirtung() setzt bewirtungsgrund');
|
||||||
|
$this->assertEquals('Person A, Person B', $beleg['teilnehmerliste'], 'updateBewirtung() setzt teilnehmerliste');
|
||||||
|
|
||||||
|
Beleg::updateEigenanteil30($belegId, 1);
|
||||||
|
$this->assertEquals(1, (int)Beleg::find($belegId)['eigenanteil_30'], 'updateEigenanteil30() setzt das Flag');
|
||||||
|
|
||||||
|
// Beleg::find() überschreibt betrag_brutto mit einer SUM-Subquery über
|
||||||
|
// gebuchte Zahlungen (0 ohne Buchungen) – die rohe Spalte daher direkt prüfen.
|
||||||
|
Beleg::updateBetragBrutto($belegId, 123.45);
|
||||||
|
$rohwert = Database::fetchOne('SELECT betrag_brutto FROM belege WHERE id = ?', [$belegId]);
|
||||||
|
$this->assertEquals(123.45, (float)($rohwert['betrag_brutto'] ?? 0), 'updateBetragBrutto() aktualisiert den Betrag');
|
||||||
|
|
||||||
|
$hash = hash('sha256', 'test-inhalt-' . $belegId);
|
||||||
|
Beleg::updateStorage($belegId, '2026/TEST/original.pdf', 'Testdatei.pdf', $hash);
|
||||||
|
$belegNachUpdate = Beleg::find($belegId);
|
||||||
|
$this->assertEquals('2026/TEST/original.pdf', $belegNachUpdate['storage_path'], 'updateStorage() aktualisiert storage_path');
|
||||||
|
$this->assertEquals('Testdatei.pdf', $belegNachUpdate['filename'], 'updateStorage() aktualisiert filename');
|
||||||
|
$this->assertEquals($hash, $belegNachUpdate['file_hash'], 'updateStorage() aktualisiert file_hash');
|
||||||
|
|
||||||
|
$supplierId = Supplier::findOrCreate('Testlieferant Modellabfragen');
|
||||||
|
Beleg::setSupplierId($belegId, $supplierId);
|
||||||
|
$this->assertEquals($supplierId, (int)Beleg::find($belegId)['supplier_id'], 'setSupplierId() setzt den Lieferanten ohne locked-Prüfung');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testUstvaSubmission(): void
|
||||||
|
{
|
||||||
|
$year = 2077;
|
||||||
|
$month = 6;
|
||||||
|
|
||||||
|
$this->assert(UstvaSubmission::find($year, $month) === false, 'Vor dem Speichern existiert noch keine Übermittlung');
|
||||||
|
|
||||||
|
UstvaSubmission::upsert($year, $month, '<xml>erster-stand</xml>', ['81' => ['wert' => 100]]);
|
||||||
|
$ersterStand = UstvaSubmission::find($year, $month);
|
||||||
|
$this->assertEquals('<xml>erster-stand</xml>', $ersterStand['xml_content'], 'upsert() legt beim ersten Aufruf einen Datensatz an');
|
||||||
|
|
||||||
|
UstvaSubmission::upsert($year, $month, '<xml>zweiter-stand</xml>', ['81' => ['wert' => 200]]);
|
||||||
|
$zweiterStand = UstvaSubmission::find($year, $month);
|
||||||
|
$this->assertEquals('<xml>zweiter-stand</xml>', $zweiterStand['xml_content'], 'upsert() überschreibt beim zweiten Aufruf den Inhalt');
|
||||||
|
$this->assertEquals((int)$ersterStand['id'], (int)$zweiterStand['id'], 'upsert() legt beim zweiten Aufruf keinen zweiten Datensatz an');
|
||||||
|
|
||||||
|
$alleIds = array_column(UstvaSubmission::all(), 'id');
|
||||||
|
$this->assert(in_array((int)$zweiterStand['id'], $alleIds, true), 'all() enthält die gespeicherte Übermittlung');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testBankImportBatch(): void
|
||||||
|
{
|
||||||
|
$sourceId = BankSource::create([
|
||||||
|
'type' => 'csv',
|
||||||
|
'bezeichnung' => 'Test Modellabfragen CSV',
|
||||||
|
'kuerzel' => 'TMA',
|
||||||
|
'farbe' => '#123456',
|
||||||
|
'skip_rows' => 0,
|
||||||
|
'has_header_row' => 1,
|
||||||
|
'delimiter' => ';',
|
||||||
|
'enclosure' => '"',
|
||||||
|
'col_buchungstag' => 0,
|
||||||
|
'col_wertstellung' => 1,
|
||||||
|
'col_vorgang' => 2,
|
||||||
|
'col_buchungstext' => 3,
|
||||||
|
'col_wert' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = [
|
||||||
|
['buchungstag' => date('Y-m-d'), 'wertstellung' => date('Y-m-d'), 'vorgang' => 'Gutschrift', 'buchungstext' => 'Modellabfragen Testzeile 1', 'wert' => '10.00'],
|
||||||
|
['buchungstag' => date('Y-m-d'), 'wertstellung' => date('Y-m-d'), 'vorgang' => 'Gutschrift', 'buchungstext' => 'Modellabfragen Testzeile 2', 'wert' => '20.00'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$batchId = BankTransaction::importBatch($sourceId, 'modellabfragen-test.csv', $rows);
|
||||||
|
$this->assertGreaterThan(0, $batchId, 'importBatch() legt einen Import-Batch an');
|
||||||
|
|
||||||
|
$importiert = Database::fetchAll('SELECT * FROM bank_transactions WHERE batch_id = ? ORDER BY id', [$batchId]);
|
||||||
|
$this->assertEquals(2, count($importiert), 'importBatch() legt beide Zeilen im selben Batch an');
|
||||||
|
$this->assertEquals('Modellabfragen Testzeile 1', $importiert[0]['buchungstext'] ?? null, 'Erste Zeile wird unverändert übernommen');
|
||||||
|
$this->assertEquals('20.00', $importiert[1]['wert'] ?? null, 'Zweite Zeile wird unverändert übernommen');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regressionsschutz: kein Controller darf wieder direkt auf Database:: zugreifen.
|
||||||
|
*
|
||||||
|
* Ausnahme AssetController.php: dort verbleiben vier Aufrufe außerhalb des
|
||||||
|
* AfA-Umfangs von Fix #155 (Anlage-Verknüpfung einer Buchung, vorheriges
|
||||||
|
* AfA-Konto, harte Löschung im Testmodus) – laut Issue #156 nicht Teil
|
||||||
|
* dieses Tickets.
|
||||||
|
*/
|
||||||
|
private function testKeineDirektenDatenbankaufrufeInControllern(): void
|
||||||
|
{
|
||||||
|
$verzeichnis = dirname(__DIR__) . '/src/Controllers';
|
||||||
|
$ausnahmen = ['AssetController.php'];
|
||||||
|
|
||||||
|
$treffer = [];
|
||||||
|
foreach (glob($verzeichnis . '/*.php') ?: [] as $datei) {
|
||||||
|
if (in_array(basename($datei), $ausnahmen, true)) continue;
|
||||||
|
$inhalt = file_get_contents($datei);
|
||||||
|
if ($inhalt !== false && str_contains($inhalt, 'Database::')) {
|
||||||
|
$treffer[] = basename($datei);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
empty($treffer),
|
||||||
|
empty($treffer)
|
||||||
|
? 'Kein Controller (außer den dokumentierten Ausnahmen) ruft Database:: direkt auf'
|
||||||
|
: 'Unerwartete Database::-Aufrufe in: ' . implode(', ', $treffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newBeleg(string $suffix, string $belegDatum, ?string $rechnungsNr, float $betragBrutto): int
|
||||||
|
{
|
||||||
|
$this->belegSeq++;
|
||||||
|
return Beleg::create([
|
||||||
|
'beleg_nr' => Beleg::generateBelegNr(),
|
||||||
|
'filename' => "modellabfragen-{$suffix}-{$this->belegSeq}.pdf",
|
||||||
|
'storage_path' => '',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'file_hash' => hash('sha256', $suffix . $this->belegSeq),
|
||||||
|
'beleg_datum' => $belegDatum,
|
||||||
|
'rechnungs_nr' => $rechnungsNr,
|
||||||
|
'betrag_brutto' => $betragBrutto,
|
||||||
|
'belegtyp' => $rechnungsNr !== null ? 'Ausgangsrechnung' : 'Eingangsrechnung',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
tests/86_BelegDetailStrukturTest.php
Normal file
98
tests/86_BelegDetailStrukturTest.php
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
tests/87_ViewHelperTest.php
Normal file
61
tests/87_ViewHelperTest.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 87: View::kontrastfarbe() und View::gruppiereKonten() (Fix #158)
|
||||||
|
*
|
||||||
|
* Kontrastfarben-Formel und Kontengruppierung waren mehrfach über die Views
|
||||||
|
* kopiert, mit bereits eingetretener Divergenz beim Zahlungskonten-Filter.
|
||||||
|
* Dieser Test hält das gebündelte Verhalten in src/View.php fest.
|
||||||
|
*/
|
||||||
|
class ViewHelperTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 87: View-Helfer (Kontrastfarbe, Kontengruppierung)';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals('#1e293b', View::kontrastfarbe('#ffffff'), 'Heller Hintergrund → dunkle Schrift');
|
||||||
|
$this->assertEquals('#ffffff', View::kontrastfarbe('#000000'), 'Dunkler Hintergrund → helle Schrift');
|
||||||
|
|
||||||
|
// Ungültige/zu kurze Werte liefern die Standardfarbe statt einer
|
||||||
|
// stillen Rechnung auf 0 (die vormals immer "hell" ergeben hätte).
|
||||||
|
$this->assertEquals('#1e293b', View::kontrastfarbe(''), 'Leerer Wert → Standardfarbe');
|
||||||
|
$this->assertEquals('#1e293b', View::kontrastfarbe('abc'), 'Wert ohne # → Standardfarbe');
|
||||||
|
$this->assertEquals('#1e293b', View::kontrastfarbe('#xyz123'), 'Ungültiges Hex → Standardfarbe');
|
||||||
|
|
||||||
|
// Dreistellige Kurzform wird korrekt expandiert: #abc → #aabbcc,
|
||||||
|
// Helligkeit (0.299*170+0.587*187+0.114*204=182.9) > 128 → dunkle Schrift
|
||||||
|
$this->assertEquals('#1e293b', View::kontrastfarbe('#abc'), 'Kurzform #abc wird expandiert');
|
||||||
|
|
||||||
|
// Eigene Standardfarben werden respektiert
|
||||||
|
$this->assertEquals('dunkel', View::kontrastfarbe('#ffffff', 'dunkel', 'hell'), 'Eigene Standardfarben (hell)');
|
||||||
|
$this->assertEquals('dunkel', View::kontrastfarbe('', 'dunkel', 'hell'), 'Eigene Standardfarben (ungültig)');
|
||||||
|
|
||||||
|
$accounts = [
|
||||||
|
['kontonummer' => '4400', 'kontenart' => 'Betriebseinnahme'],
|
||||||
|
['kontonummer' => '1000', 'kontenart' => 'Zahlungskonto'], // Kasse
|
||||||
|
['kontonummer' => '1200', 'kontenart' => 'Zahlungskonto'], // Bank 1
|
||||||
|
['kontonummer' => '1299', 'kontenart' => 'Zahlungskonto'], // Grenzwert oben
|
||||||
|
['kontonummer' => '1300', 'kontenart' => 'Zahlungskonto'], // außerhalb
|
||||||
|
['kontonummer' => '1800', 'kontenart' => 'Privat'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$ohneFilter = View::gruppiereKonten($accounts);
|
||||||
|
$this->assertEquals(6, array_sum(array_map('count', $ohneFilter)), 'Ohne Filter enthält gruppiereKonten() alle Konten');
|
||||||
|
$this->assertEquals(4, count($ohneFilter['Zahlungskonto']), 'Ohne Filter bleiben alle Zahlungskonten in der Gruppe');
|
||||||
|
$this->assertEquals(1, count($ohneFilter['Betriebseinnahme']), 'Andere Kontenarten bleiben unangetastet');
|
||||||
|
$this->assertEquals(1, count($ohneFilter['Privat']), 'Andere Kontenarten bleiben unangetastet');
|
||||||
|
|
||||||
|
$mitFilter = View::gruppiereKonten($accounts, true);
|
||||||
|
$this->assertEquals(
|
||||||
|
['1200', '1299'],
|
||||||
|
array_column($mitFilter['Zahlungskonto'], 'kontonummer'),
|
||||||
|
'Mit Filter bleiben nur Zahlungskonten 1200–1299'
|
||||||
|
);
|
||||||
|
$this->assertEquals(1, count($mitFilter['Betriebseinnahme']), 'Filter lässt andere Kontenarten unangetastet');
|
||||||
|
$this->assertEquals(1, count($mitFilter['Privat']), 'Filter lässt andere Kontenarten unangetastet');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue