From ca8496f770c22ee75fd7a8f28c1d395428683347 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 17 Aug 2026 22:04:48 +0200 Subject: [PATCH] =?UTF-8?q?Kontoblatt:=20Klasse=20direkt=20ausw=C3=A4hlen,?= =?UTF-8?q?=20Zeilen=20einheitlich=20zweizeilig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Klassenspalte ist anklickbar wie die Zuordnung daneben: Ein Klick öffnet die Klassenliste in der Zelle, die Auswahl wird sofort über POST /buchungen/{id}/klasse gespeichert. Buchung::klasseSetzen() lässt Kategorie, Gegenkonto und Splitzeilen unberührt. Die Buchungszeilen sind jetzt alle zwei Textzeilen hoch, ihr Inhalt steht senkrecht in der Mitte. Was nicht auf zwei Zeilen passt, wird abgeschnitten; den vollständigen Text zeigt der Tooltip – bei Empfänger und Buchungstext nur dort, wo wirklich etwas fehlt, über dem Kategoriefeld einer Splitbuchung deren Zeilen mit Kategorie und Betrag. Co-Authored-By: Claude Opus 5 --- Changes.md | 33 +++++++ docs/buchungen.md | 44 ++++++++- public/index.php | 2 + public/static/app.css | 59 +++++++++--- public/static/kontoblatt.js | 116 +++++++++++++++++++----- src/Controllers/BuchungenController.php | 29 ++++++ src/Models/Buchung.php | 26 ++++++ src/Views/buchungen/kontoblatt.php | 85 +++++++++++++++-- tests/23_ZuordnungTest.php | 56 ++++++++++++ tests/e2e/specs/buchungen.spec.js | 72 +++++++++++++++ 10 files changed, 477 insertions(+), 45 deletions(-) diff --git a/Changes.md b/Changes.md index 87dd03c..df18f6e 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,38 @@ # Änderungen +## Ruhige Buchungszeilen im Kontoblatt (17.08.2026) + +Die Zeilen des Kontoblatts sind jetzt alle gleich hoch – zwei Textzeilen – und ihr Inhalt +steht senkrecht in der Mitte. Bisher wuchs eine Zeile mit ihrem Text, sodass die Liste beim +Blättern sprang; der Buchungstext war zudem nach 90 Zeichen hart abgeschnitten und der Rest +nirgends zu sehen. + +- Was auf zwei Zeilen nicht passt, schneidet das Stylesheet ab. Der vollständige Text steht + im Tooltip – und zwar nur dort, wo tatsächlich etwas fehlt: Ob ein Text abgeschnitten ist, + entscheidet sich erst nach dem Umbruch, `kontoblatt.js` setzt den Tooltip deshalb nach dem + Laden und nach jeder Größenänderung des Fensters. +- Der Buchungstext wird vollständig ausgegeben statt nach 90 Zeichen gekürzt; sichtbar bleibt + dank Abschnitt gleich viel, im Tooltip steht nun aber alles. +- Über dem Kategoriefeld einer **Splitbuchung** nennt der Tooltip die Zeilen der Aufteilung: + je Zeile Kategorie (oder Gegenkonto) und Betrag. In der Spalte selbst steht nur + *Splitbuchung*; wohin das Geld geht, war bisher erst im Dialog zu sehen. + +## Klasse im Kontoblatt direkt auswählen (17.08.2026) + +Die Klassenspalte des Kontoblatts ist jetzt anklickbar – nach derselben Bauart wie die +Zuordnung daneben: Ein Klick öffnet die Klassenliste in der Zelle, und die gewählte Klasse +wird sofort gespeichert. Der erste Eintrag *— keine —* löscht die vorhandene Klasse. Bisher +war dafür jedes Mal der Umweg über die Buchungsmaske nötig. + +- Neue Route `POST /buchungen/{id}/klasse` auf `Buchung::klasseSetzen()`. Die Methode achtet + den Monatsabschluss und weist eine Klasse ab, die es nicht gibt. +- Die Klasse steht **neben** der Zuordnung, nicht in ihr: Kategorie, Gegenkonto und + Splitzeilen bleiben unberührt, eine gesplittete Buchung bleibt gesplittet. +- Die Liste gibt es – wie die der Zuordnung – genau einmal im HTML; `kontoblatt.js` hängt sie + in die angeklickte Zelle. Ohne JavaScript führt der Klick weiterhin in die Buchungsmaske. +- Zeilen der Gegensicht bleiben unverändert Text: Sie werden auf ihrem Ursprungskonto + geändert. + ## Splitbuchungen in den Abgleichregeln (13.08.2026) Eine Regel für den Abgleich kann jetzt eine **Splitbuchung** vorgeben: mehrere Kategorien mit diff --git a/docs/buchungen.md b/docs/buchungen.md index 0fe834e..ae59a85 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -98,10 +98,10 @@ Drei Punkte, die sich nicht ändern dürfen: * 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. +* Feld und Auswahlliste sind **gleich breit** (`13rem`), 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. Senkrecht rückt nichts, weil die Zeilenhöhe ohnehin feststeht (siehe unten). Die Auswahl eines Depots (`depot:7`) öffnet statt beider den **Wertpapierdialog**; ohne JavaScript führt sie auf `/buchungen/{id}/wertpapier?depot=7`, wo dieselben Felder als @@ -114,6 +114,42 @@ gewesen. Wer eine Aufteilung wieder in eine einzelne Zuordnung wandeln will, lö im Dialog und ordnet die dann unzugeordnete Buchung anschließend zu – oder geht über die Buchungsmaske. +## Klasse im Kontoblatt + +Die Klassenspalte ist nach derselben Bauart anklickbar: eine zweite Liste +(`#klassenauswahl`), die `kontoblatt.js` in die angeklickte Zelle hängt. Ihr erster Eintrag +*— keine —* löscht die vorhandene Klasse, jeder andere setzt sie; gespeichert wird sofort +beim Anklicken, über `POST /buchungen/{id}/klasse` und `Buchung::klasseSetzen()`. + +Die Klasse steht **neben** der Zuordnung, nicht in ihr: Sie ist die zweite +Auswertungsdimension. Anders als `Buchung::zuordnen()` rührt `klasseSetzen()` deshalb weder +Kategorie noch Gegenkonto noch Splitzeilen an – auch eine gesplittete Buchung bleibt beim +Wechsel der Klasse gesplittet. Den Monatsabschluss achtet die Methode wie alle anderen. + +## Zeilenhöhe und Tooltips im Kontoblatt + +Jede Buchungszeile ist **zwei Textzeilen hoch**, gleich wie viel in ihr steht, und ihr +Inhalt steht senkrecht in der Mitte. Eine Zeile, die mit ihrem Text wächst, ließe die Liste +beim Blättern hüpfen; eine, die auf eine Zeile schrumpft, machte lange Empfänger unlesbar. +Die Höhe hängt an `table.kontoblatt td`, das Abschneiden an `.zellentext` (zwei Zeilen, +danach Auslassungspunkte). Empfänger und Buchungstext stehen deshalb in einem `
` und +nicht unmittelbar in der Zelle: Abschneiden kann nur ein Element, das kein Tabellenfeld ist. + +Die Marke der Umbuchungs-Gegenseite steht **neben** dem abgeschnittenen Kasten +(`.zellenreihe`), nicht darin – sonst zählte sie als eine der beiden Zeilen und die Zeile +würde höher als die übrigen. + +Den vollständigen Text zeigt der Tooltip: + +* **Empfänger und Buchungstext**: `kontoblatt.js` setzt den Tooltip nach dem Laden und nach + jeder Größenänderung des Fensters – aber nur dort, wo wirklich etwas abgeschnitten ist. + Ob das der Fall ist, weiß erst der Browser nach dem Umbruch, und das hängt an der + Fensterbreite. Ein Tooltip, der bloß wiederholt, was ohnehin dasteht, wäre lästig. +* **Kategorie und Klasse** tragen ihren Tooltip vom Server. Bei einer **Splitbuchung** nennt + er die Zeilen der Aufteilung: je Zeile Kategorie (oder Gegenkonto) und Betrag, sonst + nichts. Notiz und Klasse stünden nur im Weg; wer sie ändern will, öffnet ohnehin den + Dialog. + ## Splitbuchungen Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen diff --git a/public/index.php b/public/index.php index 078ac66..7f0eb68 100644 --- a/public/index.php +++ b/public/index.php @@ -90,6 +90,8 @@ $router->post('/buchungen/{id}/zuordnung', fn(array $p) => (new BuchungenController())->zuordnung($p)); $router->post('/buchungen/{id}/splitbuchung', fn(array $p) => (new BuchungenController())->splitbuchung($p)); +$router->post('/buchungen/{id}/klasse', + fn(array $p) => (new BuchungenController())->klasse($p)); // Das Wertpapiergeschäft zu einer Buchung – Dialog im Kontoblatt, Seite ohne // JavaScript, ein Ziel für beide. $router->get('/buchungen/{id}/wertpapier', diff --git a/public/static/app.css b/public/static/app.css index dfda714..f8ad4fc 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1025,16 +1025,45 @@ tr.zugeklappt { display: none; } .filterleiste label { font-size: .88rem; color: var(--text-gedaempft); } table.kontoblatt { font-size: .89rem; } -table.kontoblatt td { padding: .3rem .6rem; } table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent) 5%, transparent); } -.gekuerzt { - max-width: 26rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +/* Alle Buchungszeilen sind gleich hoch – zwei Textzeilen – und ihr Inhalt steht + in der Mitte. Eine Zeile, die mit ihrem Text wächst, ließe das Kontoblatt beim + Blättern hüpfen; eine, die auf eine Zeile schrumpft, machte lange Empfänger + unlesbar. Was auf zwei Zeilen nicht passt, wird abgeschnitten und steht + vollständig im Tooltip. */ +table.kontoblatt td { + padding: .3rem .6rem; + line-height: 1.35; + height: calc(2 * 1.35em + .6rem); + vertical-align: middle; } +.zellentext { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + overflow-wrap: anywhere; +} + +/* Text und Marke nebeneinander: Abgeschnitten wird nur der Text, die Marke + bleibt vollständig stehen. */ +.zellenreihe { + display: flex; + align-items: center; + gap: .4rem; +} + +.zellenreihe .zellentext { min-width: 0; } + +/* Im abgeschnittenen Kasten wird die Marke zum Blockelement – ohne diese Breite + liefe ihr Rahmen bis zum Rand der Spalte. */ +a.zuordnungsfeld .marke { width: fit-content; } + +.buchungstext { max-width: 26rem; } + .blaettern { display: flex; align-items: center; @@ -1050,21 +1079,29 @@ table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent) 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; } +td.klassenzelle { width: 9rem; } -a.zuordnungsfeld { - display: block; +a.zuordnungsfeld, +a.klassenfeld { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; max-width: 13rem; min-height: 1.35rem; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; color: inherit; text-decoration: none; border-bottom: 1px dashed transparent; } +a.klassenfeld { max-width: 9rem; } + a.zuordnungsfeld:hover, -a.zuordnungsfeld:focus-visible { border-bottom-color: var(--rand-kraeftig); } +a.zuordnungsfeld:focus-visible, +a.klassenfeld:hover, +a.klassenfeld:focus-visible { border-bottom-color: var(--rand-kraeftig); } .zuordnungsauswahl { display: flex; gap: .3rem; } .zuordnungsauswahl[hidden] { display: none; } diff --git a/public/static/kontoblatt.js b/public/static/kontoblatt.js index 38be5e8..b6f1184 100644 --- a/public/static/kontoblatt.js +++ b/public/static/kontoblatt.js @@ -1,30 +1,72 @@ -/* Kontoblatt: Kategorie oder Gegenkonto an Ort und Stelle zuordnen. +/* Kontoblatt: Zuordnung und Klasse an Ort und Stelle ändern, abgeschnittene + * Texte im Tooltip vollständig zeigen. * - * 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. + * Ein Klick auf die Kategorie- oder die Klassenspalte öffnet die zugehörige + * Auswahlliste in der Zelle. Jede Liste 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. + * Ohne JavaScript führt derselbe Klick in die Buchungsmaske; Zuordnung und + * Klasse sind dort wie bisher zu ändern. */ (function () { 'use strict'; const tabelle = document.querySelector('table.kontoblatt'); + if (!tabelle) { + return; + } + + /* ── Tooltips für abgeschnittene Texte ─────────────────────────────────── + * + * Empfänger und Buchungstext stehen auf zwei Zeilen; was darüber hinausgeht, + * schneidet das Stylesheet ab. Der Tooltip mit dem vollständigen Text hängt + * deshalb nicht im HTML, sondern entsteht hier: Ob ein Text überhaupt + * abgeschnitten ist, weiß erst der Browser nach dem Umbruch – und das hängt + * an der Fensterbreite. + * + * Kategorie und Klasse tragen ihren Tooltip vom Server; er nennt bei + * Splitbuchungen mehr, als in der Zelle steht. + */ + function tooltipsSetzen() { + tabelle.querySelectorAll('.zellentext').forEach(function (kasten) { + const abgeschnitten = kasten.scrollHeight > kasten.clientHeight + 1 + || kasten.scrollWidth > kasten.clientWidth + 1; + const text = kasten.textContent.trim(); + + if (abgeschnitten && text !== '') { + kasten.title = text; + } else { + kasten.removeAttribute('title'); + } + }); + } + + tooltipsSetzen(); + + let umbruchZaehler = 0; + window.addEventListener('resize', function () { + window.clearTimeout(umbruchZaehler); + umbruchZaehler = window.setTimeout(tooltipsSetzen, 150); + }); + const auswahl = document.getElementById('zuordnungsauswahl'); - if (!tabelle || !auswahl) { + if (!auswahl) { return; } const abfrage = tabelle.dataset.abfrage || ''; const liste = auswahl.querySelector('select'); + const klassenauswahl = document.getElementById('klassenauswahl'); + const klassenliste = klassenauswahl ? klassenauswahl.querySelector('select') : null; const dialog = document.getElementById('splitdialog'); const splitformular = document.getElementById('splitformular'); - // Das Feld, das gerade durch die Auswahlliste ersetzt ist. + // Das Feld, das gerade durch eine Auswahlliste ersetzt ist, und das + // Formular, das an seiner Stelle steht. let offenesFeld = null; + let offenesFormular = null; function ziel(feld, aktion) { return '/buchungen/' + feld.dataset.buchung + '/' + aktion + abfrage; @@ -37,8 +79,9 @@ offenesFeld.hidden = false; offenesFeld = null; - auswahl.hidden = true; - document.body.appendChild(auswahl); + offenesFormular.hidden = true; + document.body.appendChild(offenesFormular); + offenesFormular = null; } /* Der Splitdialog zu einer Zeile – oder, wo es kein gibt, die @@ -52,31 +95,48 @@ } } - function oeffnen(feld) { + /* Das Feld verschwindet, das Formular tritt an seine Stelle. */ + function oeffnen(feld, formular, aktion, wert) { schliessen(); offenesFeld = feld; + offenesFormular = formular; feld.hidden = true; - auswahl.action = ziel(feld, 'zuordnung'); - liste.value = feld.dataset.wert || ''; - feld.parentNode.appendChild(auswahl); - auswahl.hidden = false; + const auswahlliste = formular.querySelector('select'); + formular.action = ziel(feld, aktion); + auswahlliste.value = wert; + feld.parentNode.appendChild(formular); + formular.hidden = false; - liste.focus(); + auswahlliste.focus(); // Die Liste soll sofort aufklappen; showPicker gibt es nicht überall. try { - liste.showPicker(); + auswahlliste.showPicker(); } catch (fehler) { /* Dann klappt sie beim ersten Klick auf – kein Verlust. */ } } tabelle.addEventListener('click', function (ereignis) { + if (ereignis.metaKey || ereignis.ctrlKey || ereignis.shiftKey) { + return; + } + + // Die Klasse steht neben der Zuordnung, nicht in ihr: Auch eine + // Splitbuchung behält beim Wechsel ihre Zeilen. + const klassenfeld = klassenauswahl && ereignis.target.closest('a.klassenfeld'); + if (klassenfeld) { + ereignis.preventDefault(); + const klasse = klassenfeld.dataset.klasse || ''; + oeffnen(klassenfeld, klassenauswahl, 'klasse', klasse === '0' ? '' : klasse); + return; + } + // 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) { + if (!feld) { return; } @@ -89,7 +149,7 @@ return; } - oeffnen(feld); + oeffnen(feld, auswahl, 'zuordnung', feld.dataset.wert || ''); }); /* Ein Depot ist keine fertige Zuordnung: Stückzahl, Kurs und Nebenkosten @@ -108,7 +168,7 @@ } liste.addEventListener('change', function () { - if (!offenesFeld) { + if (!offenesFeld || offenesFormular !== auswahl) { return; } @@ -137,6 +197,18 @@ auswahl.requestSubmit(); }); + /* Die gewählte Klasse wird sofort gespeichert – der erste Eintrag der Liste + * löscht die vorhandene. Ein eigener Knopf würde nur einen zweiten Klick + * für dasselbe verlangen. */ + if (klassenliste) { + klassenliste.addEventListener('change', function () { + if (!offenesFeld || offenesFormular !== klassenauswahl) { + return; + } + klassenauswahl.requestSubmit(); + }); + } + // Escape und der Klick daneben verwerfen die Auswahl. document.addEventListener('keydown', function (ereignis) { if (ereignis.key === 'Escape' && offenesFeld) { @@ -148,7 +220,7 @@ if (!(ereignis.target instanceof Element) || !offenesFeld) { return; } - if (!ereignis.target.closest('.zuordnungszelle')) { + if (!ereignis.target.closest('.zuordnungszelle, .klassenzelle')) { schliessen(); } }); diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index 9b196c4..aed78bd 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -227,6 +227,35 @@ class BuchungenController extends BaseController View::redirect($ziel, 'Zuordnung gespeichert.'); } + /** + * Schnellauswahl aus dem Kontoblatt: die Klasse einer Buchung setzen. Ein + * leerer Wert löscht die vorhandene Klasse. + * + * @param array $params + */ + public function klasse(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']); + $klasseId = (int)$this->post('klasse_id'); + + try { + Buchung::klasseSetzen((int)$buchung['id'], $klasseId > 0 ? $klasseId : null); + } catch (\Throwable $e) { + View::redirect($ziel, $e->getMessage(), 'fehler'); + return; + } + + View::redirect($ziel, $klasseId > 0 ? 'Klasse gespeichert.' : 'Klasse gelöscht.'); + } + /** * Der Splitdialog des Kontoblatts: die Zeilen einer Buchung ersetzen. * Ohne Zeilen wird die Splitbuchung aufgehoben. diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index e90d85a..0e63eb3 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -194,6 +194,32 @@ class Buchung }); } + /** + * Die Klasse einer Buchung – die Schnellauswahl im Kontoblatt. `null` + * löscht sie. + * + * Die Zuordnung bleibt unberührt: Die Klasse ist die zweite + * Auswertungsdimension neben Kategorie und Gegenkonto, keine Alternative + * dazu. Auch eine Splitbuchung behält deshalb ihre Zeilen. + */ + public static function klasseSetzen(int $id, ?int $klasseId): void + { + $buchung = self::find($id); + if ($buchung === null) { + throw new \RuntimeException('Die Buchung gibt es nicht.'); + } + Monatsabschluss::verlangeOffen((string)$buchung['datum']); + + if ($klasseId !== null && Klasse::find($klasseId) === null) { + throw new \InvalidArgumentException('Die Klasse gibt es nicht.'); + } + + Database::execute( + 'UPDATE buchungen SET klasse_id = ? WHERE id = ? AND benutzer_id = ?', + [$klasseId, $id, Mandant::id()] + ); + } + /** * Die Splitzeilen einer Buchung ersetzen – der Splitdialog des Kontoblatts. * Eine leere Liste hebt die Splitbuchung auf; die Buchung steht danach ohne diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index 400bfc5..bdc781b 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -83,6 +83,27 @@ $splitdaten = static function (array $splitzeilen) use ($zuordnungswert): string ], $splitzeilen), JSON_UNESCAPED_UNICODE); }; +/** + * Der Tooltip einer Splitbuchung: je Zeile Kategorie und Betrag. In der Spalte + * steht nur „Splitbuchung“ – wohin das Geld geht, wäre sonst erst im Dialog zu + * sehen. Notiz und Klasse bleiben draußen; sie stünden im Weg. + * + * @param list> $splitzeilen + */ +$splituebersicht = static function (array $splitzeilen) use ($kategorieNamen, $kontenNamen): string { + $texte = []; + foreach ($splitzeilen as $zeile) { + $name = match (true) { + !empty($zeile['gegenkonto_id']) => '[' . ($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') . ']', + !empty($zeile['kategorie_id']) => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? '?'), + default => '(ohne Kategorie)', + }; + $texte[] = $name . ': ' . View::betrag($zeile['betrag']); + } + + return implode("\n", $texte); +}; + include __DIR__ . '/../layout/kopf.php'; ?> @@ -196,16 +217,28 @@ include __DIR__ . '/../layout/kopf.php'; + - - - Umbuchung - +
+
+ + Umbuchung + +
- +
+ +
- + +
$gegenkonto, default => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? ''), }; + + // Eine Splitbuchung nennt im Tooltip ihre Zeilen, alles andere + // seine eigene – womöglich abgeschnittene – Beschriftung. + $zuordnungstitel = match (true) { + isset($splits[(int)$zeile['id']]) => $splituebersicht($splits[(int)$zeile['id']]), + $beschriftung !== '' => $beschriftung, + default => 'Kategorie oder Gegenkonto zuordnen', + }; ?> @@ -252,7 +293,7 @@ include __DIR__ . '/../layout/kopf.php'; data-splits="" - title=""> + title=""> Splitbuchung @@ -261,7 +302,22 @@ include __DIR__ . '/../layout/kopf.php'; - + + + + + + + + + + + @@ -297,6 +353,19 @@ include __DIR__ . '/../layout/kopf.php'; + + +
diff --git a/tests/23_ZuordnungTest.php b/tests/23_ZuordnungTest.php index 0663235..e41dacb 100644 --- a/tests/23_ZuordnungTest.php +++ b/tests/23_ZuordnungTest.php @@ -33,6 +33,7 @@ class ZuordnungTest extends TestCase $this->splitzeilenErsetzen(); $this->splitbuchungLoeschen(); $this->splitzeilenJeBuchung(); + $this->klasseWechseln(); $this->pruefungen(); } @@ -216,6 +217,53 @@ class ZuordnungTest extends TestCase Buchung::loeschen($keine); } + /** + * Die Schnellauswahl der Klassenspalte: setzen, wechseln, löschen – und die + * Zuordnung bleibt dabei, wie sie ist. + */ + private function klasseWechseln(): void + { + $zweite = Klasse::anlegen('Zuordnung Beruflich'); + $id = $this->buchung(-70.0, '2026-03-17'); + + Buchung::zuordnen($id, $this->kategorie, null); + Buchung::klasseSetzen($id, $this->klasse); + + $buchung = Buchung::find($id); + $this->assertEquals($this->klasse, (int)$buchung['klasse_id'], 'Die Klasse steht in der Buchung'); + $this->assertEquals( + $this->kategorie, + (int)$buchung['kategorie_id'], + 'Die Zuordnung bleibt von der Klasse unberührt' + ); + + Buchung::klasseSetzen($id, $zweite); + $this->assertEquals($zweite, (int)Buchung::find($id)['klasse_id'], 'Die Klasse lässt sich wechseln'); + + // Der erste Eintrag der Liste – „keine“ – löscht die vorhandene Klasse. + Buchung::klasseSetzen($id, null); + $this->assertNull(Buchung::find($id)['klasse_id'], 'Und wieder löschen'); + + // Eine Splitbuchung behält ihre Zeilen: Die Klasse steht daneben. + Buchung::splitsErsetzen($id, [ + ['kategorie_id' => $this->kategorie, 'betrag' => -40.0, 'notiz' => 'A'], + ['kategorie_id' => $this->kategorie, 'betrag' => -30.0, 'notiz' => 'B'], + ]); + Buchung::klasseSetzen($id, $this->klasse); + $this->assertEquals(2, count(Buchung::splits($id)), 'Die Splitzeilen bleiben erhalten'); + $this->assertEquals(1, (int)Buchung::find($id)['gesplittet'], 'Und die Buchung bleibt gesplittet'); + + $fremdeKlasse = false; + try { + Buchung::klasseSetzen($id, 999999); + } catch (\Throwable) { + $fremdeKlasse = true; + } + $this->assert($fremdeKlasse, 'Eine Klasse, die es nicht gibt, wird abgewiesen'); + + Buchung::loeschen($id); + } + private function pruefungen(): void { $id = $this->buchung(-80.0, '2026-03-15'); @@ -271,5 +319,13 @@ class ZuordnungTest extends TestCase $zuFuerSplits = true; } $this->assert($zuFuerSplits, 'Auch die Splitzeilen bleiben, wie sie sind'); + + $zuFuerKlasse = false; + try { + Buchung::klasseSetzen($id, $this->klasse); + } catch (\Throwable) { + $zuFuerKlasse = true; + } + $this->assert($zuFuerKlasse, 'Und die Klasse ebenso'); } } diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index d16ebc9..234fea1 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -8,6 +8,7 @@ const MARKE = Date.now().toString().slice(-6) const GIRO = `E2E Giro ${MARKE}` const SPAR = `E2E Spar ${MARKE}` const KATEGORIE = `E2E Einkauf ${MARKE}` +const KLASSE = `E2E Privat ${MARKE}` async function anmelden(page) { await page.goto('/login') @@ -51,6 +52,10 @@ test.describe('Buchungen', () => { await page.selectOption('#art', 'ausgabe') await page.click('.formular button[type="submit"]') + await page.goto('/verwaltung/klassen') + await page.fill('form[action="/verwaltung/klassen/neu"] #name', KLASSE) + await page.click('form[action="/verwaltung/klassen/neu"] button[type="submit"]') + await page.close() }) @@ -141,6 +146,36 @@ test.describe('Buchungen', () => { .toContainText(KATEGORIE) }) + test('Klasse im Kontoblatt zuordnen und wieder löschen', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-04-06') + await page.fill('#betrag', '8,80') + await page.fill('#empfaenger', `Ohne Klasse ${MARKE}`) + await page.click('.buchungsmaske button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + // Dieselbe Bauart wie bei der Zuordnung: die Liste wandert in die Zelle. + const zeile = page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` }) + await zeile.locator('a.klassenfeld').click() + + const liste = zeile.locator('#klassenauswahl select') + await expect(liste).toBeVisible() + await liste.selectOption({ label: KLASSE }) + + await expect(page.locator('.meldung-erfolg')).toContainText('Klasse gespeichert') + await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` })) + .toContainText(KLASSE) + + // Der erste Eintrag der Liste löscht die vorhandene Klasse wieder. + const danach = page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` }) + await danach.locator('a.klassenfeld').click() + await danach.locator('#klassenauswahl select').selectOption('') + + await expect(page.locator('.meldung-erfolg')).toContainText('Klasse gelöscht') + await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` })) + .not.toContainText(KLASSE) + }) + test('Umbuchung im Kontoblatt zuordnen', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-04-04') @@ -257,6 +292,43 @@ test.describe('Buchungen', () => { const zeile = page.locator('tr', { hasText: `Versicherung ${MARKE}` }) await expect(zeile).toContainText('Splitbuchung') await expect(zeile).toContainText('-260,49') + + // In der Spalte steht nur „Splitbuchung“ – wohin das Geld geht, zeigt + // der Tooltip: je Zeile Kategorie und Betrag. + const titel = await zeile.locator('a.zuordnungsfeld').getAttribute('title') + expect(titel).toContain(`${KATEGORIE}: -162,51`) + expect(titel).toContain(`${KATEGORIE}: -97,98`) + }) + + test('Buchungszeilen sind gleich hoch, abgeschnittene Texte stehen im Tooltip', async ({ page }) => { + const LANG = `Verwendungszweck ${MARKE} mit sehr vielen Wörtern, `.repeat(6).trim() + + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-05-04') + await page.fill('#betrag', '11,11') + await page.fill('#empfaenger', `Langtext ${MARKE}`) + await page.fill('#buchungstext', LANG) + await page.click('.buchungsmaske button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + // Einheitlich zwei Textzeilen hoch: Eine Zeile, die mit ihrem Text + // wächst, ließe das Kontoblatt beim Blättern hüpfen. + const hoehen = await page.locator('table.kontoblatt tbody tr').evaluateAll( + (zeilen) => zeilen.map((zeile) => Math.round(zeile.getBoundingClientRect().height)) + ) + expect(hoehen.length).toBeGreaterThan(1) + // Ein Pixel Toleranz: Die letzte Zeile hat keine Trennlinie. + expect(Math.max(...hoehen) - Math.min(...hoehen)).toBeLessThanOrEqual(1) + + const zeile = page.locator('tr', { hasText: `Langtext ${MARKE}` }) + + // Der abgeschnittene Buchungstext steht vollständig im Tooltip … + await expect(zeile.locator('td.buchungstext .zellentext')) + .toHaveAttribute('title', LANG) + + // … der kurze Empfänger daneben bekommt keinen: Dort fehlt nichts. + await expect(zeile.locator('td').nth(3).locator('.zellentext')) + .not.toHaveAttribute('title') }) test('Splitzeilen, die nicht aufgehen, werden abgewiesen', async ({ page }) => {