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:
commit
b613dd7f65
13 changed files with 336 additions and 45 deletions
16
Changes.md
16
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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; ?>
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ',', '.')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; ?>
|
||||
|
|
|
|||
|
|
@ -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'] ?? '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue