diff --git a/Changes.md b/Changes.md index ad6f6e4..d7fcdd0 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,56 @@ # Änderungen +## Umbuchungen: Gegenkonto sichtbar, Zuordnung im Kontoblatt (12.08.2026) + +Eine Umbuchung war auf dem Gegenkonto nicht mehr zuzuordnen: Die Zeile trug die Marke +*Umbuchung*, die Kategoriespalte daneben blieb leer. Zu sehen etwa bei den Buchungen +zwischen DKB Girokonto und Paypal – die Daten waren richtig, die Anzeige verschwieg sie. + +**Beide Sichten nennen das Gegenüber** (`Views/buchungen/kontoblatt.php`) +- In der Kategoriespalte steht jetzt auch in der Gegensicht das Konto in eckigen Klammern, + von dem die Umbuchung kommt. Die Abfrage lieferte das Konto längst mit; die Ansicht hat + es nur nicht gezeigt. + +**Kategorie und Gegenkonto in einem Feld** (`Views/buchungen/zuordnungsliste.php`, +`BuchungenController`) +- Unter den Kategorien stehen die eigenen Konten in eckigen Klammern – wie im + FinanzManager, dessen Kategoriefeld `[Konto]` annimmt. Eines davon macht die Buchung zur + Umbuchung. Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung; das + eigene Konto fehlt in der Liste. +- Dieselbe Liste steht in den Splitzeilen. Eine Splitzeile mit Gegenkonto ließ sich bisher + nur im Import anlegen und ging beim Bearbeiten der Buchung stillschweigend verloren. + +**Schnellzuordnung im Kontoblatt** (`public/static/kontoblatt.js`, `Buchung::zuordnen()`) +- Ein Klick auf die Kategoriespalte öffnet die Liste in der Zelle: leerer Eintrag, + *Splitbuchung*, Kategorien, Konten. Die Auswahl speichert sofort + (`POST /buchungen/{id}/zuordnung`) und kommt in dieselbe Ansicht zurück – Filter und + Seite hängen am Ziel. +- Die Liste gibt es einmal und wandert in die angeklickte Zelle. Eine Liste je Zeile wären + bei hundert Zeilen ebenso viele vollständige Kategoriebäume im HTML. +- Feld und Liste sind gleich breit und gleich hoch, die Zelle zeigt nur, was auch in der + Liste steht – sonst rückte beim Anklicken die halbe Zeile. +- Ohne JavaScript führt derselbe Klick in die Buchungsmaske: Die Zelle ist ein Link. +- Auch die Gegenseite einer fremden Umbuchung ist anklickbar; sie führt zu ihrer Buchung + auf dem anderen Konto, wo sie geändert wird. + +**Splitdialog** (`Views/buchungen/kontoblatt.php`, `Buchung::splitsErsetzen()`) +- Der Eintrag *Splitbuchung* öffnet ein modales Fenster mit Zeilen für Kategorie oder + Gegenkonto, Klasse, Notiz und Betrag; der Restbetrag wird mitgerechnet, weitere Zeilen + kommen auf Knopfdruck. Eine bestehende Aufteilung steht schon drin. +- Ein Klick auf eine bereits gesplittete Buchung öffnet direkt den Dialog statt der + Auswahlliste: Für sie hatte die Liste nichts anzubieten außer dem Weg dorthin. +- Rechts unten im Dialog steht *Splitbuchung löschen*. Der Knopf schickt dasselbe + Formular mit `aktion=loeschen`, verwirft alle Zeilen samt ihren Zuordnungen – auch die + Gegenseite einer Splitzeile mit Gegenkonto – und die Buchung steht danach unzugeordnet + im Kontoblatt. Er erscheint nur dort, wo es etwas aufzuheben gibt, und steht abseits + von *Speichern* und *Abbrechen*. +- Bleiben alle Zeilen leer, wird die Splitbuchung ebenso aufgehoben. +- Beide Wege achten den Monatsabschluss und lassen den Rest der Buchung unberührt. + +`public/static/betrag.js` bündelt das Lesen deutscher Beträge und die Restbetragsrechnung +für Maske und Kontoblatt. `tests/23_ZuordnungTest.php` prüft Zuordnung, Splitzeilen und +Gegensicht; `tests/e2e/specs/buchungen.spec.js` die drei Wege im Browser. + ## Wertpapiere: Depotbuchungen sammelweise löschen (12.08.2026) Die Liste der Depotbuchungen räumt jetzt so auf wie das Kontoblatt – nach einem Import, diff --git a/README.md b/README.md index 7783c0b..e1d2871 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ Haushalt wird beides parallel genutzt. **Alle sieben Phasen aus `PLAN.md` sind umgesetzt.** -* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt, - Buchungsmaske mit Splitbuchungen und Umbuchungen; QIF-Import mit Stichtag. +* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt + mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen; + QIF-Import mit Stichtag. * **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf, Snapshot und Sperre. * **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit diff --git a/docs/buchungen.md b/docs/buchungen.md index 416f817..e281942 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -21,6 +21,13 @@ Zeilen aus 2 und 3 tragen `gegensicht = true`. Sie sind nicht bearbeitbar – da dem Ursprungskonto –, damit eine Umbuchung nicht aus zwei Richtungen widersprüchlich geändert wird. +**Beide Sichten nennen das Gegenüber.** In der Kategoriespalte steht das jeweils andere +Konto in eckigen Klammern: auf dem Ausgangskonto das Ziel, auf dem Gegenkonto das +Ursprungskonto. In der Gegensicht liefert die Abfrage dafür `b.konto_id` als +`gegenkonto_id` – dieselbe Spalte, aus der anderen Richtung gelesen. Ohne diese Angabe +stünde auf dem Gegenkonto eine Zeile, die sich „Umbuchung“ nennt und verschweigt, woher +sie kommt. + Dieselbe Dreiteilung gilt für die Salden (`Buchung::saldenJeKonto()`), die alle Konten mit sechs Abfragen auf einmal berechnet statt je Konto einzeln. @@ -43,6 +50,56 @@ Filter und Seite hängen an der Abfragezeichenfolge des Formularziels. Der Contr den Weg zurück aus den bekannten Feldern neu zusammen, statt ein mitgeschicktes Ziel zu übernehmen; so lässt sich kein fremder Weiterleitungsort einschleusen. +## Kategorie und Gegenkonto in einem Feld + +Eine Buchung ist entweder kategorisiert **oder** eine Umbuchung – beides zugleich gibt es +nicht. Deshalb stehen Kategorien und Konten in derselben Auswahlliste; die Konten unten, +in eckigen Klammern, wie das Kategoriefeld im FinanzManager sie schreibt. Das eigene Konto +fehlt in der Liste. + +Die Liste steht in `Views/buchungen/zuordnungsliste.php` und wird an vier Stellen +eingebunden: Buchungsmaske, deren Splitzeilen, Schnellzuordnung im Kontoblatt und +Splitdialog. Die Werte tragen ein Präfix, das `BuchungenController::zuordnungAus()` wieder +auseinandernimmt: + +| Wert | Bedeutung | +|---|---| +| `` (leer) | keine Zuordnung | +| `kategorie:12` | Kategorie 12 | +| `konto:7` | Umbuchung mit Gegenkonto 7 | +| `split` | Splitdialog öffnen (nur im Kontoblatt) | + +Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung des Betrags. + +## Schnellzuordnung im Kontoblatt + +Ein Klick auf die Kategoriespalte öffnet die Liste in der Zelle; die Auswahl geht an +`POST /buchungen/{id}/zuordnung`, der Eintrag *Splitbuchung* öffnet stattdessen den +Splitdialog (`POST /buchungen/{id}/splitbuchung`). Beide Wege gehen über +`Buchung::zuordnen()` und `Buchung::splitsErsetzen()`, die den Rest der Buchung unberührt +lassen und den Monatsabschluss achten. + +Drei Punkte, die sich nicht ändern dürfen: + +* Die Auswahlliste gibt es **einmal**; `kontoblatt.js` hängt sie in die angeklickte Zelle + und danach zurück. Eine Liste je Zeile wären bei hundert Zeilen ebenso viele + vollständige Kategoriebäume im HTML. +* Ohne JavaScript führt derselbe Klick in die Buchungsmaske – die Zelle ist ein Link, kein + Knopf. +* Zeilen mit `gegensicht = true` führen zu ihrer Buchung, statt die Auswahl zu öffnen: Sie + werden auf ihrem Ursprungskonto geändert, wie beim Bearbeiten und Löschen. + `kontoblatt.js` erkennt sie an der Klasse `gegenseite`. +* Feld und Auswahlliste sind **gleich breit und gleich hoch** (`13rem` / `1.35rem`), und + die Zelle zeigt nichts an, was nicht auch in der Liste stünde – kein „zuordnen …“. Sonst + rückt beim Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten + Kategorie, und der Empfänger daneben bräche um. + +Eine schon gesplittete Buchung öffnet auf Klick **direkt den Splitdialog**, nicht die +Auswahlliste: Deren einziger sinnvoller Eintrag wäre für sie ohnehin *Splitbuchung* +gewesen. Wer eine Aufteilung wieder in eine einzelne Zuordnung wandeln will, löscht sie +im Dialog und ordnet die dann unzugeordnete Buchung anschließend zu – oder geht über die +Buchungsmaske. + ## Splitbuchungen Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen @@ -50,9 +107,17 @@ vorliegen, räumt das Model `kategorie_id` und `gegenkonto_id` der Buchung; die steckt dann ausschließlich in den Zeilen. Die Summe der Zeilen muss dem Buchungsbetrag entsprechen – geprüft im Model (Toleranz ein -halber Cent, für Rundungen aus der Betragsumrechnung) und in der Maske als -Restbetragsanzeige. Eine Splitzeile darf ein Gegenkonto tragen; dann erscheint auf jenem -Konto nur der Betrag dieser Zeile. +halber Cent, für Rundungen aus der Betragsumrechnung) und in beiden Masken als +Restbetragsanzeige. Beide rechnen mit `public/static/betrag.js`; die Regel für das +deutsche Zahlenformat steht damit einmal im Browser und einmal in PHP, nicht dreimal. Eine +Splitzeile darf ein Gegenkonto tragen; dann erscheint auf jenem Konto nur der Betrag dieser +Zeile. Auf das eigene Konto darf sie nicht zeigen – die Buchung zählte dort doppelt. + +Der Knopf *Splitbuchung löschen* im Dialog schickt `POST /buchungen/{id}/splitbuchung` mit +`aktion=loeschen`; der Controller ruft dann `Buchung::splitsErsetzen($id, [])`, ganz gleich +was in den Feldern steht. Gelöscht werden die Zeilen samt Zuordnung, nicht die Buchung – +sie steht danach mit ihrem Betrag und ohne Kategorie im Kontoblatt, und die Gegenseite +einer Zeile mit Gegenkonto verschwindet von jenem Konto. ## Salden @@ -70,7 +135,7 @@ nicht; es wird im Kontoformular gepflegt. ## Beträge in der Oberfläche Eingegeben wird immer ein **positiver** Betrag; die Richtung bestimmt die Auswahl -*Ausgabe / Einnahme / Umbuchung*. `KontenController::zahl()` liest das deutsche Format und +*Ausgabe / Einnahme*. `KontenController::zahl()` liest das deutsche Format und entscheidet bei einem Punkt ohne Komma nach der Stellenzahl: drei Ziffern dahinter heißt Tausendertrenner (`1.500`), sonst Dezimaltrenner (`1234.56`). diff --git a/public/index.php b/public/index.php index 9f20bf2..be4241a 100644 --- a/public/index.php +++ b/public/index.php @@ -85,6 +85,11 @@ $router->post('/konten/{id}/buchungen/loeschen', $router->get('/buchungen/{id}', fn(array $p) => (new BuchungenController())->bearbeiten($p)); $router->post('/buchungen/{id}', fn(array $p) => (new BuchungenController())->speichern($p)); $router->post('/buchungen/{id}/loeschen', fn(array $p) => (new BuchungenController())->loeschen($p)); +// Schnellzuordnung und Splitdialog aus dem Kontoblatt. +$router->post('/buchungen/{id}/zuordnung', + fn(array $p) => (new BuchungenController())->zuordnung($p)); +$router->post('/buchungen/{id}/splitbuchung', + fn(array $p) => (new BuchungenController())->splitbuchung($p)); // Verwaltung: Kategorien, Klassen, Kontengruppen, QIF-Import. $router->get('/verwaltung', fn() => (new VerwaltungController())->index()); diff --git a/public/static/app.css b/public/static/app.css index 8984961..11ada55 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -999,6 +999,47 @@ table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent) margin-top: .8rem; } +/* Schnellzuordnung: Die Kategoriespalte ist anklickbar wie ein Eingabefeld, + sieht aber nicht danach aus – sonst flimmerte die ganze Spalte. Der + gestrichelte Unterstrich erscheint erst beim Überfahren. + + Feld und Auswahlliste sind gleich breit und gleich hoch. Sonst rückte beim + Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten + Kategorie, und der Empfänger daneben bräche um. */ +td.zuordnungszelle { width: 13rem; } + +a.zuordnungsfeld { + display: block; + max-width: 13rem; + min-height: 1.35rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: inherit; + text-decoration: none; + border-bottom: 1px dashed transparent; +} + +a.zuordnungsfeld:hover, +a.zuordnungsfeld:focus-visible { border-bottom-color: var(--rand-kraeftig); } + +.zuordnungsauswahl { display: flex; gap: .3rem; } +.zuordnungsauswahl[hidden] { display: none; } + +.zuordnungsauswahl select { + width: 100%; + max-width: 13rem; + height: 1.35rem; + padding: 0 .2rem; + border: 1px solid var(--akzent); + border-radius: 4px; + background: var(--flaeche); + color: var(--text); + font: inherit; + font-family: var(--schrift); + font-size: .85rem; +} + /* Die Splittabelle sitzt im Formular und braucht schmalere Zellen. */ table.splittabelle td { padding: .3rem .4rem; } table.splittabelle select { max-width: 14rem; width: 100%; } @@ -1006,6 +1047,28 @@ table.splittabelle input.rechts { text-align: right; } .buchungsmaske [hidden] { display: none; } +/* Der Splitdialog des Kontoblatts. Als und nicht als eigene Seite: + Die Aufteilung gehört zur Zeile, aus der sie geöffnet wurde. */ +.splitdialog { + width: min(46rem, 92vw); + padding: 1.1rem 1.25rem 1.25rem; + border: 1px solid var(--rand-kraeftig); + border-radius: var(--radius); + background: var(--flaeche); + color: var(--text); + box-shadow: 0 10px 30px rgb(0 0 0 / 25%); +} + +.splitdialog::backdrop { background: rgb(0 0 0 / 35%); } + +.splitdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; } +.splitdialog .knopfleiste { margin-top: .9rem; } +.splitdialog .splittabelle { margin-top: .6rem; } + +/* Das Löschen der ganzen Aufteilung steht rechts außen – weit weg von + „Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */ +.splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; } + /* ── Formulare ───────────────────────────────────────────────────────────── */ .formular { diff --git a/public/static/betrag.js b/public/static/betrag.js new file mode 100644 index 0000000..d6e08e7 --- /dev/null +++ b/public/static/betrag.js @@ -0,0 +1,58 @@ +/* Beträge im deutschen Format lesen und schreiben. + * + * Dieselbe Regel wie KontenController::zahl(): ohne Komma entscheidet die + * Stellenzahl, ob der Punkt Tausender- oder Dezimaltrenner ist. Die Maske und + * das Kontoblatt rechnen beide Restbeträge mit – deshalb steht das hier und + * nicht zweimal. + */ + +(function () { + 'use strict'; + + function zahl(text) { + const bereinigt = String(text).replace(/[\s€]/g, ''); + if (bereinigt === '') { + return 0; + } + if (bereinigt.indexOf(',') !== -1) { + return parseFloat(bereinigt.replace(/\./g, '').replace(',', '.')) || 0; + } + + const punkte = (bereinigt.match(/\./g) || []).length; + const letzter = bereinigt.split('.').pop(); + if (punkte > 1 || (punkte === 1 && letzter.length === 3)) { + return parseFloat(bereinigt.replace(/\./g, '')) || 0; + } + return parseFloat(bereinigt) || 0; + } + + function formatiere(wert) { + return wert.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + /* Die Summe der Splitzeilen unter `felder` gegen `ziel` – das Ergebnis geht + * so in die Anzeige, wie beide Masken es brauchen. */ + function splitsumme(felder, ziel) { + let summe = 0; + let belegt = false; + + felder.forEach(function (feld) { + if (feld.value.trim() === '') { + return; + } + belegt = true; + summe += zahl(feld.value); + }); + + const rest = Math.round((ziel - summe) * 100) / 100; + + return { + belegt: belegt, + summe: summe, + rest: rest, + text: belegt ? formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')') : '0,00' + }; + } + + window.FmBetrag = { zahl: zahl, formatiere: formatiere, splitsumme: splitsumme }; +})(); diff --git a/public/static/buchungsmaske.js b/public/static/buchungsmaske.js index 310ca8c..f06c32d 100644 --- a/public/static/buchungsmaske.js +++ b/public/static/buchungsmaske.js @@ -1,9 +1,10 @@ -/* Buchungsmaske: Felder je nach Art ein- und ausblenden, Restbetrag der - * Splitzeilen mitrechnen. +/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen. * * Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles - * bedienbar: dann sind sämtliche Felder sichtbar, und die Splitsumme prüft - * ohnehin das Model. + * bedienbar: Die Splitsumme prüft ohnehin das Model. + * + * Kategorie und Gegenkonto stehen in einem Feld, deshalb gibt es hier nichts + * mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung. */ (function () { @@ -11,90 +12,35 @@ const art = document.getElementById('art'); const maske = document.querySelector('.buchungsmaske'); - if (!art || !maske) { + if (!art || !maske || !window.FmBetrag) { return; } - const kategorie = maske.querySelector('.nur-kategorie'); - const umbuchung = maske.querySelector('.nur-umbuchung'); - const gegenkonto = document.getElementById('gegenkonto_id'); const summenfeld = document.getElementById('splitsumme'); const betragsfeld = document.getElementById('betrag'); - function zeigePassendeFelder() { - const istUmbuchung = art.value.indexOf('umbuchung') === 0; - if (kategorie) { - kategorie.hidden = istUmbuchung; - } - if (umbuchung) { - umbuchung.hidden = !istUmbuchung; - } - if (gegenkonto) { - gegenkonto.required = istUmbuchung; - } - } - - /* Beträge stehen im deutschen Format im Feld. Dieselbe Regel wie - * KontenController::zahl(): ohne Komma entscheidet die Stellenzahl, ob der - * Punkt Tausender- oder Dezimaltrenner ist. */ - function zahl(text) { - const bereinigt = String(text).replace(/[\s€]/g, ''); - if (bereinigt === '') { - return 0; - } - if (bereinigt.indexOf(',') !== -1) { - return parseFloat(bereinigt.replace(/\./g, '').replace(',', '.')) || 0; - } - - const punkte = (bereinigt.match(/\./g) || []).length; - const letzter = bereinigt.split('.').pop(); - if (punkte > 1 || (punkte === 1 && letzter.length === 3)) { - return parseFloat(bereinigt.replace(/\./g, '')) || 0; - } - return parseFloat(bereinigt) || 0; - } - - function formatiere(wert) { - return wert.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - } - function rechneSumme() { if (!summenfeld) { return; } - let summe = 0; - let belegt = false; - maske.querySelectorAll('.splitbetrag').forEach(function (feld) { - if (feld.value.trim() === '') { - return; - } - belegt = true; - summe += zahl(feld.value); - }); - - if (!belegt) { - summenfeld.textContent = '0,00'; - summenfeld.classList.remove('negativ'); - return; - } - // Der Buchungsbetrag wird positiv eingegeben; das Vorzeichen ergibt // sich aus der Art. Für den Vergleich zählt der Betrag mit Richtung. - let ziel = zahl(betragsfeld ? betragsfeld.value : '0'); - if (art.value === 'ausgabe' || art.value === 'umbuchung_ab') { + let ziel = window.FmBetrag.zahl(betragsfeld ? betragsfeld.value : '0'); + if (art.value !== 'einnahme') { ziel = -ziel; } - const rest = Math.round((ziel - summe) * 100) / 100; - summenfeld.textContent = formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')'); - summenfeld.classList.toggle('negativ', rest !== 0); + const stand = window.FmBetrag.splitsumme( + Array.from(maske.querySelectorAll('.splitbetrag')), + ziel + ); + + summenfeld.textContent = stand.text; + summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); } - art.addEventListener('change', function () { - zeigePassendeFelder(); - rechneSumme(); - }); + art.addEventListener('change', rechneSumme); maske.addEventListener('input', function (ereignis) { if (ereignis.target.classList.contains('splitbetrag') || ereignis.target.id === 'betrag') { @@ -102,6 +48,5 @@ } }); - zeigePassendeFelder(); rechneSumme(); })(); diff --git a/public/static/kontoblatt.js b/public/static/kontoblatt.js new file mode 100644 index 0000000..0f5adbc --- /dev/null +++ b/public/static/kontoblatt.js @@ -0,0 +1,218 @@ +/* Kontoblatt: Kategorie oder Gegenkonto an Ort und Stelle zuordnen. + * + * Ein Klick auf die Kategoriespalte öffnet die Auswahlliste in der Zelle. Sie + * gibt es genau einmal und wandert in die angeklickte Zelle – wie das + * Zahnradmenü im Budgetplaner. Eine Liste je Zeile wären bei hundert Zeilen + * ebenso viele vollständige Kategoriebäume im HTML. + * + * Ohne JavaScript führt derselbe Klick in die Buchungsmaske; die Zuordnung ist + * dort wie bisher zu ändern. + */ + +(function () { + 'use strict'; + + const tabelle = document.querySelector('table.kontoblatt'); + const auswahl = document.getElementById('zuordnungsauswahl'); + if (!tabelle || !auswahl) { + return; + } + + const abfrage = tabelle.dataset.abfrage || ''; + const liste = auswahl.querySelector('select'); + const dialog = document.getElementById('splitdialog'); + const splitformular = document.getElementById('splitformular'); + + // Das Feld, das gerade durch die Auswahlliste ersetzt ist. + let offenesFeld = null; + + function ziel(feld, aktion) { + return '/buchungen/' + feld.dataset.buchung + '/' + aktion + abfrage; + } + + function schliessen() { + if (!offenesFeld) { + return; + } + offenesFeld.hidden = false; + offenesFeld = null; + + auswahl.hidden = true; + document.body.appendChild(auswahl); + } + + /* Der Splitdialog zu einer Zeile – oder, wo es kein gibt, die + * Buchungsmaske. Die Weiche steht hier oben, weil beide Wege hierher + * führen: der Klick auf eine Splitbuchung und die Wahl „Splitbuchung“. */ + function splitOeffnen(feld) { + if (dialog && splitformular && window.FmBetrag) { + splitdialogOeffnen(feld); + } else { + window.location.href = '/buchungen/' + feld.dataset.buchung; + } + } + + function oeffnen(feld) { + schliessen(); + + offenesFeld = feld; + feld.hidden = true; + + auswahl.action = ziel(feld, 'zuordnung'); + liste.value = feld.dataset.wert || ''; + feld.parentNode.appendChild(auswahl); + auswahl.hidden = false; + + liste.focus(); + // Die Liste soll sofort aufklappen; showPicker gibt es nicht überall. + try { + liste.showPicker(); + } catch (fehler) { + /* Dann klappt sie beim ersten Klick auf – kein Verlust. */ + } + } + + tabelle.addEventListener('click', function (ereignis) { + // Die Gegenseite einer fremden Umbuchung führt zu ihrer Buchung, statt + // hier die Auswahl zu öffnen: Geändert wird sie auf ihrem Konto. + const feld = ereignis.target.closest('a.zuordnungsfeld:not(.gegenseite)'); + if (!feld || ereignis.metaKey || ereignis.ctrlKey || ereignis.shiftKey) { + return; + } + + ereignis.preventDefault(); + + // Eine schon aufgeteilte Buchung führt geradewegs in den Splitdialog: + // Die Auswahlliste hätte für sie nichts anzubieten außer dem Weg dorthin. + if (feld.dataset.wert === 'split') { + splitOeffnen(feld); + return; + } + + oeffnen(feld); + }); + + liste.addEventListener('change', function () { + if (!offenesFeld) { + return; + } + + const feld = offenesFeld; + + if (liste.value === 'split') { + schliessen(); + splitOeffnen(feld); + return; + } + + // Eine Rückfrage wegen verlorener Splitzeilen braucht es hier nicht mehr: + // Für eine aufgeteilte Buchung öffnet sich diese Liste gar nicht erst. + auswahl.requestSubmit(); + }); + + // Escape und der Klick daneben verwerfen die Auswahl. + document.addEventListener('keydown', function (ereignis) { + if (ereignis.key === 'Escape' && offenesFeld) { + schliessen(); + } + }); + + document.addEventListener('click', function (ereignis) { + if (!(ereignis.target instanceof Element) || !offenesFeld) { + return; + } + if (!ereignis.target.closest('.zuordnungszelle')) { + schliessen(); + } + }); + + /* ── Splitdialog ─────────────────────────────────────────────────────── */ + + if (!dialog || !splitformular || !window.FmBetrag) { + return; + } + + const zeilenkasten = document.getElementById('splitdialogzeilen'); + const summenfeld = document.getElementById('splitdialogsumme'); + const loeschknopf = document.getElementById('splitloeschen'); + const vorlage = zeilenkasten.innerHTML; + let buchungsbetrag = 0; + + function felder() { + return Array.from(zeilenkasten.querySelectorAll('.splitbetrag')); + } + + function rechneSumme() { + const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag); + summenfeld.textContent = stand.text; + summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); + } + + /* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren. + * Die Namen tragen den Index (`split[3][betrag]`) – ohne Umnummerierung + * überschriebe die Kopie ihre Vorlage. */ + function zeileAnhaengen() { + const letzte = zeilenkasten.lastElementChild; + const kopie = letzte.cloneNode(true); + const nr = zeilenkasten.children.length; + + kopie.querySelectorAll('[name]').forEach(function (feld) { + feld.name = feld.name.replace(/^split\[\d+\]/, 'split[' + nr + ']'); + if (feld.tagName === 'SELECT') { + feld.selectedIndex = 0; + } else { + feld.value = ''; + } + }); + + zeilenkasten.appendChild(kopie); + return kopie; + } + + function splitdialogOeffnen(feld) { + splitformular.action = ziel(feld, 'splitbuchung'); + buchungsbetrag = Number(feld.dataset.betrag || '0'); + + document.getElementById('splitbezeichnung').textContent = feld.dataset.bezeichnung || ''; + document.getElementById('splitbuchungsbetrag').textContent = + window.FmBetrag.formatiere(buchungsbetrag); + + // Immer mit leeren Zeilen beginnen: Der Dialog gilt für alle Buchungen + // der Seite, die Zeilen der vorigen dürfen nicht stehen bleiben. + zeilenkasten.innerHTML = vorlage; + + const vorhandene = JSON.parse(feld.dataset.splits || '[]'); + + // Zu löschen gibt es nur, was schon aufgeteilt ist. Beim erstmaligen + // Aufteilen wäre der Knopf ohne Wirkung – und ohne Sinn. + loeschknopf.hidden = vorhandene.length === 0; + + vorhandene.forEach(function (zeile, nr) { + const tr = zeilenkasten.children[nr] || zeileAnhaengen(); + + tr.querySelector('select[name$="[zuordnung]"]').value = zeile.zuordnung || ''; + tr.querySelector('select[name$="[klasse_id]"]').value = zeile.klasse_id + ? String(zeile.klasse_id) + : ''; + tr.querySelector('input[name$="[notiz]"]').value = zeile.notiz || ''; + tr.querySelector('input[name$="[betrag]"]').value = zeile.betrag || ''; + }); + + rechneSumme(); + dialog.showModal(); + } + + zeilenkasten.addEventListener('input', function (ereignis) { + if (ereignis.target.classList.contains('splitbetrag')) { + rechneSumme(); + } + }); + + document.getElementById('splitzeileMehr').addEventListener('click', function () { + zeileAnhaengen().querySelector('select').focus(); + }); + + document.getElementById('splitabbrechen').addEventListener('click', function () { + dialog.close(); + }); +})(); diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index cabf51f..a960994 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -50,6 +50,16 @@ class BuchungenController extends BaseController $seite = max(1, (int)$this->query('seite', '1')); $zeilen = array_slice($zeilen, ($seite - 1) * $grenze, $grenze); + // Die Splitzeilen der angezeigten Buchungen in einer Abfrage – der + // Splitdialog zeigt damit eine bestehende Aufteilung, ohne sie + // nachzuladen. + $gesplittete = []; + foreach ($zeilen as $zeile) { + if ((int)$zeile['gesplittet'] === 1 && !$zeile['gegensicht']) { + $gesplittete[] = (int)$zeile['id']; + } + } + $this->render('buchungen/kontoblatt.php', [ 'titel' => 'Kontoblatt: ' . $konto['name'], 'konto' => Konto::mitSalden($konto), @@ -60,8 +70,8 @@ class BuchungenController extends BaseController 'filter' => $filter, 'kategorien' => Kategorie::baumFlach(), 'klassen' => Klasse::alle(), - // Namen vorab, damit die Tabelle nicht je Zeile nachfragt. - 'kontenNamen' => array_column(Konto::alle(), 'name', 'id'), + 'konten' => Konto::alle(), + 'splits' => Buchung::splitsJeBuchung($gesplittete), ]); } @@ -153,6 +163,72 @@ class BuchungenController extends BaseController View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gelöscht.'); } + /** + * Schnellzuordnung aus dem Kontoblatt: Kategorie oder Gegenkonto einer + * Buchung setzen, ohne die Maske zu öffnen. + * + * @param array $params + */ + public function zuordnung(array $params): void + { + $this->verlangeAnmeldung(); + + $buchung = Buchung::find((int)$params['id']); + if ($buchung === null) { + $this->nichtGefunden('/buchungen/' . $params['id']); + return; + } + + $ziel = $this->kontoblattPfad((int)$buchung['konto_id']); + + try { + $zuordnung = self::zuordnungAus($this->post('zuordnung')); + Buchung::zuordnen( + (int)$buchung['id'], + $zuordnung['kategorie_id'], + $zuordnung['gegenkonto_id'] + ); + } catch (\Throwable $e) { + View::redirect($ziel, $e->getMessage(), 'fehler'); + return; + } + + View::redirect($ziel, 'Zuordnung gespeichert.'); + } + + /** + * Der Splitdialog des Kontoblatts: die Zeilen einer Buchung ersetzen. + * Ohne Zeilen wird die Splitbuchung aufgehoben. + * + * Der Knopf „Splitbuchung löschen“ schickt dasselbe Formular mit + * `aktion=loeschen`. Seine Zeilen zählen dann nicht: Gelöscht wird die + * Aufteilung, und die Buchung steht danach ohne Zuordnung da. + * + * @param array $params + */ + public function splitbuchung(array $params): void + { + $this->verlangeAnmeldung(); + + $buchung = Buchung::find((int)$params['id']); + if ($buchung === null) { + $this->nichtGefunden('/buchungen/' . $params['id']); + return; + } + + $ziel = $this->kontoblattPfad((int)$buchung['konto_id']); + $loeschen = $this->post('aktion') === 'loeschen'; + + try { + Buchung::splitsErsetzen((int)$buchung['id'], $loeschen ? [] : $this->splitzeilen()); + } catch (\Throwable $e) { + View::redirect($ziel, $e->getMessage(), 'fehler'); + return; + } + + View::redirect($ziel, $loeschen ? 'Splitbuchung gelöscht.' : 'Splitbuchung gespeichert.'); + } + /** * Mehrere im Kontoblatt angekreuzte Buchungen auf einmal löschen. * @@ -245,19 +321,17 @@ class BuchungenController extends BaseController */ private function formulardaten(int $kontoId): array { - $art = $this->post('art', 'ausgabe'); - $betrag = KontenController::zahl($this->post('betrag', '0')); - // In der Maske wird ein positiver Betrag eingegeben; die Richtung - // bestimmt die Auswahl Einnahme / Ausgabe / Umbuchung. - $betrag = abs($betrag); - if ($art === 'ausgabe' || $art === 'umbuchung_ab') { + // bestimmt die Auswahl Einnahme / Ausgabe. + $betrag = abs(KontenController::zahl($this->post('betrag', '0'))); + if ($this->post('art', 'ausgabe') !== 'einnahme') { $betrag = -$betrag; } - $gegenkonto = in_array($art, ['umbuchung_ab', 'umbuchung_zu'], true) - ? $this->post('gegenkonto_id') - : null; + // Kategorie und Gegenkonto stehen in einem Feld: Ein Konto in der Liste + // macht die Buchung zur Umbuchung – wie das Kategoriefeld im + // FinanzManager, das `[Konto]` annimmt. + $zuordnung = self::zuordnungAus($this->post('zuordnung')); $daten = [ 'konto_id' => $kontoId, @@ -268,33 +342,71 @@ class BuchungenController extends BaseController 'buchungstext' => $this->post('buchungstext'), 'nummer' => $this->post('nummer'), 'abgeglichen' => isset($_POST['abgeglichen']), - 'kategorie_id' => $gegenkonto === null ? $this->post('kategorie_id') : null, + 'kategorie_id' => $zuordnung['kategorie_id'], 'klasse_id' => $this->post('klasse_id'), - 'gegenkonto_id' => $gegenkonto, + 'gegenkonto_id' => $zuordnung['gegenkonto_id'], 'quelle' => 'manuell', ]; - $splits = []; - $zeilen = $_POST['split'] ?? []; - if (is_array($zeilen)) { - foreach ($zeilen as $zeile) { - if (!is_array($zeile)) { - continue; - } - $wert = trim((string)($zeile['betrag'] ?? '')); - if ($wert === '') { - continue; - } - $splits[] = [ - 'kategorie_id' => $zeile['kategorie_id'] ?? null, - 'klasse_id' => $zeile['klasse_id'] ?? null, - 'betrag' => KontenController::zahl($wert), - 'notiz' => (string)($zeile['notiz'] ?? ''), - ]; - } + return [$daten, $this->splitzeilen()]; + } + + /** + * Die ausgefüllten Splitzeilen eines Formulars. Zeilen ohne Betrag zählen + * nicht – so bleiben die leeren Zeilen der Maske folgenlos. + * + * @return list> + */ + private function splitzeilen(): array + { + $zeilen = $_POST['split'] ?? []; + if (!is_array($zeilen)) { + return []; } - return [$daten, $splits]; + $splits = []; + foreach ($zeilen as $zeile) { + if (!is_array($zeile)) { + continue; + } + $wert = trim((string)($zeile['betrag'] ?? '')); + if ($wert === '') { + continue; + } + + $zuordnung = self::zuordnungAus((string)($zeile['zuordnung'] ?? '')); + $splits[] = [ + 'kategorie_id' => $zuordnung['kategorie_id'], + 'gegenkonto_id' => $zuordnung['gegenkonto_id'], + 'klasse_id' => $zeile['klasse_id'] ?? null, + 'betrag' => KontenController::zahl($wert), + 'notiz' => (string)($zeile['notiz'] ?? ''), + ]; + } + + return $splits; + } + + /** + * Ein Auswahlwert aus der gemeinsamen Liste „Kategorie oder Gegenkonto“: + * `kategorie:12`, `konto:7` oder leer. + * + * @return array{kategorie_id: ?int, gegenkonto_id: ?int} + */ + public static function zuordnungAus(string $wert): array + { + $leer = ['kategorie_id' => null, 'gegenkonto_id' => null]; + + [$art, $id] = array_pad(explode(':', $wert, 2), 2, ''); + if ((int)$id <= 0) { + return $leer; + } + + return match ($art) { + 'kategorie' => ['kategorie_id' => (int)$id, 'gegenkonto_id' => null], + 'konto' => ['kategorie_id' => null, 'gegenkonto_id' => (int)$id], + default => $leer, + }; } private function nichtGefunden(string $pfad): void diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index 7834bbd..e90d85a 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -46,6 +46,36 @@ class Buchung ); } + /** + * Die Splitzeilen mehrerer Buchungen in einer Abfrage – das Kontoblatt gibt + * sie dem Splitdialog mit, damit er eine bestehende Aufteilung zeigt, ohne + * je Zeile nachzufragen. + * + * @param list $buchungIds + * @return array>> buchung_id => Zeilen + */ + public static function splitsJeBuchung(array $buchungIds): array + { + $ids = array_values(array_unique(array_map('intval', $buchungIds))); + if ($ids === []) { + return []; + } + + $zeilen = Database::fetchAll( + 'SELECT * FROM buchung_splits + WHERE buchung_id IN (' . implode(',', array_fill(0, count($ids), '?')) . ') + ORDER BY buchung_id, sortierung, id', + $ids + ); + + $jeBuchung = []; + foreach ($zeilen as $zeile) { + $jeBuchung[(int)$zeile['buchung_id']][] = $zeile; + } + + return $jeBuchung; + } + /** @return list */ public static function adressen(int $buchungId): array { @@ -130,6 +160,68 @@ class Buchung }); } + /** + * Kategorie oder Gegenkonto einer Buchung setzen – die Schnellzuordnung aus + * dem Kontoblatt. Alles andere an der Buchung bleibt unberührt. + * + * Beides zugleich gibt es nicht: Eine Buchung ist entweder kategorisiert + * oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die + * Zuordnung steckt danach wieder oben. + */ + public static function zuordnen(int $id, ?int $kategorieId, ?int $gegenkontoId): void + { + $buchung = self::find($id); + if ($buchung === null) { + throw new \RuntimeException('Die Buchung gibt es nicht.'); + } + Monatsabschluss::verlangeOffen((string)$buchung['datum']); + + if ($gegenkontoId !== null) { + $kategorieId = null; + self::pruefeGegenkonto($gegenkontoId, (int)$buchung['konto_id']); + } + if ($kategorieId !== null && Kategorie::find($kategorieId) === null) { + throw new \InvalidArgumentException('Die Kategorie gibt es nicht.'); + } + + Database::transaktion(static function () use ($id, $kategorieId, $gegenkontoId): void { + Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]); + Database::execute( + 'UPDATE buchungen SET kategorie_id = ?, gegenkonto_id = ?, gesplittet = 0 + WHERE id = ? AND benutzer_id = ?', + [$kategorieId, $gegenkontoId, $id, Mandant::id()] + ); + }); + } + + /** + * Die Splitzeilen einer Buchung ersetzen – der Splitdialog des Kontoblatts. + * Eine leere Liste hebt die Splitbuchung auf; die Buchung steht danach ohne + * Zuordnung da. + * + * @param list> $splits + */ + public static function splitsErsetzen(int $id, array $splits): void + { + $buchung = self::find($id); + if ($buchung === null) { + throw new \RuntimeException('Die Buchung gibt es nicht.'); + } + Monatsabschluss::verlangeOffen((string)$buchung['datum']); + + self::pruefeSplits($splits, (float)$buchung['betrag'], (int)$buchung['konto_id']); + + Database::transaktion(static function () use ($id, $splits): void { + Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]); + Database::execute( + 'UPDATE buchungen SET kategorie_id = NULL, gegenkonto_id = NULL, gesplittet = ? + WHERE id = ? AND benutzer_id = ?', + [$splits === [] ? 0 : 1, $id, Mandant::id()] + ); + self::speichereSplits($id, $splits); + }); + } + public static function loeschen(int $id): void { $buchung = self::find($id); @@ -430,27 +522,14 @@ class Buchung $kategorie = self::id($daten['kategorie_id'] ?? null); $gesplittet = $splits !== []; - if ($gegenkonto !== null && $gegenkonto === $kontoId) { - throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.'); + if ($gegenkonto !== null) { + self::pruefeGegenkonto($gegenkonto, $kontoId); } - if ($gesplittet && ($kategorie !== null || $gegenkonto !== null)) { + if ($gesplittet) { // Bei einer Splitbuchung steckt die Zuordnung in den Zeilen. $kategorie = null; $gegenkonto = null; - } - - if ($gesplittet) { - $summe = 0.0; - foreach ($splits as $split) { - $summe += round((float)($split['betrag'] ?? 0), 2); - } - if (abs($summe - $betrag) > self::CENT) { - throw new \InvalidArgumentException(sprintf( - 'Die Splitzeilen ergeben %s, die Buchung lautet über %s.', - number_format($summe, 2, ',', '.'), - number_format($betrag, 2, ',', '.') - )); - } + self::pruefeSplits($splits, $betrag, $kontoId); } $valuta = trim((string)($daten['valuta'] ?? '')); @@ -474,6 +553,48 @@ class Buchung ]; } + /** Ein Konto kann nicht sein eigenes Gegenkonto sein. */ + private static function pruefeGegenkonto(int $gegenkontoId, int $kontoId): void + { + if ($gegenkontoId === $kontoId) { + throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.'); + } + if (Konto::find($gegenkontoId) === null) { + throw new \InvalidArgumentException('Das Gegenkonto gibt es nicht.'); + } + } + + /** + * Die Summe der Zeilen muss den Buchungsbetrag ergeben, und keine Zeile darf + * auf das eigene Konto zeigen – sonst zählte die Buchung dort doppelt. + * + * @param list> $splits + */ + private static function pruefeSplits(array $splits, float $betrag, int $kontoId): void + { + if ($splits === []) { + return; + } + + $summe = 0.0; + foreach ($splits as $split) { + $summe += round((float)($split['betrag'] ?? 0), 2); + + $gegenkonto = self::id($split['gegenkonto_id'] ?? null); + if ($gegenkonto !== null) { + self::pruefeGegenkonto($gegenkonto, $kontoId); + } + } + + if (abs($summe - $betrag) > self::CENT) { + throw new \InvalidArgumentException(sprintf( + 'Die Splitzeilen ergeben %s, die Buchung lautet über %s.', + number_format($summe, 2, ',', '.'), + number_format($betrag, 2, ',', '.') + )); + } + } + /** @param list> $splits */ private static function speichereSplits(int $buchungId, array $splits): void { diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index e89c38b..344f977 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -14,14 +14,23 @@ $neu = $buchung === null; $ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id']; $betrag = $neu ? 0.0 : (float)$buchung['betrag']; -$art = 'ausgabe'; -if (!$neu) { - if (!empty($buchung['gegenkonto_id'])) { - $art = $betrag < 0 ? 'umbuchung_ab' : 'umbuchung_zu'; - } else { - $art = $betrag >= 0 ? 'einnahme' : 'ausgabe'; +// Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist, +// steht im Feld Kategorie / Gegenkonto. +$art = !$neu && $betrag >= 0 ? 'einnahme' : 'ausgabe'; + +/** Vorauswahl der gemeinsamen Liste Kategorie / Gegenkonto. */ +$zuordnungswert = static function (?array $zeile): string { + if (!empty($zeile['gegenkonto_id'])) { + return 'konto:' . (int)$zeile['gegenkonto_id']; } -} + if (!empty($zeile['kategorie_id'])) { + return 'kategorie:' . (int)$zeile['kategorie_id']; + } + return ''; +}; + +$eigenesKonto = (int)$konto['id']; +$mitSplit = false; // Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu. $splitZeilen = $splits; @@ -44,10 +53,8 @@ include __DIR__ . '/../layout/kopf.php'; -
- - -
- -
- - -

- Die Umbuchung wird einmal gespeichert und erscheint auf beiden Kontoblättern – - auf dem Gegenkonto mit umgekehrtem Vorzeichen. -

-
+ + +

+ Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon + macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf + beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen. +

- - - - + @@ -193,6 +177,7 @@ include __DIR__ . '/../layout/kopf.php'; + diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index 4b0a1a2..867c759 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -10,7 +10,8 @@ use Finanzmanager\View; /** @var array $filter */ /** @var list> $kategorien */ /** @var list> $klassen */ -/** @var array $kontenNamen */ +/** @var list> $konten */ +/** @var array>> $splits */ $kategorieNamen = []; foreach ($kategorien as $k) { @@ -20,6 +21,9 @@ $klassenNamen = []; foreach ($klassen as $k) { $klassenNamen[(int)$k['id']] = $k['name']; } +$kontenNamen = array_column($konten, 'name', 'id'); + +$eigenesKonto = (int)$konto['id']; $gefiltert = ($filter['suche'] ?? '') !== '' || !empty($filter['von']) || !empty($filter['bis']) || !empty($filter['kategorie_id']) || !empty($filter['klasse_id']) || !empty($filter['nur_offene']); @@ -39,8 +43,44 @@ $seitenPfad = static function (int $nr) use ($filter): string { }; // Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – Filter und Seite -// hängen deshalb am Ziel des Löschformulars. -$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $seitenPfad($seite); +// hängen deshalb am Ziel des Löschformulars. Dasselbe gilt für die +// Schnellzuordnung: Ihre Ziele setzt kontoblatt.js aus dieser Abfrage zusammen. +$abfrage = $seitenPfad($seite); +$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $abfrage; + +/** + * Die Zuordnung einer Zeile als Wert der gemeinsamen Auswahlliste – dieselben + * Präfixe wie in der Buchungsmaske. + */ +$zuordnungswert = static function (array $zeile): string { + if ((int)($zeile['gesplittet'] ?? 0) === 1) { + return 'split'; + } + if (!empty($zeile['gegenkonto_id'])) { + return 'konto:' . (int)$zeile['gegenkonto_id']; + } + if (!empty($zeile['kategorie_id'])) { + return 'kategorie:' . (int)$zeile['kategorie_id']; + } + return ''; +}; + +/** + * Die Splitzeilen einer Buchung, wie der Dialog sie braucht. Sie hängen als + * JSON an der Zelle statt nachgeladen zu werden: Es sind die Zeilen der wenigen + * aufgeteilten Buchungen dieser Seite, und ein zweiter Weg zu denselben Daten + * wäre auch ein zweiter Weg, sie falsch zu lesen. + * + * @param list> $splitzeilen + */ +$splitdaten = static function (array $splitzeilen) use ($zuordnungswert): string { + return (string)json_encode(array_map(static fn(array $zeile): array => [ + 'zuordnung' => $zuordnungswert($zeile), + 'klasse_id' => (int)($zeile['klasse_id'] ?? 0), + 'notiz' => (string)$zeile['notiz'], + 'betrag' => View::betrag($zeile['betrag'], false), + ], $splitzeilen), JSON_UNESCAPED_UNICODE); +}; include __DIR__ . '/../layout/kopf.php'; ?> @@ -124,7 +164,7 @@ include __DIR__ . '/../layout/kopf.php';

- +
- @@ -197,6 +270,90 @@ include __DIR__ . '/../layout/kopf.php'; + + + + + + + + + +
+ +

Splitbuchung

+

+ über + 0,00 +

+ +
@@ -165,13 +205,46 @@ include __DIR__ . '/../layout/kopf.php'; - - Splitbuchung - - [] + 'Splitbuchung', + $gegenkonto !== '' => $gegenkonto, + default => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? ''), + }; + ?> + + + + + + - + + + data-splits="" + + title=""> + + Splitbuchung + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
Kategorie / GegenkontoKlasseNotizBetrag
+ + + + + +
Summe der Zeilen / Restbetrag0,00
+ +

+ Die Summe der Zeilen muss dem Buchungsbetrag entsprechen. Bleiben alle + Zeilen leer, wird die Splitbuchung aufgehoben. +

+ +
+ + + + + +
+ +
+ + + diff --git a/src/Views/buchungen/zuordnungsliste.php b/src/Views/buchungen/zuordnungsliste.php new file mode 100644 index 0000000..6a1b6e6 --- /dev/null +++ b/src/Views/buchungen/zuordnungsliste.php @@ -0,0 +1,42 @@ +, damit dieselbe Liste in der Buchungsmaske, in den Splitzeilen und + * in der Schnellzuordnung des Kontoblatts steht. + * + * Kategorie und Gegenkonto teilen sich ein Feld, weil sie sich ausschließen: + * Eine Buchung ist entweder kategorisiert oder eine Umbuchung. Die Werte tragen + * deshalb ein Präfix (`kategorie:12`, `konto:7`), das der Controller in + * BuchungenController::zuordnungAus() wieder auseinandernimmt. + * + * @var list> $kategorien Kategoriebaum, flach mit Pfad + * @var list> $konten alle Konten des Mandanten + * @var int $eigenesKonto Konto der Buchung – kein Gegenkonto seiner selbst + * @var string $gewaehlt vorausgewählter Wert, '' für keine Zuordnung + * @var bool $mitSplit Eintrag „Splitbuchung“ anbieten (nur im Kontoblatt) + */ +?> + + + + + + + + + + + + + + + + + diff --git a/tests/23_ZuordnungTest.php b/tests/23_ZuordnungTest.php new file mode 100644 index 0000000..0663235 --- /dev/null +++ b/tests/23_ZuordnungTest.php @@ -0,0 +1,275 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->auswahlwerteLesen(); + $this->kategorieUndGegenkontoWechseln(); + $this->gegensichtNenntIhrKonto(); + $this->splitzeilenErsetzen(); + $this->splitbuchungLoeschen(); + $this->splitzeilenJeBuchung(); + $this->pruefungen(); + } + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'Zuordnung Giro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1, + ]); + $this->spar = Konto::anlegen([ + 'name' => 'Zuordnung Spar', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->kategorie = Kategorie::anlegen(['name' => 'Zuordnung Einkauf', 'art' => 'ausgabe']); + $this->klasse = Klasse::anlegen('Zuordnung Privat'); + } + + private function buchung(float $betrag = -50.0, string $datum = '2026-03-10'): int + { + return Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => $datum, + 'betrag' => $betrag, + 'empfaenger' => 'Noch zuzuordnen', + ]); + } + + /** Kategorie und Gegenkonto stehen in einem Feld – hier wird es gelesen. */ + private function auswahlwerteLesen(): void + { + $this->assertEquals( + ['kategorie_id' => 12, 'gegenkonto_id' => null], + BuchungenController::zuordnungAus('kategorie:12'), + 'Eine Kategorie wird erkannt' + ); + $this->assertEquals( + ['kategorie_id' => null, 'gegenkonto_id' => 7], + BuchungenController::zuordnungAus('konto:7'), + 'Ein Gegenkonto wird erkannt' + ); + + $leer = ['kategorie_id' => null, 'gegenkonto_id' => null]; + $this->assertEquals($leer, BuchungenController::zuordnungAus(''), 'Leer heißt keine Zuordnung'); + $this->assertEquals($leer, BuchungenController::zuordnungAus('split'), 'Splitbuchung ist keine Zuordnung'); + $this->assertEquals($leer, BuchungenController::zuordnungAus('konto:0'), 'Kennung 0 zählt nicht'); + $this->assertEquals($leer, BuchungenController::zuordnungAus('unfug:3'), 'Unbekanntes Präfix zählt nicht'); + } + + private function kategorieUndGegenkontoWechseln(): void + { + $id = $this->buchung(); + + Buchung::zuordnen($id, $this->kategorie, null); + $buchung = Buchung::find($id); + $this->assertEquals($this->kategorie, (int)$buchung['kategorie_id'], 'Die Kategorie steht in der Buchung'); + $this->assertNull($buchung['gegenkonto_id'], 'Ohne Gegenkonto'); + + // Aus der Kategorie wird eine Umbuchung: beides zugleich gibt es nicht. + Buchung::zuordnen($id, $this->kategorie, $this->spar); + $buchung = Buchung::find($id); + $this->assertEquals($this->spar, (int)$buchung['gegenkonto_id'], 'Das Gegenkonto steht in der Buchung'); + $this->assertNull($buchung['kategorie_id'], 'Die Kategorie weicht dem Gegenkonto'); + + // Die Umbuchung wirkt sofort auf beiden Seiten. + $this->assertEquals(50.0, Konto::saldo($this->spar), 'Das Gegenkonto bekommt die Gegenseite'); + + Buchung::zuordnen($id, null, null); + $buchung = Buchung::find($id); + $this->assertNull($buchung['kategorie_id'], 'Die Zuordnung lässt sich wieder leeren'); + $this->assertNull($buchung['gegenkonto_id'], 'Auch das Gegenkonto'); + $this->assertEquals(0.0, Konto::saldo($this->spar), 'Und das Gegenkonto ist den Betrag wieder los'); + } + + /** + * Der Anzeigefehler, um den es ging: Auf dem Gegenkonto stand eine Umbuchung + * ohne erkennbares Gegenüber. Die Zeile muss das Ursprungskonto nennen. + */ + private function gegensichtNenntIhrKonto(): void + { + $id = $this->buchung(-120.0, '2026-03-11'); + Buchung::zuordnen($id, null, $this->spar); + + $zeilen = array_values(array_filter( + Buchung::kontoblatt($this->spar), + static fn(array $zeile): bool => (int)$zeile['id'] === $id + )); + + $this->assertEquals(1, count($zeilen), 'Die Umbuchung erscheint auf dem Gegenkonto'); + $this->assert($zeilen[0]['gegensicht'], 'Und zwar als Gegensicht'); + $this->assertEquals( + $this->giro, + (int)$zeilen[0]['gegenkonto_id'], + 'Die Gegensicht nennt das Konto, von dem die Umbuchung kommt' + ); + $this->assertEquals(120.0, (float)$zeilen[0]['betrag'], 'Mit umgekehrtem Vorzeichen'); + + Buchung::loeschen($id); + } + + private function splitzeilenErsetzen(): void + { + $id = $this->buchung(-100.0, '2026-03-12'); + Buchung::zuordnen($id, $this->kategorie, null); + + Buchung::splitsErsetzen($id, [ + ['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -60.0, 'notiz' => 'Einkauf'], + ['gegenkonto_id' => $this->spar, 'betrag' => -40.0, 'notiz' => 'Rücklage'], + ]); + + $buchung = Buchung::find($id); + $this->assertEquals(1, (int)$buchung['gesplittet'], 'Die Buchung ist jetzt gesplittet'); + $this->assertNull($buchung['kategorie_id'], 'Die Kategorie von oben weicht den Zeilen'); + + $splits = Buchung::splits($id); + $this->assertEquals(2, count($splits), 'Zwei Zeilen sind gespeichert'); + $this->assertEquals($this->klasse, (int)$splits[0]['klasse_id'], 'Die Klasse der ersten Zeile'); + $this->assertEquals($this->spar, (int)$splits[1]['gegenkonto_id'], 'Das Gegenkonto der zweiten Zeile'); + $this->assertEquals(40.0, Konto::saldo($this->spar), 'Nur der Betrag dieser Zeile geht aufs Gegenkonto'); + + // Eine einzelne Zuordnung hebt die Aufteilung auf. + Buchung::zuordnen($id, $this->kategorie, null); + $this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Die Splitbuchung ist aufgehoben'); + $this->assertEquals([], Buchung::splits($id), 'Und die Zeilen sind weg'); + $this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist den Anteil wieder los'); + + // Ohne Zeilen bleibt eine Buchung ohne Zuordnung stehen. + Buchung::splitsErsetzen($id, [ + ['kategorie_id' => $this->kategorie, 'betrag' => -100.0, 'notiz' => ''], + ]); + Buchung::splitsErsetzen($id, []); + $buchung = Buchung::find($id); + $this->assertEquals(0, (int)$buchung['gesplittet'], 'Leere Zeilen heben die Splitbuchung auf'); + $this->assertNull($buchung['kategorie_id'], 'Die Buchung steht danach ohne Zuordnung da'); + $this->assertEquals(-100.0, (float)$buchung['betrag'], 'Der Buchungsbetrag bleibt unberührt'); + + Buchung::loeschen($id); + } + + /** + * „Splitbuchung löschen“ aus dem Splitdialog: Die Aufteilung fällt weg und + * mit ihr jede Zuordnung – auch die einer Zeile mit Gegenkonto, deren + * Gegenseite auf dem anderen Konto zu verschwinden hat. + */ + private function splitbuchungLoeschen(): void + { + $id = $this->buchung(-90.0, '2026-03-16'); + + Buchung::splitsErsetzen($id, [ + ['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -55.0, 'notiz' => 'Teil A'], + ['gegenkonto_id' => $this->spar, 'betrag' => -35.0, 'notiz' => 'Teil B'], + ]); + $this->assertEquals(35.0, Konto::saldo($this->spar), 'Die Zeile mit Gegenkonto wirkt zunächst'); + + Buchung::splitsErsetzen($id, []); + + $buchung = Buchung::find($id); + $this->assertEquals([], Buchung::splits($id), 'Alle Splitzeilen sind gelöscht'); + $this->assertEquals(0, (int)$buchung['gesplittet'], 'Die Buchung gilt nicht mehr als gesplittet'); + $this->assertNull($buchung['kategorie_id'], 'Sie hat keine Kategorie'); + $this->assertNull($buchung['gegenkonto_id'], 'Und kein Gegenkonto'); + $this->assertEquals(-90.0, (float)$buchung['betrag'], 'Die Buchung selbst bleibt stehen'); + $this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist seinen Anteil wieder los'); + + Buchung::loeschen($id); + } + + private function splitzeilenJeBuchung(): void + { + $eine = $this->buchung(-30.0, '2026-03-13'); + $keine = $this->buchung(-20.0, '2026-03-14'); + + Buchung::splitsErsetzen($eine, [ + ['kategorie_id' => $this->kategorie, 'betrag' => -10.0, 'notiz' => 'A'], + ['kategorie_id' => $this->kategorie, 'betrag' => -20.0, 'notiz' => 'B'], + ]); + + $jeBuchung = Buchung::splitsJeBuchung([$eine, $keine]); + $this->assertEquals(2, count($jeBuchung[$eine]), 'Die Zeilen kommen der Buchung zugeordnet zurück'); + $this->assert(!isset($jeBuchung[$keine]), 'Eine Buchung ohne Zeilen fehlt in der Liste'); + $this->assertEquals([], Buchung::splitsJeBuchung([]), 'Ohne Kennungen wird nicht abgefragt'); + + Buchung::loeschen($eine); + Buchung::loeschen($keine); + } + + private function pruefungen(): void + { + $id = $this->buchung(-80.0, '2026-03-15'); + + $aufSichSelbst = false; + try { + Buchung::zuordnen($id, null, $this->giro); + } catch (\Throwable) { + $aufSichSelbst = true; + } + $this->assert($aufSichSelbst, 'Ein Konto kann nicht sein eigenes Gegenkonto sein'); + + $splitAufSichSelbst = false; + try { + Buchung::splitsErsetzen($id, [['gegenkonto_id' => $this->giro, 'betrag' => -80.0]]); + } catch (\Throwable) { + $splitAufSichSelbst = true; + } + $this->assert($splitAufSichSelbst, 'Auch eine Splitzeile nicht'); + + $krummeSumme = false; + try { + Buchung::splitsErsetzen($id, [['kategorie_id' => $this->kategorie, 'betrag' => -30.0]]); + } catch (\Throwable) { + $krummeSumme = true; + } + $this->assert($krummeSumme, 'Zeilen, die nicht aufgehen, werden abgewiesen'); + $this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Und die Buchung bleibt ungesplittet'); + + $fremdeKategorie = false; + try { + Buchung::zuordnen($id, 999999, null); + } catch (\Throwable) { + $fremdeKategorie = true; + } + $this->assert($fremdeKategorie, 'Eine Kategorie, die es nicht gibt, wird abgewiesen'); + + // Ein abgeschlossener Monat ist auch für die Schnellzuordnung zu. + Monatsabschluss::abschliessen(2026, 3); + + $zu = false; + try { + Buchung::zuordnen($id, $this->kategorie, null); + } catch (\Throwable) { + $zu = true; + } + $this->assert($zu, 'Im abgeschlossenen Monat wird nichts mehr zugeordnet'); + + $zuFuerSplits = false; + try { + Buchung::splitsErsetzen($id, []); + } catch (\Throwable) { + $zuFuerSplits = true; + } + $this->assert($zuFuerSplits, 'Auch die Splitzeilen bleiben, wie sie sind'); + } +} diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index 7ce48f4..d16ebc9 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -72,7 +72,7 @@ test.describe('Buchungen', () => { await page.fill('#datum', '2026-03-18') await page.fill('#betrag', '49,90') await page.fill('#empfaenger', 'Supermarkt') - await page.selectOption('#kategorie_id', { label: KATEGORIE }) + await page.selectOption('#zuordnung', { label: KATEGORIE }) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() @@ -86,11 +86,13 @@ test.describe('Buchungen', () => { test('Umbuchung erscheint auf beiden Kontoblättern', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) - await page.selectOption('#art', 'umbuchung_ab') + // Die Umbuchung entsteht im Kategoriefeld: Unter den Kategorien stehen + // die eigenen Konten in eckigen Klammern. + await page.selectOption('#art', 'ausgabe') await page.fill('#datum', '2026-04-01') await page.fill('#betrag', '500') await page.fill('#empfaenger', `Übertrag ${MARKE}`) - await page.selectOption('#gegenkonto_id', { label: SPAR }) + await page.selectOption('#zuordnung', { label: `[${SPAR}]` }) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() @@ -100,12 +102,135 @@ test.describe('Buchungen', () => { await expect(hin).toContainText('-500,00') await expect(hin).toContainText(`[${SPAR}]`) - // Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen. + // Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen – und mit dem + // Konto, von dem er kommt. Ohne diese Angabe stünde dort eine Umbuchung + // ohne erkennbares Gegenüber. await page.goto(`/konten/${sparId}`) const her = page.locator('tr', { hasText: `Übertrag ${MARKE}` }) await expect(her).toContainText('500,00') await expect(her).toContainText('Umbuchung') + await expect(her).toContainText(`[${GIRO}]`) await expect(page.locator('.saldoanzeige').first()).toContainText('5.500,00') + + // Auch die Gegenseite ist anklickbar: Sie führt zu ihrer Buchung, die + // auf dem anderen Konto liegt und nur dort geändert wird. + await her.locator('a.zuordnungsfeld.gegenseite').click() + await expect(page).toHaveURL(/\/buchungen\/\d+/) + await expect(page.locator('h1')).toContainText('Buchung bearbeiten') + await expect(page.locator('#zuordnung')).toHaveValue(/^konto:\d+$/) + }) + + test('Kategorie im Kontoblatt zuordnen', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-04-03') + await page.fill('#betrag', '12,30') + await page.fill('#empfaenger', `Ohne Kategorie ${MARKE}`) + await page.click('.buchungsmaske button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + const zeile = page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` }) + await zeile.locator('a.zuordnungsfeld').click() + + // Die Auswahlliste gibt es einmal und wandert in die angeklickte Zelle. + const liste = zeile.locator('#zuordnungsauswahl select') + await expect(liste).toBeVisible() + await liste.selectOption({ label: KATEGORIE }) + + await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung') + await expect(page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` })) + .toContainText(KATEGORIE) + }) + + test('Umbuchung im Kontoblatt zuordnen', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-04-04') + await page.fill('#betrag', '75') + await page.fill('#empfaenger', `Nachträglich ${MARKE}`) + await page.click('.buchungsmaske button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + const zeile = page.locator('tr', { hasText: `Nachträglich ${MARKE}` }) + await zeile.locator('a.zuordnungsfeld').click() + await zeile.locator('#zuordnungsauswahl select').selectOption({ label: `[${SPAR}]` }) + + await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung') + await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` })) + .toContainText(`[${SPAR}]`) + + // Und die Gegenseite steht sofort auf dem anderen Konto. + await page.goto(`/konten/${sparId}`) + await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` })) + .toContainText(`[${GIRO}]`) + }) + + test('Splitbuchung aus dem Kontoblatt heraus aufteilen', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-04-05') + await page.fill('#betrag', '90') + await page.fill('#empfaenger', `Aufzuteilen ${MARKE}`) + await page.click('.buchungsmaske button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + const zeile = page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }) + await zeile.locator('a.zuordnungsfeld').click() + await zeile.locator('#zuordnungsauswahl select').selectOption('split') + + const dialog = page.locator('#splitdialog') + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('-90,00') + + await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE }) + await dialog.locator('input[name="split[0][betrag]"]').fill('-50') + await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` }) + await dialog.locator('input[name="split[1][betrag]"]').fill('-40') + + // Der Dialog rechnet den Restbetrag mit; er muss aufgehen. + await expect(page.locator('#splitdialogsumme')).not.toContainText('Rest') + + // Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen. + await dialog.locator('button[type="submit"].primaer').click() + + await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung') + await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first()) + .toContainText('Splitbuchung') + + // Die Zeile mit Gegenkonto erscheint anteilig auf dem anderen Konto. + await page.goto(`/konten/${sparId}`) + await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })).toContainText('40,00') + }) + + test('Splitbuchung öffnet den Dialog und lässt sich dort löschen', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-04-06') + await page.fill('#betrag', '70') + await page.fill('#empfaenger', `Wieder einfach ${MARKE}`) + await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) + await page.fill('input[name="split[0][betrag]"]', '-30') + await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE }) + await page.fill('input[name="split[1][betrag]"]', '-40') + await page.click('.buchungsmaske button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + // Der Klick führt nicht in die Auswahlliste, sondern in den Dialog – + // mit den vorhandenen Zeilen darin. + const zeile = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` }) + await zeile.locator('a.zuordnungsfeld').click() + + const dialog = page.locator('#splitdialog') + await expect(dialog).toBeVisible() + await expect(page.locator('#zuordnungsauswahl select')).toBeHidden() + await expect(dialog.locator('input[name="split[0][betrag]"]')).toHaveValue('-30,00') + + await dialog.locator('#splitloeschen').click() + + await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung gelöscht') + const danach = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` }) + await expect(danach).not.toContainText('Splitbuchung') + await expect(danach).not.toContainText(KATEGORIE) + + // Und die Zuordnung geht jetzt wieder über die Auswahlliste. + await danach.locator('a.zuordnungsfeld').click() + await expect(danach.locator('#zuordnungsauswahl select')).toBeVisible() }) test('Splitbuchung mit zwei Zeilen', async ({ page }) => { @@ -115,11 +240,11 @@ test.describe('Buchungen', () => { await page.fill('#betrag', '260,49') await page.fill('#empfaenger', `Versicherung ${MARKE}`) - await page.selectOption('select[name="split[0][kategorie_id]"]', { label: KATEGORIE }) + await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) await page.fill('input[name="split[0][notiz]"]', 'Kasko') await page.fill('input[name="split[0][betrag]"]', '-162,51') - await page.selectOption('select[name="split[1][kategorie_id]"]', { label: KATEGORIE }) + await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE }) await page.fill('input[name="split[1][notiz]"]', 'Haftpflicht') await page.fill('input[name="split[1][betrag]"]', '-97,98') @@ -140,7 +265,7 @@ test.describe('Buchungen', () => { await page.fill('#datum', '2026-05-03') await page.fill('#betrag', '100') await page.fill('#empfaenger', `Krumm ${MARKE}`) - await page.selectOption('select[name="split[0][kategorie_id]"]', { label: KATEGORIE }) + await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) await page.fill('input[name="split[0][betrag]"]', '-60') await expect(page.locator('#splitsumme')).toContainText('Rest') @@ -157,11 +282,13 @@ test.describe('Buchungen', () => { await page.fill('input[name="suche"]', 'Supermarkt') await page.click('.filterleiste button[type="submit"]') - await expect(page.locator('tbody tr')).toHaveCount(1) - await expect(page.locator('tbody')).toContainText('Supermarkt') + // Auf der Seite steht noch eine zweite Tabelle: die leeren Zeilen des + // Splitdialogs. Gemeint ist hier das Kontoblatt. + await expect(page.locator('table.kontoblatt tbody tr')).toHaveCount(1) + await expect(page.locator('table.kontoblatt tbody')).toContainText('Supermarkt') // Mit Filter ist die Saldospalte leer – ein laufender Saldo wäre gelogen. // Spalte 0 ist das Ankreuzfeld der Mehrfachauswahl. - await expect(page.locator('tbody tr td').nth(8)).toContainText('–') + await expect(page.locator('table.kontoblatt tbody tr td').nth(8)).toContainText('–') }) test('Eine Buchung ohne Kategorie setzt ihr Konto in der Übersicht fett', async ({ page }) => { @@ -206,7 +333,7 @@ test.describe('Buchungen', () => { expect(gefragt.join(' ')).toContain('löschen') await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht') - await expect(page.locator('tbody')).not.toContainText('Supermarkt') + await expect(page.locator('table.kontoblatt tbody')).not.toContainText('Supermarkt') }) test('Mehrere Buchungen über die Auswahl im Kontoblatt löschen', async ({ page }) => { @@ -227,7 +354,7 @@ test.describe('Buchungen', () => { await expect(knopf).toBeDisabled() // Das Ankreuzfeld in der Titelzeile wählt alle angezeigten Buchungen. - const anzahl = await page.locator('tbody input[name="buchungen[]"]').count() + const anzahl = await page.locator('table.kontoblatt tbody input[name="buchungen[]"]').count() await page.check('.alle-waehlen') await expect(knopf).toContainText(`(${anzahl})`)