From 233b570e769a2a2c21ca488d9ab79084ebed07fe Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 28 Sep 2026 08:27:32 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20#84:=20Cockpit-Legendeneintr=C3=A4ge=20?= =?UTF-8?q?=C3=B6ffnen=20die=20Buchungen=20dahinter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jeder Legendeneintrag der Cockpit-Kacheln führt zu den Buchungen, aus denen sich sein Wert zusammensetzt: Kategorien, „Nicht kategorisiert“ und „Übrige“ in „Meine Ausgaben“, Einnahmen/Ausgaben/Saldo in „Einnahmen und Ausgaben“, Konten der Vermögensentwicklung ins Kontoblatt. Ziel ist der Drilldown der Auswertungen mit dem Filter der Kachel; zeilenbuchungen() kennt dafür einen Art-Filter, „alle Bewegungen“ und mehrere Kategorien. Co-Authored-By: Claude Opus 5.5 --- Changes.md | 16 ++++ docs/auswertungen.md | 7 ++ docs/cockpit.md | 25 +++++- public/static/app.css | 9 +- src/Controllers/AuswertungenController.php | 15 +++- src/Services/Auswertung.php | 66 ++++++++++++--- src/Services/Cockpit.php | 57 +++++++++++-- src/Views/auswertungen/drilldown.php | 29 +++++-- src/Views/cockpit/ausgabendiagramm.php | 12 ++- src/Views/cockpit/monatsdiagramm.php | 15 ++-- src/Views/cockpit/vermoegensdiagramm.php | 7 +- tests/15_CockpitTest.php | 99 ++++++++++++++++++++++ tests/e2e/specs/module.spec.js | 24 ++++++ 13 files changed, 336 insertions(+), 45 deletions(-) diff --git a/Changes.md b/Changes.md index 2216d61..2d6b12b 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,21 @@ # Änderungen +## Cockpit: Legendeneinträge öffnen die Buchungen dahinter (28.09.2026) + +In allen Cockpit-Kacheln mit Legende führt ein Klick auf einen Legendeneintrag zu den +Buchungen, aus denen sich der angezeigte Wert zusammensetzt (#84). + +- *Meine Ausgaben*: jede Kategorie, „Nicht kategorisiert“ und „Übrige (n)“ öffnen die + Ausgaben des laufenden Jahres dieser Kategorien (samt Unterkategorien). +- *Einnahmen und Ausgaben*: „Einnahmen“, „Ausgaben“ und „Saldo“ öffnen die Buchungen der + letzten zwölf Monate. +- *Vermögensentwicklung*: ein Kontoname öffnet das Kontoblatt. +- Ziel ist der bestehende Drilldown der Auswertungen mit dem Filter der Kachel; + `Auswertung::zeilenbuchungen()` kennt dafür neu einen Art-Filter (Einnahme/Ausgabe), + „alle Bewegungen“ und mehrere Kategorien auf einmal. Aus dem Cockpit geht es zurück ins + Cockpit. Die Summe der Liste entspricht dem Legendenwert. +- Tests in `tests/15_CockpitTest.php` und `tests/e2e/specs/module.spec.js`. + ## IBAN und Bank der Gegenseite im Kontoblatt (27.09.2026) Bei einer Buchung aus dem Bankabruf ließ sich oft nicht mehr erkennen, woher sie diff --git a/docs/auswertungen.md b/docs/auswertungen.md index 4cdd8c4..7c19121 100644 --- a/docs/auswertungen.md +++ b/docs/auswertungen.md @@ -149,6 +149,13 @@ Gefiltert wird nicht, indem die Zeile in einen neuen Filter übersetzt wird, son trifft der Drilldown auch das, was ein Filter gar nicht ausdrücken könnte: die Zeile *Nicht kategorisiert* etwa, oder eine Astsumme über mehrere Unterkategorien. +Die Legenden der Cockpit-Kacheln nutzen denselben Weg (`/auswertungen/buchungen`) mit drei +Zusätzen: `art=einnahme|ausgabe` siebt zusätzlich nach Einnahme oder Ausgabe (entschieden +wie im Bericht über die Art der Kategorie, ohne Kategorie über das Vorzeichen), eine leere +`zeile` nimmt alle Bewegungen, und `zeile=kategorie:12,15,0` fasst mehrere Kategorien +zusammen (die `0` steht für *Nicht kategorisiert*). `herkunft=cockpit` lenkt den Weg +zurück ins Cockpit statt in einen Bericht. + ## CSV-Export Dieselben Zeilen, dieselben Zahlen, dieselben Vorzeichen wie am Bildschirm – wer den diff --git a/docs/cockpit.md b/docs/cockpit.md index 9a60652..9b5631f 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -115,6 +115,26 @@ Ein Ausgriff über zwei Spalten machte sie zum Fremdkörper zwischen den anderen Die Legende führt nur Farbe und Kontoname und steht darunter, in kleinerer Schrift und so vielen Spalten, wie die Kachel hergibt (`auto-fill`). Die Beträge stehen am Balken im Tooltip und in der Kachel *Kontostände*; in der Legende kosteten sie nur Breite. +Jeder Kontoname der Legende führt in das Kontoblatt des Kontos – dort stehen die +Buchungen, aus denen sich seine Stände zusammensetzen. + +## Legenden führen zu den Buchungen + +Jeder Legendeneintrag ist ein Link auf die Buchungen, aus denen sich sein Wert +zusammensetzt. Ziel ist der Drilldown der Auswertungen (`/auswertungen/buchungen`, siehe +`docs/auswertungen.md`), aufgerufen mit **demselben Filter, mit dem die Kachel rechnet** – +`Cockpit::buchungsadresse()` baut die Adresse. Die Liste siebt dieselben Bewegungen nach +wie die Kachel; ihre Summe trifft deshalb den Legendenwert (mit buchhalterischem +Vorzeichen, Ausgaben also mit Minus). Der Weg zurück führt ins Cockpit. + +| Kachel | Legendeneintrag | Buchungsliste | +|---|---|---| +| Einnahmen und Ausgaben | Einnahmen / Ausgaben | alle Einnahmen bzw. Ausgaben der letzten zwölf Monate | +| Einnahmen und Ausgaben | Saldo | beides zusammen, ohne Umbuchungen | +| Meine Ausgaben | Kategorie | Ausgaben des laufenden Jahres in dieser obersten Kategorie samt Unterkategorien | +| Meine Ausgaben | Nicht kategorisiert | Ausgaben ohne Kategorie – unkategorisierte Gutschriften nicht | +| Meine Ausgaben | Übrige (n) | alle im Sammelsegment zusammengefassten Kategorien | +| Vermögensentwicklung | Konto | das Kontoblatt des Kontos | ## Einnahmen und Ausgaben @@ -168,7 +188,8 @@ regelmäßigen Buchungen. * `tests/15_CockpitTest.php` – Kachelauswahl (Reihenfolge, Unbekanntes, leere Auswahl), Monatsreihe mit Trennung von Einnahmen und Ausgaben, Umbuchungen bleiben draußen, - Verteilung auf die Wurzelkategorie, Kürzung auf die Segmentgrenze, offene Vorgänge + Verteilung auf die Wurzelkategorie, Kürzung auf die Segmentgrenze, offene Vorgänge, + Legendeneinträge führen zu Buchungslisten, deren Summe den Legendenwert trifft * `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände, Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette, Ebenen (jedes Konto genau einmal, letzte Ebene ohne Sammelstück, zu tiefe Ebene fällt @@ -176,3 +197,5 @@ regelmäßigen Buchungen. * `tests/e2e/specs/smoke.spec.js` – die Kachel zeichnet zwölf Balken, keine Farbe der Legende kommt doppelt vor, der Schalter behält seinen Zustand über den Neuaufruf, und *Übrige Konten* führt eine Ebene tiefer und wieder zurück +* `tests/e2e/specs/module.spec.js` – Legendeneinträge der Kacheln *Einnahmen und Ausgaben* + und *Meine Ausgaben* öffnen die Buchungsliste und führen zurück ins Cockpit diff --git a/public/static/app.css b/public/static/app.css index 402d749..8b26b8c 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -554,15 +554,16 @@ form.inline { display: inline; margin: 0; } white-space: nowrap; } -/* „Übrige Konten“ führt eine Ebene tiefer: Der Eintrag ist ein Link und sieht - auch danach aus – sonst probierte ihn niemand aus. */ -.legendenname.tiefer { +/* Legendeneinträge führen in die Buchungen dahinter („Übrige Konten“ eine + Ebene tiefer): Der Eintrag ist ein Link und sieht auch danach aus – sonst + probierte ihn niemand aus. */ +a.legendenname { color: var(--akzent); text-decoration: underline dotted; text-underline-offset: 2px; } -.legendenname.tiefer:hover { text-decoration: underline; } +a.legendenname:hover { text-decoration: underline; } .diagrammpfad { margin: 0; diff --git a/src/Controllers/AuswertungenController.php b/src/Controllers/AuswertungenController.php index 8f2c359..8a89813 100644 --- a/src/Controllers/AuswertungenController.php +++ b/src/Controllers/AuswertungenController.php @@ -127,16 +127,23 @@ class AuswertungenController extends BaseController $filter, $this->query('zeile'), $this->query('spalte'), - $this->query('ast') === '1' + $this->query('ast') === '1', + null, + $this->query('art') ); + // Aus einer Cockpit-Kachel geht es zurück ins Cockpit, nicht in einen + // Bericht, den der Nutzer nie geöffnet hat. + $ausCockpit = $this->query('herkunft') === 'cockpit'; + // Nicht „daten“ nennen: View::render() extrahiert mit EXTR_SKIP, und // eine gleichnamige lokale Variable dort bliebe unangetastet – die View // bekäme dann das gesamte Datenarray statt dieses einen Eintrags. $this->render('auswertungen/drilldown.php', [ - 'titel' => $zeile['name'], - 'zeile' => $zeile, - 'zurueck' => $this->berichtsadresse($filter, $this->query('bericht_id')), + 'titel' => $zeile['name'], + 'zeile' => $zeile, + 'zurueck' => $ausCockpit ? '/' : $this->berichtsadresse($filter, $this->query('bericht_id')), + 'ausCockpit' => $ausCockpit, ]); } diff --git a/src/Services/Auswertung.php b/src/Services/Auswertung.php index 02ab7c5..815abd4 100644 --- a/src/Services/Auswertung.php +++ b/src/Services/Auswertung.php @@ -180,7 +180,7 @@ class Auswertung * @param int $grenze So viele Segmente einzeln, der Rest wird gesammelt. * @return array{ * art: string, - * zeilen: list, + * zeilen: list, name:string, betrag:float, anteil:float}>, * summe: float, * zeitraum: array{vorgabe:string, von:string, bis:string}, * beschriftung: string, @@ -246,17 +246,22 @@ class Auswertung } $zeilen[] = [ 'id' => (int)$id, + 'ids' => [(int)$id], 'name' => $id === 0 ? self::OHNE_KATEGORIE : (string)($namen[$id] ?? self::OHNE_KATEGORIE), 'betrag' => $betrag, 'anteil' => 0.0, ]; } + // „ids“ hält fest, welche Wurzeln ein Segment umfasst – beim + // Sammelsegment sind es mehrere. Die Kachel braucht das für den Weg in + // die Buchungen dahinter. if (count($zeilen) > $grenze) { $rest = array_slice($zeilen, $grenze - 1); $zeilen = array_slice($zeilen, 0, $grenze - 1); $zeilen[] = [ 'id' => 0, + 'ids' => array_column($rest, 'id'), 'name' => 'Übrige (' . count($rest) . ')', 'betrag' => round(array_sum(array_column($rest, 'betrag')), 2), 'anteil' => 0.0, @@ -652,10 +657,18 @@ class Auswertung * entstanden ist. Nur so trifft der Drilldown auch das, was ein Filter gar * nicht ausdrücken könnte – „Nicht kategorisiert“ etwa, oder eine Astsumme. * + * Die Cockpit-Kacheln nutzen denselben Weg für ihre Legenden. Sie brauchen + * zusätzlich die Art (eine Legende „Ausgaben“ darf keine unkategorisierte + * Gutschrift zeigen), eine leere Zeile für „alle Buchungen der Art“ und + * mehrere Kategorien auf einmal für das Sammelsegment „Übrige“ + * („kategorie:12,15,0“ – die 0 steht für „Nicht kategorisiert“). + * * @param array $filter - * @param string $zeile Zeilenschlüssel aus dem Bericht, z. B. „kategorie:12“ + * @param string $zeile Zeilenschlüssel aus dem Bericht, z. B. „kategorie:12“; + * '' für alle Bewegungen * @param string $spalte Spaltenschlüssel oder '' für den ganzen Zeitraum * @param bool $ast true bei einer „Gesamt …“-Zeile: mit Unterkategorien + * @param string $art 'einnahme', 'ausgabe' oder '' für beide * @return array */ public static function zeilenbuchungen( @@ -663,8 +676,10 @@ class Auswertung string $zeile, string $spalte = '', bool $ast = false, - ?string $heute = null + ?string $heute = null, + string $art = '' ): array { + $art = in_array($art, ['einnahme', 'ausgabe'], true) ? $art : ''; $filter = self::normalisiere($filter); $zeitraum = self::zeitraum($filter, $heute); $filter['von'] = $zeitraum['von']; @@ -687,15 +702,36 @@ class Auswertung $dimension = array_key_exists($teile[0], self::ZEILENSUMMEN) ? $teile[0] : 'kategorie'; $wert = $teile[1] ?? ''; + $alle = $zeile === ''; + // Bei einer Astsumme zählen die Unterkategorien mit – die Zeile zeigt ja - // deren Summe. - $astIds = $ast && $dimension === 'kategorie' && $wert !== '' && $wert !== '0' - ? array_flip(self::mitNachfahren([(int)$wert])) - : null; + // deren Summe. Mehrere Kategorien sind immer eine Menge von Ids. + $astIds = null; + if (!$alle && $dimension === 'kategorie' && ($ast || str_contains($wert, ','))) { + $teilwerte = array_map('trim', explode(',', $wert)); + $ids = array_values(array_filter(array_map('intval', $teilwerte))); + + // mitNachfahren() liefert für eine leere Liste [0] – das hieße hier + // „Nicht kategorisiert“, und das soll nur mit, wenn es gefragt ist. + $astIds = $ids === [] ? [] : array_flip($ast ? self::mitNachfahren($ids) : $ids); + unset($astIds[0]); + if (in_array('0', $teilwerte, true)) { + $astIds[0] = 0; + } + } + + $arten = self::kategorienArten(); $bewegungen = array_values(array_filter( self::bewegungen($filter), - static function (array $bewegung) use ($dimension, $wert, $astIds): bool { + static function (array $bewegung) use ($dimension, $wert, $astIds, $alle, $art, $arten): bool { + if ($art !== '' && self::artDerBewegung($bewegung, $arten) !== $art) { + return false; + } + if ($alle) { + return true; + } + $schluessel = self::zeilenschluessel($bewegung, $dimension); if ($astIds !== null) { @@ -713,8 +749,15 @@ class Auswertung return [ 'zeile' => $zeile, - 'dimension' => self::ZEILENSUMMEN[$dimension], - 'name' => self::zeilenname($dimension, $wert, $ast), + 'dimension' => $alle ? 'Alle Kategorien' : self::ZEILENSUMMEN[$dimension], + 'name' => $alle + ? match ($art) { + 'einnahme' => 'Einnahmen', + 'ausgabe' => 'Ausgaben', + default => 'Einnahmen und Ausgaben', + } + : self::zeilenname($dimension, $wert, $ast), + 'art' => $art, 'spaltentitel' => $spaltentitel, 'buchungen' => $liste, 'summe' => round(array_sum(array_column($liste, 'betrag')), 2), @@ -728,6 +771,9 @@ class Auswertung if ($wert === '' || $wert === '0') { return self::ohneName($dimension); } + if ($dimension === 'kategorie' && str_contains($wert, ',')) { + return 'Übrige Kategorien (' . count(explode(',', $wert)) . ')'; + } $namen = self::namen($dimension); $name = $namen[$dimension === 'empfaenger' ? $wert : (int)$wert] ?? $wert; diff --git a/src/Services/Cockpit.php b/src/Services/Cockpit.php index c769320..494bbfd 100644 --- a/src/Services/Cockpit.php +++ b/src/Services/Cockpit.php @@ -114,16 +114,18 @@ class Cockpit * summe_einnahmen: float, * summe_ausgaben: float, * beschriftung: string, - * hatWerte: bool + * hatWerte: bool, + * pfade: array{einnahme:string, ausgabe:string, saldo:string} * } */ public static function monatsreihe(?string $heute = null): array { - $bericht = Auswertung::bericht([ + $filter = [ 'typ' => 'summenbericht', 'zeitraum' => 'letzte_12_monate', 'aufteilung' => 'monat', - ], $heute); + ]; + $bericht = Auswertung::bericht($filter, $heute); // Beide Abschnitte liefern ihre Summen bereits vorzeichenlos. $reihen = ['einnahme' => [], 'ausgabe' => []]; @@ -157,6 +159,13 @@ class Cockpit 'summe_ausgaben' => round(array_sum($ausgaben), 2), 'beschriftung' => $bericht['beschriftung'], 'hatWerte' => array_sum($einnahmen) + array_sum($ausgaben) > 0.0, + // Die Legende führt zu den Buchungen hinter ihren drei Summen; der + // Saldo sind schlicht alle, mit Vorzeichen. + 'pfade' => [ + 'einnahme' => self::buchungsadresse($filter, 'einnahme'), + 'ausgabe' => self::buchungsadresse($filter, 'ausgabe'), + 'saldo' => self::buchungsadresse($filter, ''), + ], ]; } @@ -167,12 +176,42 @@ class Cockpit */ public static function ausgaben(?string $heute = null): array { - return Auswertung::verteilung( - ['typ' => 'summenbericht', 'zeitraum' => 'aktuelles_jahr'], - 'ausgabe', - 8, - $heute - ); + $filter = ['typ' => 'summenbericht', 'zeitraum' => 'aktuelles_jahr']; + $verteilung = Auswertung::verteilung($filter, 'ausgabe', 8, $heute); + + // Jedes Segment ist eine Wurzel samt Unterkategorien – beim Sammelsegment + // „Übrige“ mehrere davon, bei „Nicht kategorisiert“ die Id 0. + foreach ($verteilung['zeilen'] as $nummer => $zeile) { + $verteilung['zeilen'][$nummer]['pfad'] = self::buchungsadresse( + $filter, + 'ausgabe', + 'kategorie:' . implode(',', $zeile['ids']), + true + ); + } + + return $verteilung; + } + + /** + * Der Weg von einem Legendeneintrag in die Buchungen dahinter: derselbe + * Drilldown wie aus einer Berichtszeile, mit dem Filter der Kachel. So + * rechnet die Liste mit denselben Bewegungen wie die Kachel, und ihre + * Summe trifft den Legendenwert. + * + * @param array $filter der Filter, mit dem die Kachel rechnet + * @param string $art 'einnahme', 'ausgabe' oder '' für beide + * @param string $zeile Zeilenschlüssel wie im Bericht, '' für alle Bewegungen + */ + public static function buchungsadresse(array $filter, string $art, string $zeile = '', bool $ast = false): string + { + return '/auswertungen/buchungen?' . http_build_query($filter + [ + 'angepasst' => '1', + 'zeile' => $zeile, + 'ast' => $ast ? '1' : '0', + 'art' => $art, + 'herkunft' => 'cockpit', + ]); } /** diff --git a/src/Views/auswertungen/drilldown.php b/src/Views/auswertungen/drilldown.php index 2212ae2..12d6344 100644 --- a/src/Views/auswertungen/drilldown.php +++ b/src/Views/auswertungen/drilldown.php @@ -3,28 +3,42 @@ use Finanzmanager\View; /** - * Der Drilldown: die Buchungen hinter einer Berichtszeile. + * Der Drilldown: die Buchungen hinter einer Berichtszeile – oder hinter einem + * Legendeneintrag einer Cockpit-Kachel. * * @var array $zeile * @var string $zurueck + * @var bool $ausCockpit */ +$ausCockpit = $ausCockpit ?? false; +$artname = match ($zeile['art'] ?? '') { + 'einnahme' => 'Einnahmen', + 'ausgabe' => 'Ausgaben', + default => '', +}; + include __DIR__ . '/../layout/kopf.php'; ?>
- Hauptansicht - Bericht + + Cockpit + + Hauptansicht + Bericht + Buchungen

-

·

+

+ ·

@@ -82,8 +96,9 @@ include __DIR__ . '/../layout/kopf.php';

- Die Beträge stehen mit ihrem buchhalterischen Vorzeichen – im Summenbericht erscheint - dieselbe Summe unter „Ausgaben“ ohne Minus. + Die Beträge stehen mit ihrem buchhalterischen Vorzeichen –

diff --git a/src/Views/cockpit/ausgabendiagramm.php b/src/Views/cockpit/ausgabendiagramm.php index 330fcb3..c3ec5f5 100644 --- a/src/Views/cockpit/ausgabendiagramm.php +++ b/src/Views/cockpit/ausgabendiagramm.php @@ -4,13 +4,14 @@ use Finanzmanager\View; /** * Ausgaben nach oberster Kategorie als Ring, daneben die Legende mit Betrag und - * Anteil. + * Anteil. Jeder Legendeneintrag führt zu den Buchungen, aus denen sein Betrag + * besteht. * * Ein Ring statt einer vollen Torte: Die Mitte trägt die Gesamtsumme, und * Flächen am Rand lassen sich besser vergleichen als spitze Tortenstücke. * * @var array{ - * zeilen: list, + * zeilen: list, name:string, betrag:float, anteil:float, pfad?:string}>, * summe: float, * beschriftung: string, * leer: bool @@ -99,7 +100,12 @@ $serie = static fn(int $nummer): string => 'serie-' . ($nummer % 12 + 1); $zeile): ?>
  • - + + + + + + diff --git a/src/Views/cockpit/vermoegensdiagramm.php b/src/Views/cockpit/vermoegensdiagramm.php index 1c0e3e5..6fa1e42 100644 --- a/src/Views/cockpit/vermoegensdiagramm.php +++ b/src/Views/cockpit/vermoegensdiagramm.php @@ -150,14 +150,17 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
      $konto): ?> + der Kachel „Kontostände“ – in der Legende kosteten sie nur Breite. + Ein Konto führt in sein Kontoblatt: Dort stehen die Buchungen, + aus denen sich seine Stände zusammensetzen. */ ?>
    • - +
    • diff --git a/tests/15_CockpitTest.php b/tests/15_CockpitTest.php index e12625d..ec4eadf 100644 --- a/tests/15_CockpitTest.php +++ b/tests/15_CockpitTest.php @@ -35,6 +35,7 @@ class CockpitTest extends TestCase $this->verteilungFasstAufDerWurzelZusammen(); $this->verteilungKuerztAufDieGrenze(); $this->offeneVorgaengeZaehlenWasWartet(); + $this->legendenFuehrenZuDenBuchungen(); } private function stammdaten(): void @@ -249,4 +250,102 @@ class CockpitTest extends TestCase $this->assertNotEmpty($vorgang['text'], 'Und trägt einen Text'); } } + + /** + * Jeder Legendeneintrag führt zu einer Buchungsliste, deren Summe genau + * seinen Wert trifft – auch „Nicht kategorisiert“ und das Sammelsegment. + */ + private function legendenFuehrenZuDenBuchungen(): void + { + // Eine unkategorisierte Gutschrift: Sie ist eine Einnahme und darf in + // der Ausgabenlegende „Nicht kategorisiert“ nicht auftauchen. + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-08-08', 'betrag' => 50, + 'empfaenger' => 'Unbekannte Gutschrift', + ]); + + $ausgaben = Cockpit::ausgaben(self::HEUTE); + $namen = array_column($ausgaben['zeilen'], 'name'); + + $this->assert(in_array(Auswertung::OHNE_KATEGORIE, $namen, true), 'Die Kachel führt „Nicht kategorisiert“'); + $this->assert( + (bool)array_filter($namen, static fn(string $name): bool => str_starts_with($name, 'Übrige (')), + 'Und ein Sammelsegment „Übrige“' + ); + + foreach ($ausgaben['zeilen'] as $zeile) { + $this->assertNotEmpty($zeile['pfad'] ?? '', 'Legendeneintrag „' . $zeile['name'] . '“ hat einen Weg'); + $liste = $this->folge($zeile['pfad']); + $this->assertEquals( + -$zeile['betrag'], + $liste['summe'], + 'Die Buchungen hinter „' . $zeile['name'] . '“ ergeben den Legendenwert' + ); + $this->assertEquals('ausgabe', $liste['art'], 'Gefiltert wird auf Ausgaben'); + } + + $ohne = null; + foreach ($ausgaben['zeilen'] as $zeile) { + if ($zeile['name'] === Auswertung::OHNE_KATEGORIE) { + $ohne = $this->folge($zeile['pfad']); + } + } + $empfaenger = array_column($ohne['buchungen'] ?? [], 'empfaenger'); + $this->assert(in_array('Unbekannt', $empfaenger, true), 'Die unkategorisierte Ausgabe steht in der Liste'); + $this->assert(!in_array('Unbekannte Gutschrift', $empfaenger, true), 'Die unkategorisierte Gutschrift nicht'); + + $wohnen = null; + foreach ($ausgaben['zeilen'] as $zeile) { + if ($zeile['name'] === '300 - Wohnen') { + $wohnen = $this->folge($zeile['pfad']); + } + } + $this->assertEquals(2, count($wohnen['buchungen'] ?? []), 'Die Wurzel zieht Miete und Strom mit'); + + // Einnahmen und Ausgaben: drei Legendeneinträge, drei Summen. + $reihe = Cockpit::monatsreihe(self::HEUTE); + + $einnahmen = $this->folge($reihe['pfade']['einnahme']); + $this->assertEquals($reihe['summe_einnahmen'], $einnahmen['summe'], 'Die Einnahmen-Liste trifft die Legende'); + + $ausgabenListe = $this->folge($reihe['pfade']['ausgabe']); + $this->assertEquals(-$reihe['summe_ausgaben'], $ausgabenListe['summe'], 'Die Ausgaben-Liste trifft die Legende'); + + $saldo = $this->folge($reihe['pfade']['saldo']); + $this->assertEquals( + round($reihe['summe_einnahmen'] - $reihe['summe_ausgaben'], 2), + $saldo['summe'], + 'Die Saldo-Liste trifft die Legende' + ); + $this->assert( + !in_array('Aufs Sparbuch', array_column($saldo['buchungen'], 'empfaenger'), true), + 'Umbuchungen bleiben draußen wie in der Kachel' + ); + $this->assert( + str_contains($reihe['pfade']['saldo'], 'herkunft=cockpit'), + 'Der Weg zurück führt ins Cockpit' + ); + } + + /** + * Ruft den Drilldown so auf, wie der Controller es mit dieser Adresse täte – + * nur mit festem Stichtag. + * + * @return array + */ + private function folge(string $pfad): array + { + $this->assert(str_starts_with($pfad, '/auswertungen/buchungen?'), 'Der Weg führt in die Buchungsliste'); + + parse_str((string)parse_url($pfad, PHP_URL_QUERY), $query); + + return Auswertung::zeilenbuchungen( + Auswertung::normalisiere($query), + (string)($query['zeile'] ?? ''), + (string)($query['spalte'] ?? ''), + ($query['ast'] ?? '') === '1', + self::HEUTE, + (string)($query['art'] ?? '') + ); + } } diff --git a/tests/e2e/specs/module.spec.js b/tests/e2e/specs/module.spec.js index b8b0178..68dc959 100644 --- a/tests/e2e/specs/module.spec.js +++ b/tests/e2e/specs/module.spec.js @@ -86,6 +86,30 @@ test.describe('Module der Phasen 4 bis 7', () => { } }) + test('Legendeneinträge im Cockpit öffnen die Buchungen dahinter', async ({ page }) => { + await page.goto('/') + + // Einnahmen und Ausgaben: der Legendeneintrag führt in die Buchungsliste. + const monatskachel = page.locator('.kachel', { has: page.locator('h2', { hasText: 'Einnahmen und Ausgaben' }) }) + await monatskachel.locator('a.legendenname', { hasText: 'Einnahmen' }).click() + await expect(page).toHaveURL(/\/auswertungen\/buchungen\?.*art=einnahme/) + await expect(page.locator('.berichtskopf h2')).toHaveText('Einnahmen') + await expect(page.locator('table.liste tbody tr').first()).toBeVisible() + + // Der Weg zurück führt ins Cockpit, nicht in einen Bericht. + await page.click('a.knopf:has-text("Zurück zum Cockpit")') + await expect(page).toHaveURL(/\/$/) + + // Meine Ausgaben: jede Kategorie der Legende ist ein Link. + const ausgabenkachel = page.locator('.kachel', { has: page.locator('h2', { hasText: 'Meine Ausgaben' }) }) + const eintrag = ausgabenkachel.locator('.ringlegende a.legendenname').first() + const name = (await eintrag.textContent() || '').trim() + await eintrag.click() + await expect(page).toHaveURL(/art=ausgabe/) + await expect(page.locator('.berichtskopf h2')).toContainText(name.replace(/ \(\d+\)$/, '')) + await expect(page.locator('tfoot .summenzeile')).toBeVisible() + }) + test('Abgewählte Kacheln verschwinden und kommen zurück', async ({ page }) => { await page.goto('/einstellungen?bereich=darstellung')