Merge pull request 'Cockpit: Vermögensentwicklung als gestapeltes Balkendiagramm' (#7) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/7
This commit is contained in:
commit
7962cb9626
13 changed files with 1274 additions and 10 deletions
34
Changes.md
34
Changes.md
|
|
@ -1,5 +1,39 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Cockpit: Vermögensentwicklung als gestapeltes Balkendiagramm (11.08.2026)
|
||||||
|
|
||||||
|
- Die Kachel *Vermögensentwicklung* war ein Platzhalter und zeigt jetzt die Kontostände
|
||||||
|
der letzten zwölf Monate je Monatsende – ein Balken pro Monat, ein Farbton pro Konto
|
||||||
|
(`Vermoegensentwicklung::reihe()`, `src/Views/cockpit/vermoegensdiagramm.php`).
|
||||||
|
- Negative Stände stapeln unter der Nulllinie.
|
||||||
|
- Schalter *nur aktive* oben rechts an der Kachel, im Standard eingeschaltet: Er blendet
|
||||||
|
stillgelegte Konten samt Legendeneintrag aus und merkt sich seinen Zustand über die
|
||||||
|
Einstellungen (`cockpit_vermoegen_nur_aktive`) – auch über das Abmelden hinaus. Er ist
|
||||||
|
das einzige Kriterium; das Σ-Kennzeichen filtert hier **nicht** mit, weil stillgelegte
|
||||||
|
Konten es meist nicht tragen und beim Ausschalten sonst genau das fehlte, was man sehen
|
||||||
|
will.
|
||||||
|
- `Buchung::bewegungenJeKontoUndMonat()` liefert die Monatsbewegungen in einer Abfrage je
|
||||||
|
Quelle – zwölf Stichtagsabfragen wären es sonst.
|
||||||
|
- Mehr Konten als Farbtöne: Die größten behalten ihre Farbe, der Rest wird zu *Übrige
|
||||||
|
Konten (n)* zusammengefasst. Zwei Konten in derselben Farbe wären in der Legende nicht
|
||||||
|
auseinanderzuhalten.
|
||||||
|
- Eigenes SVG, keine Diagrammbibliothek. Die Farben stehen als Klasse am Element und
|
||||||
|
folgen damit dem hellen wie dem dunklen Thema; jedes Balkenstück trägt ein `<title>`
|
||||||
|
als Tooltip, ohne eine Zeile JavaScript.
|
||||||
|
- **Übrige Konten (n)** ist ein Link: Er führt eine Ebene tiefer (`/?ebene=N`) und zeigt
|
||||||
|
die nächstkleineren Konten einzeln – beliebig tief, bis der Rest in die Palette passt.
|
||||||
|
Unter der Legende steht der Weg zurück. Jedes Konto erscheint auf genau einer Ebene, und
|
||||||
|
eine zu hohe Ebene fällt auf die letzte zurück (`Vermoegensentwicklung::ebenen()`).
|
||||||
|
- Die Balken nutzen die Kachelbreite aus, indem sie **auseinanderrücken**, nicht indem sie
|
||||||
|
breiter werden: Die Zeichenfläche rechnet waagerecht in Prozent, die Balkenbreite bleibt
|
||||||
|
bei 16 Punkten. Achsenbeschriftung und Zeichenfläche sind dafür zwei SVGs nebeneinander –
|
||||||
|
ein einzelnes könnte von seinen Prozentangaben keine feste Achsenbreite abziehen.
|
||||||
|
- Die Kachel ist so groß wie ihre Nachbarn: Der Ausgriff über zwei Rasterspalten
|
||||||
|
(`.kachel.breit`) und das engere Cockpit-Raster (`.kachelraster.dicht`) entfallen wieder.
|
||||||
|
- Die Legende steht unter der Zeichnung statt daneben und in kleinerer Schrift (.74rem);
|
||||||
|
sie füllt so viele Spalten, wie die Kachel hergibt. Der Schalter teilt sich die erste
|
||||||
|
Zeile mit der Überschrift, damit er in der schmaleren Kachel oben rechts bleibt.
|
||||||
|
|
||||||
## Budgetplaner: drei Ansichten und Verteilen über das Zahnrad (11.08.2026)
|
## Budgetplaner: drei Ansichten und Verteilen über das Zahnrad (11.08.2026)
|
||||||
|
|
||||||
- Abgelaufene Monate werden **nicht mehr zusammengefasst**. Alle zwölf Monate stehen
|
- Abgelaufene Monate werden **nicht mehr zusammengefasst**. Alle zwölf Monate stehen
|
||||||
|
|
|
||||||
5
PLAN.md
5
PLAN.md
|
|
@ -269,8 +269,9 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
|
||||||
„Planung", „Steuern"): **Summenbericht, Buchungsbericht, Vergleichsbericht,
|
„Planung", „Steuern"): **Summenbericht, Buchungsbericht, Vergleichsbericht,
|
||||||
Kontostandsbericht, Budgetbericht**, jeweils mit Zeitraum, Spalten-Aufteilung,
|
Kontostandsbericht, Budgetbericht**, jeweils mit Zeitraum, Spalten-Aufteilung,
|
||||||
Zeilen-Summe (Kategorie/Klasse/Konto/Empfänger), Filter auf Konten/Kategorien/Klassen.
|
Zeilen-Summe (Kategorie/Klasse/Konto/Empfänger), Filter auf Konten/Kategorien/Klassen.
|
||||||
* Grafiken als SVG: Einnahmen/Ausgaben je Monat, Vermögensentwicklung, Ausgaben nach
|
* Grafiken als SVG: Einnahmen/Ausgaben je Monat, Vermögensentwicklung *(erledigt: Cockpit-
|
||||||
Kategorie.
|
Kachel mit den letzten zwölf Monaten, gestapelt je Konto — `docs/cockpit.md`)*, Ausgaben
|
||||||
|
nach Kategorie.
|
||||||
* Export CSV; Drilldown von der Berichtszeile in die zugrunde liegenden Buchungen.
|
* Export CSV; Drilldown von der Berichtszeile in die zugrunde liegenden Buchungen.
|
||||||
* **Cockpit** mit konfigurierbaren Kacheln (Vermögensentwicklung, Einnahmen/Ausgaben,
|
* **Cockpit** mit konfigurierbaren Kacheln (Vermögensentwicklung, Einnahmen/Ausgaben,
|
||||||
Meine Ausgaben, Kontostände, offene Vorgänge).
|
Meine Ausgaben, Kontostände, offene Vorgänge).
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@ Phase 0 bis 3 sind gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log,
|
||||||
Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen;
|
Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen;
|
||||||
Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für
|
Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für
|
||||||
Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der
|
Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der
|
||||||
Monatsabschluss samt Prüflauf, Snapshot und Sperre. Als Nächstes folgen nach `PLAN.md`
|
Monatsabschluss samt Prüflauf, Snapshot und Sperre. Aus Phase 4 steht die erste
|
||||||
die Auswertungen und das Cockpit (Phase 4).
|
Cockpit-Kachel: die Vermögensentwicklung der letzten zwölf Monate als gestapeltes
|
||||||
|
Balkendiagramm. Als Nächstes folgen nach `PLAN.md` die übrigen Auswertungen.
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|
||||||
|
|
@ -85,4 +86,5 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf.
|
||||||
- `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen
|
- `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen
|
||||||
- `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden
|
- `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden
|
||||||
- `docs/planung.md` – Budgetplaner und Monatsabschluss
|
- `docs/planung.md` – Budgetplaner und Monatsabschluss
|
||||||
|
- `docs/cockpit.md` – Startseite und die Kachel Vermögensentwicklung
|
||||||
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
||||||
|
|
|
||||||
113
docs/cockpit.md
Normal file
113
docs/cockpit.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Cockpit
|
||||||
|
|
||||||
|
Die Startseite. Jede Kachel beantwortet eine Frage auf einen Blick; ausgebaut wird sie
|
||||||
|
Stück für Stück in Phase 4.
|
||||||
|
|
||||||
|
## Vermögensentwicklung
|
||||||
|
|
||||||
|
Ein gestapeltes Balkendiagramm der **Kontostände zum Monatsende** über die letzten zwölf
|
||||||
|
Monate – ein Balken je Monat, ein Farbton je Konto.
|
||||||
|
|
||||||
|
### Woher die Zahlen kommen
|
||||||
|
|
||||||
|
Welche Konten zählen, entscheidet allein der Schalter „nur aktive“. Das Σ-Kennzeichen
|
||||||
|
(`in_summe`) spielt hier **keine** Rolle – anders als in der Kontenübersicht. Stillgelegte
|
||||||
|
Konten tragen es meist nicht; würde es mitfiltern, fiele beim Ausschalten des Schalters
|
||||||
|
genau das weg, was man sehen will. Die Summe der Kachel kann bei ausgeschaltetem Schalter
|
||||||
|
deshalb von der Summe der Kontenübersicht abweichen.
|
||||||
|
|
||||||
|
Der Stand eines Monats ist der Stand des Vormonats plus dessen Bewegungen:
|
||||||
|
|
||||||
|
* `Buchung::saldenJeKonto()` liefert den Stand am Tag vor dem Zeitraum – alles Ältere
|
||||||
|
steckt damit im ersten Balken.
|
||||||
|
* `Buchung::bewegungenJeKontoUndMonat()` liefert die Bewegungen innerhalb des Zeitraums,
|
||||||
|
nach Monat getrennt.
|
||||||
|
|
||||||
|
Zwei Abfragen statt zwölf Stichtagsabfragen. Beide Methoden zählen dieselben drei Quellen:
|
||||||
|
die Buchungen des Kontos, die Gegenseiten fremder Umbuchungen und die Gegenseiten aus
|
||||||
|
Splitzeilen. Eine Umbuchung verschiebt deshalb zwischen zwei Balkenstücken, ohne die Summe
|
||||||
|
zu ändern.
|
||||||
|
|
||||||
|
Konten, die zwölf Monate lang auf null standen, fallen weg; sie zeichnen ohnehin keinen
|
||||||
|
Balken.
|
||||||
|
|
||||||
|
### Der Schalter „nur aktive“
|
||||||
|
|
||||||
|
Oben rechts an der Kachel sitzt ein Ein/Aus-Schalter. Eingeschaltet – der Standard – zählen
|
||||||
|
nur Konten, die noch geführt werden. Ausgeschaltet kommen stillgelegte dazu: Sonst fehlte im
|
||||||
|
Rückblick Vermögen, das damals auf einem inzwischen aufgelösten Konto lag.
|
||||||
|
|
||||||
|
Der Zustand liegt unter `cockpit_vermoegen_nur_aktive` in den Einstellungen und gilt bis zur
|
||||||
|
nächsten Umschaltung – auch über das Abmelden hinaus. Im Einstellungsdialog erscheint er
|
||||||
|
nicht: Er gehört an die Kachel, nicht in einen Reiter.
|
||||||
|
|
||||||
|
Umgeschaltet wird über einen abschickenden Knopf, nicht über ein Ankreuzfeld. Ein
|
||||||
|
Ankreuzfeld könnte sein Formular nur mit einem Inline-Skript selbst abschicken, und die
|
||||||
|
Content-Security-Policy verbietet Inline-Skripte.
|
||||||
|
|
||||||
|
### Stapel und Farben
|
||||||
|
|
||||||
|
Positive Stände stapeln sich über der Nulllinie, negative darunter – ein überzogenes
|
||||||
|
Girokonto oder eine Kreditkarte zieht den Balken nach unten.
|
||||||
|
|
||||||
|
Die Palette in `app.css` hat zwölf Töne (`.serie-1` bis `.serie-12`). Gibt es mehr Konten,
|
||||||
|
behalten die größten ihre eigene Farbe und der Rest wird zu **Übrige Konten (n)**
|
||||||
|
zusammengefasst – zwei Konten in derselben Farbe wären in der Legende nicht auseinander zu
|
||||||
|
halten. Jede Serie setzt `--farbe`; davon leben der Balken (`fill`) und der Legendenpunkt
|
||||||
|
(`background`) gleichermaßen, und beide folgen dem hellen wie dem dunklen Thema.
|
||||||
|
|
||||||
|
### Ebenen: in „Übrige Konten“ hineinsehen
|
||||||
|
|
||||||
|
Der Legendeneintrag **Übrige Konten (n)** ist ein Link. Er führt auf `/?ebene=1`: Dort
|
||||||
|
stehen die nächstelf Konten einzeln, und was dann noch bleibt, sammelt sich erneut. So
|
||||||
|
geht es beliebig tief, bis der Rest in die Palette passt – dann entfällt das Sammelstück,
|
||||||
|
und das ist die letzte Ebene. `Vermoegensentwicklung::ebenen()` rechnet aus, wie viele es
|
||||||
|
sind; eine zu hohe Ebene fällt auf die letzte zurück, statt eine leere Kachel zu zeigen.
|
||||||
|
|
||||||
|
Zurück geht es über die Zeile unter der Legende: *eine Ebene zurück* und, ab der dritten,
|
||||||
|
*zu den größten Konten*.
|
||||||
|
|
||||||
|
Jedes Konto steht auf genau einer Ebene: Was oben schon eine eigene Farbe hatte, ist im
|
||||||
|
Sammelstück nicht mehr enthalten. Sonst wüchse der Rest mit jeder Ebene, statt zu
|
||||||
|
schrumpfen. Weil auf tieferen Ebenen die großen Konten fehlen, passt sich die Achse an –
|
||||||
|
die kleinen Konten werden dadurch überhaupt erst sichtbar. Die Ebene steht in der Adresse
|
||||||
|
und nicht in den Einstellungen: Beim nächsten Aufruf beginnt man wieder oben.
|
||||||
|
|
||||||
|
### SVG von Hand
|
||||||
|
|
||||||
|
Keine Diagrammbibliothek (siehe `PLAN.md`): `src/Views/cockpit/vermoegensdiagramm.php`
|
||||||
|
schreibt das SVG selbst. Farben stehen als Klasse am Element, nicht als `fill`-Attribut –
|
||||||
|
ein Attribut käme zwar an der Content-Security-Policy vorbei, könnte aber nicht auf die
|
||||||
|
Themenumschaltung reagieren. Ein `style`-Attribut verwürfe die CSP ohnehin.
|
||||||
|
|
||||||
|
Die Achse rundet über `Vermoegensentwicklung::skala()` auf Schritte von 1, 2, 2,5 oder 5
|
||||||
|
mal einer Zehnerpotenz und schließt die Null immer ein. Jedes Balkenstück trägt ein
|
||||||
|
`<title>` mit Konto, Monat und Betrag – das ist der Tooltip, ohne eine Zeile JavaScript.
|
||||||
|
|
||||||
|
Senkrecht rechnet die Zeichnung in Bildpunkten, waagerecht in Prozent: Die Balkenmitte von
|
||||||
|
Monat *i* liegt bei `(i + 0,5) / 12` der Fläche, der Balken selbst ist feste 16 Punkte breit
|
||||||
|
und wird per `transform` um seine halbe Breite nach links gerückt. In einer breiten Kachel
|
||||||
|
rücken die Balken damit **auseinander**, statt breiter zu werden.
|
||||||
|
|
||||||
|
Deshalb sind es zwei SVGs nebeneinander (`.diagrammflaeche`): links die Achsenbeschriftung
|
||||||
|
mit fester Breite, rechts die Zeichenfläche. In einem einzigen SVG ginge das nicht –
|
||||||
|
Prozentangaben rechnen immer auf die volle Breite, und ein Attribut kann davon keine feste
|
||||||
|
Achsenbreite abziehen. Ein `viewBox` verbietet sich aus demselben Grund: Es würde alles
|
||||||
|
gleichmäßig strecken, die Balken eingeschlossen.
|
||||||
|
|
||||||
|
Die Kachel ist eine gewöhnliche Kachel des Cockpit-Rasters, so breit wie ihre Nachbarn:
|
||||||
|
Ein Ausgriff über zwei Spalten machte sie zum Fremdkörper zwischen den anderen dreien.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
* `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
|
||||||
|
zurück), Skala
|
||||||
|
* `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
|
||||||
|
|
@ -55,6 +55,7 @@ $router->get('/einrichtung', fn() => (new LoginController())->einrichtungFormul
|
||||||
$router->post('/einrichtung', fn() => (new LoginController())->einrichtungSpeichern());
|
$router->post('/einrichtung', fn() => (new LoginController())->einrichtungSpeichern());
|
||||||
|
|
||||||
$router->get('/', fn() => (new CockpitController())->index());
|
$router->get('/', fn() => (new CockpitController())->index());
|
||||||
|
$router->post('/cockpit/vermoegen/konten', fn() => (new CockpitController())->kontenfilter());
|
||||||
|
|
||||||
$router->get('/einstellungen', fn() => (new EinstellungenController())->index());
|
$router->get('/einstellungen', fn() => (new EinstellungenController())->index());
|
||||||
$router->post('/einstellungen', fn() => (new EinstellungenController())->speichern());
|
$router->post('/einstellungen', fn() => (new EinstellungenController())->speichern());
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,27 @@ form.inline { display: inline; margin: 0; }
|
||||||
|
|
||||||
.kachel header { margin-bottom: .6rem; }
|
.kachel header { margin-bottom: .6rem; }
|
||||||
|
|
||||||
|
.kachel header.mitschalter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: .5rem 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Schalter teilt sich die Zeile mit der Überschrift und bleibt oben rechts;
|
||||||
|
der erklärende Satz nimmt die ganze nächste Zeile. Stünde er in derselben
|
||||||
|
Zeile, drückte er den Schalter in einer schmalen Kachel nach unten. */
|
||||||
|
.kachel header.mitschalter > h2 { min-width: 0; }
|
||||||
|
|
||||||
|
.kachel header.mitschalter > .kachelhinweis { flex: 1 0 100%; margin: 0; }
|
||||||
|
|
||||||
|
.kachelhinweis {
|
||||||
|
margin: .25rem 0 0;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.kachel h2 {
|
.kachel h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
@ -321,6 +342,184 @@ form.inline { display: inline; margin: 0; }
|
||||||
.checkliste li.erledigt { color: var(--text); }
|
.checkliste li.erledigt { color: var(--text); }
|
||||||
.checkliste li.erledigt::before { content: "●"; color: var(--positiv); }
|
.checkliste li.erledigt::before { content: "●"; color: var(--positiv); }
|
||||||
|
|
||||||
|
/* ── Diagramme ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Selbst erzeugtes SVG, keine Diagrammbibliothek. Die Farbe eines Kontos steht
|
||||||
|
als Klasse am Element: Ein fill-Attribut ginge zwar an der CSP vorbei, könnte
|
||||||
|
aber nicht auf das dunkle Thema reagieren. Jede Serie setzt --farbe, davon
|
||||||
|
leben Balken (fill) und Legendenpunkt (background) gleichermaßen. */
|
||||||
|
.serie-1 { --farbe: #4c78a8; }
|
||||||
|
.serie-2 { --farbe: #f58518; }
|
||||||
|
.serie-3 { --farbe: #54a24b; }
|
||||||
|
.serie-4 { --farbe: #e45756; }
|
||||||
|
.serie-5 { --farbe: #72b7b2; }
|
||||||
|
.serie-6 { --farbe: #b279a2; }
|
||||||
|
.serie-7 { --farbe: #d8b02c; }
|
||||||
|
.serie-8 { --farbe: #9d755d; }
|
||||||
|
.serie-9 { --farbe: #2f8f8a; }
|
||||||
|
.serie-10 { --farbe: #8f8ad4; }
|
||||||
|
.serie-11 { --farbe: #e07b91; }
|
||||||
|
.serie-12 { --farbe: #7b8a99; }
|
||||||
|
|
||||||
|
.diagrammbereich {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Achsenbeschriftung und Zeichenfläche sind zwei SVGs nebeneinander. Die Achse
|
||||||
|
ist fest, die Fläche nimmt den Rest – und weil deren Balken waagerecht in
|
||||||
|
Prozent sitzen, rücken sie in einer breiten Kachel auseinander, statt breiter
|
||||||
|
zu werden. Ein einziges SVG könnte das nicht: Prozentangaben rechnen immer
|
||||||
|
auf die volle Breite, die feste Achse ließe sich nicht abziehen. */
|
||||||
|
.diagrammflaeche {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagrammachse { flex: none; }
|
||||||
|
|
||||||
|
.diagramm {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagramm rect { fill: var(--farbe); }
|
||||||
|
|
||||||
|
.diagramm-gitter {
|
||||||
|
stroke: var(--rand);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagramm-nulllinie {
|
||||||
|
stroke: var(--rand-kraeftig);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagramm-achse,
|
||||||
|
.diagramm-beschriftung {
|
||||||
|
fill: var(--text-gedaempft);
|
||||||
|
font-family: var(--schrift);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ziffernschrift für die Achse, aber schmal genug für 21 Punkte Spaltenabstand
|
||||||
|
bleiben die Monatskürzel darunter. */
|
||||||
|
.diagramm-achse {
|
||||||
|
font-family: var(--schrift-zahl);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagramm-beschriftung.schwach { opacity: .7; }
|
||||||
|
|
||||||
|
/* Unter der Zeichnung und in kleiner Schrift: So bleibt die Kachel so breit wie
|
||||||
|
ihre Nachbarn, und zwölf Kontonamen passen in zwei Spalten darunter. */
|
||||||
|
.diagrammlegende {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
|
||||||
|
gap: 0 1rem;
|
||||||
|
font-size: .74rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagrammlegende li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendenfarbe {
|
||||||
|
width: .65rem;
|
||||||
|
height: .65rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--farbe);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendenname {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
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 {
|
||||||
|
color: var(--akzent);
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendenname.tiefer:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.diagrammpfad {
|
||||||
|
margin: 0;
|
||||||
|
font-size: .74rem;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ein/Aus-Schalter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Die Bahn mit dem laufenden Knopf, wie man ihn von Mobilgeräten kennt. Ein
|
||||||
|
abschickender Knopf statt eines Ankreuzfelds: Ohne Inline-Skript könnte ein
|
||||||
|
Ankreuzfeld sein Formular nicht selbst abschicken, und Inline-Skripte
|
||||||
|
verbietet die Content-Security-Policy. */
|
||||||
|
.schalterform { margin: 0; }
|
||||||
|
|
||||||
|
.schalter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .15rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
font: inherit;
|
||||||
|
font-size: .85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schalter[aria-checked="true"] { color: var(--text); }
|
||||||
|
|
||||||
|
.schalter:focus-visible {
|
||||||
|
outline: 2px solid var(--akzent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schalterbahn {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
width: 2.3rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--rand-kraeftig);
|
||||||
|
transition: background .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schalterknopf {
|
||||||
|
position: absolute;
|
||||||
|
top: .15rem;
|
||||||
|
left: .15rem;
|
||||||
|
width: .95rem;
|
||||||
|
height: .95rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgb(0 0 0 / 35%);
|
||||||
|
transition: transform .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schalter[aria-checked="true"] .schalterbahn { background: var(--akzent); }
|
||||||
|
.schalter[aria-checked="true"] .schalterknopf { transform: translateX(1.05rem); }
|
||||||
|
|
||||||
/* ── Tabellen ────────────────────────────────────────────────────────────── */
|
/* ── Tabellen ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
table.liste {
|
table.liste {
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,55 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Finanzmanager\Controllers;
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Einstellung;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
|
use Finanzmanager\Services\Vermoegensentwicklung;
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
class CockpitController extends BaseController
|
class CockpitController extends BaseController
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Der Schalter „nur aktive“ an der Vermögenskachel. Er merkt sich seinen
|
||||||
|
* Zustand über die Einstellungen und nicht in der Sitzung: Er soll auch beim
|
||||||
|
* nächsten Anmelden noch so stehen, wie der Nutzer ihn gelassen hat. Im
|
||||||
|
* Einstellungsdialog taucht er nicht auf – er gehört an die Kachel.
|
||||||
|
*/
|
||||||
|
public const SCHALTER_NUR_AKTIVE = 'cockpit_vermoegen_nur_aktive';
|
||||||
|
|
||||||
public function index(): void
|
public function index(): void
|
||||||
{
|
{
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
// Die Kontostandskachel zeigt schon jetzt echte Zahlen; die übrigen
|
$nurAktive = Einstellung::getBool(self::SCHALTER_NUR_AKTIVE, true);
|
||||||
// Kacheln brauchen Buchungen und füllen sich ab Phase 2.
|
|
||||||
|
// Die Ebene steht in der Adresse, nicht in den Einstellungen: Wer in die
|
||||||
|
// „Übrigen Konten“ hineinsieht, will beim nächsten Aufruf wieder oben
|
||||||
|
// anfangen – und kann den Blick trotzdem als Lesezeichen ablegen.
|
||||||
|
$ebene = max(0, (int)$this->query('ebene'));
|
||||||
|
|
||||||
$this->render('cockpit/index.php', [
|
$this->render('cockpit/index.php', [
|
||||||
'titel' => 'Cockpit',
|
'titel' => 'Cockpit',
|
||||||
'uebersicht' => Konto::uebersicht(),
|
'uebersicht' => Konto::uebersicht(),
|
||||||
|
'vermoegen' => Vermoegensentwicklung::reihe(
|
||||||
|
$nurAktive,
|
||||||
|
Vermoegensentwicklung::MONATE,
|
||||||
|
null,
|
||||||
|
$ebene
|
||||||
|
),
|
||||||
|
'nurAktive' => $nurAktive,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function kontenfilter(): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
Einstellung::set(
|
||||||
|
self::SCHALTER_NUR_AKTIVE,
|
||||||
|
$this->post('nur_aktive') === '1' ? '1' : '0',
|
||||||
|
'darstellung'
|
||||||
|
);
|
||||||
|
|
||||||
|
View::redirect('/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,51 @@ class Buchung
|
||||||
return $salden;
|
return $salden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dieselben drei Quellen wie saldenJeKonto(), aber nach Monat getrennt und
|
||||||
|
* auf einen Zeitraum begrenzt. Für die Vermögensentwicklung: Ein Stichtag je
|
||||||
|
* Monat wären zwölf Abfragen, so genügt eine je Quelle.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, float>> konto_id => ['JJJJ-MM' => Bewegung]
|
||||||
|
*/
|
||||||
|
public static function bewegungenJeKontoUndMonat(string $von, string $bis): array
|
||||||
|
{
|
||||||
|
$benutzer = Mandant::id();
|
||||||
|
$p = [$benutzer, $von, $bis];
|
||||||
|
|
||||||
|
$quellen = [
|
||||||
|
"SELECT b.konto_id AS k, DATE_FORMAT(b.datum, '%Y-%m') AS m, SUM(b.betrag) AS s
|
||||||
|
FROM buchungen b
|
||||||
|
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ?
|
||||||
|
GROUP BY b.konto_id, m",
|
||||||
|
|
||||||
|
"SELECT b.gegenkonto_id AS k, DATE_FORMAT(b.datum, '%Y-%m') AS m, -SUM(b.betrag) AS s
|
||||||
|
FROM buchungen b
|
||||||
|
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ?
|
||||||
|
AND b.gegenkonto_id IS NOT NULL AND b.gesplittet = 0
|
||||||
|
GROUP BY b.gegenkonto_id, m",
|
||||||
|
|
||||||
|
"SELECT s.gegenkonto_id AS k, DATE_FORMAT(b.datum, '%Y-%m') AS m, -SUM(s.betrag) AS s
|
||||||
|
FROM buchung_splits s
|
||||||
|
JOIN buchungen b ON b.id = s.buchung_id
|
||||||
|
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ?
|
||||||
|
AND s.gegenkonto_id IS NOT NULL
|
||||||
|
GROUP BY s.gegenkonto_id, m",
|
||||||
|
];
|
||||||
|
|
||||||
|
$bewegungen = [];
|
||||||
|
foreach ($quellen as $sql) {
|
||||||
|
foreach (Database::fetchAll($sql, $p) as $zeile) {
|
||||||
|
$id = (int)$zeile['k'];
|
||||||
|
$monat = (string)$zeile['m'];
|
||||||
|
|
||||||
|
$bewegungen[$id][$monat] = ($bewegungen[$id][$monat] ?? 0.0) + (float)$zeile['s'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bewegungen;
|
||||||
|
}
|
||||||
|
|
||||||
/** Wie viele Buchungen hat das Konto (beide Sichten)? */
|
/** Wie viele Buchungen hat das Konto (beide Sichten)? */
|
||||||
public static function anzahlFuerKonto(int $kontoId): int
|
public static function anzahlFuerKonto(int $kontoId): int
|
||||||
{
|
{
|
||||||
|
|
|
||||||
252
src/Services/Vermoegensentwicklung.php
Normal file
252
src/Services/Vermoegensentwicklung.php
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Services;
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Buchung;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Kontostände der letzten zwölf Monate, je Konto und Monatsende – Grundlage
|
||||||
|
* der Cockpit-Kachel *Vermögensentwicklung*.
|
||||||
|
*
|
||||||
|
* Einziges Kriterium ist der Schalter „nur aktive“ an der Kachel: eingeschaltet
|
||||||
|
* zählen die geführten Konten, ausgeschaltet alle. Das Σ-Kennzeichen (`in_summe`)
|
||||||
|
* bleibt hier ausdrücklich außen vor – stillgelegte Konten tragen es meist nicht,
|
||||||
|
* und dann fiele beim Ausschalten genau das weg, was man sehen will.
|
||||||
|
*/
|
||||||
|
class Vermoegensentwicklung
|
||||||
|
{
|
||||||
|
public const MONATE = 12;
|
||||||
|
|
||||||
|
/** So viele Farbtöne hat die Palette in `app.css`. */
|
||||||
|
public const SERIEN = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $ebene 0 zeigt die größten Konten, jede weitere Ebene die
|
||||||
|
* jeweils nächstkleineren aus „Übrige Konten“.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>,
|
||||||
|
* konten: list<array{id:int, name:string, werte:list<float>}>,
|
||||||
|
* summen: list<float>,
|
||||||
|
* hatWerte: bool,
|
||||||
|
* ebene: int,
|
||||||
|
* ebenen: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function reihe(
|
||||||
|
bool $nurAktive = true,
|
||||||
|
int $anzahl = self::MONATE,
|
||||||
|
?string $heute = null,
|
||||||
|
int $ebene = 0
|
||||||
|
): array {
|
||||||
|
$monate = self::monate($anzahl, $heute);
|
||||||
|
$erster = $monate[0];
|
||||||
|
$letzter = $monate[count($monate) - 1];
|
||||||
|
|
||||||
|
// Alles vor dem Zeitraum steckt im Anfangsstand; innerhalb zählt Monat
|
||||||
|
// für Monat dazu. So genügen zwei Abfragen statt einer je Stichtag.
|
||||||
|
$vorlauf = date('Y-m-d', strtotime($erster['bis'] . ' first day of this month -1 day'));
|
||||||
|
|
||||||
|
$anfang = Buchung::saldenJeKonto($vorlauf);
|
||||||
|
$bewegungen = Buchung::bewegungenJeKontoUndMonat(
|
||||||
|
date('Y-m-01', strtotime($erster['bis'])),
|
||||||
|
$letzter['bis']
|
||||||
|
);
|
||||||
|
|
||||||
|
$konten = [];
|
||||||
|
$summen = array_fill(0, count($monate), 0.0);
|
||||||
|
|
||||||
|
foreach (Konto::alle($nurAktive) as $konto) {
|
||||||
|
$id = (int)$konto['id'];
|
||||||
|
$stand = (float)$konto['anfangssaldo'] + ($anfang[$id] ?? 0.0);
|
||||||
|
$werte = [];
|
||||||
|
$bewegt = false;
|
||||||
|
|
||||||
|
foreach ($monate as $nummer => $monat) {
|
||||||
|
$schluessel = sprintf('%04d-%02d', $monat['jahr'], $monat['monat']);
|
||||||
|
$stand += $bewegungen[$id][$schluessel] ?? 0.0;
|
||||||
|
$werte[] = round($stand, 2);
|
||||||
|
|
||||||
|
$summen[$nummer] += $stand;
|
||||||
|
$bewegt = $bewegt || round($stand, 2) !== 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konten, die im ganzen Zeitraum auf null standen, blähen nur die
|
||||||
|
// Legende auf – sie zeichnen ohnehin keinen Balken.
|
||||||
|
if ($bewegt) {
|
||||||
|
$konten[] = ['id' => $id, 'name' => (string)$konto['name'], 'werte' => $werte];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ebenen = self::ebenen(count($konten));
|
||||||
|
$ebene = max(0, min($ebene, $ebenen - 1));
|
||||||
|
|
||||||
|
if (count($konten) > self::SERIEN) {
|
||||||
|
$konten = self::zusammenfassen($konten, count($monate), $ebene);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hatWerte = false;
|
||||||
|
foreach ($summen as $nummer => $summe) {
|
||||||
|
$summen[$nummer] = round($summe, 2);
|
||||||
|
$hatWerte = $hatWerte || $summen[$nummer] !== 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'monate' => $monate,
|
||||||
|
'konten' => $konten,
|
||||||
|
'summen' => array_values($summen),
|
||||||
|
'hatWerte' => $hatWerte || $konten !== [],
|
||||||
|
'ebene' => $ebene,
|
||||||
|
'ebenen' => $ebenen,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Ebenen es braucht, um `$konten` Konten einzeln zu zeigen: Jede
|
||||||
|
* Ebene zeigt so viele Konten, wie Farbtöne da sind – eine weniger, wenn
|
||||||
|
* noch ein Stück „Übrige Konten“ dazukommt.
|
||||||
|
*/
|
||||||
|
public static function ebenen(int $konten): int
|
||||||
|
{
|
||||||
|
if ($konten <= self::SERIEN) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1 + (int)ceil(($konten - self::SERIEN) / (self::SERIEN - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mehr Konten als Farbtöne: Die größten behalten ihre eigene Farbe, der Rest
|
||||||
|
* wird zu einem Balkenstück zusammengefasst. Sonst bekämen zwei Konten
|
||||||
|
* denselben Ton – und wer die Legende liest, ordnete falsch zu.
|
||||||
|
*
|
||||||
|
* `$ebene` blättert in diesem Rest weiter: Ebene 1 überspringt die größten
|
||||||
|
* Konten und zeigt die nächstkleineren einzeln, Ebene 2 die darauf folgenden
|
||||||
|
* und so fort. Passt der Rest in die Palette, entfällt das Sammelstück.
|
||||||
|
*
|
||||||
|
* @param list<array{id:int, name:string, werte:list<float>}> $konten
|
||||||
|
* @return list<array{id:int, name:string, werte:list<float>}>
|
||||||
|
*/
|
||||||
|
private static function zusammenfassen(array $konten, int $spalten, int $ebene = 0): array
|
||||||
|
{
|
||||||
|
$groesse = [];
|
||||||
|
foreach ($konten as $nummer => $konto) {
|
||||||
|
$groesse[$nummer] = max(array_map('abs', $konto['werte']));
|
||||||
|
}
|
||||||
|
arsort($groesse);
|
||||||
|
|
||||||
|
$rangfolge = array_slice(array_keys($groesse), $ebene * (self::SERIEN - 1));
|
||||||
|
|
||||||
|
// Passt der Rest vollständig in die Palette, braucht es kein Sammelstück
|
||||||
|
// mehr – dann ist das die letzte Ebene.
|
||||||
|
$sammelt = count($rangfolge) > self::SERIEN;
|
||||||
|
|
||||||
|
// Die größten behalten, aber in ihrer ursprünglichen Reihenfolge – der
|
||||||
|
// Stapel soll nicht bei jedem Aufruf anders sortiert sein.
|
||||||
|
$eigene = $sammelt ? array_slice($rangfolge, 0, self::SERIEN - 1) : $rangfolge;
|
||||||
|
sort($eigene);
|
||||||
|
|
||||||
|
$rest = array_fill(0, $spalten, 0.0);
|
||||||
|
$restKonten = 0;
|
||||||
|
$ergebnis = [];
|
||||||
|
|
||||||
|
foreach ($konten as $nummer => $konto) {
|
||||||
|
if (in_array($nummer, $eigene, true)) {
|
||||||
|
$ergebnis[] = $konto;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was auf einer früheren Ebene stand, ist hier gar nicht mehr dabei –
|
||||||
|
// sonst wüchse das Sammelstück mit jeder Ebene statt zu schrumpfen.
|
||||||
|
if (!in_array($nummer, $rangfolge, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($konto['werte'] as $spalte => $wert) {
|
||||||
|
$rest[$spalte] += $wert;
|
||||||
|
}
|
||||||
|
$restKonten++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($restKonten > 0) {
|
||||||
|
$ergebnis[] = [
|
||||||
|
'id' => 0,
|
||||||
|
'name' => 'Übrige Konten (' . $restKonten . ')',
|
||||||
|
'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ergebnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die letzten `$anzahl` Monate bis einschließlich des laufenden, jeweils mit
|
||||||
|
* dem Monatsletzten als Stichtag.
|
||||||
|
*
|
||||||
|
* @return list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>
|
||||||
|
*/
|
||||||
|
public static function monate(int $anzahl = self::MONATE, ?string $heute = null): array
|
||||||
|
{
|
||||||
|
$heute = $heute ?? date('Y-m-d');
|
||||||
|
$start = strtotime(substr($heute, 0, 8) . '01');
|
||||||
|
|
||||||
|
$monate = [];
|
||||||
|
for ($rueckwaerts = $anzahl - 1; $rueckwaerts >= 0; $rueckwaerts--) {
|
||||||
|
$zeit = strtotime('-' . $rueckwaerts . ' month', $start);
|
||||||
|
$jahr = (int)date('Y', $zeit);
|
||||||
|
$nr = (int)date('n', $zeit);
|
||||||
|
|
||||||
|
$monate[] = [
|
||||||
|
'jahr' => $jahr,
|
||||||
|
'monat' => $nr,
|
||||||
|
'bis' => date('Y-m-t', $zeit),
|
||||||
|
'kuerzel' => Budgetplaner::MONATSKUERZEL[$nr],
|
||||||
|
'jahrKurz' => date('y', $zeit),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $monate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skala für die Achse: Nulllinie inbegriffen, Schritte in 1/2/2,5/5 mal
|
||||||
|
* Zehnerpotenz – die Beschriftung soll lesbare Zahlen zeigen, nicht die
|
||||||
|
* krummen Maxima der Konten.
|
||||||
|
*
|
||||||
|
* @param list<float> $werte
|
||||||
|
* @return array{min: float, max: float, schritt: float}
|
||||||
|
*/
|
||||||
|
public static function skala(array $werte): array
|
||||||
|
{
|
||||||
|
$max = max(0.0, ...($werte === [] ? [0.0] : $werte));
|
||||||
|
$min = min(0.0, ...($werte === [] ? [0.0] : $werte));
|
||||||
|
|
||||||
|
if ($max - $min < 0.01) {
|
||||||
|
return ['min' => 0.0, 'max' => 1.0, 'schritt' => 1.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$schritt = self::schritt(($max - $min) / 4);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'min' => floor($min / $schritt) * $schritt,
|
||||||
|
'max' => ceil($max / $schritt) * $schritt,
|
||||||
|
'schritt' => $schritt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function schritt(float $roh): float
|
||||||
|
{
|
||||||
|
$potenz = 10 ** floor(log10(max($roh, 0.01)));
|
||||||
|
|
||||||
|
foreach ([1, 2, 2.5, 5, 10] as $faktor) {
|
||||||
|
if ($roh <= $faktor * $potenz) {
|
||||||
|
return $faktor * $potenz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 10 * $potenz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
/** @var array{gruppen: list<array<string,mixed>>, summe: float} $uebersicht */
|
/** @var array{gruppen: list<array<string,mixed>>, summe: float} $uebersicht */
|
||||||
|
/** @var array{monate: list<array<string,mixed>>, konten: list<array<string,mixed>>, summen: list<float>, hatWerte: bool} $vermoegen */
|
||||||
|
/** @var bool $nurAktive */
|
||||||
|
/** @var string $csrfToken */
|
||||||
|
|
||||||
include __DIR__ . '/../layout/kopf.php';
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
|
|
@ -13,9 +16,33 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<div class="kachelraster">
|
<div class="kachelraster">
|
||||||
<section class="kachel">
|
<section class="kachel">
|
||||||
<header><h2>Vermögensentwicklung</h2></header>
|
<header class="mitschalter">
|
||||||
<p class="platzhalter">Zeigt Kontostände über die Zeit, sobald Buchungen
|
<h2>Vermögensentwicklung</h2>
|
||||||
erfasst sind (Phase 2).</p>
|
|
||||||
|
<?php /* Ein Knopf, kein Ankreuzfeld: Ohne Inline-Skript könnte ein
|
||||||
|
Ankreuzfeld das Formular nicht selbst abschicken, und die
|
||||||
|
Content-Security-Policy verbietet Inline-Skripte. */ ?>
|
||||||
|
<form method="post" action="/cockpit/vermoegen/konten" class="schalterform">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<input type="hidden" name="nur_aktive" value="<?= $nurAktive ? '0' : '1' ?>">
|
||||||
|
<button type="submit" class="schalter" role="switch"
|
||||||
|
aria-checked="<?= $nurAktive ? 'true' : 'false' ?>">
|
||||||
|
<span class="schalterbahn" aria-hidden="true"><span class="schalterknopf"></span></span>
|
||||||
|
<span class="schaltertext">nur aktive</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="kachelhinweis">Kontostände der letzten zwölf Monate, je Monatsende
|
||||||
|
gestapelt – ein Farbton pro Konto.</p>
|
||||||
|
</header>
|
||||||
|
<?php if (!$vermoegen['hatWerte']): ?>
|
||||||
|
<p class="platzhalter">
|
||||||
|
Zeigt die Kontostände über die Zeit, sobald <?= $nurAktive ? 'aktive ' : '' ?>Konten
|
||||||
|
mit Σ-Kennzeichen Buchungen haben.
|
||||||
|
</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php $reihe = $vermoegen; include __DIR__ . '/vermoegensdiagramm.php'; ?>
|
||||||
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="kachel">
|
<section class="kachel">
|
||||||
|
|
@ -66,7 +93,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<li class="erledigt">Phase 0 – Grundgerüst, Anmeldung, Einstellungen</li>
|
<li class="erledigt">Phase 0 – Grundgerüst, Anmeldung, Einstellungen</li>
|
||||||
<li class="erledigt">Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li>
|
<li class="erledigt">Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li>
|
||||||
<li class="erledigt">Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
|
<li class="erledigt">Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
|
||||||
<li>Phase 3 – Budgetplaner und Monatsabschluss</li>
|
<li class="erledigt">Phase 3 – Budgetplaner und Monatsabschluss</li>
|
||||||
<li>Phase 4 – Auswertungen und Cockpit-Kacheln</li>
|
<li>Phase 4 – Auswertungen und Cockpit-Kacheln</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
175
src/Views/cockpit/vermoegensdiagramm.php
Normal file
175
src/Views/cockpit/vermoegensdiagramm.php
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\Services\Vermoegensentwicklung;
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gestapeltes Balkendiagramm der Kontostände je Monatsende – ein Balken je
|
||||||
|
* Monat, ein Farbton je Konto.
|
||||||
|
*
|
||||||
|
* Eigenes SVG statt einer Diagrammbibliothek (siehe PLAN.md). Die Farben stehen
|
||||||
|
* als Klasse am Element und kommen aus `app.css`: Ein `fill`-Attribut je Konto
|
||||||
|
* ginge zwar, verlöre aber die Themenumschaltung, und ein `style`-Attribut
|
||||||
|
* verwürfe die Content-Security-Policy ohnehin.
|
||||||
|
*
|
||||||
|
* @var array{
|
||||||
|
* monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>,
|
||||||
|
* konten: list<array{id:int, name:string, werte:list<float>}>,
|
||||||
|
* summen: list<float>,
|
||||||
|
* hatWerte: bool,
|
||||||
|
* ebene: int,
|
||||||
|
* ebenen: int
|
||||||
|
* } $reihe
|
||||||
|
*/
|
||||||
|
|
||||||
|
$monate = $reihe['monate'];
|
||||||
|
$konten = $reihe['konten'];
|
||||||
|
$anzahl = count($monate);
|
||||||
|
$ebene = $reihe['ebene'];
|
||||||
|
$ebenen = $reihe['ebenen'];
|
||||||
|
|
||||||
|
// Die Achse muss den gestapelten Positiv- und Negativteil jedes Balkens fassen.
|
||||||
|
// Die Nettosumme liegt immer dazwischen und braucht keine eigene Grenze.
|
||||||
|
$grenzwerte = [];
|
||||||
|
foreach ($monate as $nummer => $monat) {
|
||||||
|
$plus = 0.0;
|
||||||
|
$minus = 0.0;
|
||||||
|
foreach ($konten as $konto) {
|
||||||
|
$wert = $konto['werte'][$nummer];
|
||||||
|
if ($wert >= 0) {
|
||||||
|
$plus += $wert;
|
||||||
|
} else {
|
||||||
|
$minus += $wert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$grenzwerte[] = $plus;
|
||||||
|
$grenzwerte[] = $minus;
|
||||||
|
}
|
||||||
|
|
||||||
|
$skala = Vermoegensentwicklung::skala($grenzwerte);
|
||||||
|
|
||||||
|
// Senkrecht in echten Bildpunkten, waagerecht in Prozent: Die Zeichenfläche
|
||||||
|
// nimmt die Breite, die die Kachel hergibt, und die Balken rücken darin
|
||||||
|
// auseinander, statt breiter zu werden. Die Achsenbeschriftung sitzt in einem
|
||||||
|
// eigenen, festen SVG daneben – sonst müsste die Prozentrechnung ihre Breite
|
||||||
|
// abziehen, und das kann ein SVG-Attribut nicht.
|
||||||
|
$balken = 16;
|
||||||
|
$achse = 56;
|
||||||
|
$oben = 10;
|
||||||
|
$plotH = 150;
|
||||||
|
$unten = 32;
|
||||||
|
$hoehe = $oben + $plotH + $unten;
|
||||||
|
|
||||||
|
$y = static function (float $wert) use ($skala, $oben, $plotH): float {
|
||||||
|
return round($oben + $plotH * ($skala['max'] - $wert) / ($skala['max'] - $skala['min']), 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Die Mitte einer Monatsspalte, als Anteil der Zeichenfläche. */
|
||||||
|
$mitte = static function (int $nummer) use ($anzahl): string {
|
||||||
|
return round(($nummer + 0.5) / $anzahl * 100, 3) . '%';
|
||||||
|
};
|
||||||
|
|
||||||
|
$achsenzahl = static function (float $wert): string {
|
||||||
|
return number_format($wert, 0, ',', '.');
|
||||||
|
};
|
||||||
|
|
||||||
|
$serie = static function (int $nummer): string {
|
||||||
|
return 'serie-' . ($nummer % 12 + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
$zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
|
||||||
|
. ' bis ' . $monate[$anzahl - 1]['kuerzel'] . ' ' . $monate[$anzahl - 1]['jahrKurz'];
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="diagrammbereich">
|
||||||
|
<div class="diagrammflaeche">
|
||||||
|
<svg class="diagrammachse" width="<?= $achse ?>" height="<?= $hoehe ?>" aria-hidden="true">
|
||||||
|
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
|
||||||
|
<text class="diagramm-achse" x="<?= $achse - 8 ?>" y="<?= $y($wert) + 4 ?>"
|
||||||
|
text-anchor="end"><?= View::h($achsenzahl($wert)) ?></text>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<?php /* Ohne viewBox: Die Koordinaten bleiben Bildpunkte, nur die
|
||||||
|
Prozentangaben wachsen mit der Fläche. */ ?>
|
||||||
|
<svg class="diagramm" height="<?= $hoehe ?>" role="img"
|
||||||
|
aria-label="Kontostände je Monatsende, <?= View::h($zeitraum) ?>">
|
||||||
|
<title>Vermögensentwicklung <?= View::h($zeitraum) ?></title>
|
||||||
|
|
||||||
|
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
|
||||||
|
<line class="diagramm-gitter" x1="0" x2="100%"
|
||||||
|
y1="<?= $y($wert) ?>" y2="<?= $y($wert) ?>"></line>
|
||||||
|
<?php endfor; ?>
|
||||||
|
|
||||||
|
<line class="diagramm-nulllinie" x1="0" x2="100%"
|
||||||
|
y1="<?= $y(0.0) ?>" y2="<?= $y(0.0) ?>"></line>
|
||||||
|
|
||||||
|
<?php foreach ($monate as $nummer => $monat): ?>
|
||||||
|
<?php
|
||||||
|
$oberkante = 0.0;
|
||||||
|
$unterkante = 0.0;
|
||||||
|
?>
|
||||||
|
<?php /* Die Balkenmitte liegt auf dem Prozentwert; die halbe
|
||||||
|
Balkenbreite nach links holt die Verschiebung. */ ?>
|
||||||
|
<g transform="translate(-<?= $balken / 2 ?> 0)">
|
||||||
|
<?php foreach ($konten as $index => $konto): ?>
|
||||||
|
<?php
|
||||||
|
$wert = $konto['werte'][$nummer];
|
||||||
|
if (abs($wert) < 0.005) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wert > 0) {
|
||||||
|
$von = $oberkante;
|
||||||
|
$bis = $oberkante + $wert;
|
||||||
|
$oberkante = $bis;
|
||||||
|
} else {
|
||||||
|
$von = $unterkante + $wert;
|
||||||
|
$bis = $unterkante;
|
||||||
|
$unterkante = $von;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<rect class="<?= $serie($index) ?>" x="<?= $mitte($nummer) ?>" y="<?= $y($bis) ?>"
|
||||||
|
width="<?= $balken ?>" height="<?= round($y($von) - $y($bis), 2) ?>">
|
||||||
|
<title><?= View::h($konto['name'] . ' – ' . $monat['kuerzel'] . ' ' . $monat['jahr']
|
||||||
|
. ': ' . View::betrag($wert)) ?></title>
|
||||||
|
</rect>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text class="diagramm-beschriftung" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 18 ?>"
|
||||||
|
text-anchor="middle"><?= View::h($monat['kuerzel']) ?></text>
|
||||||
|
<?php if ($monat['monat'] === 1 || $nummer === 0): ?>
|
||||||
|
<text class="diagramm-beschriftung schwach" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 5 ?>"
|
||||||
|
text-anchor="middle"><?= View::h($monat['jahrKurz']) ?></text>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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. */ ?>
|
||||||
|
<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>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<?php if ($ebene > 0): ?>
|
||||||
|
<p class="diagrammpfad">
|
||||||
|
Ebene <?= $ebene + 1 ?> von <?= $ebenen ?> ·
|
||||||
|
<a href="/?ebene=<?= $ebene - 1 ?>">← eine Ebene zurück</a>
|
||||||
|
<?php if ($ebene > 1): ?>
|
||||||
|
· <a href="/">zu den größten Konten</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
313
tests/13_VermoegensentwicklungTest.php
Normal file
313
tests/13_VermoegensentwicklungTest.php
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Buchung;
|
||||||
|
use Finanzmanager\Models\Kategorie;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
use Finanzmanager\Services\Vermoegensentwicklung;
|
||||||
|
|
||||||
|
class VermoegensentwicklungTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 13: Vermögensentwicklung';
|
||||||
|
|
||||||
|
/** Ein fester Stichtag – sonst hinge das Ergebnis am Tag des Testlaufs. */
|
||||||
|
private const HEUTE = '2026-08-11';
|
||||||
|
|
||||||
|
private int $giro = 0;
|
||||||
|
private int $sparkonto = 0;
|
||||||
|
private int $kreditkarte = 0;
|
||||||
|
private int $auslagen = 0;
|
||||||
|
private int $kategorie = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->mandantAnlegen();
|
||||||
|
$this->stammdaten();
|
||||||
|
|
||||||
|
$this->zwoelfMonateBisHeute();
|
||||||
|
$this->staendeWachsenMonatFuerMonat();
|
||||||
|
$this->umbuchungVerschiebtZwischenKonten();
|
||||||
|
$this->summenkennzeichenFiltertNicht();
|
||||||
|
$this->schalterFiltertStillgelegteKonten();
|
||||||
|
$this->mehrKontenAlsFarbenWerdenZusammengefasst();
|
||||||
|
$this->ebenenBlaetternDurchDieUebrigenKonten();
|
||||||
|
$this->skalaRundetAufLesbareSchritte();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stammdaten(): void
|
||||||
|
{
|
||||||
|
$this->giro = Konto::anlegen([
|
||||||
|
'name' => 'Vermögensgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
$this->sparkonto = Konto::anlegen([
|
||||||
|
'name' => 'Vermögenssparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
$this->kreditkarte = Konto::anlegen([
|
||||||
|
'name' => 'Vermögenskarte', 'kontoart' => 'kreditkarte', 'anfangssaldo' => -200, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
// Durchlaufende Auslagen sind kein Vermögen und tragen kein Σ-Kennzeichen.
|
||||||
|
$this->auslagen = Konto::anlegen([
|
||||||
|
'name' => 'Vermögensauslagen', 'kontoart' => 'sonstiges', 'anfangssaldo' => 5000,
|
||||||
|
'aktiv' => 1, 'in_summe' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->kategorie = Kategorie::findenOderAnlegenNachPfad('600 - Vermögenstest', 'ausgabe');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reihe(bool $nurAktive = true, int $ebene = 0): array
|
||||||
|
{
|
||||||
|
return Vermoegensentwicklung::reihe($nurAktive, 12, self::HEUTE, $ebene);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
private function konto(array $reihe, int $id): ?array
|
||||||
|
{
|
||||||
|
foreach ($reihe['konten'] as $konto) {
|
||||||
|
if ($konto['id'] === $id) {
|
||||||
|
return $konto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function zwoelfMonateBisHeute(): void
|
||||||
|
{
|
||||||
|
$monate = Vermoegensentwicklung::monate(12, self::HEUTE);
|
||||||
|
|
||||||
|
$this->assertEquals(12, count($monate), 'Zwölf Monate');
|
||||||
|
$this->assertEquals(2025, $monate[0]['jahr'], 'Der Rückblick beginnt im Vorjahr');
|
||||||
|
$this->assertEquals(9, $monate[0]['monat'], 'Zwölf Monate vor August 2026 ist September 2025');
|
||||||
|
$this->assertEquals(2026, $monate[11]['jahr'], 'Der letzte Balken ist der laufende Monat');
|
||||||
|
$this->assertEquals(8, $monate[11]['monat'], 'August 2026');
|
||||||
|
$this->assertEquals('2026-08-31', $monate[11]['bis'], 'Stichtag ist das Monatsende');
|
||||||
|
$this->assertEquals('2026-02-28', $monate[5]['bis'], 'Auch im Februar das echte Monatsende');
|
||||||
|
$this->assertEquals('Sep', $monate[0]['kuerzel'], 'Monatskürzel für die Achse');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Stand eines Monats ist der Stand des Vormonats plus dessen Bewegungen –
|
||||||
|
* und Buchungen vor dem Zeitraum stecken im Anfangsstand des ersten Balkens.
|
||||||
|
*/
|
||||||
|
private function staendeWachsenMonatFuerMonat(): void
|
||||||
|
{
|
||||||
|
// Lange vor dem Zeitraum: zählt in den ersten Balken hinein.
|
||||||
|
Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro, 'datum' => '2024-05-10', 'betrag' => 500,
|
||||||
|
'empfaenger' => 'Altbestand', 'kategorie_id' => $this->kategorie,
|
||||||
|
]);
|
||||||
|
// Im Zeitraum: hebt den Stand ab Dezember 2025.
|
||||||
|
Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro, 'datum' => '2025-12-05', 'betrag' => 300,
|
||||||
|
'empfaenger' => 'Zufluss', 'kategorie_id' => $this->kategorie,
|
||||||
|
]);
|
||||||
|
// Nach dem letzten Balken: bleibt außen vor.
|
||||||
|
Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro, 'datum' => '2026-11-01', 'betrag' => 9999,
|
||||||
|
'empfaenger' => 'Zukunft', 'kategorie_id' => $this->kategorie,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reihe = $this->reihe();
|
||||||
|
$giro = $this->konto($reihe, $this->giro);
|
||||||
|
|
||||||
|
$this->assertNotEmpty($giro, 'Das Girokonto hat einen Balken');
|
||||||
|
$this->assertEquals(1500.0, $giro['werte'][0], 'September 2025: Anfangssaldo plus Altbestand');
|
||||||
|
$this->assertEquals(1500.0, $giro['werte'][2], 'November 2025 unverändert');
|
||||||
|
$this->assertEquals(1800.0, $giro['werte'][3], 'Dezember 2025 mit dem Zufluss');
|
||||||
|
$this->assertEquals(1800.0, $giro['werte'][11], 'Der Stand trägt bis in den letzten Monat');
|
||||||
|
|
||||||
|
// Die Kreditkarte steht im Minus – sie zieht die Summe herunter.
|
||||||
|
$karte = $this->konto($reihe, $this->kreditkarte);
|
||||||
|
$this->assertEquals(-200.0, $karte['werte'][11], 'Negativer Stand bleibt negativ');
|
||||||
|
$this->assert($reihe['hatWerte'], 'Es gibt etwas zu zeichnen');
|
||||||
|
|
||||||
|
// Giro 1800, Kreditkarte -200, Auslagen 5000, Sparbuch 0.
|
||||||
|
$this->assertEquals(6600.0, $reihe['summen'][11], 'Die Summe ist der Stapel aller Konten');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function umbuchungVerschiebtZwischenKonten(): void
|
||||||
|
{
|
||||||
|
Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro, 'datum' => '2026-03-15', 'betrag' => -400,
|
||||||
|
'empfaenger' => 'Umbuchung aufs Sparbuch', 'gegenkonto_id' => $this->sparkonto,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reihe = $this->reihe();
|
||||||
|
$giro = $this->konto($reihe, $this->giro);
|
||||||
|
$spar = $this->konto($reihe, $this->sparkonto);
|
||||||
|
|
||||||
|
$this->assertEquals(1800.0, $giro['werte'][5], 'Februar 2026 noch ohne Umbuchung');
|
||||||
|
$this->assertEquals(1400.0, $giro['werte'][6], 'März 2026: 400 weniger auf dem Giro');
|
||||||
|
$this->assertEquals(400.0, $spar['werte'][6], 'Dieselben 400 liegen auf dem Sparbuch');
|
||||||
|
$this->assertEquals(
|
||||||
|
$reihe['summen'][5],
|
||||||
|
$reihe['summen'][6],
|
||||||
|
'Eine Umbuchung ändert das Vermögen nicht'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Σ-Kennzeichen entscheidet hier nichts: Stillgelegte Konten tragen es
|
||||||
|
* meist nicht, und dann fiele beim Ausschalten des Schalters genau das weg,
|
||||||
|
* was man sehen will.
|
||||||
|
*/
|
||||||
|
private function summenkennzeichenFiltertNicht(): void
|
||||||
|
{
|
||||||
|
$reihe = $this->reihe();
|
||||||
|
|
||||||
|
$this->assertNotEmpty(
|
||||||
|
$this->konto($reihe, $this->auslagen),
|
||||||
|
'Ein aktives Konto ohne Σ-Kennzeichen zeichnet trotzdem einen Balken'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ein Konto, das im ganzen Zeitraum auf null steht, bläht nur die Legende auf.
|
||||||
|
$leer = Konto::anlegen([
|
||||||
|
'name' => 'Vermögensleerkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
$this->assertNull($this->konto($this->reihe(), $leer), 'Ein durchgehend leeres Konto fehlt');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Schalter „nur aktive“ an der Kachel: eingeschaltet zählt nur, was noch
|
||||||
|
* geführt wird; ausgeschaltet auch das Vermögen auf stillgelegten Konten.
|
||||||
|
*/
|
||||||
|
private function schalterFiltertStillgelegteKonten(): void
|
||||||
|
{
|
||||||
|
$stillgelegt = Konto::anlegen([
|
||||||
|
'name' => 'Vermögensaltkonto', 'kontoart' => 'bank', 'anfangssaldo' => 2500, 'aktiv' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNull(
|
||||||
|
$this->konto($this->reihe(), $stillgelegt),
|
||||||
|
'Eingeschaltet bleibt das stillgelegte Konto draußen'
|
||||||
|
);
|
||||||
|
|
||||||
|
$alle = $this->reihe(false);
|
||||||
|
$this->assertNotEmpty($this->konto($alle, $stillgelegt), 'Ausgeschaltet ist es dabei');
|
||||||
|
$this->assertEquals(
|
||||||
|
2500.0,
|
||||||
|
$this->konto($alle, $stillgelegt)['werte'][11],
|
||||||
|
'Mit seinem Stand'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
round($this->reihe()['summen'][11] + 2500.0, 2),
|
||||||
|
$alle['summen'][11],
|
||||||
|
'Und die Summe wächst um genau diesen Stand'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwölf Farbtöne hat die Palette; darüber hinaus behalten die größten Konten
|
||||||
|
* ihre Farbe, der Rest wird zu einem Stück zusammengefasst.
|
||||||
|
*/
|
||||||
|
private function mehrKontenAlsFarbenWerdenZusammengefasst(): void
|
||||||
|
{
|
||||||
|
for ($nummer = 1; $nummer <= 15; $nummer++) {
|
||||||
|
Konto::anlegen([
|
||||||
|
'name' => 'Vermögensklein ' . $nummer,
|
||||||
|
'kontoart' => 'bank',
|
||||||
|
'anfangssaldo' => $nummer,
|
||||||
|
'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reihe = $this->reihe();
|
||||||
|
$konten = $reihe['konten'];
|
||||||
|
$letzte = $konten[count($konten) - 1];
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
Vermoegensentwicklung::SERIEN,
|
||||||
|
count($konten),
|
||||||
|
'Nicht mehr Balkenstücke als Farbtöne'
|
||||||
|
);
|
||||||
|
$this->assertEquals(0, $letzte['id'], 'Das letzte Stück steht für kein einzelnes Konto');
|
||||||
|
$this->assert(str_starts_with($letzte['name'], 'Übrige Konten ('), 'Es heißt „Übrige Konten“');
|
||||||
|
|
||||||
|
// Die größten behalten ihre eigene Farbe – das Sparbuch ist dabei.
|
||||||
|
$this->assertNotEmpty($this->konto($reihe, $this->sparkonto), 'Ein großes Konto bleibt eigenständig');
|
||||||
|
|
||||||
|
// Zusammengefasst wird, nicht weggelassen: Die Summe bleibt dieselbe.
|
||||||
|
$gestapelt = 0.0;
|
||||||
|
foreach ($konten as $konto) {
|
||||||
|
$gestapelt += $konto['werte'][11];
|
||||||
|
}
|
||||||
|
$this->assertEquals(
|
||||||
|
$reihe['summen'][11],
|
||||||
|
round($gestapelt, 2),
|
||||||
|
'Die Balkenstücke ergeben zusammen die Summe'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Übrige Konten“ ist keine Sackgasse: Jede Ebene zeigt die nächstkleineren
|
||||||
|
* Konten einzeln, bis der Rest in die Palette passt.
|
||||||
|
*/
|
||||||
|
private function ebenenBlaetternDurchDieUebrigenKonten(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals(1, Vermoegensentwicklung::ebenen(12), 'Was in die Palette passt, hat eine Ebene');
|
||||||
|
$this->assertEquals(2, Vermoegensentwicklung::ebenen(13), 'Ein Konto zu viel macht die zweite Ebene auf');
|
||||||
|
$this->assertEquals(2, Vermoegensentwicklung::ebenen(23), 'Elf eigene und zwölf übrige: noch zwei Ebenen');
|
||||||
|
$this->assertEquals(3, Vermoegensentwicklung::ebenen(24), 'Eines mehr, und es braucht eine dritte');
|
||||||
|
|
||||||
|
// Aus dem vorigen Test stehen mehr Konten da, als die Palette Töne hat.
|
||||||
|
$erste = $this->reihe(true, 0);
|
||||||
|
$zweite = $this->reihe(true, 1);
|
||||||
|
|
||||||
|
$this->assertGreaterThan(1, $erste['ebenen'], 'Der Testbestand füllt mehr als eine Ebene');
|
||||||
|
$this->assertEquals(0, $erste['ebene'], 'Ohne Angabe steht man auf der ersten Ebene');
|
||||||
|
$this->assertEquals(1, $zweite['ebene'], 'Die zweite Ebene ist erreichbar');
|
||||||
|
|
||||||
|
$ids = static fn(array $reihe): array => array_map(
|
||||||
|
static fn(array $konto): int => $konto['id'],
|
||||||
|
$reihe['konten']
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assert(in_array(0, $ids($erste), true), 'Die erste Ebene sammelt den Rest ein');
|
||||||
|
$this->assertEquals(
|
||||||
|
[],
|
||||||
|
array_intersect(array_diff($ids($erste), [0]), $ids($zweite)),
|
||||||
|
'Kein Konto steht auf zwei Ebenen'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Die letzte Ebene braucht kein Sammelstück mehr – sonst käme man nie an.
|
||||||
|
$letzte = $this->reihe(true, $erste['ebenen'] - 1);
|
||||||
|
$this->assert(!in_array(0, $ids($letzte), true), 'Auf der letzten Ebene stehen nur noch echte Konten');
|
||||||
|
|
||||||
|
// Zusammen ergeben alle Ebenen wieder den ganzen Bestand.
|
||||||
|
$gesehen = [];
|
||||||
|
for ($ebene = 0; $ebene < $erste['ebenen']; $ebene++) {
|
||||||
|
$gesehen = array_merge($gesehen, array_diff($ids($this->reihe(true, $ebene)), [0]));
|
||||||
|
}
|
||||||
|
$this->assertEquals(
|
||||||
|
count($gesehen),
|
||||||
|
count(array_unique($gesehen)),
|
||||||
|
'Über alle Ebenen hinweg taucht kein Konto doppelt auf'
|
||||||
|
);
|
||||||
|
$this->assertGreaterThan(
|
||||||
|
Vermoegensentwicklung::SERIEN,
|
||||||
|
count($gesehen),
|
||||||
|
'Und in Summe sind es mehr Konten, als eine Ebene fasst'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Eine Ebene jenseits der letzten gibt es nicht: Sie fällt darauf zurück.
|
||||||
|
$zuTief = $this->reihe(true, 99);
|
||||||
|
$this->assertEquals($erste['ebenen'] - 1, $zuTief['ebene'], 'Zu tief geraten landet man auf der letzten');
|
||||||
|
$this->assertEquals($ids($letzte), $ids($zuTief), 'Mit denselben Konten');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function skalaRundetAufLesbareSchritte(): void
|
||||||
|
{
|
||||||
|
$skala = Vermoegensentwicklung::skala([0.0, 1234.0, 4870.0]);
|
||||||
|
$this->assertEquals(0.0, $skala['min'], 'Die Null gehört immer dazu');
|
||||||
|
$this->assertGreaterThan(4869.0, $skala['max'], 'Der größte Wert passt hinein');
|
||||||
|
$this->assertEquals(0.0, fmod($skala['max'], $skala['schritt']), 'Das Maximum liegt auf einem Schritt');
|
||||||
|
|
||||||
|
$mitMinus = Vermoegensentwicklung::skala([-820.0, 2500.0]);
|
||||||
|
$this->assert($mitMinus['min'] <= -820.0, 'Auch der negative Ausschlag passt hinein');
|
||||||
|
$this->assert($mitMinus['max'] >= 2500.0, 'Und der positive');
|
||||||
|
|
||||||
|
$leer = Vermoegensentwicklung::skala([]);
|
||||||
|
$this->assertGreaterThan(0.0, $leer['schritt'], 'Ohne Werte bleibt die Skala brauchbar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,72 @@ test.describe('Grundgerüst', () => {
|
||||||
await expect(page.locator('.seitenleiste')).toContainText('Kontenübersicht')
|
await expect(page.locator('.seitenleiste')).toContainText('Kontenübersicht')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Vermögensentwicklung zeigt zwölf gestapelte Monatsbalken', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
|
||||||
|
const kachel = page.locator('.kachel', { hasText: 'Vermögensentwicklung' })
|
||||||
|
await expect(kachel.locator('svg.diagramm')).toBeVisible()
|
||||||
|
|
||||||
|
// Zwölf Monate, jeder mit eigener Beschriftung unter dem Balken.
|
||||||
|
await expect(kachel.locator('.diagramm-beschriftung:not(.schwach)')).toHaveCount(12)
|
||||||
|
await expect(kachel.locator('.diagramm rect').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Jedes Balkenstück gehört zu einem Eintrag der Legende, keiner doppelt.
|
||||||
|
const farben = await kachel.locator('.legendenfarbe:not(.leer)').evaluateAll(
|
||||||
|
knoten => knoten.map(k => getComputedStyle(k).backgroundColor)
|
||||||
|
)
|
||||||
|
expect(farben.length).toBeGreaterThan(0)
|
||||||
|
expect(new Set(farben).size).toBe(farben.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('„Übrige Konten“ führt eine Ebene tiefer und wieder zurück', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
|
||||||
|
const kachel = page.locator('.kachel', { hasText: 'Vermögensentwicklung' })
|
||||||
|
const sammel = kachel.locator('.legendenname.tiefer')
|
||||||
|
|
||||||
|
// Der Testbestand hat mehr Konten als Farbtöne; sonst gäbe es nichts
|
||||||
|
// aufzuklappen und der Test hätte hier nichts zu prüfen.
|
||||||
|
await expect(sammel).toHaveText(/Übrige Konten \(\d+\)/)
|
||||||
|
|
||||||
|
const oben = await kachel.locator('.legendenname').allTextContents()
|
||||||
|
await sammel.click()
|
||||||
|
await expect(page).toHaveURL(/ebene=1/)
|
||||||
|
await expect(kachel.locator('.diagrammpfad')).toContainText('Ebene 2 von')
|
||||||
|
|
||||||
|
// Eine Ebene tiefer stehen andere Konten in der Legende.
|
||||||
|
const unten = await kachel.locator('.legendenname').allTextContents()
|
||||||
|
expect(unten.filter(name => oben.includes(name) && !name.startsWith('Übrige'))).toHaveLength(0)
|
||||||
|
|
||||||
|
await kachel.locator('.diagrammpfad a').first().click()
|
||||||
|
await expect(page).toHaveURL(/ebene=0/)
|
||||||
|
expect(await kachel.locator('.legendenname').allTextContents()).toEqual(oben)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Schalter „nur aktive“ behält seinen Zustand', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
|
||||||
|
const schalter = page.locator('.kachel', { hasText: 'Vermögensentwicklung' })
|
||||||
|
.locator('.schalter')
|
||||||
|
await expect(schalter).toContainText('nur aktive')
|
||||||
|
|
||||||
|
// Ein früherer Lauf kann den Schalter umgelegt haben.
|
||||||
|
if (await schalter.getAttribute('aria-checked') === 'false') {
|
||||||
|
await schalter.click()
|
||||||
|
}
|
||||||
|
await expect(schalter).toHaveAttribute('aria-checked', 'true')
|
||||||
|
|
||||||
|
await schalter.click()
|
||||||
|
await expect(schalter).toHaveAttribute('aria-checked', 'false')
|
||||||
|
|
||||||
|
// Gespeichert, nicht nur angezeigt: Der Zustand überlebt den Neuaufruf.
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(schalter).toHaveAttribute('aria-checked', 'false')
|
||||||
|
|
||||||
|
await schalter.click()
|
||||||
|
await expect(schalter).toHaveAttribute('aria-checked', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
test('Einstellungsdialog zeigt alle Reiter', async ({ page }) => {
|
test('Einstellungsdialog zeigt alle Reiter', async ({ page }) => {
|
||||||
await anmelden(page)
|
await anmelden(page)
|
||||||
await page.click('a[href="/einstellungen"]')
|
await page.click('a[href="/einstellungen"]')
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue