Modul-Review 3: Struktur, Dashboard, CSV-Export und Grafiken
Deploy App / deploy (push) Successful in 1m59s

Versionierung:
- startet neu bei 0.1; erst eine gesetzte Hauptversion macht daraus 1.0
- Szenario-Liste zeigt die echte Version (z.B. 0.17) statt "1.x", plus
  neue Spalte "Phasen"
- Migration 20260724120000_version_zero_start (nur der Default)

Plan-Dashboard:
- Kacheln anklickbar (fuehren in ihren Bereich), neue Kachel "Berichte"
- Plan umbenennen ueber Stift-Symbol
- Ist-Abweichung nennt das Jahr des juengsten Ist-Datensatzes und ist bei
  positiver Abweichung gruen statt rot

Szenario-Liste:
- Klick auf die Zeile oeffnet die Matrix (Matrix-Knopf entfaellt)
- je Zeile Kopie (Vorlage frei waehlbar) und Loeschen
- laedt nach einer Loeschung neu (zeigte vorher den alten Stand)

Seitenleiste:
- Szenarien wieder verschachtelt nach Herkunft
- Effektive Werte / Analysen / Berichte buendig zum Knoten "Szenarien"

CSV-Export (neues Modul lib/csv.ts):
- vier Bloecke: Kopf, Lebensphasen, ganze Matrix (Elemente x Phasen inkl.
  Uebergangs-Entscheide im Klartext), Jahreswerte
- mit BOM (Excel-Umlaute), Dateiname transliteriert Umlaute
- vorher enthielt die Datei kein einziges finanzielles Element

Grafiken:
- Szenario-Waehler gilt fuer alle drei Grafiken
- BUGFIX Szenario-Vergleich: WealthChart nutzte den Namen als Datenschluessel
  -> gleichnamige Szenarien ueberschrieben sich (Legende zeigte beide, Chart
  nur eine). Neu die ID; stille Deckelung auf 4 Serien entfaellt
- eigene Legende mit freier Farbwahl je Serie + Erklaerung des Linienstils
- Vermoegensaufteilung neu: gestapelte Flaeche ueber die Planjahre + Ring
  fuer die relative Aufteilung zu einem waehlbaren Zeitpunkt
- alle Diagrammfarben aus neuen Theme-Tokens (--chart-1..6, --chart-grid)

Doku-Drift bereinigt: Kapitel 2.1, 3.2.2-3.2.7 und 3.10 beschrieben noch den
Stand vor V7 (Grundprofil am Szenario, parentPlanId, Scenario.startYear,
window.confirm, drei Sidebar-Unterpunkte).

Nebenbei: verstuemmelte Hex-Farbe --danger-soft (warm) repariert, deutsche
Plural-/Umlautfehler in den Uebersichts-Kacheln.

SPEZIFIKATION 0.31. 267 -> 275 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 12:16:07 +02:00
parent 96fdd00d5c
commit b42f9b385f
22 changed files with 1199 additions and 289 deletions
+134 -67
View File
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.30 |
| **Version** | 0.31 |
| **Datum** | 2026-07-24 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `efcc04c` inkl. Tour-Korrekturen (Branch `main`) |
| **Codestand** | Arbeitsstand nach `96fdd00` inkl. Modul-Review 3 (Struktur & Grafiken) (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 0.31 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3 (Plan-/Szenario-Struktur, Dashboard, Grafiken).** (1) **Versionierung startet bei 0.1** statt 1.0 (Kap. 3.8): Ein Szenario läuft in der 0er-Reihe (0.1, 0.2, … 0.137), bis eine **Hauptversion** gesetzt wird erst dann entsteht 1.0. Vorher begann jedes Szenario bereits bei 1.0, wodurch die Hauptversion ihre Bedeutung verlor. Die Szenario-Liste zeigt neu die **echte** Version statt «1.x», dazu eine Spalte **Phasen**. (2) **Plan-Dashboard:** Kacheln sind **anklickbar** und führen in ihren Bereich, neu inkl. **Berichte**; der Plan lässt sich über ein Stift-Symbol **umbenennen**; die Ist-Abweichung nennt das **Jahr** des jüngsten Ist-Datensatzes und ist bei einer positiven Abweichung **grün** statt rot. (3) **Szenario-Liste:** Ein Klick auf die **Zeile** öffnet die Matrix (der «Matrix»-Knopf entfällt), dazu je Zeile **Kopie** und **Löschen**; die Kopiervorlage ist damit frei wählbar und nicht mehr auf das Basisszenario festgelegt. Nach einer Löschung lädt die Liste neu (zeigte vorher den alten Stand). (4) **Seitenleiste:** Szenarien werden wieder **verschachtelt** dargestellt (Tiefe = Herkunftskette); «Effektive Werte», «Analysen» und «Berichte» stehen neu **bündig zum Knoten «Szenarien»** statt auf Höhe der einzelnen Szenarien. (5) **CSV-Export vollständig neu** (Kap. 3.6.5, neues Modul `csv.ts`): vier Blöcke Kopf, Lebensphasen, **die ganze Matrix** (Elemente × Phasen mit Beginn/Ende und den Übergangs-Entscheiden im Klartext) und **Jahreswerte**; mit BOM, damit Excel die Umlaute erkennt. Vorher enthielt die Datei kein einziges finanzielles Element. (6) **Grafiken:** Ein **Szenario-Wähler** gilt neu für **alle drei** Grafiken (vorher nur der Vermögensverlauf, und der nur additiv). Der **Vergleichs-Fehler** ist behoben: `WealthChart` benutzte den Szenario-**Namen** als Datenschlüssel, wodurch sich gleichnamige Szenarien gegenseitig überschrieben (Legende zeigte beide, der Chart nur eine) neu die **ID**; die stille Deckelung auf vier Serien entfällt. Die **Legende** ist eigenständig, erlaubt eine **freie Farbwahl je Serie** und erklärt den Linienstil (gestrichelt = Plan, durchgezogen = effektiv). Die **Vermögensaufteilung** ist neu eine **gestapelte Fläche über alle Planjahre** plus ein **Ring** für die relative Aufteilung zu einem wählbaren Zeitpunkt (vorher gestapelte Balken je Phase mit schräger Beschriftung). **Alle Diagrammfarben** kommen aus neuen Theme-Tokens (`--chart-1``--chart-6`, `--chart-grid`) statt fester Hex-Werte. (7) **Dokumentation nachgezogen:** Die Kapitel 2.1, 3.2.23.2.7 und 3.10 beschrieben noch den Stand **vor V7** (Grundprofil am Szenario, `parentPlanId`, `Scenario.startYear`, `window.confirm`, drei Sidebar-Unterpunkte). (8) Nebenbei: verstümmelte Hex-Farbe `--danger-soft` im Warm-Schema repariert, deutsche Plural-/Umlautfehler in den Übersichts-Kacheln, Dateiname des CSV-Exports transliteriert Umlaute statt sie zu `_` zu machen. 8 Tests ergänzt (267 → 275). |
| 0.30 | 2026-07-24 | Claude (Opus 4.8) | **Tour-Korrekturen und 3a-Verschiebung.** (1) Der Tour-**Spotlight** wird neu aus **vier fixed-Flächen** um die Bounding-Box des Ziels gezeichnet (Kap. 9.24) statt aus einem `box-shadow`-Trick. Grund: Der Schatten liess sticky Matrix-Köpfe (hoher z-index) hell durchscheinen und wurde im Matrix-Scrollbereich abgeschnitten (dann blieb fast alles hell). Die vier Flächen funktionieren unabhängig von z-index und overflow und folgen dem Ziel per `requestAnimationFrame`. (2) **Tour-Schritte** überarbeitet: neu erklärt sind **Zeitachse** und **Endvermögen**; der vormals «Analysen»-Schritt beschreibt jetzt korrekt die **obere Funktions-Leiste** (die Analyse-Werkzeuge sind dort nicht mehr), und ein neuer Schritt zeigt das **linke Menü** (Analysen, Berichte, Effektive Werte). Unsichtbare Ziele (z. B. das Menü auf schmalen Screens) werden übersprungen. (3) Der Schalter **«Selbstständig grosse Säule 3a»** wandert im Assistenten von Schritt 4 zu **Schritt 5**, weil er die 3a-Einzahlung (also die Sparraten-Verteilung) betrifft. Kein Eingriff in den Rechenkern; 267 Tests unverändert grün. |
| 0.29 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 2, Feinschliff (Assistent, Tour, Ansicht).** (1) **Assistent:** neuer **Willkommens-Screen** vor Schritt 1 mit dem Gesamtbild der fünf Schritte, danach eine **persistente Schritt-Leiste** (links im breiten Modal, mobil als Fortschrittsbalken) der aktuelle Schritt hervorgehoben, erledigte mit Haken, kommende gedämpft. Schritt «Vorsorge & Vermögen» fragt neu auch die **erwartete Rendite** bei PK, 3a und Wertschriften ab (vorher fest verdrahtet). Im Schritt «Sparen & Verteilen» rechnet die Sparquote-Vorschau **Hypothekarzinsen**, die als «noch nicht in den Ausgaben» markiert sind, korrekt zu den Ausgaben dazu (wie der Rechenkern bei `interestHandling: ADD`); der **noch nicht verteilte Rest** steht neu **prominent oben** (zwischen PK und Verteilung), und die Verteilung ist nach **Gemeinsam / Person A / Person B** gruppiert. (2) **Tour** (Kap. 9.24): statt der bisher bewusst schlichten Hervorhebung jetzt ein **Spotlight** der Rest der Ansicht wird abgedunkelt (ein 9999-px-Kastenschatten, kein separates Overlay), der pulsierende Rahmen ist deutlich stärker, die Karte ist grösser und **springt** auf die dem Ziel gegenüberliegende Bildschirmhälfte (verdeckt es nie); neu mit **«Überspringen»**-Knopf. (3) **Szenario-Ansicht** (Kap. 3.7.7): Grundprofil, Zeitachse und die Kennzahlen (Endvermögen nominal + real, Ruinalter) bilden neu **einen kompakten Block** aus drei Spalten (25 / 50 / 25 %) statt zweier über die ganze Breite gezogener Zeilen. Neue Modal-Grösse `xwide`. Kein Eingriff in den Rechenkern; 267 Tests unverändert grün. |
| 0.28 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 2 (Onboarding & Ansicht): Layout-Umbau, Tour und Assistenten-Redesign.** (1) **Szenario-Ansicht neu geordnet** (Kap. 3.7.7): oben eine schlanke Funktions-Leiste (Änderungshistorie · Tour · Neues Szenario · Rechenwege · CSV-Export · Löschen · Abweichungs-Badge), darunter Nächste Schritte → Grundprofil → Zeitachse → Anzeige-Umschalter → Matrix. Grafiken, Effektive Werte, Live-Simulation, Monte-Carlo und Einflussfaktoren sind aus der Leiste **entfernt** sie laufen über die eigenen Menüpunkte (Analysen / Effektive Werte). (2) **Tour** liegt neu in `AppShell` (statt `PlanView`) und startet nach **jeder** Plan-Erstellung Assistent, Beispielplan **und** leerer Plan unabhängig davon, ob sie schon einmal beendet wurde (Kap. 3.7.8); die Erfolgsmeldung hält damit ihr Versprechen. (3) **Assistent durchgehend in Du-Form** im Einzelmodus (Paarmodus weiter «ihr» / je Person). (4) **Schritt-Redesign** (Kap. 3.2.8): Schritt «Vorsorge & Vermögen» erfasst nur noch **Bestandswerte**; ein **neuer Schritt «Sparen & Verteilen»** zeigt die Sparquote (Nettoeinkommen Ausgaben) und lässt sie auf 3a, Wertschriften, Amortisation und Schuldtilgung verteilen der Rest bleibt sichtbar auf dem Cash-Konto. Die **PK-Einzahlung** steht dort bewusst separat, mit dem Hinweis, dass sie vom Bruttolohn kommt und die Sparquote **nicht** schmälert (deckt sich mit dem Rechenkern). Immobilien fragen im Assistenten neu **Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab; der Lohn bekommt 1 % Default-Erhöhung. (5) **«Grosse Säule 3a» für Selbstständige** (Roadmap-Feedback): ein Schalter am 3a-Element (und im Assistenten) hebt die Beitrags-Obergrenze von 7258 auf ca. 36288 CHF an (neue Konstante `PILLAR_3A_MAX_SELF_EMPLOYED`, **2026-Wert zu verifizieren**; neues Feld `selfEmployed3a`). (6) Nebenbei: `POST /plans` liefert bei Validierungsfehlern eine lesbare Meldung statt eines rohen zod-Objekts. Keine Änderung am Rechenkern; 267 Tests unverändert grün. |
@@ -127,11 +128,12 @@ Referenz: `prisma/schema.prisma` Zeilen 810, `src/lib/types.ts` Zeilen 384
```
User
└── Plan (Behälter: nur Name — KEINE Finanzdaten)
── Scenario[] (die berechenbare Einheit)
│ Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert
└── Plan (der HAUSHALT: Name, Haushaltsform, Startjahr)
── PlanPerson[] (Rolle, Name, Alter — gelten für ALLE Szenarien)
└── Scenario[] (die berechenbare Einheit = eine Planungsvariante)
│ szenario-eigen: Inflation, Cash-Startwert
│ isBase = genau eines je Plan · parentScenarioId = Baum + Vergleichsbasis
├── Person[] (1 bei SINGLE, 2 bei COUPLE — je Szenario eigen!)
├── Person[] (nur das PENSIONSALTER je Rolle — szenario-eigen)
├── Phase[] (Kette 1..n; sourcePhaseId = Gegenstück in der Vorlage)
└── FinancialElement[] (szenario-weit; sourceElementId = Gegenstück in der Vorlage)
├── ElementPhaseValue[] (Werte je Phase, JSON)
@@ -146,14 +148,15 @@ und pro Phasenübergang einen Entscheid-Satz. Dadurch bleibt die Identität eine
Vermögensgegenstands über die Zeit erhalten Voraussetzung für die Fortschreibung (Carry) und
die Vermögensaufteilungs-Grafik.
**(2) Das Grundprofil liegt am Szenario, nicht am Plan** (V6). Nur so lassen sich die
wertvollsten Szenario-Fragen abbilden allen voran ein abweichendes **Pensionsalter**
(„Was, wenn ich mit 62 statt 65 aufhöre?"), das in `Person` steckt. Wäre das Profil geteilt,
wären Frühpensionierungs-Szenarien unmöglich.
**(2) Der Haushalt liegt am Plan, die Annahmen am Szenario** (V7, siehe
[9.30](#930-warum-der-haushalt-am-plan-hängt)). Haushaltsform, Personen (Name, Alter) und
Planstartjahr beschreiben den **Haushalt** sie gelten für alle Szenarien. Unterscheiden sie
sich, ist es ein anderer **Plan**. Szenario-eigen sind die **Annahmen**: Inflation,
Cash-Startwert und am wichtigsten das **Pensionsalter** je Person. Nur deshalb sind
Frühpensionierungs-Szenarien überhaupt möglich („Was, wenn ich mit 62 statt 65 aufhöre?").
Der **Plan** ist damit ein reiner Behälter: Er bündelt Szenarien und trägt den Eigentümer
(`userId`). Ownership von Szenario/Phase/Element läuft über die Kette
`Scenario → Plan → User`.
Der **Plan** trägt zudem den Eigentümer (`userId`). Ownership von Szenario/Phase/Element läuft
über die Kette `Scenario → Plan → User`.
Referenz: `prisma/schema.prisma`.
@@ -317,49 +320,59 @@ Der Dialog «Leer starten» fragt Name plus das vollständige Grundprofil:
Konsistenzregel: `SINGLE` erfordert genau eine Person, `COUPLE` genau zwei (Person A und B).
Verletzung → HTTP 400 mit Klartextmeldung.
Referenz: `src/components/PlanProfileFields.tsx` Zeilen 10121018, `src/app/api/plans/route.ts`
Zeilen 635.
Referenz: `src/components/PlanProfileFields.tsx`, `src/app/api/plans/route.ts`.
Ein neu erstellter Plan hat **keine Phasen und keine Elemente**; `initialCash` ist 0.
### 3.2.2 Grundprofil ändern
Über „Einstellungen" in der Planansicht. Wichtig: Die Personen werden serverseitig in einer
Transaktion **gelöscht und neu angelegt** (`deleteMany` + `create`). Die Person-IDs ändern sich
dadurch. Da Elemente über `ownerRole` (nicht über `personId`) zugeordnet sind, bleibt die
Zuordnung erhalten.
Über **«Profil bearbeiten»** im Grundprofil-Block der Szenario-Ansicht (Panel «Szenario-Profil»).
Der Endpunkt ist `PATCH /api/scenarios/<scenarioId>`: Er schreibt Haushaltsform, Personen (Name,
Alter) und Startjahr an den **Plan**, Inflation und Pensionsalter ans **Szenario** die Trennung
aus V7 ([9.30](#930-warum-der-haushalt-am-plan-hängt)). Das **Pensionsalter** ist dort nur noch
Anzeige; verschoben wird es über «Pensionsalter anpassen» ([3.12](#312-pensionsalter-anpassen)),
weil es auf einer Phasengrenze liegt.
Wichtig: Die Personen werden serverseitig in einer Transaktion **gelöscht und neu angelegt**
(`deleteMany` + `create`). Die Person-IDs ändern sich dadurch. Da Elemente über `ownerRole`
(nicht über `personId`) zugeordnet sind, bleibt die Zuordnung erhalten.
Ein Wechsel von `COUPLE` auf `SINGLE` entfernt Person B. Elemente mit `ownerRole = PERSON_B`
bleiben in der Datenbank bestehen, finden aber keinen Owner mehr siehe
[9.2](#92-verwaiste-person_b-elemente).
Referenz: `src/app/api/plans/[planId]/route.ts` Zeilen 5570.
**Plan umbenennen:** über das Stift-Symbol neben dem Plannamen im Plan-Dashboard
(`PATCH /api/plans/<planId>`, nur das Feld `name`).
Referenz: `src/app/api/scenarios/[scenarioId]/route.ts`, `src/app/api/plans/[planId]/route.ts`.
### 3.2.3 Cash-Anfangswert
Klick auf die erste Zelle der Cash-Zeile öffnet den Dialog „Cash-Anfangswert". Wertebereich
0 bis 1'000'000'000, wird auf ganze Franken gerundet.
Referenz: `src/components/PlanView.tsx` Zeilen 11301156, `src/app/api/plans/[planId]/route.ts` Zeile 36.
Referenz: `src/components/PlanView.tsx`, `PATCH /api/scenarios/<scenarioId>` (Feld `initialCash`).
### 3.2.4 Plan löschen
Aus der Übersichtskachel oder der Planansicht, mit Browser-`confirm()`. Löscht per Datenbank-Cascade
Personen, Phasen, Elemente und alle Werte. Szenarien, die auf diesen Plan als `parentPlanId`
zeigen, werden **nicht** gelöscht ihre `parentPlanId` wird auf `NULL` gesetzt
(`onDelete: SetNull`).
Aus der Übersichtskachel oder der Seitenleiste, mit dem eigenen Bestätigungs-Dialog
(`useConfirm`, siehe [3.7.6](#376-sprache-und-ui-primitiven) kein Browser-`confirm()` mehr).
Löscht per Datenbank-Cascade **alle Szenarien** des Plans und darüber Personen, Phasen, Elemente
und sämtliche Werte, dazu die Ist-Datensätze, gespeicherten Analysen und Berichte des Plans.
Referenz: `prisma/schema.prisma` Zeile 82.
Referenz: `prisma/schema.prisma`, `src/app/api/plans/[planId]/route.ts`.
### 3.2.5 Szenarien
Beim Anlegen eines Plans entsteht **automatisch das Basisszenario** (`isBase = true`,
Name „Basisszenario"). Der beim Anlegen erfasste Profilteil (Haushaltsform, Personen,
Inflation) landet dort, der Name am Plan.
Name „Basisszenario"). Vom erfassten Profil landen **Haushaltsform, Personen und Startjahr am
Plan**, **Inflation und Pensionsalter am Basisszenario** (V7).
Ein weiteres Szenario ist eine **vollständige Kopie eines beliebigen bestehenden Szenarios**
(nicht nur des Basisszenarios). Kopiert werden Grundprofil, alle Personen, alle Phasen, alle
Elemente sowie sämtliche Phasen- und Übergangswerte.
(nicht nur des Basisszenarios die Vorlage ist in der Szenario-Liste je Zeile über «Kopie»
wählbar). Kopiert werden die szenario-eigenen Angaben (Inflation, Cash-Startwert,
Pensionsalter), alle Phasen, alle Elemente sowie sämtliche Phasen- und Übergangswerte. Der
Haushalt wird **nicht** kopiert er liegt am Plan und gilt ohnehin für alle Szenarien.
Gesetzt werden dabei:
- `parentScenarioId` = das kopierte Szenario → ergibt den **Baum** in der Seitenleiste **und**
@@ -404,11 +417,11 @@ Referenz: `src/lib/diff.ts`.
### 3.2.7 Planstart (Kalenderjahr)
Das Grundprofil enthält das Feld **Planstart (Jahr)** das Kalenderjahr, in dem Jahr 1 der
Planung liegt (`Scenario.startYear`). Es dient **ausschliesslich der Darstellung**: Zeitachse
Planung liegt (`Plan.startYear`, seit V7 plan-weit). Es dient **ausschliesslich der Darstellung**: Zeitachse
und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unverändert in
**relativen** Jahren ab Planbeginn `startYear` fliesst in keine Formel ein.
Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Szenarien wurden per Migration darauf
Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Pläne wurden per Migration darauf
gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr 1)`.
### 3.2.8 Geführter Assistent und Beispielplan
@@ -978,10 +991,13 @@ Referenz: `src/components/PlanView.tsx` Zeilen 701769.
### 3.6.4 Analyse-Bereich „Grafiken"
Die Auswertungen liegen **nicht** unter der Matrix, sondern in einem eigenen Bereich: Der Button
**Grafiken** in der oberen Aktionsleiste (neben „Neues Szenario aus diesem" und
„Monte-Carlo-Simulation") öffnet sie als breiten Dialog. So bleibt die Matrix die ruhige
Hauptansicht.
Die Auswertungen liegen **nicht** unter der Matrix, sondern in einem eigenen Bereich: Die Kachel
**Grafiken** unter dem Menüpunkt **Analysen** ([3.10.2](#3102-analysen-vier-kacheln)) öffnet sie
als breiten Dialog. So bleibt die Matrix die ruhige Hauptansicht.
**Szenario-Wahl:** Ganz oben steht ein Auswahlfeld, welches Szenario die Grafiken zeigen es
gilt für **alle drei**. Vorher war der Bereich fest an das geöffnete Szenario gebunden, und nur
der Vermögensverlauf konnte weitere überlagern.
**Kennzahl-Karten:** Endvermögen nominal und Endvermögen real. (Die frühere Karte „Geschätzter
Nachlass" ist entfallen sie war rechnerisch identisch mit dem nominalen Endvermögen und
@@ -994,32 +1010,57 @@ ist grün (Sparquote) oder rot (Verzehr) eingefärbt. Der Keil zwischen roter un
anschaulich „das, was die Inflation frisst".
**Grafik 2 Vermögensverlauf nach Alter** (`WealthChart`): Liniendiagramm über das Alter von
Person A. Je Szenario eine durchgezogene Linie (nominal) und eine gestrichelte (real).
Datenpunkte: **jedes Planjahr** (nicht nur die Phasengrenzen) dadurch werden Verläufe
Person A. Datenpunkte: **jedes Planjahr** (nicht nur die Phasengrenzen) dadurch werden Verläufe
*innerhalb* einer Phase sichtbar, etwa das Abschmelzen im Kapitalverzehr. Grundlage ist
`YearPoint.wealthNominal/wealthReal` (4.10). Über Checkboxen lassen sich die **Geschwister-
Szenarien überlagern**; deren Daten werden bei Bedarf nachgeladen und im Client
zwischengespeichert.
`YearPoint.wealthNominal/wealthReal` (4.10). Über Checkboxen lassen sich weitere **Szenarien
überlagern**; deren Daten werden bei Bedarf nachgeladen und im Client zwischengespeichert.
**Grafik 3 Vermögensaufteilung pro Phase**: Gestapeltes Balkendiagramm mit zwei Balken je Phase
(Beginn / Ende). Gestapelt werden alle Elemente der Kategorien PK, 3a, Immobilie, Sonstiges
Vermögen, die irgendwann einen positiven Wert haben. Stapelung nach `elementId` (nicht Name),
damit gleichnamige Elemente nicht kollidieren. Negative Werte werden auf 0 geklammert.
Der Datenschlüssel je Serie ist die **Szenario-ID**, nicht der Name. Bis 0.30 diente der Name als
Schlüssel zwei gleichnamige Szenarien überschrieben sich dadurch gegenseitig, und die Legende
zeigte eine Linie an, die es im Diagramm gar nicht gab. Die **Legende** ist eigenständig (nicht
die von Recharts): Sie erlaubt je Serie eine **freie Farbwahl** und erklärt den Linienstil
(gestrichelt = reine Planwerte, durchgezogen = mit effektiven Werten).
Referenz: `src/components/Dashboard.tsx`, `src/components/WealthChart.tsx`, `src/components/SparquoteChart.tsx`.
**Grafik 3 Vermögensaufteilung im Zeitverlauf**: Links eine **gestapelte Fläche** über alle
Planjahre, die Phasengrenzen als feine gestrichelte Senkrechte; rechts ein **Ring**, der die
**relative** Aufteilung zu einem wählbaren Zeitpunkt zeigt (Klick in die Fläche wählt das Jahr,
Vorgabe ist das Planende). Gestapelt werden alle Elemente der Kategorien PK, 3a, Immobilie,
Sonstiges Vermögen, die irgendwann einen positiven Wert haben. Stapelung nach `elementId` (nicht
Name), damit gleichnamige Elemente nicht kollidieren. Negative Werte werden auf 0 geklammert.
Bis 0.30 waren das gestapelte Balken mit zwei Säulen je Phase und schräg gestellter
Beschriftung ab drei Phasen kaum mehr lesbar, und die relative Aufteilung war gar nicht
ablesbar. Die Fläche zeigt den Verlauf statt zweier Stichproben je Phase; die Jahreswerte dafür
liegen seit 0.11 ohnehin vor (`ElementPhaseComputed.yearly`), es wird nichts neu gerechnet.
Referenz: `src/components/Dashboard.tsx`, `src/components/WealthChart.tsx`,
`src/components/SparquoteChart.tsx`, `src/components/AllocationChart.tsx`.
### 3.6.5 CSV-Export
`GET /api/plans/<planId>/export` liefert eine semikolon-getrennte CSV, eine Zeile pro Phase:
`GET /api/scenarios/<scenarioId>/export` liefert die **vollständige Matrix** als
semikolon-getrennte CSV. Bis 0.30 war das eine reine Phasen-Zusammenfassung zehn Spalten, eine
Zeile je Phase, **kein einziges finanzielles Element**; was am Bildschirm stand, liess sich damit
nicht weiterverarbeiten.
```
Phase;Typ;Dauer;Einkommen (Beginn);Ausgaben (Beginn);Quote (Beginn);Quote (Ende);Cash (Ende);Endvermoegen (nominal);Endvermoegen (real)
```
Die Datei enthält **vier Blöcke** untereinander (Excel kommt damit zurecht, und es ist sofort
erkennbar, was zusammengehört):
Bei vorhandenem Ruin folgt eine Schlusszeile `Ruin: Kapital aufgebraucht mit Alter <N>`.
Dateiname = Planname, nicht-alphanumerische Zeichen durch `_` ersetzt.
| Block | Inhalt |
|---|---|
| **Kopf** | Szenario, Haushaltsform, Personen (Alter/Pensionsalter), Planstart, Inflation, Cash-Anfangswert, Erzeugungszeitpunkt |
| **LEBENSPHASEN** | je Phase: Typ, Dauer, Altersspanne, Einkommen/Ausgaben, Quote (Beginn/Ende), Cash (Beginn/Ende), Vermögen (Beginn/Ende, nominal + real) |
| **MATRIX** | Zeilen = Cash + alle Elemente; je Phase **zwei** Spalten (Beginn/Ende), dazwischen je Übergang **eine** Spalte mit dem Entscheid im Klartext (z. B. «Kapitalbezug (Steuer 8 %)») |
| **JAHRESWERTE** | je Planjahr: Kalenderjahr, Alter, Vermögen nominal/real, Cash, Einkommen, Ausgaben, Sparquote |
Referenz: `src/lib/calculations.ts` Zeilen 620648.
Der Jahreswerte-Block ist der, mit dem sich in Excel selbst weiterrechnen lässt; die Matrix
bildet 1:1 die Bildschirm-Ansicht ab.
Zwei Details, die vorher Ärger machten: Die Datei beginnt mit einem **BOM** ohne dieses
Zeichen zerlegt Excel unter Windows alle Umlaute , und der **Dateiname** transliteriert Umlaute
(ä→ae) statt sie durch `_` zu ersetzen. Bei vorhandenem Ruin folgt eine Schlusszeile.
Referenz: `src/lib/csv.ts` (rein, ohne I/O), `src/app/api/scenarios/[scenarioId]/export/route.ts`.
### 3.6.6 Analyse-Bereich „Einflussfaktoren"
@@ -1058,7 +1099,7 @@ Jede Element-Zeile und jeder Phasenkopf trägt oben rechts ein **Expand-Icon** (
| Reiter | Inhalt |
|---|---|
| Übersicht | Typ, Dauer, Vermögen Beginn/Ende; Vermögensaufteilung als gestapelter Balken (Beginn und Ende) |
| Übersicht | Typ, Dauer, Vermögen Beginn/Ende; Vermögensaufteilung als Fläche über die Planjahre mit Ring für den gewählten Zeitpunkt |
| Wasserfall | Vermögens- und Cash-Wasserfall ([4.14.2](#4142-die-beiden-wasserfälle)) |
| Rechenweg | Quote, Spar-/Verzehrrate, Cash-Fortschreibung, Vermögen, Phasentyp, Cash-Übergang |
@@ -1289,6 +1330,12 @@ die oberste. Animationen (150220 ms) respektieren `prefers-reduced-motion`.
je Farbschema abgestimmt) statt der Akzentfarbe «hier fehlt eine Eingabe» und «hier kannst
du klicken» sind damit unterscheidbar.
**Diagramm-Palette:** Seit 0.31 kommen auch die Farben aller Diagramme aus Tokens
(`--chart-1``--chart-6`, dazu `--chart-grid`) statt aus festen Hex-Werten je Farbschema
gedämpft abgestimmt. Recharts bekommt die Variablen direkt als Attributwert (`var(--chart-1)`),
der Browser löst sie im SVG auf. Vorher stachen die Diagramme mit greller Standardpalette aus
dem Schema heraus und sahen im Dunkelmodus falsch aus.
### 3.7.7 Inspector-Panel statt Modals
Alle **Einzel-Bearbeitungen** Phasenzelle, Übergangszelle, Cash-Übergang, Cash-Anfangswert,
@@ -1387,6 +1434,12 @@ sie. Zwei Sicherungen ergänzen das:
Eine **Hauptversion** wird nie zusammengefasst und nie nachträglich verändert; die nächste
Änderung beginnt bei A.1.
**Ein Szenario startet bei 0.1.** Die Nebenversion zählt beliebig weit hoch (0.1, 0.2, … 0.137);
die **0** bedeutet «noch kein verabschiedeter Stand». Erst eine bewusst gesetzte **Hauptversion**
macht daraus **1.0**. Bis 0.30 begann jedes Szenario bereits bei 1.0 damit war die Hauptversion
bedeutungslos, weil jeder Plan schon eine hatte. Bestehende Historien werden **nicht**
umnummeriert; nur der Startwert ist neu (Migration `20260724120000_version_zero_start`).
### 3.8.2 Was die Historie zeigt
Je Version: **wer** (Benutzername), **wann** (Zeitpunkt der letzten Änderung dieser Sitzung)
@@ -1575,15 +1628,20 @@ Referenz: `src/lib/actuals.ts`, `src/lib/dataview.ts`, `src/components/ActualsDi
## 3.10 Navigation auf Plan-Ebene und gespeicherte Analysen
Die Seitenleiste ist zweistufig: Unter jedem **Plan** liegen drei leicht eingezogene
Unterpunkte. Ein Klick auf den **Plan-Namen** öffnet dessen Dashboard.
Die Seitenleiste ist zweistufig: Unter jedem **Plan** liegen vier Unterpunkte. Ein Klick auf den
**Plan-Namen** öffnet dessen Dashboard.
| Ort | Führt zu |
|---|---|
| Plan-Name | **Plan-Dashboard** (Kennzahlen) |
| Szenarien | **Szenario-Liste** darunter bleibt der Szenario-Baum, dessen Einträge direkt in die Matrix führen |
| Plan-Name | **Plan-Dashboard** (Kennzahlen, Umbenennen) |
| Szenarien | **Szenario-Liste** darunter der Szenario-Baum, **verschachtelt** nach Herkunft; seine Einträge führen direkt in die Matrix |
| Effektive Werte | Liste + Wizard der Ist-Werte ([3.9](#39-effektive-werte-plan-ist-vergleich)) |
| Analysen | Vier Werkzeug-Kacheln + Liste gespeicherter Analysen |
| Berichte | PDF-Berichte erzeugen und wieder herunterladen ([3.11](#311-pdf-berichte)) |
Die vier Unterpunkte stehen **bündig zum Knoten «Szenarien»**; nur die einzelnen Szenarien
darunter sind eingerückt und zwar je nach Tiefe ihrer Herkunftskette, sodass in der
Seitenleiste sichtbar ist, woraus ein Szenario entstanden ist.
### 3.10.1 Plan- vs. Szenario-Ebene der Kennzahlen
@@ -1591,13 +1649,19 @@ Seit V7 ([9.30](#930-warum-der-haushalt-am-plan-hängt)) liegen Haushaltsform, P
Startjahr am Plan; Endvermögen, Ruinalter, Phasen und Elemente sind dagegen **szenario-eigen**.
Das Plan-Dashboard zeigt deshalb die Haushaltsdaten direkt, alle gerechneten Kennzahlen aber
ausdrücklich als **«laut Basisszenario»** das Basisszenario ist der kanonische Vertreter.
Falls Ist-Werte erfasst sind, weist es zusätzlich die **Abweichung** des Endvermögens gegenüber
dem Plan aus.
Die vier Kennzahl-Kacheln (Szenarien, Effektive Werte, Analysen, Berichte) sind **anklickbar**
und führen in ihren Bereich. Über ein **Stift-Symbol** neben dem Plannamen lässt sich der Plan
umbenennen.
Die **Szenario-Liste** zeigt je Szenario Name, aktuelle Hauptversion, Anzahl Elemente, das
Endvermögen und ob das Kapital reicht; das Basisszenario ist farblich hervorgehoben, und die
Herkunft (aus welchem Szenario kopiert) steht darunter. Zwei Aktionen je Zeile: **Historie**
und **Matrix**.
Falls Ist-Werte erfasst sind, weist das Dashboard die **Abweichung** des Endvermögens gegenüber
dem Plan aus benannt mit dem **Jahr des jüngsten Ist-Datensatzes** («Mit den effektiven Werten
von 2032 …») und **grün**, wenn die Realität besser ist als der Plan, sonst rot.
Die **Szenario-Liste** zeigt je Szenario Name, die **aktuelle Version** (z. B. «0.17»), Anzahl
**Phasen** und **Elemente**, das Endvermögen und ob das Kapital reicht; das Basisszenario ist
farblich hervorgehoben, und die Herkunft (aus welchem Szenario kopiert) steht darunter. Ein
Klick auf die **Zeile** öffnet die Matrix; je Zeile gibt es zusätzlich **Historie**, **Kopie**
(macht dieses Szenario zur Vorlage) und ausser beim Basisszenario **Löschen**.
### 3.10.2 Analysen: vier Kacheln
@@ -3233,7 +3297,8 @@ PlanComputed ← an den Client geliefert
| Datei | Verantwortung |
|---|---|
| `calculations.ts` | Berechnungskern + CSV-Export. Keine I/O. |
| `calculations.ts` | Berechnungskern. Keine I/O. |
| `csv.ts` | CSV-Export der vollständigen Matrix (vier Blöcke, Kap. 3.6.5). Rein. |
| `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` |
| `types.ts` | Domänentypen für API und Berechnung |
| `constants.ts` | Schweizer Systemparameter |
@@ -3475,6 +3540,7 @@ sondern zu leeren Werten.
| `20260720090000_actuals` | Tabelle `ActualsSet` (effektive Werte je Plan, Werte als JSONB, Cash separat) |
| `20260720140000_plan_level_profile` | **V7**: Haushaltsform, Personen (Name/Alter) und Startjahr vom Szenario auf den Plan; neue Tabelle `PlanPerson`; `Person` behält nur das Pensionsalter. Datenübernahme aus dem Basisszenario. |
| `20260721090000_reports` | Tabelle `Report`: gewählte Parameter, eingefrorenes Modell und die PDF-Datei als `BYTEA` |
| `20260724120000_version_zero_start` | `Scenario.currentMajor` startet bei **0** statt 1 die Versionierung beginnt bei 0.1 (Kap. 3.8). Nur der Default; bestehende Zeilen bleiben |
| `20260720160000_saved_analyses` | Tabelle `SavedAnalysis` (Eingaben + Ergebnis als JSONB, denormalisierte Kerndaten) |
**Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um dadurch
@@ -3846,7 +3912,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `actuals.test.ts` | 24 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung, Wirkung über die Phasengrenze hinaus |
| `dataview.test.ts` | 7 | beide Sichten in einem Zug, Rückfall ohne Ist-Werte, Abweichungsmessung |
| `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen |
| `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien |
| `versioning.test.ts` | 23 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien |
| `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus |
| `report.test.ts` | 9 | Zusammenfassung, Basis-Angabe zu JEDER Kennzahl, Szenario-Deckelung, Plan/Ist-Block, Haftungsausschluss, gültige PDF-Datei |
| `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung |
@@ -3857,7 +3923,8 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
| `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
| `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP |
| **Total** | **267** | |
| `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` |
| **Total** | **275** | |
## 8.2 Testfälle
@@ -0,0 +1,9 @@
-- Versionierung startet neu bei 0.1 statt 1.0 (SPEZIFIKATION 3.8).
--
-- Ein Szenario laeuft in der 0er-Reihe (0.1, 0.2, ... 0.137), bis der Benutzer bewusst eine
-- HAUPTVERSION setzt -- erst dann wird daraus 1.0. Vorher begann jedes Szenario bereits bei
-- 1.0, wodurch die Hauptversion ihre Bedeutung verlor.
--
-- Nur der DEFAULT wird umgestellt; bestehende Zeilen bleiben unveraendert, damit eine bereits
-- gelaufene Historie nicht rueckwirkend umnummeriert wird.
ALTER TABLE "Scenario" ALTER COLUMN "currentMajor" SET DEFAULT 0;
+1 -1
View File
@@ -224,7 +224,7 @@ model Scenario {
updatedAt DateTime @updatedAt
// Laufende Hauptversion. Die Nebenversionen zaehlen in ScenarioVersion.
currentMajor Int @default(1)
currentMajor Int @default(0)
persons Person[]
phases Phase[]
+11 -2
View File
@@ -19,7 +19,13 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
persons: { orderBy: { role: "asc" } },
scenarios: {
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
include: { ...planInclude, _count: { select: { elements: true, versions: true } } },
include: {
...planInclude,
_count: { select: { elements: true, versions: true } },
// Höchste Version je Szenario -- die Liste zeigt die echte Nummer (z. B. «0.17»),
// nicht nur die Hauptversion.
versions: { orderBy: [{ major: "desc" }, { minor: "desc" }], take: 1, select: { major: true, minor: true } },
},
},
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
_count: { select: { scenarios: true, actuals: true, analyses: true } },
@@ -71,13 +77,16 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
const pi = toPlanInput(s);
const computed = computePlan(pi);
const last = computed.phases[computed.phases.length - 1];
const top = s.versions[0];
return {
id: s.id,
name: s.name,
isBase: s.isBase,
parentScenarioId: s.parentScenarioId,
currentMajor: s.currentMajor,
// Die tatsächliche aktuelle Version, nicht nur die Hauptversion. Ohne Historie noch keine.
version: top ? `${top.major}.${top.minor}` : null,
elementCount: s._count.elements,
phaseCount: computed.phases.length,
versionCount: s._count.versions,
endNominal: last ? Math.round(last.endWealthNominal) : 0,
endReal: last ? Math.round(last.endWealthReal) : 0,
@@ -1,7 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { toPlanInput, getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { computePlan, planToCsv } from "@/lib/calculations";
import { computePlan } from "@/lib/calculations";
import { planToCsv } from "@/lib/csv";
// Umlaute und ss ausschreiben, alles Uebrige auf Unterstrich -- ein Dateiname mit Umlauten
// kommt je nach Betriebssystem verstuemmelt an.
function safeFileName(name: string): string {
const map: Record<string, string> = { "ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss" };
const t = name.replace(/[äöüÄÖÜß]/g, (c) => map[c]);
return t.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "") || "szenario";
}
export async function GET(
_request: NextRequest,
@@ -25,7 +34,7 @@ export async function GET(
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${scenario.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`,
"Content-Disposition": `attachment; filename="${safeFileName(scenario.name)}.csv"`,
},
});
}
+31 -1
View File
@@ -39,6 +39,15 @@
--attention-fg: #ffffff;
--attention-soft: #fef3c7;
--attention-soft-fg: #92400e;
/* Diagramm-Palette: bewusst gedämpfte Töne, je Farbschema abgestimmt. Recharts bekommt
die Variablen direkt als Attributwert (var(--chart-1)) -- der Browser löst sie im SVG auf. */
--chart-1: #5b5bd6;
--chart-2: #3b8ea5;
--chart-3: #4c9a6a;
--chart-4: #c98a2e;
--chart-5: #c25d6b;
--chart-6: #8b6bb1;
--chart-grid: #e4e4e7;
}
:root[data-theme="dark"] {
@@ -71,6 +80,13 @@
--attention-fg: #1c1400;
--attention-soft: rgba(245, 158, 11, 0.16);
--attention-soft-fg: #fcd34d;
--chart-1: #818cf8;
--chart-2: #5eb3c9;
--chart-3: #6ec898;
--chart-4: #e0b25c;
--chart-5: #e08a9a;
--chart-6: #b39ae0;
--chart-grid: #3f3f46;
}
/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundärton. */
@@ -90,7 +106,7 @@
--accent-soft: #fcebe2;
--accent-soft-fg: #b24521;
--danger: #c0392b;
--danger-soft: #fbeä7;
--danger-soft: #fbeae7;
--success: #2e9e7b;
--person-a: #e8663c;
--diff: #b45309;
@@ -104,6 +120,13 @@
--attention-fg: #ffffff;
--attention-soft: #fdeed3;
--attention-soft-fg: #8f5410;
--chart-1: #e8663c;
--chart-2: #4e8fa3;
--chart-3: #7ba05b;
--chart-4: #d99441;
--chart-5: #a9556b;
--chart-6: #8c6a9e;
--chart-grid: #eadfd2;
}
/* Ohne gespeicherte Wahl der Dunkel-OS-Einstellung folgen. */
@@ -138,6 +161,13 @@
--attention-fg: #1c1400;
--attention-soft: rgba(245, 158, 11, 0.16);
--attention-soft-fg: #fcd34d;
--chart-1: #818cf8;
--chart-2: #5eb3c9;
--chart-3: #6ec898;
--chart-4: #e0b25c;
--chart-5: #e08a9a;
--chart-6: #b39ae0;
--chart-grid: #3f3f46;
}
}
+189 -43
View File
@@ -1,17 +1,54 @@
"use client";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { useMemo, useState } from "react";
import {
Area,
AreaChart,
Cell,
Legend,
Pie,
PieChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
export const CHART_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
// Diagramm-Palette aus den Theme-Tokens (globals.css) statt fester Hex-Werte -- so passt sie
// sich Hell/Dunkel/Warm an. Recharts reicht die Werte als SVG-Attribut durch, der Browser
// löst `var(...)` dort auf.
export const CHART_PALETTE = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-6)",
];
// Einheitliches Aussehen der Recharts-Tooltips (folgt dem Farbschema).
export const TOOLTIP_STYLE = {
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: 12,
color: "var(--fg)",
} as const;
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
// Gestapelte Vermögensaufteilung je Phase (Beginn und Ende). Eigene Komponente, weil sie
// sowohl im Grafiken-Dialog als auch in der Live-Simulation gebraucht wird.
export function AllocationChart({ computed, height = 288 }: { computed: PlanComputed; height?: number }) {
// Gestapelte Vermögensaufteilung.
//
// Bis 0.30 waren das gestapelte Balken mit je zwei Säulen pro Phase (Beginn/Ende) und schräg
// gestellter Beschriftung -- schwer lesbar, sobald es mehr als zwei Phasen gab. Neu:
// * eine gestapelte FLÄCHE über alle Planjahre (zeigt den Verlauf statt zweier Stichproben),
// Phasengrenzen als feine senkrechte Linien,
// * daneben ein RING für ein wählbares Jahr, der die RELATIVE Aufteilung zeigt.
// Ein Klick in die Fläche wählt das Jahr des Rings.
export function AllocationChart({ computed, height = 300 }: { computed: PlanComputed; height?: number }) {
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
const assetEls = useMemo(() => {
@@ -28,50 +65,159 @@ export function AllocationChart({ computed, height = 288 }: { computed: PlanComp
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
}, [computed]);
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende.
const barData = useMemo(
() =>
computed.phases.flatMap((phase) => {
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
// Je Planjahr eine Zeile mit dem Wert jedes Elements. Die Jahreswerte liegen bereits in
// `ElementPhaseComputed.yearly` (seit 0.11) -- hier wird nur umsortiert, nichts gerechnet.
const areaData = useMemo(() => {
const byYear = new Map<number, Record<string, number>>();
const ageOf = new Map<number, number>();
for (const phase of computed.phases) {
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
for (const y of el.yearly) {
const row = byYear.get(y.year) ?? {};
row[el.elementId] = Math.max(0, Math.round(y.value));
byYear.set(y.year, row);
ageOf.set(y.year, y.age);
}
return [beginn, ende];
}),
[computed]
);
}
}
return [...byYear.entries()]
.sort(([a], [b]) => a - b)
.map(([year, values]) => ({ year, age: ageOf.get(year) ?? 0, ...values }));
}, [computed]);
// Phasengrenzen (kumulierte Dauer) für die Trennlinien.
const boundaryAges = useMemo(() => {
const out: number[] = [];
let acc = 0;
for (let i = 0; i < computed.phases.length - 1; i++) {
acc += computed.phases[i].durationYears;
const age = areaData.find((r) => r.year === acc)?.age;
if (typeof age === "number") out.push(age);
}
return out;
}, [computed, areaData]);
// Gewähltes Jahr für den Ring -- Vorgabe: das letzte (Endzustand).
const [pickedYear, setPickedYear] = useState<number | null>(null);
const ringRow = areaData.find((r) => r.year === pickedYear) ?? areaData[areaData.length - 1];
const ringData = useMemo(() => {
if (!ringRow) return [];
return assetEls
.map((el) => ({ name: el.name, value: Number(ringRow[el.id as keyof typeof ringRow] ?? 0) }))
.filter((d) => d.value > 0);
}, [assetEls, ringRow]);
const ringTotal = ringData.reduce((s, d) => s + d.value, 0);
if (assetEls.length === 0) {
return <p className="text-sm text-muted">Dieser Plan enthält keine Vermögenselemente.</p>;
}
return (
<div className="w-full" style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{assetEls.map((el, i) => (
<Bar
key={el.id}
dataKey={el.id}
name={el.name}
stackId="a"
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
isAnimationActive={false}
/>
))}
</BarChart>
</ResponsiveContainer>
<div className="flex flex-col gap-4 lg:flex-row">
{/* Verlauf */}
<div className="min-w-0 flex-1">
<div className="mb-1 text-xs font-medium text-muted">
Verlauf über alle Planjahre <span className="text-faint">· Klick wählt das Jahr für den Ring</span>
</div>
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={areaData}
margin={{ top: 8, right: 8, left: 8, bottom: 4 }}
onClick={(e) => {
const y = (e as { activePayload?: { payload?: { year?: number } }[] })?.activePayload?.[0]?.payload?.year;
if (typeof y === "number") setPickedYear(y);
}}
>
<XAxis
dataKey="age"
type="number"
domain={["dataMin", "dataMax"]}
tick={{ fontSize: 11 }}
tickFormatter={(v) => `${v} J.`}
stroke="var(--faint)"
/>
<YAxis
tick={{ fontSize: 11 }}
stroke="var(--faint)"
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v) => (typeof v === "number" ? formatChf(v) : v)}
labelFormatter={(v) => `Alter ${v}`}
/>
{boundaryAges.map((age) => (
<ReferenceLine key={age} x={age} stroke="var(--border-strong)" strokeDasharray="3 3" />
))}
{/* Markierung des Ring-Jahres. */}
{ringRow && <ReferenceLine x={ringRow.age} stroke="var(--accent)" strokeWidth={1.5} />}
{assetEls.map((el, i) => (
<Area
key={el.id}
type="monotone"
dataKey={el.id}
name={el.name}
stackId="a"
stroke={CHART_PALETTE[i % CHART_PALETTE.length]}
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
fillOpacity={0.75}
isAnimationActive={false}
/>
))}
<Legend wrapperStyle={{ fontSize: 12 }} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Relative Aufteilung im gewählten Jahr */}
<div className="lg:w-72 lg:shrink-0">
<div className="mb-1 text-xs font-medium text-muted">
Aufteilung mit {ringRow?.age ?? ""} Jahren
{pickedYear !== null && (
<button
type="button"
onClick={() => setPickedYear(null)}
className="ml-2 text-[11px] font-normal text-accent hover:underline"
>
zurücksetzen
</button>
)}
</div>
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={ringData}
dataKey="value"
nameKey="name"
innerRadius="55%"
outerRadius="80%"
paddingAngle={1}
isAnimationActive={false}
>
{ringData.map((d, i) => (
<Cell key={d.name} fill={CHART_PALETTE[i % CHART_PALETTE.length]} />
))}
</Pie>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v) =>
typeof v === "number"
? `${formatChf(v)} · ${ringTotal > 0 ? Math.round((v / ringTotal) * 100) : 0} %`
: v
}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="-mt-2 text-center text-xs text-muted">
Total <strong className="text-fg">{formatChf(ringTotal)}</strong>
</div>
</div>
</div>
);
}
+53 -13
View File
@@ -117,6 +117,8 @@ function AppShellInner({ username }: { username: string }) {
const [showPalette, setShowPalette] = useState(false);
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
// Erhoehen erzwingt ein Neuladen der Szenario-Liste (bleibt bei einer Loeschung montiert).
const [scenarioListKey, setScenarioListKey] = useState(0);
// Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im
// DOM der Szenario-Ansicht). Zwei Auslöser:
@@ -276,6 +278,9 @@ function AppShellInner({ username }: { username: string }) {
return;
}
await loadPlans();
// Die Szenario-Liste bleibt beim Löschen aus der Seitenleiste montiert -- ohne dieses
// Signal zeigte sie den gelöschten Eintrag weiter.
setScenarioListKey((k) => k + 1);
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
}
@@ -384,7 +389,7 @@ function AppShellInner({ username }: { username: string }) {
<button
type="button"
onClick={() => openPlanTab(p.id, tab)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-3 text-left text-xs font-medium transition-colors ${
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-3 pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === tab ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
@@ -452,7 +457,7 @@ function AppShellInner({ username }: { username: string }) {
<button
type="button"
onClick={() => void openActualsTab(p.id)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-3 text-left text-xs font-medium transition-colors ${
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-3 pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === "actuals" ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
@@ -565,18 +570,27 @@ function AppShellInner({ username }: { username: string }) {
{/* Plan-Ebene: Dashboard / Szenarien / Analysen */}
{!showSpec && !showSystemParams && planNav?.tab === "dashboard" && (
<PlanDashboardView planId={planNav.planId} />
<PlanDashboardView
planId={planNav.planId}
onNavigate={(tab) => (tab === "actuals" ? void openActualsTab(planNav.planId) : openPlanTab(planNav.planId, tab))}
onRenamed={() => void loadPlans()}
/>
)}
{!showSpec && !showSystemParams && planNav?.tab === "scenarios" && (
<ScenarioListView
planId={planNav.planId}
reloadKey={scenarioListKey}
onOpenMatrix={openScenario}
onOpenHistory={(sid) => {
void loadDetail(sid, true).then(() => setShowHistory(true));
}}
onNew={() => {
const base = activePlan?.scenarios.find((s) => s.isBase) ?? activePlan?.scenarios[0];
if (base) setCopyFrom({ id: base.id, planId: base.planId, planName: activePlan?.name ?? "", name: base.name, isBase: base.isBase, parentScenarioId: base.parentScenarioId } as ScenarioMeta);
onCopyFrom={(sid) => {
const src = activePlan?.scenarios.find((s) => s.id === sid);
if (src) setCopyFrom(src);
}}
onDelete={(sid) => {
const s = activePlan?.scenarios.find((x) => x.id === sid);
if (s) void handleDeleteScenario(s);
}}
/>
)}
@@ -968,17 +982,43 @@ function ScenarioTree({
onCopy: (s: ScenarioMeta) => void;
onDelete: (s: ScenarioMeta) => void;
}) {
// Flache, gleichmässig eingerückte Liste: Basisszenario zuoberst, danach die Varianten.
// Die Herkunfts-Verschachtelung wird in der Szenario-Liste (Spalte «aus …») gezeigt, nicht
// mehr durch die Einrückung in der Seitenleiste.
const ordered = [...scenarios].sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
// Echte Verschachtelung: Ein Szenario wird unter seiner Vorlage eingerückt (Tiefe = Länge
// der Herkunftskette). So ist in der Seitenleiste sofort sichtbar, woraus ein Szenario
// entstanden ist -- die Szenario-Liste zeigt dasselbe zusätzlich als Spalte «aus …».
const byId = new Map(scenarios.map((s) => [s.id, s]));
const depthOf = (s: ScenarioMeta): number => {
let d = 0;
let cur = s.parentScenarioId ? byId.get(s.parentScenarioId) : undefined;
// Deckel gegen eine (theoretisch) zyklische Kette.
while (cur && d < 8) {
d += 1;
cur = cur.parentScenarioId ? byId.get(cur.parentScenarioId) : undefined;
}
return d;
};
// Vorordnung: Basis zuoberst, Kinder direkt unter ihrer Vorlage.
const ordered: ScenarioMeta[] = [];
const visit = (parentId: string | null) => {
for (const s of scenarios) {
const pid = s.isBase ? null : s.parentScenarioId;
if ((pid ?? null) !== parentId) continue;
ordered.push(s);
visit(s.id);
}
};
visit(null);
// Szenarien, deren Vorlage gelöscht wurde, hängen sonst nirgends -- ans Ende.
for (const s of scenarios) if (!ordered.includes(s)) ordered.push(s);
if (ordered.length === 0) return null;
return (
<>
{ordered.map((s) => (
<div
key={s.id}
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 pl-9 text-sm transition-colors ${
style={{ paddingLeft: `${2.25 + depthOf(s) * 0.75}rem` }}
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm transition-colors ${
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
@@ -1093,9 +1133,9 @@ function DashboardHome({
{/* Ein paar Kennzahlen pro Plan direkt in der Übersicht. */}
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted">
<span>{p.scenarios.length} Szenario{p.scenarios.length === 1 ? "" : "s"}</span>
<span>{p.scenarios.length} Szenario{p.scenarios.length === 1 ? "" : "en"}</span>
{p.startYear && <span>ab {p.startYear}</span>}
{p._count && p._count.actuals > 0 && <span>{p._count.actuals} Ist-Datensatz{p._count.actuals === 1 ? "" : "e"}</span>}
{p._count && p._count.actuals > 0 && <span>{p._count.actuals} {p._count.actuals === 1 ? "Ist-Datensatz" : "Ist-Datensätze"}</span>}
{p._count && p._count.analyses > 0 && <span>{p._count.analyses} Analyse{p._count.analyses === 1 ? "" : "n"}</span>}
</div>
</div>
+85 -32
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { BarChart3, LineChart as LineChartIcon } from "lucide-react";
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
@@ -27,9 +27,9 @@ const CHART_OPTIONS: { id: ChartId; label: string }[] = [
];
export function Dashboard({
plan: currentPlan,
plan: openedPlan,
planId,
computed: currentComputed,
computed: openedComputed,
siblings,
actuals = [],
origins = [],
@@ -44,18 +44,46 @@ export function Dashboard({
}) {
// Eine Grafik zur Zeit -- man wählt zuerst, welche (Punkt 5 der Analysen-Ansicht).
const [chartId, setChartId] = useState<ChartId>("wealth");
// Welches Szenario die Grafik zeigt. Gilt für ALLE drei Grafiken -- vorher war das fest an
// das geöffnete Szenario gebunden, und nur der Vermögensverlauf konnte weitere dazunehmen.
const allScenarios = useMemo<PlanListItem[]>(
() => [{ id: openedPlan.id, name: openedPlan.name }, ...siblings],
[openedPlan.id, openedPlan.name, siblings]
);
const [primaryId, setPrimaryId] = useState(openedPlan.id);
const [loaded, setLoaded] = useState<Record<string, { plan: PlanInput; computed: PlanComputed }>>({});
useEffect(() => {
if (primaryId === openedPlan.id || loaded[primaryId]) return;
let cancelled = false;
(async () => {
const d = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/scenarios/${primaryId}`);
if (!cancelled) setLoaded((prev) => ({ ...prev, [primaryId]: { plan: d.plan, computed: d.computed } }));
})();
return () => {
cancelled = true;
};
}, [primaryId, openedPlan.id, loaded]);
const basePlan = primaryId === openedPlan.id ? openedPlan : (loaded[primaryId]?.plan ?? openedPlan);
const baseComputed = primaryId === openedPlan.id ? openedComputed : (loaded[primaryId]?.computed ?? openedComputed);
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(basePlan.id, basePlan);
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
const basis = useAnalysisBasis(plan, actuals, origins);
// Version UND Datenquelle bestimmen, was gezeigt wird. Beim Arbeitsstand mit Plandaten
// bleibt es beim bereits vom Server Gerechneten -- kein zweiter Lauf nötig.
const computed = useMemo(
() => (versionId === "current" && basis.source === "PLAN" ? currentComputed : basis.computed),
[versionId, currentComputed, basis.source, basis.computed]
() => (versionId === "current" && basis.source === "PLAN" ? baseComputed : basis.computed),
[versionId, baseComputed, basis.source, basis.computed]
);
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
// Vom Nutzer in der Legende gewählte Farben je Serie -- überschreibt die Palette.
const [colors, setColors] = useState<Record<string, string>>({});
async function toggleCompare(id: string) {
if (compareIds.includes(id)) {
@@ -69,38 +97,59 @@ export function Dashboard({
}
}
// Vergleichbar sind alle Szenarien ausser dem gerade gezeigten.
const comparable = allScenarios.filter((s) => s.id !== primaryId);
const series: TimelineSeries[] = useMemo(() => {
const result: TimelineSeries[] = [{ label: plan.name, color: CHART_PALETTE[0], computed }];
const result: TimelineSeries[] = [
{ id: plan.id, label: plan.name, color: colors[plan.id] ?? CHART_PALETTE[0], computed },
];
// Im Ist-Modus die reine Planlinie als gestrichelte Referenz daneben -- ohne Anker sagt
// eine Ist-Linie allein nichts aus.
if (basis.source === "ACTUAL") {
const id = `${plan.id}__plan`;
result.push({
id,
label: `${plan.name} (Plan)`,
color: CHART_PALETTE[0],
color: colors[id] ?? CHART_PALETTE[0],
computed: basis.planComputed,
dashed: true,
});
}
compareIds.forEach((id, i) => {
const c = compareData[id];
const name = siblings.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
const name = allScenarios.find((p) => p.id === id)?.name ?? id;
if (c) {
result.push({ id, label: name, color: colors[id] ?? CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
}
});
// Vier Serien sind die Grenze der Lesbarkeit -- darüber hilft keine Farbpalette mehr.
return result.slice(0, 4);
}, [plan.name, computed, compareIds, compareData, siblings, basis.source, basis.planComputed]);
return result;
}, [plan.id, plan.name, computed, compareIds, compareData, allScenarios, basis.source, basis.planComputed, colors]);
const otherPlans = siblings;
const lastPhase = computed.phases[computed.phases.length - 1];
const showScenarioPicker = allScenarios.length > 1;
return (
<div className="flex flex-col gap-6">
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={setVersionId}
loading={versionLoading}
/>
{/* Szenario-Auswahl: gilt für alle drei Grafiken. */}
{showScenarioPicker && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-muted">Szenario</span>
<select
value={primaryId}
onChange={(e) => setPrimaryId(e.target.value)}
className="rounded-lg border border-border bg-input px-2.5 py-1.5 text-sm text-fg focus:border-accent focus:outline-none"
>
{allScenarios.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
)}
<VersionBar scenarioId={basePlan.id} versionId={versionId} onChange={setVersionId} loading={versionLoading} />
<AnalysisBar basis={basis} />
@@ -123,7 +172,7 @@ export function Dashboard({
<SaveAnalysisButton
planId={planId}
type="CHART"
scenarioName={currentPlan.name}
scenarioName={plan.name}
versionLabel={versionId === "current" ? null : versionId}
build={() => buildChartResult(chartId, computed, series, basis)}
/>
@@ -138,10 +187,10 @@ export function Dashboard({
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
<LineChartIcon className="h-4 w-4 text-accent" />
Einkommen vs. Ausgaben pro Jahr
Einkommen vs. Ausgaben pro Jahr · {plan.name}
</h3>
<p className="mb-3 text-xs text-muted">
Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- (grün) bzw. Verzehrquote (rot).
Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- bzw. Verzehrquote.
Die blasse Linie sind die realen Ausgaben &ndash; der Abstand zur nominalen Linie ist der Inflationsanteil.
</p>
<SparquoteChart computed={computed} />
@@ -156,10 +205,10 @@ export function Dashboard({
Vermögensverlauf nach Alter
</h3>
</div>
{otherPlans.length > 0 && (
{comparable.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
<span className="text-xs text-muted">Szenarien vergleichen:</span>
{otherPlans.map((p) => (
<span className="text-xs text-muted">Weitere Szenarien überlagern:</span>
{comparable.map((p) => (
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
<input type="checkbox" checked={compareIds.includes(p.id)} onChange={() => toggleCompare(p.id)} />
{p.name}
@@ -167,7 +216,11 @@ export function Dashboard({
))}
</div>
)}
<WealthChart series={series} metric={basis.metric} />
<WealthChart
series={series}
metric={basis.metric}
onColorChange={(id, color) => setColors((prev) => ({ ...prev, [id]: color }))}
/>
</section>
)}
@@ -175,11 +228,11 @@ export function Dashboard({
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
<BarChart3 className="h-4 w-4 text-accent" />
Vermögensaufteilung pro Phase (Beginn &amp; Ende)
Vermögensaufteilung im Zeitverlauf · {plan.name}
</h3>
<p className="mb-3 text-xs text-muted">
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
Gesamtvolumen dem Beginn der nächsten &ndash; die Aufteilung kann durch Umschichtung abweichen.
Links der Verlauf über alle Planjahre (gestapelt; die gestrichelten Linien sind die Phasengrenzen), rechts
die relative Aufteilung zu einem wählbaren Zeitpunkt.
</p>
<AllocationChart computed={computed} />
</section>
@@ -230,8 +283,8 @@ function buildChartResult(
xLabel: "Jahr",
yFormat: "chf" as const,
series: [
{ label: "Einkommen", color: "#16a34a", points: computed.yearly.map((y) => ({ x: y.year, y: y.income })) },
{ label: "Ausgaben", color: "#dc2626", points: computed.yearly.map((y) => ({ x: y.year, y: y.expenseNominal })) },
{ label: "Einkommen", color: "var(--chart-3)", points: computed.yearly.map((y) => ({ x: y.year, y: y.income })) },
{ label: "Ausgaben", color: "var(--chart-5)", points: computed.yearly.map((y) => ({ x: y.year, y: y.expenseNominal })) },
],
},
},
+4 -4
View File
@@ -43,8 +43,8 @@ const CHARTS: { id: ChartId; label: string; hint: string }[] = [
},
];
const BASE_COLOR = "#9ca3af";
const LIVE_COLOR = "#4f46e5";
const BASE_COLOR = "var(--faint)";
const LIVE_COLOR = "var(--chart-1)";
export function LiveSimDialog({
plan: currentPlan,
@@ -243,8 +243,8 @@ export function LiveSimDialog({
<WealthChart
series={[
// Referenz zuerst, damit die Live-Linie darüber liegt.
{ label: "Dein Plan", color: BASE_COLOR, computed: base.computed },
{ label: "Simuliert", color: LIVE_COLOR, computed: live.computed },
{ id: "base", label: "Dein Plan", color: BASE_COLOR, computed: base.computed },
{ id: "live", label: "Simuliert", color: LIVE_COLOR, computed: live.computed },
]}
/>
)}
+1 -1
View File
@@ -35,7 +35,7 @@ import type { PlanInput, ScenarioMeta } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
// Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt.
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
const PALETTE = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6)"];
// Ergebnis je Szenario: ZWEI Welten (historische vs. geplante Renditen), aus jeder werden
// ZWEI Schwellen abgelesen (Plan-Endbetrag und Zielbetrag) -- die vier Fälle aus 4.12.7.
+219 -39
View File
@@ -3,16 +3,21 @@
import { useCallback, useEffect, useState } from "react";
import {
BarChart3,
CalendarClock,
Check,
Dices,
Eye,
FileSpreadsheet,
GitBranch,
History,
Layers,
LineChart as LineChartIcon,
Pencil,
Plus,
SlidersHorizontal,
Table2,
Tornado,
Trash2,
X,
} from "lucide-react";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client";
@@ -20,6 +25,8 @@ import { formatChf } from "@/lib/format";
import { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
import type { PersonRole } from "@/lib/types";
export type PlanTab = "dashboard" | "scenarios" | "actuals" | "analyses" | "reports";
// --- Typen der Dashboard-Antwort ---
interface DashboardResponse {
plan: {
@@ -45,8 +52,9 @@ interface DashboardResponse {
name: string;
isBase: boolean;
parentScenarioId: string | null;
currentMajor: number;
version: string | null;
elementCount: number;
phaseCount: number;
versionCount: number;
endNominal: number;
endReal: number;
@@ -74,21 +82,69 @@ function useDashboard(planId: string, reloadKey: number) {
return { data, error };
}
function Stat({ label, value, hint, tone }: { label: string; value: string; hint?: string; tone?: "danger" }) {
return (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="text-xs text-muted">{label}</div>
<div className={`mt-1 text-xl font-semibold ${tone === "danger" ? "text-danger" : "text-fg"}`}>{value}</div>
// Anklickbare Kennzahl-Kachel. Ohne `onClick` bleibt sie eine reine Anzeige.
function Stat({
label,
value,
hint,
tone,
icon: Icon,
onClick,
}: {
label: string;
value: string;
hint?: string;
tone?: "danger" | "success";
icon?: typeof Layers;
onClick?: () => void;
}) {
const toneClass = tone === "danger" ? "text-danger" : tone === "success" ? "text-success" : "text-fg";
const body = (
<>
<div className="flex items-center gap-1.5 text-xs text-muted">
{Icon && <Icon className="h-3.5 w-3.5 text-accent" />}
{label}
</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${toneClass}`}>{value}</div>
{hint && <div className="mt-0.5 text-[11px] text-faint">{hint}</div>}
</div>
</>
);
if (!onClick) {
return <div className="rounded-2xl border border-border bg-surface p-4 shadow-sm">{body}</div>;
}
return (
<button
type="button"
onClick={onClick}
className="group rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
>
{body}
<div className="mt-1 text-[11px] font-medium text-accent opacity-0 transition-opacity group-hover:opacity-100">
Öffnen
</div>
</button>
);
}
// =========================================================================================
// Plan-Dashboard
// =========================================================================================
export function PlanDashboardView({ planId }: { planId: string }) {
const { data, error } = useDashboard(planId, 0);
export function PlanDashboardView({
planId,
onNavigate,
onRenamed,
}: {
planId: string;
onNavigate: (tab: PlanTab) => void;
onRenamed: () => void;
}) {
const [reloadKey, setReloadKey] = useState(0);
const { data, error } = useDashboard(planId, reloadKey);
const [editing, setEditing] = useState(false);
const [draftName, setDraftName] = useState("");
const [saving, setSaving] = useState(false);
const toast = useToast();
if (error) return <p className="text-sm text-danger">{error}</p>;
if (!data) return <p className="text-sm text-muted">Wird geladen</p>;
@@ -97,43 +153,134 @@ export function PlanDashboardView({ planId }: { planId: string }) {
.map((p) => `${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")} (${p.age})`)
.join(" · ");
// Jüngster erfasster Ist-Datensatz -- er bestimmt, gegen welchen Stand verglichen wird.
const latestActualYear = base && base.actualYears.length > 0 ? Math.max(...base.actualYears) : null;
const delta = base && base.actualEndNominal !== null ? base.actualEndNominal - base.endNominal : null;
async function saveName() {
const name = draftName.trim();
if (!name) return;
setSaving(true);
try {
await api.patch(`/api/plans/${planId}`, { name });
setEditing(false);
setReloadKey((k) => k + 1);
onRenamed();
toast("success", "Plan umbenannt.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Umbenennen fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-lg font-semibold text-fg">{plan.name}</h2>
{editing ? (
<div className="flex flex-wrap items-center gap-2">
<input
autoFocus
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveName();
if (e.key === "Escape") setEditing(false);
}}
maxLength={120}
className="rounded-lg border border-border bg-input px-3 py-1.5 text-lg font-semibold text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
/>
<Button size="sm" onClick={saveName} disabled={saving}>
<Check className="h-4 w-4" /> Speichern
</Button>
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="group flex items-center gap-2">
<h2 className="text-lg font-semibold text-fg">{plan.name}</h2>
<button
type="button"
aria-label="Plan umbenennen"
title="Plan umbenennen"
onClick={() => {
setDraftName(plan.name);
setEditing(true);
}}
className="rounded-md p-1 text-faint opacity-60 transition-opacity hover:bg-surface-2 hover:text-fg group-hover:opacity-100"
>
<Pencil className="h-4 w-4" />
</button>
</div>
)}
<p className="text-sm text-muted">
{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons}
{plan.startYear ? ` · Planstart ${plan.startYear}` : ""}
</p>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Stat label="Szenarien" value={String(counts.scenarios)} />
<Stat label="Effektive Werte" value={String(counts.actuals)} hint="erfasste Ist-Datensätze" />
<Stat label="Gespeicherte Analysen" value={String(counts.analyses)} />
<Stat label="Lebensphasen" value={base ? String(base.phaseCount) : ""} hint="laut Basisszenario" />
{/* Anklickbare Kacheln -- jede führt in ihren Bereich. */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Stat
label="Szenarien"
value={String(counts.scenarios)}
hint="Varianten dieser Planung"
icon={Layers}
onClick={() => onNavigate("scenarios")}
/>
<Stat
label="Effektive Werte"
value={String(counts.actuals)}
hint="erfasste Ist-Datensätze"
icon={CalendarClock}
onClick={() => onNavigate("actuals")}
/>
<Stat
label="Gespeicherte Analysen"
value={String(counts.analyses)}
hint="Grafiken, Simulationen, Tornados"
icon={BarChart3}
onClick={() => onNavigate("analyses")}
/>
<Stat
label="Berichte"
value="PDF"
hint="erzeugen und wieder herunterladen"
icon={FileSpreadsheet}
onClick={() => onNavigate("reports")}
/>
</div>
{base && (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-3 text-sm font-semibold text-fg">
<div className="rounded-2xl border border-border bg-surface p-5 shadow-sm">
<h3 className="mb-3 flex flex-wrap items-baseline gap-2 text-sm font-semibold text-fg">
Basisszenario «{base.name}»
<span className="ml-2 text-[11px] font-normal text-faint">alle Kennzahlen beziehen sich hierauf</span>
<span className="text-[11px] font-normal text-faint">alle Kennzahlen beziehen sich hierauf</span>
</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Stat label="Endvermögen (nominal)" value={formatChf(base.endNominal)} />
<Stat label="Endvermögen (real)" value={formatChf(base.endReal)} />
<Stat label="Lebensphasen" value={String(base.phaseCount)} />
{base.ruinAge !== null ? (
<Stat label="Kapital reicht" value={`bis Alter ${base.ruinAge}`} tone="danger" hint="danach aufgebraucht" />
) : (
<Stat label="Kapital reicht" value="bis Planende" />
<Stat label="Kapital reicht" value="bis Planende" tone="success" />
)}
</div>
{base.actualEndNominal !== null && (
<p className="mt-3 rounded-lg border border-accent bg-accent-soft/20 px-3 py-2 text-xs text-accent-soft-fg">
Mit den effektiven Werten liegt das Endvermögen (nominal) bei{" "}
<strong>{formatChf(base.actualEndNominal)}</strong> eine Abweichung von{" "}
<strong>{formatChf(base.actualEndNominal - base.endNominal)}</strong> gegenüber dem Plan.
{base.actualEndNominal !== null && delta !== null && (
<p
className={`mt-4 rounded-xl border px-3 py-2 text-xs ${
delta >= 0 ? "border-success bg-success/10 text-fg" : "border-danger bg-danger-soft text-fg"
}`}
>
Mit den effektiven Werten{latestActualYear ? ` von ${latestActualYear}` : ""} liegt das Endvermögen
(nominal) bei <strong>{formatChf(base.actualEndNominal)}</strong> eine Abweichung von{" "}
<strong className={delta >= 0 ? "text-success" : "text-danger"}>
{delta >= 0 ? "+" : ""}
{formatChf(delta)}
</strong>{" "}
gegenüber dem Plan.
</p>
)}
</div>
@@ -147,28 +294,36 @@ export function PlanDashboardView({ planId }: { planId: string }) {
// =========================================================================================
export function ScenarioListView({
planId,
reloadKey = 0,
onOpenMatrix,
onOpenHistory,
onNew,
onCopyFrom,
onDelete,
}: {
planId: string;
reloadKey?: number;
onOpenMatrix: (scenarioId: string) => void;
onOpenHistory: (scenarioId: string) => void;
onNew: () => void;
// Kopiervorlage ist frei wählbar -- nicht mehr zwingend das Basisszenario.
onCopyFrom: (scenarioId: string) => void;
onDelete: (scenarioId: string) => void;
}) {
const { data, error } = useDashboard(planId, 0);
const { data, error } = useDashboard(planId, reloadKey);
if (error) return <p className="text-sm text-danger">{error}</p>;
if (!data) return <p className="text-sm text-muted">Wird geladen</p>;
const nameById = new Map(data.scenarios.map((s) => [s.id, s.name]));
const baseId = data.scenarios.find((s) => s.isBase)?.id ?? data.scenarios[0]?.id;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-fg">Szenarien</h2>
<Button onClick={onNew}>
<Plus className="h-4 w-4" /> Neues Szenario
</Button>
{baseId && (
<Button onClick={() => onCopyFrom(baseId)}>
<Plus className="h-4 w-4" /> Neues Szenario
</Button>
)}
</div>
<div className="overflow-x-auto rounded-xl border border-border">
@@ -177,6 +332,7 @@ export function ScenarioListView({
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
<th className="px-3 py-2 text-right font-semibold">Version</th>
<th className="px-3 py-2 text-right font-semibold">Phasen</th>
<th className="px-3 py-2 text-right font-semibold">Elemente</th>
<th className="px-3 py-2 text-right font-semibold">Endvermögen (nom.)</th>
<th className="px-3 py-2 text-right font-semibold">Kapital reicht</th>
@@ -185,11 +341,20 @@ export function ScenarioListView({
</thead>
<tbody>
{data.scenarios.map((s) => (
<tr key={s.id} className={`border-t border-border ${s.isBase ? "bg-accent-soft/20" : ""}`}>
// Die ganze Zeile öffnet die Matrix -- der frühere «Matrix»-Knopf entfällt.
<tr
key={s.id}
onClick={() => onOpenMatrix(s.id)}
className={`cursor-pointer border-t border-border transition-colors hover:bg-accent-soft/40 ${
s.isBase ? "bg-accent-soft/20" : ""
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span className="font-medium text-fg">{s.name}</span>
{s.isBase && <span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>}
{s.isBase && (
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>
)}
</div>
{s.parentScenarioId && (
<div className="flex items-center gap-1 text-[11px] text-faint">
@@ -197,7 +362,8 @@ export function ScenarioListView({
</div>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.currentMajor}.x</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.version ?? ""}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.phaseCount}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.elementCount}</td>
<td className="px-3 py-2 text-right tabular-nums text-fg">{formatChf(s.endNominal)}</td>
<td className="px-3 py-2 text-right">
@@ -207,10 +373,12 @@ export function ScenarioListView({
<span className="text-success">Planende</span>
)}
</td>
<td className="px-3 py-2">
{/* Aktionen: stopPropagation, sonst öffnet der Zeilenklick die Matrix. */}
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
title="Änderungshistorie"
onClick={() => onOpenHistory(s.id)}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
>
@@ -218,11 +386,23 @@ export function ScenarioListView({
</button>
<button
type="button"
onClick={() => onOpenMatrix(s.id)}
title="Neues Szenario aus diesem"
onClick={() => onCopyFrom(s.id)}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
>
<Table2 className="h-3.5 w-3.5" /> Matrix
<Plus className="h-3.5 w-3.5" /> Kopie
</button>
{!s.isBase && (
<button
type="button"
aria-label="Szenario löschen"
title="Szenario löschen"
onClick={() => onDelete(s.id)}
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</td>
</tr>
@@ -394,7 +574,7 @@ function FlipTile({ tile, onClick }: { tile: (typeof TILES)[number]; onClick: ()
onMouseEnter={() => setFlipped(true)}
onMouseLeave={() => setFlipped(false)}
// Touch hat kein Hover -- ein Antippen des Info-Bereichs klappt um, statt gleich zu starten.
className="group relative flex h-40 flex-col items-start justify-between overflow-hidden rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-colors hover:border-accent"
className="group relative flex h-40 flex-col items-start justify-between overflow-hidden rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
>
{!flipped ? (
<>
+1 -1
View File
@@ -139,7 +139,7 @@ function SavedChart({ chart }: { chart: NonNullable<SavedResult["chart"]> }) {
for (const s of chart.series) row[s.label] = s.points.find((p) => p.x === x)?.y ?? (null as unknown as number);
return row;
});
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
const PALETTE = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6)"];
return (
<div className="h-72 w-full">
+3 -3
View File
@@ -14,9 +14,9 @@ import {
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
const INCOME_COLOR = "#16a34a";
const EXPENSE_COLOR = "#dc2626";
const REAL_COLOR = "#9ca3af";
const INCOME_COLOR = "var(--chart-3)";
const EXPENSE_COLOR = "var(--chart-5)";
const REAL_COLOR = "var(--faint)";
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Fläche
// dazwischen ist die Spar-/Verzehrquote (grün = Sparen, rot = Verzehr). Reale Ausgaben als
+116 -45
View File
@@ -1,19 +1,16 @@
"use client";
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useEffect, useState } from "react";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { TOOLTIP_STYLE } from "@/components/AllocationChart";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
export interface TimelineSeries {
// Eindeutiger Schlüssel der Serie. WICHTIG: Bis 0.30 diente der ANZEIGENAME als Datenschlüssel
// -- zwei Szenarien mit gleichem Namen überschrieben sich dadurch gegenseitig (die Legende
// zeigte beide, der Chart nur eine). Deshalb ein eigener, garantiert eindeutiger Schlüssel.
id: string;
label: string;
color: string;
computed: PlanComputed;
@@ -36,15 +33,39 @@ function pointsFor(computed: PlanComputed) {
return pts;
}
// `var(--chart-1)` in einen konkreten Hex-Wert auflösen -- das Farbwahl-Feld (input[type=color])
// braucht einen echten Wert. Läuft nur im Browser und nur, wenn eine Variable übergeben wurde.
function resolveColor(color: string): string {
if (!color.startsWith("var(")) return color;
if (typeof window === "undefined") return "#888888";
const name = color.slice(4, -1).trim();
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || "#888888";
}
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
// überlagerte Pläne für den Szenario-Vergleich.
export function WealthChart({
series,
metric = "nominal",
onColorChange,
}: {
series: TimelineSeries[];
metric?: "nominal" | "real";
// Optional: erlaubt das Umfärben einer Serie über die Legende.
onColorChange?: (id: string, color: string) => void;
}) {
// Aufgelöste Farben für die Farbwahl-Felder; nach dem ersten Rendern (und bei Theme-Wechsel).
const [resolved, setResolved] = useState<Record<string, string>>({});
const colorKey = series.map((s) => `${s.id}:${s.color}`).join("|");
useEffect(() => {
const next: Record<string, string> = {};
for (const s of series) next[s.id] = resolveColor(s.color);
// eslint-disable-next-line react-hooks/set-state-in-effect -- Farbauflösung braucht das DOM
setResolved(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [colorKey]);
if (series.length === 0 || series[0].computed.phases.length === 0) {
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
}
@@ -56,47 +77,97 @@ export function WealthChart({
const row: Record<string, number | null> = { age };
for (const s of withPoints) {
const pt = s.points.find((p) => p.age === age);
row[s.label] = pt ? (metric === "real" ? pt.real : pt.nominal) : null;
row[s.id] = pt ? (metric === "real" ? pt.real : pt.nominal) : null;
}
return row;
});
const nameById = new Map(series.map((s) => [s.id, s.label]));
const hasDashed = series.some((s) => s.dashed);
return (
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="age"
type="number"
domain={["dataMin", "dataMax"]}
tick={{ fontSize: 11 }}
tickFormatter={(v) => `${v} J.`}
/>
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip
formatter={(v) => (typeof v === "number" ? formatChf(v) : v)}
labelFormatter={(v) => `Alter ${v}`}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
{withPoints.map((s) => (
<Line
key={s.label}
type="monotone"
dataKey={s.label}
stroke={s.color}
strokeWidth={2}
strokeDasharray={s.dashed ? "5 3" : undefined}
dot={false}
connectNulls
isAnimationActive={false}
<div className="w-full">
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
<XAxis
dataKey="age"
type="number"
domain={["dataMin", "dataMax"]}
tick={{ fontSize: 11 }}
stroke="var(--faint)"
tickFormatter={(v) => `${v} J.`}
/>
))}
</LineChart>
</ResponsiveContainer>
<YAxis
tick={{ fontSize: 11 }}
stroke="var(--faint)"
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v, key) => [typeof v === "number" ? formatChf(v) : v, nameById.get(String(key)) ?? key]}
labelFormatter={(v) => `Alter ${v}`}
/>
{withPoints.map((s) => (
<Line
key={s.id}
type="monotone"
dataKey={s.id}
name={s.label}
stroke={s.color}
strokeWidth={2}
strokeDasharray={s.dashed ? "5 3" : undefined}
dot={false}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
{/* Eigene Legende statt der von Recharts: Sie trägt die Farbwahl und erklärt den
Linienstil -- ohne das wäre unklar, wofür die gestrichelte Linie steht. */}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5">
{series.map((s) => (
<span key={s.id} className="flex items-center gap-1.5 text-xs text-muted">
{onColorChange ? (
<label className="relative flex cursor-pointer items-center" title="Farbe wählen">
<span
className="block h-3 w-6 rounded-sm"
style={{
background: s.dashed
? `repeating-linear-gradient(90deg, ${s.color} 0 5px, transparent 5px 8px)`
: s.color,
}}
/>
<input
type="color"
value={resolved[s.id] ?? "#888888"}
onChange={(e) => onColorChange(s.id, e.target.value)}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
) : (
<span
className="block h-3 w-6 rounded-sm"
style={{
background: s.dashed
? `repeating-linear-gradient(90deg, ${s.color} 0 5px, transparent 5px 8px)`
: s.color,
}}
/>
)}
{s.label}
</span>
))}
{hasDashed && (
<span className="text-[11px] text-faint">
gestrichelt = reine Planwerte · durchgezogen = mit effektiven Werten
</span>
)}
</div>
</div>
);
}
+1 -29
View File
@@ -1826,32 +1826,4 @@ function retiresInPhase(
return p.age + yearsBeforeNext >= retirementAge.get(personId)!;
}
// CSV-Export (eine Zeile pro Lebensphase, Kernkennzahlen).
export function planToCsv(plan: PlanInput, computed: PlanComputed): string {
const header = [
"Phase",
"Typ",
"Dauer",
"Einkommen (Beginn)",
"Ausgaben (Beginn)",
"Quote (Beginn)",
"Quote (Ende)",
"Cash (Ende)",
"Endvermögen (nominal)",
"Endvermögen (real)",
];
const rows = computed.phases.map((p) => [
p.name,
p.type,
String(p.durationYears),
p.incomeStart.toFixed(0),
p.expenseStart.toFixed(0),
p.quotaStart.toFixed(0),
p.quotaEnd.toFixed(0),
p.cashEnd.toFixed(0),
p.endWealthNominal.toFixed(0),
p.endWealthReal.toFixed(0),
]);
if (computed.ruinAge !== null) rows.push([`Ruin: Kapital aufgebraucht mit Alter ${computed.ruinAge}`]);
return [header, ...rows].map((r) => r.join(";")).join("\n");
}
// Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary).
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import { planToCsv } from "@/lib/csv";
import type { PlanInput } from "@/lib/types";
// Plan mit zwei Phasen, einem Uebergangs-Entscheid und mehreren Elementen -- deckt alle vier
// Bloecke des Exports ab.
function plan(): PlanInput {
return {
id: "s",
name: "Test-Szenario",
householdType: "SINGLE",
inflationRateDefault: 1.5,
initialCash: 20000,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: "Anna", age: 60, retirementAge: 65 }],
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 5, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 10, cashTransition: {} },
],
elements: [
{
id: "inc", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1,
phaseValues: { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} },
transitionValues: {},
},
{
id: "exp", category: "EXPENSE", name: "Lebenshaltung", ownerRole: "HOUSEHOLD", orderIndex: 2,
phaseValues: { p1: { amount: 70000 }, p2: { amount: 70000 } },
transitionValues: {},
},
{
id: "pk", category: "PENSION_FUND", name: "Pensionskasse", ownerRole: "PERSON_A", orderIndex: 3,
phaseValues: { p1: { currentValue: 500000, annualContribution: 15000, expectedReturn: 2 }, p2: {} },
transitionValues: { p1: { payoutMode: "CAPITAL", capitalTaxRate: 8 } },
},
],
} as unknown as PlanInput;
}
const build = () => planToCsv(plan(), computePlan(plan()));
describe("planToCsv", () => {
it("beginnt mit dem BOM, damit Excel die Umlaute erkennt", () => {
expect(build().charCodeAt(0)).toBe(0xfeff);
});
it("enthaelt alle vier Bloecke", () => {
const csv = build();
expect(csv).toContain("LEBENSPHASEN");
expect(csv).toContain("MATRIX");
expect(csv).toContain("JAHRESWERTE");
expect(csv).toContain("Test-Szenario");
});
it("fuehrt JEDES finanzielle Element als eigene Zeile", () => {
const csv = build();
for (const name of ["Lohn", "Lebenshaltung", "Pensionskasse", "Cash"]) {
expect(csv).toContain(name);
}
});
it("hat je Phase eine Beginn- und eine Ende-Spalte plus die Uebergangsspalte", () => {
const lines = build().split("\r\n");
const header = lines.find((l) => l.startsWith("Element;"))!;
expect(header).toContain("Erwerb Beginn");
expect(header).toContain("Erwerb Ende");
expect(header).toContain("Übergang → Pension");
expect(header).toContain("Pension Ende");
});
it("schreibt den Uebergangs-Entscheid im Klartext", () => {
expect(build()).toContain("Kapitalbezug (Steuer 8 %)");
});
it("listet jedes Planjahr einmal", () => {
const csv = build();
const start = csv.indexOf("JAHRESWERTE");
const yearRows = csv
.slice(start)
.split("\r\n")
.filter((l) => /^\d+;/.test(l));
expect(yearRows).toHaveLength(15); // 5 + 10 Jahre
expect(yearRows[0]).toContain(";2026;"); // Kalenderjahr aus dem Planstart
});
it("maskiert Semikolon und Anfuehrungszeichen im Text", () => {
const p = plan();
p.elements[0].name = 'Lohn; "Haupt"';
const csv = planToCsv(p, computePlan(p));
expect(csv).toContain('"Lohn; ""Haupt"""');
});
});
+221
View File
@@ -0,0 +1,221 @@
// CSV-Export der vollständigen Matrix.
//
// Bis 0.30 war der Export eine reine Phasen-Zusammenfassung -- zehn Spalten, eine Zeile je
// Phase, KEIN einziges finanzielles Element. Was man am Bildschirm sieht, liess sich damit
// nicht weiterverarbeiten.
//
// Neu enthält die Datei vier BLÖCKE untereinander (Excel kommt damit gut zurecht, und man
// sieht auf einen Blick, was zusammengehört):
// 1. Kopf -- Plan, Szenario, Haushalt, Annahmen, Erzeugungszeitpunkt
// 2. Lebensphasen -- die Kennzahlen je Phase
// 3. Matrix -- Zeilen = Elemente, je Phase Beginn/Ende, dazwischen die Übergangs-Entscheide
// 4. Jahreswerte -- je Planjahr Vermögen, Cash, Einkommen, Ausgaben (Basis für eigene Auswertungen)
//
// Rein: keine I/O, keine UI-Abhängigkeit. Damit auch vom Server aus nutzbar.
import { CATEGORY_LABELS, num } from "@/lib/elements";
import type { TransitionData } from "@/lib/elements";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
const SEP = ";";
// Excel (Windows) erkennt UTF-8 nur mit BOM -- ohne dieses Zeichen zerfallen alle Umlaute.
const BOM = "";
function cell(v: string | number | null | undefined): string {
if (v === null || v === undefined) return "";
const s = String(v);
// Semikolon, Anführungszeichen und Zeilenumbrüche müssen maskiert werden.
return /[";\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
function row(cells: (string | number | null | undefined)[]): string {
return cells.map(cell).join(SEP);
}
const chf = (v: number) => Math.round(v).toString();
// Kurztext eines Übergangs-Entscheids. Bewusst hier und nicht aus der UI importiert --
// `src/lib` darf nicht aus `src/components` lesen (siehe server-boundary.test.ts).
function transitionText(category: string, td: TransitionData | undefined): string {
if (!td || Object.keys(td).length === 0) return "";
switch (category) {
case "PENSION_FUND": {
if (td.payoutMode === "CAPITAL") return `Kapitalbezug (Steuer ${num(td.capitalTaxRate)} %)`;
if (td.payoutMode === "PENSION") return `Verrentung (Satz ${num(td.conversionRate)} %)`;
if (td.payoutMode === "COMBI") return `Kombi: Kapital ${chf(num(td.capitalAmount))}, Rest verrentet`;
return td.withdrawalMode === "AMOUNT" ? `Vorbezug ${chf(num(td.withdrawal))}` : "Kein Bezug";
}
case "PILLAR_3A":
return td.withdrawalMode === "AMOUNT" ? `Bezug ${chf(num(td.withdrawal))}` : "Kein Bezug";
case "REAL_ESTATE":
if (td.decision === "SELL") return `Verkauf ${chf(num(td.salePrice))} (Steuer ${num(td.saleTaxRate)} %)`;
return num(td.extraAmortization) > 0 ? `Sonderamortisation ${chf(num(td.extraAmortization))}` : "Halten";
case "OTHER_ASSET":
if (td.decision === "SELL") return "Verkauf";
if (td.decision === "PARTIAL") return `Teilverkauf ${chf(num(td.partialSaleAmount))}`;
return "Halten";
case "OTHER_DEBT":
return num(td.immediateRepayment) > 0 ? `Sofort-Tilgung ${chf(num(td.immediateRepayment))}` : "";
case "AHV":
return td.reviewed ? `geprüft (Ø ${chf(num(td.avgIncomeBefore))}, ${num(td.gapYearsBefore)} Ausfalljahre)` : "";
default:
return "";
}
}
function ownerText(role: string | null): string {
if (role === "PERSON_A") return "Person A";
if (role === "PERSON_B") return "Person B";
return "Gemeinsam";
}
export function planToCsv(plan: PlanInput, computed: PlanComputed): string {
const lines: string[] = [];
const phases = computed.phases;
// --- 1. Kopf -------------------------------------------------------------------------
lines.push(row(["FPT Financial Planning Tool"]));
lines.push(row(["Szenario", plan.name]));
lines.push(row(["Haushaltsform", plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"]));
for (const p of plan.persons) {
lines.push(
row([ownerText(p.role), p.name ?? "", `Alter ${p.age}`, `Pension mit ${p.retirementAge}`])
);
}
lines.push(row(["Planstart", plan.startYear ?? ""]));
lines.push(row(["Inflation (%)", plan.inflationRateDefault]));
lines.push(row(["Cash-Anfangswert", chf(plan.initialCash)]));
lines.push(row(["Erzeugt am", new Date().toISOString().slice(0, 19).replace("T", " ")]));
lines.push(row(["Hinweis", "Alle Beträge nominal in CHF, sofern nicht anders bezeichnet."]));
lines.push("");
// --- 2. Lebensphasen -----------------------------------------------------------------
lines.push(row(["LEBENSPHASEN"]));
lines.push(
row([
"Nr.",
"Phase",
"Typ",
"Dauer (J.)",
"Alter von",
"Alter bis",
"Einkommen (Beginn)",
"Ausgaben (Beginn)",
"Quote (Beginn)",
"Quote (Ende)",
"Cash (Beginn)",
"Cash (Ende)",
"Vermögen (Beginn, nom.)",
"Vermögen (Ende, nom.)",
"Vermögen (Ende, real)",
])
);
for (const p of phases) {
const a = p.persons[0];
lines.push(
row([
p.sequenceNumber,
p.name,
p.type,
p.durationYears,
a ? a.startAge : "",
a ? a.endAge : "",
chf(p.incomeStart),
chf(p.expenseStart),
chf(p.quotaStart),
chf(p.quotaEnd),
chf(p.cashStart),
chf(p.cashEnd),
chf(p.startWealthNominal),
chf(p.endWealthNominal),
chf(p.endWealthReal),
])
);
}
lines.push("");
// --- 3. Matrix -----------------------------------------------------------------------
// Spaltenfolge wie am Bildschirm: je Phase «Beginn» und «Ende», dazwischen der Übergang.
const header: string[] = ["Element", "Kategorie", "Zuordnung"];
for (let i = 0; i < phases.length; i++) {
header.push(`${phases[i].name} Beginn`, `${phases[i].name} Ende`);
if (i < phases.length - 1) header.push(`Übergang → ${phases[i + 1].name}`);
}
lines.push(row(["MATRIX"]));
lines.push(row(header));
// Cash-Zeile zuerst -- sie steht auch in der Ansicht zuoberst.
const cashRow: (string | number)[] = ["Cash", "Cash-Konto", "Gemeinsam"];
for (let i = 0; i < phases.length; i++) {
cashRow.push(chf(phases[i].cashStart), chf(phases[i].cashEnd));
if (i < phases.length - 1) {
const ct = plan.phases.find((x) => x.id === phases[i].id)?.cashTransition;
const parts: string[] = [];
if (ct && ct.mode && ct.mode !== "NONE") {
if (num(ct.inflowAmount) > 0) parts.push(`Zufluss ${chf(num(ct.inflowAmount))}`);
if (num(ct.outflowAmount) > 0) parts.push(`Kosten ${chf(num(ct.outflowAmount))}`);
}
cashRow.push(parts.join(", "));
}
}
lines.push(row(cashRow));
// Elemente in der Reihenfolge der Ansicht.
const ordered = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
for (const el of ordered) {
const r: (string | number)[] = [el.name, CATEGORY_LABELS[el.category] ?? el.category, ownerText(el.ownerRole)];
for (let i = 0; i < phases.length; i++) {
const ce = phases[i].elements.find((x) => x.elementId === el.id);
if (!ce) {
r.push("", "");
} else if (ce.status !== "ACTIVE") {
r.push(ce.status === "SOLD" ? "verkauft" : "getilgt", "");
} else {
r.push(chf(ce.startValue), chf(ce.endValue));
}
if (i < phases.length - 1) r.push(transitionText(el.category, el.transitionValues[phases[i].id]));
}
lines.push(row(r));
}
lines.push("");
// --- 4. Jahreswerte ------------------------------------------------------------------
lines.push(row(["JAHRESWERTE"]));
lines.push(
row([
"Planjahr",
"Kalenderjahr",
"Alter",
"Vermögen (nom.)",
"Vermögen (real)",
"Cash",
"Einkommen",
"Ausgaben (nom.)",
"Sparquote",
])
);
for (const y of computed.yearly) {
lines.push(
row([
y.year,
plan.startYear ? plan.startYear + y.year - 1 : "",
y.age,
chf(y.wealthNominal),
chf(y.wealthReal),
chf(y.cash),
chf(y.income),
chf(y.expenseNominal),
chf(y.income - y.expenseNominal),
])
);
}
if (computed.ruinAge !== null) {
lines.push("");
lines.push(row([`Hinweis: Kapital aufgebraucht mit Alter ${computed.ruinAge}`]));
}
return BOM + lines.join("\r\n");
}
+2 -1
View File
@@ -97,7 +97,8 @@ describe("Datenbank-Migrationen", () => {
WHERE table_name='Scenario' AND column_name='currentMajor'`
);
expect(major.rows[0].is_nullable).toBe("NO");
expect(major.rows[0].column_default).toContain("1");
// Startwert 0: Ein Szenario laeuft in der 0er-Reihe, bis eine Hauptversion gesetzt wird.
expect(major.rows[0].column_default).toContain("0");
// A.B muss je Szenario eindeutig sein, sonst kollidieren zwei Sitzungen auf derselben
// Nummer und die Historie wird mehrdeutig.
+1 -1
View File
@@ -272,7 +272,7 @@ export async function restoreVersion(
const created = await prisma.scenarioVersion.create({
data: {
scenarioId,
major: latest?.major ?? 1,
major: latest?.major ?? 0,
minor: (latest?.minor ?? 0) + 1,
comment: restoreComment({ major: version.major, minor: version.minor }),
createdById: userId,
+8 -2
View File
@@ -73,8 +73,14 @@ describe("canonicalJson", () => {
});
describe("decideVersion", () => {
it("legt für den allerersten Stand 1.0 an", () => {
expect(decideVersion(null, snap(), "u1", T0)).toEqual({ action: "create", major: 1, minor: 0 });
it("legt für den allerersten Stand 0.1 an", () => {
// Die 0er-Reihe bedeutet "noch kein verabschiedeter Stand" -- 1.0 entsteht erst durch eine
// bewusst gesetzte Hauptversion (SPEZIFIKATION 3.8).
expect(decideVersion(null, snap(), "u1", T0)).toEqual({ action: "create", major: 0, minor: 1 });
});
it("macht aus der 0er-Reihe erst mit der Hauptversion eine 1.0", () => {
expect(nextMajor({ major: 0, minor: 17 })).toEqual({ major: 1, minor: 0 });
});
it("tut nichts, wenn sich inhaltlich nichts geändert hat", () => {
+5 -2
View File
@@ -69,8 +69,11 @@ export function decideVersion(
userId: string,
now: Date
): VersionDecision {
// Allererster Stand: 1.0.
if (!latest) return { action: "create", major: 1, minor: 0 };
// Allererster Stand: 0.1. Ein Szenario laeuft solange in der 0er-Reihe (0.1, 0.2, ... 0.137),
// bis der Nutzer bewusst eine HAUPTVERSION setzt -- erst dann wird daraus 1.0. Die 0 sagt
// also: "noch kein verabschiedeter Stand". Bis 0.30 startete das Tool bei 1.0, wodurch die
// Hauptversion ihre Bedeutung verlor.
if (!latest) return { action: "create", major: 0, minor: 1 };
// Nichts veraendert -- z. B. Dialog geoeffnet und unveraendert gespeichert. Ohne diese
// Pruefung sammelt die Historie Versionen, die nichts unterscheiden.