Merge pull request 'Fix #84: Cockpit-Legendeneinträge öffnen die Buchungen dahinter' (#85) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/85
This commit is contained in:
fds2610 2026-09-28 08:29:01 +02:00
commit b613dd7f65
13 changed files with 336 additions and 45 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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,
]);
}

View file

@ -180,7 +180,7 @@ class Auswertung
* @param int $grenze So viele Segmente einzeln, der Rest wird gesammelt.
* @return array{
* art: string,
* zeilen: list<array{id:int, name:string, betrag:float, anteil:float}>,
* zeilen: list<array{id:int, ids:list<int>, 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<string,mixed> $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<string,mixed>
*/
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;

View file

@ -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<string,mixed> $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',
]);
}
/**

View file

@ -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<string,mixed> $zeile
* @var string $zurueck
* @var bool $ausCockpit
*/
$ausCockpit = $ausCockpit ?? false;
$artname = match ($zeile['art'] ?? '') {
'einnahme' => 'Einnahmen',
'ausgabe' => 'Ausgaben',
default => '',
};
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a class="tab" href="/auswertungen">Hauptansicht</a>
<a class="tab" href="<?= View::h($zurueck) ?>">Bericht</a>
<?php if ($ausCockpit): ?>
<a class="tab" href="/">Cockpit</a>
<?php else: ?>
<a class="tab" href="/auswertungen">Hauptansicht</a>
<a class="tab" href="<?= View::h($zurueck) ?>">Bericht</a>
<?php endif; ?>
<span class="tab aktiv">Buchungen</span>
</div>
<div class="seitenaktionen">
<a href="<?= View::h($zurueck) ?>" class="knopf primaer">Zurück zum Bericht</a>
<a href="<?= View::h($zurueck) ?>" class="knopf primaer"><?= $ausCockpit ? 'Zurück zum Cockpit' : 'Zurück zum Bericht' ?></a>
</div>
<div class="berichtskopf">
<h2><?= View::h($zeile['name']) ?></h2>
<p><?= View::h($zeile['dimension']) ?> · <?= View::h($zeile['spaltentitel']) ?></p>
<p><?= View::h($zeile['dimension']) ?><?= $artname !== '' && $artname !== $zeile['name'] ? ' · ' . View::h($artname) : '' ?>
· <?= View::h($zeile['spaltentitel']) ?></p>
</div>
<?php if ($zeile['buchungen'] === []): ?>
@ -82,8 +96,9 @@ include __DIR__ . '/../layout/kopf.php';
</table>
<p class="hinweis klein">
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 – <?= $ausCockpit
? 'in der Kachel erscheinen Ausgaben ohne Minus.'
: 'im Summenbericht erscheint dieselbe Summe unter „Ausgaben“ ohne Minus.' ?>
</p>
<?php endif; ?>

View file

@ -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<array{id:int, name:string, betrag:float, anteil:float}>,
* zeilen: list<array{id:int, ids:list<int>, 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);
<?php foreach ($zeilen as $nummer => $zeile): ?>
<li>
<span class="legendenfarbe <?= $serie($nummer) ?>" aria-hidden="true"></span>
<span class="legendenname" title="<?= View::h($zeile['name']) ?>"><?= View::h($zeile['name']) ?></span>
<?php if (isset($zeile['pfad'])): ?>
<a class="legendenname" href="<?= View::h($zeile['pfad']) ?>"
title="<?= View::h($zeile['name']) ?> – Buchungen zeigen"><?= View::h($zeile['name']) ?></a>
<?php else: ?>
<span class="legendenname" title="<?= View::h($zeile['name']) ?>"><?= View::h($zeile['name']) ?></span>
<?php endif; ?>
<span class="legendenbetrag betrag"><?= View::h(View::betrag($zeile['betrag'])) ?></span>
<span class="legendenanteil"><?= View::h(
number_format($zeile['anteil'] * 100, 0, ',', '.')

View file

@ -18,7 +18,8 @@ use Finanzmanager\View;
* summe_einnahmen: float,
* summe_ausgaben: float,
* beschriftung: string,
* hatWerte: bool
* hatWerte: bool,
* pfade: array{einnahme:string, ausgabe:string, saldo:string}
* } $reihe
*/
@ -120,19 +121,23 @@ $saeule = static function (float $wert) use ($y, $nulllinie): array {
</svg>
</div>
<?php /* Jeder Eintrag führt zu den Buchungen hinter seiner Summe. */ ?>
<ul class="diagrammlegende">
<li>
<span class="legendenfarbe balken-einnahme" aria-hidden="true"></span>
<span class="legendenname">Einnahmen <?= View::h(View::betrag($reihe['summe_einnahmen'])) ?></span>
<a class="legendenname" href="<?= View::h($reihe['pfade']['einnahme']) ?>"
title="Buchungen zeigen">Einnahmen <?= View::h(View::betrag($reihe['summe_einnahmen'])) ?></a>
</li>
<li>
<span class="legendenfarbe balken-ausgabe" aria-hidden="true"></span>
<span class="legendenname">Ausgaben <?= View::h(View::betrag($reihe['summe_ausgaben'])) ?></span>
<a class="legendenname" href="<?= View::h($reihe['pfade']['ausgabe']) ?>"
title="Buchungen zeigen">Ausgaben <?= View::h(View::betrag($reihe['summe_ausgaben'])) ?></a>
</li>
<li>
<span class="legendenfarbe saldofarbe" aria-hidden="true"></span>
<span class="legendenname">Saldo
<?= View::h(View::betrag($reihe['summe_einnahmen'] - $reihe['summe_ausgaben'])) ?></span>
<a class="legendenname" href="<?= View::h($reihe['pfade']['saldo']) ?>"
title="Buchungen zeigen">Saldo
<?= View::h(View::betrag($reihe['summe_einnahmen'] - $reihe['summe_ausgaben'])) ?></a>
</li>
</ul>
</div>

View file

@ -150,14 +150,17 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
<ul class="diagrammlegende">
<?php foreach ($konten as $index => $konto): ?>
<?php /* Nur Farbe und Name: Die Beträge stehen am Balken im Tooltip und in
der Kachel „Kontostände“ – in der Legende kosteten sie nur Breite. */ ?>
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. */ ?>
<li>
<span class="legendenfarbe <?= $serie($index) ?>" aria-hidden="true"></span>
<?php if ($konto['id'] === 0): ?>
<a class="legendenname tiefer" href="/?ebene=<?= $ebene + 1 ?>"
title="Diese Konten einzeln zeigen"><?= View::h($konto['name']) ?></a>
<?php else: ?>
<span class="legendenname"><?= View::h($konto['name']) ?></span>
<a class="legendenname" href="/konten/<?= (int)$konto['id'] ?>"
title="Kontoblatt öffnen"><?= View::h($konto['name']) ?></a>
<?php endif; ?>
</li>
<?php endforeach; ?>

View file

@ -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<string,mixed>
*/
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'] ?? '')
);
}
}

View file

@ -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')