Modul-Review 3: Struktur, Dashboard, CSV-Export und Grafiken
Deploy App / deploy (push) Successful in 1m59s
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:
+134
-67
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.30 |
|
| **Version** | 0.31 |
|
||||||
| **Datum** | 2026-07-24 |
|
| **Datum** | 2026-07-24 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **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` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| 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.2–3.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.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.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 7’258 auf ca. 36’288 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. |
|
| 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 7’258 auf ca. 36’288 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 8–10, `src/lib/types.ts` Zeilen 38–4
|
|||||||
|
|
||||||
```
|
```
|
||||||
User
|
User
|
||||||
└── Plan (Behälter: nur Name — KEINE Finanzdaten)
|
└── Plan (der HAUSHALT: Name, Haushaltsform, Startjahr)
|
||||||
└── Scenario[] (die berechenbare Einheit)
|
├── PlanPerson[] (Rolle, Name, Alter — gelten für ALLE Szenarien)
|
||||||
│ Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert
|
└── Scenario[] (die berechenbare Einheit = eine Planungsvariante)
|
||||||
|
│ szenario-eigen: Inflation, Cash-Startwert
|
||||||
│ isBase = genau eines je Plan · parentScenarioId = Baum + Vergleichsbasis
|
│ 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)
|
├── Phase[] (Kette 1..n; sourcePhaseId = Gegenstück in der Vorlage)
|
||||||
└── FinancialElement[] (szenario-weit; sourceElementId = Gegenstück in der Vorlage)
|
└── FinancialElement[] (szenario-weit; sourceElementId = Gegenstück in der Vorlage)
|
||||||
├── ElementPhaseValue[] (Werte je Phase, JSON)
|
├── 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
|
Vermögensgegenstands über die Zeit erhalten – Voraussetzung für die Fortschreibung (Carry) und
|
||||||
die Vermögensaufteilungs-Grafik.
|
die Vermögensaufteilungs-Grafik.
|
||||||
|
|
||||||
**(2) Das Grundprofil liegt am Szenario, nicht am Plan** (V6). Nur so lassen sich die
|
**(2) Der Haushalt liegt am Plan, die Annahmen am Szenario** (V7, siehe
|
||||||
wertvollsten Szenario-Fragen abbilden – allen voran ein abweichendes **Pensionsalter**
|
[9.30](#930-warum-der-haushalt-am-plan-hängt)). Haushaltsform, Personen (Name, Alter) und
|
||||||
(„Was, wenn ich mit 62 statt 65 aufhöre?"), das in `Person` steckt. Wäre das Profil geteilt,
|
Planstartjahr beschreiben den **Haushalt** – sie gelten für alle Szenarien. Unterscheiden sie
|
||||||
wären Frühpensionierungs-Szenarien unmöglich.
|
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
|
Der **Plan** trägt zudem den Eigentümer (`userId`). Ownership von Szenario/Phase/Element läuft
|
||||||
(`userId`). Ownership von Szenario/Phase/Element läuft über die Kette
|
über die Kette `Scenario → Plan → User`.
|
||||||
`Scenario → Plan → User`.
|
|
||||||
|
|
||||||
Referenz: `prisma/schema.prisma`.
|
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).
|
Konsistenzregel: `SINGLE` erfordert genau eine Person, `COUPLE` genau zwei (Person A und B).
|
||||||
Verletzung → HTTP 400 mit Klartextmeldung.
|
Verletzung → HTTP 400 mit Klartextmeldung.
|
||||||
|
|
||||||
Referenz: `src/components/PlanProfileFields.tsx` Zeilen 1012–1018, `src/app/api/plans/route.ts`
|
Referenz: `src/components/PlanProfileFields.tsx`, `src/app/api/plans/route.ts`.
|
||||||
Zeilen 6–35.
|
|
||||||
|
|
||||||
Ein neu erstellter Plan hat **keine Phasen und keine Elemente**; `initialCash` ist 0.
|
Ein neu erstellter Plan hat **keine Phasen und keine Elemente**; `initialCash` ist 0.
|
||||||
|
|
||||||
### 3.2.2 Grundprofil ändern
|
### 3.2.2 Grundprofil ändern
|
||||||
|
|
||||||
Über „Einstellungen" in der Planansicht. Wichtig: Die Personen werden serverseitig in einer
|
Über **«Profil bearbeiten»** im Grundprofil-Block der Szenario-Ansicht (Panel «Szenario-Profil»).
|
||||||
Transaktion **gelöscht und neu angelegt** (`deleteMany` + `create`). Die Person-IDs ändern sich
|
Der Endpunkt ist `PATCH /api/scenarios/<scenarioId>`: Er schreibt Haushaltsform, Personen (Name,
|
||||||
dadurch. Da Elemente über `ownerRole` (nicht über `personId`) zugeordnet sind, bleibt die
|
Alter) und Startjahr an den **Plan**, Inflation und Pensionsalter ans **Szenario** – die Trennung
|
||||||
Zuordnung erhalten.
|
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`
|
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
|
bleiben in der Datenbank bestehen, finden aber keinen Owner mehr – siehe
|
||||||
[9.2](#92-verwaiste-person_b-elemente).
|
[9.2](#92-verwaiste-person_b-elemente).
|
||||||
|
|
||||||
Referenz: `src/app/api/plans/[planId]/route.ts` Zeilen 55–70.
|
**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
|
### 3.2.3 Cash-Anfangswert
|
||||||
|
|
||||||
Klick auf die erste Zelle der Cash-Zeile öffnet den Dialog „Cash-Anfangswert". Wertebereich
|
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.
|
0 bis 1'000'000'000, wird auf ganze Franken gerundet.
|
||||||
|
|
||||||
Referenz: `src/components/PlanView.tsx` Zeilen 1130–1156, `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
|
### 3.2.4 Plan löschen
|
||||||
|
|
||||||
Aus der Übersichtskachel oder der Planansicht, mit Browser-`confirm()`. Löscht per Datenbank-Cascade
|
Aus der Übersichtskachel oder der Seitenleiste, mit dem eigenen Bestätigungs-Dialog
|
||||||
Personen, Phasen, Elemente und alle Werte. Szenarien, die auf diesen Plan als `parentPlanId`
|
(`useConfirm`, siehe [3.7.6](#376-sprache-und-ui-primitiven) – kein Browser-`confirm()` mehr).
|
||||||
zeigen, werden **nicht** gelöscht – ihre `parentPlanId` wird auf `NULL` gesetzt
|
Löscht per Datenbank-Cascade **alle Szenarien** des Plans und darüber Personen, Phasen, Elemente
|
||||||
(`onDelete: SetNull`).
|
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
|
### 3.2.5 Szenarien
|
||||||
|
|
||||||
Beim Anlegen eines Plans entsteht **automatisch das Basisszenario** (`isBase = true`,
|
Beim Anlegen eines Plans entsteht **automatisch das Basisszenario** (`isBase = true`,
|
||||||
Name „Basisszenario"). Der beim Anlegen erfasste Profilteil (Haushaltsform, Personen,
|
Name „Basisszenario"). Vom erfassten Profil landen **Haushaltsform, Personen und Startjahr am
|
||||||
Inflation) landet dort, der Name am Plan.
|
Plan**, **Inflation und Pensionsalter am Basisszenario** (V7).
|
||||||
|
|
||||||
Ein weiteres Szenario ist eine **vollständige Kopie eines beliebigen bestehenden Szenarios**
|
Ein weiteres Szenario ist eine **vollständige Kopie eines beliebigen bestehenden Szenarios**
|
||||||
(nicht nur des Basisszenarios). Kopiert werden Grundprofil, alle Personen, alle Phasen, alle
|
(nicht nur des Basisszenarios – die Vorlage ist in der Szenario-Liste je Zeile über «Kopie»
|
||||||
Elemente sowie sämtliche Phasen- und Übergangswerte.
|
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:
|
Gesetzt werden dabei:
|
||||||
- `parentScenarioId` = das kopierte Szenario → ergibt den **Baum** in der Seitenleiste **und**
|
- `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)
|
### 3.2.7 Planstart (Kalenderjahr)
|
||||||
|
|
||||||
Das Grundprofil enthält das Feld **Planstart (Jahr)** – das Kalenderjahr, in dem Jahr 1 der
|
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
|
und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unverändert in
|
||||||
**relativen** Jahren ab Planbeginn – `startYear` fliesst in keine Formel ein.
|
**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)`.
|
gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr − 1)`.
|
||||||
|
|
||||||
### 3.2.8 Geführter Assistent und Beispielplan
|
### 3.2.8 Geführter Assistent und Beispielplan
|
||||||
@@ -978,10 +991,13 @@ Referenz: `src/components/PlanView.tsx` Zeilen 701–769.
|
|||||||
|
|
||||||
### 3.6.4 Analyse-Bereich „Grafiken"
|
### 3.6.4 Analyse-Bereich „Grafiken"
|
||||||
|
|
||||||
Die Auswertungen liegen **nicht** unter der Matrix, sondern in einem eigenen Bereich: Der Button
|
Die Auswertungen liegen **nicht** unter der Matrix, sondern in einem eigenen Bereich: Die Kachel
|
||||||
**Grafiken** in der oberen Aktionsleiste (neben „Neues Szenario aus diesem" und
|
**Grafiken** unter dem Menüpunkt **Analysen** ([3.10.2](#3102-analysen-vier-kacheln)) öffnet sie
|
||||||
„Monte-Carlo-Simulation") öffnet sie als breiten Dialog. So bleibt die Matrix die ruhige
|
als breiten Dialog. So bleibt die Matrix die ruhige Hauptansicht.
|
||||||
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
|
**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
|
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".
|
anschaulich „das, was die Inflation frisst".
|
||||||
|
|
||||||
**Grafik 2 – Vermögensverlauf nach Alter** (`WealthChart`): Liniendiagramm über das Alter von
|
**Grafik 2 – Vermögensverlauf nach Alter** (`WealthChart`): Liniendiagramm über das Alter von
|
||||||
Person A. Je Szenario eine durchgezogene Linie (nominal) und eine gestrichelte (real).
|
Person A. Datenpunkte: **jedes Planjahr** (nicht nur die Phasengrenzen) – dadurch werden Verläufe
|
||||||
Datenpunkte: **jedes Planjahr** (nicht nur die Phasengrenzen) – dadurch werden Verläufe
|
|
||||||
*innerhalb* einer Phase sichtbar, etwa das Abschmelzen im Kapitalverzehr. Grundlage ist
|
*innerhalb* einer Phase sichtbar, etwa das Abschmelzen im Kapitalverzehr. Grundlage ist
|
||||||
`YearPoint.wealthNominal/wealthReal` (4.10). Über Checkboxen lassen sich die **Geschwister-
|
`YearPoint.wealthNominal/wealthReal` (4.10). Über Checkboxen lassen sich weitere **Szenarien
|
||||||
Szenarien überlagern**; deren Daten werden bei Bedarf nachgeladen und im Client
|
überlagern**; deren Daten werden bei Bedarf nachgeladen und im Client zwischengespeichert.
|
||||||
zwischengespeichert.
|
|
||||||
|
|
||||||
**Grafik 3 – Vermögensaufteilung pro Phase**: Gestapeltes Balkendiagramm mit zwei Balken je Phase
|
Der Datenschlüssel je Serie ist die **Szenario-ID**, nicht der Name. Bis 0.30 diente der Name als
|
||||||
(Beginn / Ende). Gestapelt werden alle Elemente der Kategorien PK, 3a, Immobilie, Sonstiges
|
Schlüssel – zwei gleichnamige Szenarien überschrieben sich dadurch gegenseitig, und die Legende
|
||||||
Vermögen, die irgendwann einen positiven Wert haben. Stapelung nach `elementId` (nicht Name),
|
zeigte eine Linie an, die es im Diagramm gar nicht gab. Die **Legende** ist eigenständig (nicht
|
||||||
damit gleichnamige Elemente nicht kollidieren. Negative Werte werden auf 0 geklammert.
|
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
|
### 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.
|
||||||
|
|
||||||
```
|
Die Datei enthält **vier Blöcke** untereinander (Excel kommt damit zurecht, und es ist sofort
|
||||||
Phase;Typ;Dauer;Einkommen (Beginn);Ausgaben (Beginn);Quote (Beginn);Quote (Ende);Cash (Ende);Endvermoegen (nominal);Endvermoegen (real)
|
erkennbar, was zusammengehört):
|
||||||
```
|
|
||||||
|
|
||||||
Bei vorhandenem Ruin folgt eine Schlusszeile `Ruin: Kapital aufgebraucht mit Alter <N>`.
|
| Block | Inhalt |
|
||||||
Dateiname = Planname, nicht-alphanumerische Zeichen durch `_` ersetzt.
|
|---|---|
|
||||||
|
| **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 620–648.
|
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"
|
### 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 |
|
| 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)) |
|
| 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 |
|
| Rechenweg | Quote, Spar-/Verzehrrate, Cash-Fortschreibung, Vermögen, Phasentyp, Cash-Übergang |
|
||||||
|
|
||||||
@@ -1289,6 +1330,12 @@ die oberste. Animationen (150–220 ms) respektieren `prefers-reduced-motion`.
|
|||||||
je Farbschema abgestimmt) statt der Akzentfarbe – «hier fehlt eine Eingabe» und «hier kannst
|
je Farbschema abgestimmt) statt der Akzentfarbe – «hier fehlt eine Eingabe» und «hier kannst
|
||||||
du klicken» sind damit unterscheidbar.
|
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
|
### 3.7.7 Inspector-Panel statt Modals
|
||||||
|
|
||||||
Alle **Einzel-Bearbeitungen** – Phasenzelle, Übergangszelle, Cash-Übergang, Cash-Anfangswert,
|
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
|
Eine **Hauptversion** wird nie zusammengefasst und nie nachträglich verändert; die nächste
|
||||||
Änderung beginnt bei A.1.
|
Ä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
|
### 3.8.2 Was die Historie zeigt
|
||||||
|
|
||||||
Je Version: **wer** (Benutzername), **wann** (Zeitpunkt der letzten Änderung dieser Sitzung)
|
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
|
## 3.10 Navigation auf Plan-Ebene und gespeicherte Analysen
|
||||||
|
|
||||||
Die Seitenleiste ist zweistufig: Unter jedem **Plan** liegen drei leicht eingezogene
|
Die Seitenleiste ist zweistufig: Unter jedem **Plan** liegen vier Unterpunkte. Ein Klick auf den
|
||||||
Unterpunkte. Ein Klick auf den **Plan-Namen** öffnet dessen Dashboard.
|
**Plan-Namen** öffnet dessen Dashboard.
|
||||||
|
|
||||||
| Ort | Führt zu |
|
| Ort | Führt zu |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Plan-Name | **Plan-Dashboard** (Kennzahlen) |
|
| Plan-Name | **Plan-Dashboard** (Kennzahlen, Umbenennen) |
|
||||||
| Szenarien | **Szenario-Liste** – darunter bleibt der Szenario-Baum, dessen Einträge direkt in die Matrix führen |
|
| 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)) |
|
| Effektive Werte | Liste + Wizard der Ist-Werte ([3.9](#39-effektive-werte-plan-ist-vergleich)) |
|
||||||
| Analysen | Vier Werkzeug-Kacheln + Liste gespeicherter Analysen |
|
| 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
|
### 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**.
|
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
|
Das Plan-Dashboard zeigt deshalb die Haushaltsdaten direkt, alle gerechneten Kennzahlen aber
|
||||||
ausdrücklich als **«laut Basisszenario»** – das Basisszenario ist der kanonische Vertreter.
|
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
|
Die vier Kennzahl-Kacheln (Szenarien, Effektive Werte, Analysen, Berichte) sind **anklickbar**
|
||||||
dem Plan aus.
|
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
|
Falls Ist-Werte erfasst sind, weist das Dashboard die **Abweichung** des Endvermögens gegenüber
|
||||||
Endvermögen und ob das Kapital reicht; das Basisszenario ist farblich hervorgehoben, und die
|
dem Plan aus – benannt mit dem **Jahr des jüngsten Ist-Datensatzes** («Mit den effektiven Werten
|
||||||
Herkunft (aus welchem Szenario kopiert) steht darunter. Zwei Aktionen je Zeile: **Historie**
|
von 2032 …») und **grün**, wenn die Realität besser ist als der Plan, sonst rot.
|
||||||
und **Matrix**.
|
|
||||||
|
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
|
### 3.10.2 Analysen: vier Kacheln
|
||||||
|
|
||||||
@@ -3233,7 +3297,8 @@ PlanComputed ← an den Client geliefert
|
|||||||
|
|
||||||
| Datei | Verantwortung |
|
| 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()` |
|
| `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` |
|
||||||
| `types.ts` | Domänentypen für API und Berechnung |
|
| `types.ts` | Domänentypen für API und Berechnung |
|
||||||
| `constants.ts` | Schweizer Systemparameter |
|
| `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) |
|
| `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. |
|
| `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` |
|
| `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) |
|
| `20260720160000_saved_analyses` | Tabelle `SavedAnalysis` (Eingaben + Ergebnis als JSONB, denormalisierte Kerndaten) |
|
||||||
|
|
||||||
**Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um – dadurch
|
**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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `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
|
## 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;
|
||||||
@@ -224,7 +224,7 @@ model Scenario {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Laufende Hauptversion. Die Nebenversionen zaehlen in ScenarioVersion.
|
// Laufende Hauptversion. Die Nebenversionen zaehlen in ScenarioVersion.
|
||||||
currentMajor Int @default(1)
|
currentMajor Int @default(0)
|
||||||
|
|
||||||
persons Person[]
|
persons Person[]
|
||||||
phases Phase[]
|
phases Phase[]
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
|||||||
persons: { orderBy: { role: "asc" } },
|
persons: { orderBy: { role: "asc" } },
|
||||||
scenarios: {
|
scenarios: {
|
||||||
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
|
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" }] },
|
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
|
||||||
_count: { select: { scenarios: true, actuals: true, analyses: true } },
|
_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 pi = toPlanInput(s);
|
||||||
const computed = computePlan(pi);
|
const computed = computePlan(pi);
|
||||||
const last = computed.phases[computed.phases.length - 1];
|
const last = computed.phases[computed.phases.length - 1];
|
||||||
|
const top = s.versions[0];
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
isBase: s.isBase,
|
isBase: s.isBase,
|
||||||
parentScenarioId: s.parentScenarioId,
|
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,
|
elementCount: s._count.elements,
|
||||||
|
phaseCount: computed.phases.length,
|
||||||
versionCount: s._count.versions,
|
versionCount: s._count.versions,
|
||||||
endNominal: last ? Math.round(last.endWealthNominal) : 0,
|
endNominal: last ? Math.round(last.endWealthNominal) : 0,
|
||||||
endReal: last ? Math.round(last.endWealthReal) : 0,
|
endReal: last ? Math.round(last.endWealthReal) : 0,
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { toPlanInput, getOwnedScenario } from "@/lib/queries";
|
import { toPlanInput, getOwnedScenario } from "@/lib/queries";
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
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(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
@@ -25,7 +34,7 @@ export async function GET(
|
|||||||
return new NextResponse(csv, {
|
return new NextResponse(csv, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/csv; charset=utf-8",
|
"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
@@ -39,6 +39,15 @@
|
|||||||
--attention-fg: #ffffff;
|
--attention-fg: #ffffff;
|
||||||
--attention-soft: #fef3c7;
|
--attention-soft: #fef3c7;
|
||||||
--attention-soft-fg: #92400e;
|
--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"] {
|
:root[data-theme="dark"] {
|
||||||
@@ -71,6 +80,13 @@
|
|||||||
--attention-fg: #1c1400;
|
--attention-fg: #1c1400;
|
||||||
--attention-soft: rgba(245, 158, 11, 0.16);
|
--attention-soft: rgba(245, 158, 11, 0.16);
|
||||||
--attention-soft-fg: #fcd34d;
|
--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. */
|
/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundärton. */
|
||||||
@@ -90,7 +106,7 @@
|
|||||||
--accent-soft: #fcebe2;
|
--accent-soft: #fcebe2;
|
||||||
--accent-soft-fg: #b24521;
|
--accent-soft-fg: #b24521;
|
||||||
--danger: #c0392b;
|
--danger: #c0392b;
|
||||||
--danger-soft: #fbeä7;
|
--danger-soft: #fbeae7;
|
||||||
--success: #2e9e7b;
|
--success: #2e9e7b;
|
||||||
--person-a: #e8663c;
|
--person-a: #e8663c;
|
||||||
--diff: #b45309;
|
--diff: #b45309;
|
||||||
@@ -104,6 +120,13 @@
|
|||||||
--attention-fg: #ffffff;
|
--attention-fg: #ffffff;
|
||||||
--attention-soft: #fdeed3;
|
--attention-soft: #fdeed3;
|
||||||
--attention-soft-fg: #8f5410;
|
--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. */
|
/* Ohne gespeicherte Wahl der Dunkel-OS-Einstellung folgen. */
|
||||||
@@ -138,6 +161,13 @@
|
|||||||
--attention-fg: #1c1400;
|
--attention-fg: #1c1400;
|
||||||
--attention-soft: rgba(245, 158, 11, 0.16);
|
--attention-soft: rgba(245, 158, 11, 0.16);
|
||||||
--attention-soft-fg: #fcd34d;
|
--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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,54 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import type { PlanComputed } from "@/lib/calculations";
|
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"];
|
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||||||
|
|
||||||
// Gestapelte Vermögensaufteilung je Phase (Beginn und Ende). Eigene Komponente, weil sie
|
// Gestapelte Vermögensaufteilung.
|
||||||
// sowohl im Grafiken-Dialog als auch in der Live-Simulation gebraucht wird.
|
//
|
||||||
export function AllocationChart({ computed, height = 288 }: { computed: PlanComputed; height?: number }) {
|
// 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
|
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
|
||||||
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
|
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
|
||||||
const assetEls = useMemo(() => {
|
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 }));
|
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
|
||||||
}, [computed]);
|
}, [computed]);
|
||||||
|
|
||||||
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende.
|
// Je Planjahr eine Zeile mit dem Wert jedes Elements. Die Jahreswerte liegen bereits in
|
||||||
const barData = useMemo(
|
// `ElementPhaseComputed.yearly` (seit 0.11) -- hier wird nur umsortiert, nichts gerechnet.
|
||||||
() =>
|
const areaData = useMemo(() => {
|
||||||
computed.phases.flatMap((phase) => {
|
const byYear = new Map<number, Record<string, number>>();
|
||||||
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
|
const ageOf = new Map<number, number>();
|
||||||
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
|
for (const phase of computed.phases) {
|
||||||
for (const el of phase.elements) {
|
for (const el of phase.elements) {
|
||||||
if (!ASSET_CATS.includes(el.category)) continue;
|
if (!ASSET_CATS.includes(el.category)) continue;
|
||||||
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
|
for (const y of el.yearly) {
|
||||||
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
|
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) {
|
if (assetEls.length === 0) {
|
||||||
return <p className="text-sm text-muted">Dieser Plan enthält keine Vermögenselemente.</p>;
|
return <p className="text-sm text-muted">Dieser Plan enthält keine Vermögenselemente.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full" style={{ height }}>
|
<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%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
<AreaChart
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
data={areaData}
|
||||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
|
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
|
<YAxis
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="var(--faint)"
|
||||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||||
/>
|
/>
|
||||||
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
<Tooltip
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
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) => (
|
{assetEls.map((el, i) => (
|
||||||
<Bar
|
<Area
|
||||||
key={el.id}
|
key={el.id}
|
||||||
|
type="monotone"
|
||||||
dataKey={el.id}
|
dataKey={el.id}
|
||||||
name={el.name}
|
name={el.name}
|
||||||
stackId="a"
|
stackId="a"
|
||||||
|
stroke={CHART_PALETTE[i % CHART_PALETTE.length]}
|
||||||
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
|
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
|
||||||
|
fillOpacity={0.75}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</BarChart>
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</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
@@ -117,6 +117,8 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
const [showPalette, setShowPalette] = useState(false);
|
const [showPalette, setShowPalette] = useState(false);
|
||||||
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
|
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
|
||||||
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
|
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
|
// Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im
|
||||||
// DOM der Szenario-Ansicht). Zwei Auslöser:
|
// DOM der Szenario-Ansicht). Zwei Auslöser:
|
||||||
@@ -276,6 +278,9 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadPlans();
|
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);
|
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +389,7 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openPlanTab(p.id, tab)}
|
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"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void openActualsTab(p.id)}
|
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"
|
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 */}
|
{/* Plan-Ebene: Dashboard / Szenarien / Analysen */}
|
||||||
{!showSpec && !showSystemParams && planNav?.tab === "dashboard" && (
|
{!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" && (
|
{!showSpec && !showSystemParams && planNav?.tab === "scenarios" && (
|
||||||
<ScenarioListView
|
<ScenarioListView
|
||||||
planId={planNav.planId}
|
planId={planNav.planId}
|
||||||
|
reloadKey={scenarioListKey}
|
||||||
onOpenMatrix={openScenario}
|
onOpenMatrix={openScenario}
|
||||||
onOpenHistory={(sid) => {
|
onOpenHistory={(sid) => {
|
||||||
void loadDetail(sid, true).then(() => setShowHistory(true));
|
void loadDetail(sid, true).then(() => setShowHistory(true));
|
||||||
}}
|
}}
|
||||||
onNew={() => {
|
onCopyFrom={(sid) => {
|
||||||
const base = activePlan?.scenarios.find((s) => s.isBase) ?? activePlan?.scenarios[0];
|
const src = activePlan?.scenarios.find((s) => s.id === sid);
|
||||||
if (base) setCopyFrom({ id: base.id, planId: base.planId, planName: activePlan?.name ?? "", name: base.name, isBase: base.isBase, parentScenarioId: base.parentScenarioId } as ScenarioMeta);
|
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;
|
onCopy: (s: ScenarioMeta) => void;
|
||||||
onDelete: (s: ScenarioMeta) => void;
|
onDelete: (s: ScenarioMeta) => void;
|
||||||
}) {
|
}) {
|
||||||
// Flache, gleichmässig eingerückte Liste: Basisszenario zuoberst, danach die Varianten.
|
// Echte Verschachtelung: Ein Szenario wird unter seiner Vorlage eingerückt (Tiefe = Länge
|
||||||
// Die Herkunfts-Verschachtelung wird in der Szenario-Liste (Spalte «aus …») gezeigt, nicht
|
// der Herkunftskette). So ist in der Seitenleiste sofort sichtbar, woraus ein Szenario
|
||||||
// mehr durch die Einrückung in der Seitenleiste.
|
// entstanden ist -- die Szenario-Liste zeigt dasselbe zusätzlich als Spalte «aus …».
|
||||||
const ordered = [...scenarios].sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
|
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;
|
if (ordered.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{ordered.map((s) => (
|
{ordered.map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.id}
|
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"
|
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. */}
|
{/* 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">
|
<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.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>}
|
{p._count && p._count.analyses > 0 && <span>{p._count.analyses} Analyse{p._count.analyses === 1 ? "" : "n"}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { BarChart3, LineChart as LineChartIcon } from "lucide-react";
|
import { BarChart3, LineChart as LineChartIcon } from "lucide-react";
|
||||||
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
|
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
|
||||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||||
@@ -27,9 +27,9 @@ const CHART_OPTIONS: { id: ChartId; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function Dashboard({
|
export function Dashboard({
|
||||||
plan: currentPlan,
|
plan: openedPlan,
|
||||||
planId,
|
planId,
|
||||||
computed: currentComputed,
|
computed: openedComputed,
|
||||||
siblings,
|
siblings,
|
||||||
actuals = [],
|
actuals = [],
|
||||||
origins = [],
|
origins = [],
|
||||||
@@ -44,18 +44,46 @@ export function Dashboard({
|
|||||||
}) {
|
}) {
|
||||||
// Eine Grafik zur Zeit -- man wählt zuerst, welche (Punkt 5 der Analysen-Ansicht).
|
// Eine Grafik zur Zeit -- man wählt zuerst, welche (Punkt 5 der Analysen-Ansicht).
|
||||||
const [chartId, setChartId] = useState<ChartId>("wealth");
|
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.
|
// 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).
|
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
|
||||||
const basis = useAnalysisBasis(plan, actuals, origins);
|
const basis = useAnalysisBasis(plan, actuals, origins);
|
||||||
// Version UND Datenquelle bestimmen, was gezeigt wird. Beim Arbeitsstand mit Plandaten
|
// Version UND Datenquelle bestimmen, was gezeigt wird. Beim Arbeitsstand mit Plandaten
|
||||||
// bleibt es beim bereits vom Server Gerechneten -- kein zweiter Lauf nötig.
|
// bleibt es beim bereits vom Server Gerechneten -- kein zweiter Lauf nötig.
|
||||||
const computed = useMemo(
|
const computed = useMemo(
|
||||||
() => (versionId === "current" && basis.source === "PLAN" ? currentComputed : basis.computed),
|
() => (versionId === "current" && basis.source === "PLAN" ? baseComputed : basis.computed),
|
||||||
[versionId, currentComputed, basis.source, basis.computed]
|
[versionId, baseComputed, basis.source, basis.computed]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [compareIds, setCompareIds] = useState<string[]>([]);
|
const [compareIds, setCompareIds] = useState<string[]>([]);
|
||||||
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
|
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) {
|
async function toggleCompare(id: string) {
|
||||||
if (compareIds.includes(id)) {
|
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 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
|
// Im Ist-Modus die reine Planlinie als gestrichelte Referenz daneben -- ohne Anker sagt
|
||||||
// eine Ist-Linie allein nichts aus.
|
// eine Ist-Linie allein nichts aus.
|
||||||
if (basis.source === "ACTUAL") {
|
if (basis.source === "ACTUAL") {
|
||||||
|
const id = `${plan.id}__plan`;
|
||||||
result.push({
|
result.push({
|
||||||
|
id,
|
||||||
label: `${plan.name} (Plan)`,
|
label: `${plan.name} (Plan)`,
|
||||||
color: CHART_PALETTE[0],
|
color: colors[id] ?? CHART_PALETTE[0],
|
||||||
computed: basis.planComputed,
|
computed: basis.planComputed,
|
||||||
dashed: true,
|
dashed: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
compareIds.forEach((id, i) => {
|
compareIds.forEach((id, i) => {
|
||||||
const c = compareData[id];
|
const c = compareData[id];
|
||||||
const name = siblings.find((p) => p.id === id)?.name ?? id;
|
const name = allScenarios.find((p) => p.id === id)?.name ?? id;
|
||||||
if (c) result.push({ label: name, color: CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
|
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;
|
||||||
return result.slice(0, 4);
|
}, [plan.id, plan.name, computed, compareIds, compareData, allScenarios, basis.source, basis.planComputed, colors]);
|
||||||
}, [plan.name, computed, compareIds, compareData, siblings, basis.source, basis.planComputed]);
|
|
||||||
|
|
||||||
const otherPlans = siblings;
|
|
||||||
const lastPhase = computed.phases[computed.phases.length - 1];
|
const lastPhase = computed.phases[computed.phases.length - 1];
|
||||||
|
const showScenarioPicker = allScenarios.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<VersionBar
|
{/* Szenario-Auswahl: gilt für alle drei Grafiken. */}
|
||||||
scenarioId={currentPlan.id}
|
{showScenarioPicker && (
|
||||||
versionId={versionId}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
onChange={setVersionId}
|
<span className="text-xs font-medium text-muted">Szenario</span>
|
||||||
loading={versionLoading}
|
<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} />
|
<AnalysisBar basis={basis} />
|
||||||
|
|
||||||
@@ -123,7 +172,7 @@ export function Dashboard({
|
|||||||
<SaveAnalysisButton
|
<SaveAnalysisButton
|
||||||
planId={planId}
|
planId={planId}
|
||||||
type="CHART"
|
type="CHART"
|
||||||
scenarioName={currentPlan.name}
|
scenarioName={plan.name}
|
||||||
versionLabel={versionId === "current" ? null : versionId}
|
versionLabel={versionId === "current" ? null : versionId}
|
||||||
build={() => buildChartResult(chartId, computed, series, basis)}
|
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">
|
<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">
|
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||||
<LineChartIcon className="h-4 w-4 text-accent" />
|
<LineChartIcon className="h-4 w-4 text-accent" />
|
||||||
Einkommen vs. Ausgaben pro Jahr
|
Einkommen vs. Ausgaben pro Jahr · {plan.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-3 text-xs text-muted">
|
<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 – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
Die blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
||||||
</p>
|
</p>
|
||||||
<SparquoteChart computed={computed} />
|
<SparquoteChart computed={computed} />
|
||||||
@@ -156,10 +205,10 @@ export function Dashboard({
|
|||||||
Vermögensverlauf nach Alter
|
Vermögensverlauf nach Alter
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{otherPlans.length > 0 && (
|
{comparable.length > 0 && (
|
||||||
<div className="mb-3 flex flex-wrap gap-2">
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
<span className="text-xs text-muted">Szenarien vergleichen:</span>
|
<span className="text-xs text-muted">Weitere Szenarien überlagern:</span>
|
||||||
{otherPlans.map((p) => (
|
{comparable.map((p) => (
|
||||||
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
|
<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)} />
|
<input type="checkbox" checked={compareIds.includes(p.id)} onChange={() => toggleCompare(p.id)} />
|
||||||
{p.name}
|
{p.name}
|
||||||
@@ -167,7 +216,11 @@ export function Dashboard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<WealthChart series={series} metric={basis.metric} />
|
<WealthChart
|
||||||
|
series={series}
|
||||||
|
metric={basis.metric}
|
||||||
|
onColorChange={(id, color) => setColors((prev) => ({ ...prev, [id]: color }))}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -175,11 +228,11 @@ export function Dashboard({
|
|||||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
<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">
|
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||||
<BarChart3 className="h-4 w-4 text-accent" />
|
<BarChart3 className="h-4 w-4 text-accent" />
|
||||||
Vermögensaufteilung pro Phase (Beginn & Ende)
|
Vermögensaufteilung im Zeitverlauf · {plan.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-3 text-xs text-muted">
|
<p className="mb-3 text-xs text-muted">
|
||||||
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
|
Links der Verlauf über alle Planjahre (gestapelt; die gestrichelten Linien sind die Phasengrenzen), rechts
|
||||||
Gesamtvolumen dem Beginn der nächsten – die Aufteilung kann durch Umschichtung abweichen.
|
die relative Aufteilung zu einem wählbaren Zeitpunkt.
|
||||||
</p>
|
</p>
|
||||||
<AllocationChart computed={computed} />
|
<AllocationChart computed={computed} />
|
||||||
</section>
|
</section>
|
||||||
@@ -230,8 +283,8 @@ function buildChartResult(
|
|||||||
xLabel: "Jahr",
|
xLabel: "Jahr",
|
||||||
yFormat: "chf" as const,
|
yFormat: "chf" as const,
|
||||||
series: [
|
series: [
|
||||||
{ label: "Einkommen", color: "#16a34a", points: computed.yearly.map((y) => ({ x: y.year, y: y.income })) },
|
{ label: "Einkommen", color: "var(--chart-3)", 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: "Ausgaben", color: "var(--chart-5)", points: computed.yearly.map((y) => ({ x: y.year, y: y.expenseNominal })) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ const CHARTS: { id: ChartId; label: string; hint: string }[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const BASE_COLOR = "#9ca3af";
|
const BASE_COLOR = "var(--faint)";
|
||||||
const LIVE_COLOR = "#4f46e5";
|
const LIVE_COLOR = "var(--chart-1)";
|
||||||
|
|
||||||
export function LiveSimDialog({
|
export function LiveSimDialog({
|
||||||
plan: currentPlan,
|
plan: currentPlan,
|
||||||
@@ -243,8 +243,8 @@ export function LiveSimDialog({
|
|||||||
<WealthChart
|
<WealthChart
|
||||||
series={[
|
series={[
|
||||||
// Referenz zuerst, damit die Live-Linie darüber liegt.
|
// Referenz zuerst, damit die Live-Linie darüber liegt.
|
||||||
{ label: "Dein Plan", color: BASE_COLOR, computed: base.computed },
|
{ id: "base", label: "Dein Plan", color: BASE_COLOR, computed: base.computed },
|
||||||
{ label: "Simuliert", color: LIVE_COLOR, computed: live.computed },
|
{ id: "live", label: "Simuliert", color: LIVE_COLOR, computed: live.computed },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import type { PlanInput, ScenarioMeta } from "@/lib/types";
|
|||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
|
|
||||||
// Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt.
|
// 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
|
// 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.
|
// ZWEI Schwellen abgelesen (Plan-Endbetrag und Zielbetrag) -- die vier Fälle aus 4.12.7.
|
||||||
|
|||||||
+216
-36
@@ -3,16 +3,21 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
CalendarClock,
|
||||||
|
Check,
|
||||||
Dices,
|
Dices,
|
||||||
Eye,
|
Eye,
|
||||||
|
FileSpreadsheet,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
History,
|
History,
|
||||||
|
Layers,
|
||||||
LineChart as LineChartIcon,
|
LineChart as LineChartIcon,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Table2,
|
|
||||||
Tornado,
|
Tornado,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button, useConfirm, useToast } from "@/components/ui";
|
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||||
import { api } from "@/lib/api-client";
|
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 { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
|
||||||
import type { PersonRole } from "@/lib/types";
|
import type { PersonRole } from "@/lib/types";
|
||||||
|
|
||||||
|
export type PlanTab = "dashboard" | "scenarios" | "actuals" | "analyses" | "reports";
|
||||||
|
|
||||||
// --- Typen der Dashboard-Antwort ---
|
// --- Typen der Dashboard-Antwort ---
|
||||||
interface DashboardResponse {
|
interface DashboardResponse {
|
||||||
plan: {
|
plan: {
|
||||||
@@ -45,8 +52,9 @@ interface DashboardResponse {
|
|||||||
name: string;
|
name: string;
|
||||||
isBase: boolean;
|
isBase: boolean;
|
||||||
parentScenarioId: string | null;
|
parentScenarioId: string | null;
|
||||||
currentMajor: number;
|
version: string | null;
|
||||||
elementCount: number;
|
elementCount: number;
|
||||||
|
phaseCount: number;
|
||||||
versionCount: number;
|
versionCount: number;
|
||||||
endNominal: number;
|
endNominal: number;
|
||||||
endReal: number;
|
endReal: number;
|
||||||
@@ -74,21 +82,69 @@ function useDashboard(planId: string, reloadKey: number) {
|
|||||||
return { data, error };
|
return { data, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value, hint, tone }: { label: string; value: string; hint?: string; tone?: "danger" }) {
|
// Anklickbare Kennzahl-Kachel. Ohne `onClick` bleibt sie eine reine Anzeige.
|
||||||
return (
|
function Stat({
|
||||||
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
label,
|
||||||
<div className="text-xs text-muted">{label}</div>
|
value,
|
||||||
<div className={`mt-1 text-xl font-semibold ${tone === "danger" ? "text-danger" : "text-fg"}`}>{value}</div>
|
hint,
|
||||||
{hint && <div className="mt-0.5 text-[11px] text-faint">{hint}</div>}
|
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>
|
||||||
|
<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>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
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
|
// Plan-Dashboard
|
||||||
// =========================================================================================
|
// =========================================================================================
|
||||||
export function PlanDashboardView({ planId }: { planId: string }) {
|
export function PlanDashboardView({
|
||||||
const { data, error } = useDashboard(planId, 0);
|
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 (error) return <p className="text-sm text-danger">{error}</p>;
|
||||||
if (!data) return <p className="text-sm text-muted">Wird geladen…</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})`)
|
.map((p) => `${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")} (${p.age})`)
|
||||||
.join(" · ");
|
.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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
|
{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>
|
<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">
|
<p className="text-sm text-muted">
|
||||||
{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons}
|
{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons}
|
||||||
{plan.startYear ? ` · Planstart ${plan.startYear}` : ""}
|
{plan.startYear ? ` · Planstart ${plan.startYear}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
{/* Anklickbare Kacheln -- jede führt in ihren Bereich. */}
|
||||||
<Stat label="Szenarien" value={String(counts.scenarios)} />
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<Stat label="Effektive Werte" value={String(counts.actuals)} hint="erfasste Ist-Datensätze" />
|
<Stat
|
||||||
<Stat label="Gespeicherte Analysen" value={String(counts.analyses)} />
|
label="Szenarien"
|
||||||
<Stat label="Lebensphasen" value={base ? String(base.phaseCount) : "–"} hint="laut Basisszenario" />
|
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>
|
</div>
|
||||||
|
|
||||||
{base && (
|
{base && (
|
||||||
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
<div className="rounded-2xl border border-border bg-surface p-5 shadow-sm">
|
||||||
<h3 className="mb-3 text-sm font-semibold text-fg">
|
<h3 className="mb-3 flex flex-wrap items-baseline gap-2 text-sm font-semibold text-fg">
|
||||||
Basisszenario «{base.name}»
|
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>
|
</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 (nominal)" value={formatChf(base.endNominal)} />
|
||||||
<Stat label="Endvermögen (real)" value={formatChf(base.endReal)} />
|
<Stat label="Endvermögen (real)" value={formatChf(base.endReal)} />
|
||||||
|
<Stat label="Lebensphasen" value={String(base.phaseCount)} />
|
||||||
{base.ruinAge !== null ? (
|
{base.ruinAge !== null ? (
|
||||||
<Stat label="Kapital reicht" value={`bis Alter ${base.ruinAge}`} tone="danger" hint="danach aufgebraucht" />
|
<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>
|
</div>
|
||||||
{base.actualEndNominal !== null && (
|
{base.actualEndNominal !== null && delta !== null && (
|
||||||
<p className="mt-3 rounded-lg border border-accent bg-accent-soft/20 px-3 py-2 text-xs text-accent-soft-fg">
|
<p
|
||||||
Mit den effektiven Werten liegt das Endvermögen (nominal) bei{" "}
|
className={`mt-4 rounded-xl border px-3 py-2 text-xs ${
|
||||||
<strong>{formatChf(base.actualEndNominal)}</strong> – eine Abweichung von{" "}
|
delta >= 0 ? "border-success bg-success/10 text-fg" : "border-danger bg-danger-soft text-fg"
|
||||||
<strong>{formatChf(base.actualEndNominal - base.endNominal)}</strong> gegenüber dem Plan.
|
}`}
|
||||||
|
>
|
||||||
|
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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -147,28 +294,36 @@ export function PlanDashboardView({ planId }: { planId: string }) {
|
|||||||
// =========================================================================================
|
// =========================================================================================
|
||||||
export function ScenarioListView({
|
export function ScenarioListView({
|
||||||
planId,
|
planId,
|
||||||
|
reloadKey = 0,
|
||||||
onOpenMatrix,
|
onOpenMatrix,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
onNew,
|
onCopyFrom,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
planId: string;
|
planId: string;
|
||||||
|
reloadKey?: number;
|
||||||
onOpenMatrix: (scenarioId: string) => void;
|
onOpenMatrix: (scenarioId: string) => void;
|
||||||
onOpenHistory: (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 (error) return <p className="text-sm text-danger">{error}</p>;
|
||||||
if (!data) return <p className="text-sm text-muted">Wird geladen…</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 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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-fg">Szenarien</h2>
|
<h2 className="text-lg font-semibold text-fg">Szenarien</h2>
|
||||||
<Button onClick={onNew}>
|
{baseId && (
|
||||||
|
<Button onClick={() => onCopyFrom(baseId)}>
|
||||||
<Plus className="h-4 w-4" /> Neues Szenario
|
<Plus className="h-4 w-4" /> Neues Szenario
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-xl border border-border">
|
<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">
|
<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-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">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">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">Endvermögen (nom.)</th>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Kapital reicht</th>
|
<th className="px-3 py-2 text-right font-semibold">Kapital reicht</th>
|
||||||
@@ -185,11 +341,20 @@ export function ScenarioListView({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.scenarios.map((s) => (
|
{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">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-fg">{s.name}</span>
|
<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>
|
</div>
|
||||||
{s.parentScenarioId && (
|
{s.parentScenarioId && (
|
||||||
<div className="flex items-center gap-1 text-[11px] text-faint">
|
<div className="flex items-center gap-1 text-[11px] text-faint">
|
||||||
@@ -197,7 +362,8 @@ export function ScenarioListView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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-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 tabular-nums text-fg">{formatChf(s.endNominal)}</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
@@ -207,10 +373,12 @@ export function ScenarioListView({
|
|||||||
<span className="text-success">Planende</span>
|
<span className="text-success">Planende</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
title="Änderungshistorie"
|
||||||
onClick={() => onOpenHistory(s.id)}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -394,7 +574,7 @@ function FlipTile({ tile, onClick }: { tile: (typeof TILES)[number]; onClick: ()
|
|||||||
onMouseEnter={() => setFlipped(true)}
|
onMouseEnter={() => setFlipped(true)}
|
||||||
onMouseLeave={() => setFlipped(false)}
|
onMouseLeave={() => setFlipped(false)}
|
||||||
// Touch hat kein Hover -- ein Antippen des Info-Bereichs klappt um, statt gleich zu starten.
|
// 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 ? (
|
{!flipped ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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);
|
for (const s of chart.series) row[s.label] = s.points.find((p) => p.x === x)?.y ?? (null as unknown as number);
|
||||||
return row;
|
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 (
|
return (
|
||||||
<div className="h-72 w-full">
|
<div className="h-72 w-full">
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import {
|
|||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
|
|
||||||
const INCOME_COLOR = "#16a34a";
|
const INCOME_COLOR = "var(--chart-3)";
|
||||||
const EXPENSE_COLOR = "#dc2626";
|
const EXPENSE_COLOR = "var(--chart-5)";
|
||||||
const REAL_COLOR = "#9ca3af";
|
const REAL_COLOR = "var(--faint)";
|
||||||
|
|
||||||
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Fläche
|
// 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
|
// dazwischen ist die Spar-/Verzehrquote (grün = Sparen, rot = Verzehr). Reale Ausgaben als
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useEffect, useState } from "react";
|
||||||
CartesianGrid,
|
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
Legend,
|
import { TOOLTIP_STYLE } from "@/components/AllocationChart";
|
||||||
Line,
|
|
||||||
LineChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts";
|
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
|
|
||||||
export interface TimelineSeries {
|
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;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
@@ -36,15 +33,39 @@ function pointsFor(computed: PlanComputed) {
|
|||||||
return pts;
|
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
|
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
|
||||||
// überlagerte Pläne für den Szenario-Vergleich.
|
// überlagerte Pläne für den Szenario-Vergleich.
|
||||||
export function WealthChart({
|
export function WealthChart({
|
||||||
series,
|
series,
|
||||||
metric = "nominal",
|
metric = "nominal",
|
||||||
|
onColorChange,
|
||||||
}: {
|
}: {
|
||||||
series: TimelineSeries[];
|
series: TimelineSeries[];
|
||||||
metric?: "nominal" | "real";
|
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) {
|
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
||||||
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
||||||
}
|
}
|
||||||
@@ -56,37 +77,44 @@ export function WealthChart({
|
|||||||
const row: Record<string, number | null> = { age };
|
const row: Record<string, number | null> = { age };
|
||||||
for (const s of withPoints) {
|
for (const s of withPoints) {
|
||||||
const pt = s.points.find((p) => p.age === age);
|
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;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nameById = new Map(series.map((s) => [s.id, s.label]));
|
||||||
|
const hasDashed = series.some((s) => s.dashed);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="w-full">
|
||||||
<div className="h-80 w-full">
|
<div className="h-80 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="age"
|
dataKey="age"
|
||||||
type="number"
|
type="number"
|
||||||
domain={["dataMin", "dataMax"]}
|
domain={["dataMin", "dataMax"]}
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="var(--faint)"
|
||||||
tickFormatter={(v) => `${v} J.`}
|
tickFormatter={(v) => `${v} J.`}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="var(--faint)"
|
||||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(v) => (typeof v === "number" ? formatChf(v) : v)}
|
contentStyle={TOOLTIP_STYLE}
|
||||||
|
formatter={(v, key) => [typeof v === "number" ? formatChf(v) : v, nameById.get(String(key)) ?? key]}
|
||||||
labelFormatter={(v) => `Alter ${v}`}
|
labelFormatter={(v) => `Alter ${v}`}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
|
||||||
{withPoints.map((s) => (
|
{withPoints.map((s) => (
|
||||||
<Line
|
<Line
|
||||||
key={s.label}
|
key={s.id}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={s.label}
|
dataKey={s.id}
|
||||||
|
name={s.label}
|
||||||
stroke={s.color}
|
stroke={s.color}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeDasharray={s.dashed ? "5 3" : undefined}
|
strokeDasharray={s.dashed ? "5 3" : undefined}
|
||||||
@@ -98,5 +126,48 @@ export function WealthChart({
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</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
@@ -1826,32 +1826,4 @@ function retiresInPhase(
|
|||||||
return p.age + yearsBeforeNext >= retirementAge.get(personId)!;
|
return p.age + yearsBeforeNext >= retirementAge.get(personId)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSV-Export (eine Zeile pro Lebensphase, Kernkennzahlen).
|
// Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary).
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
@@ -97,7 +97,8 @@ describe("Datenbank-Migrationen", () => {
|
|||||||
WHERE table_name='Scenario' AND column_name='currentMajor'`
|
WHERE table_name='Scenario' AND column_name='currentMajor'`
|
||||||
);
|
);
|
||||||
expect(major.rows[0].is_nullable).toBe("NO");
|
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
|
// A.B muss je Szenario eindeutig sein, sonst kollidieren zwei Sitzungen auf derselben
|
||||||
// Nummer und die Historie wird mehrdeutig.
|
// Nummer und die Historie wird mehrdeutig.
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ export async function restoreVersion(
|
|||||||
const created = await prisma.scenarioVersion.create({
|
const created = await prisma.scenarioVersion.create({
|
||||||
data: {
|
data: {
|
||||||
scenarioId,
|
scenarioId,
|
||||||
major: latest?.major ?? 1,
|
major: latest?.major ?? 0,
|
||||||
minor: (latest?.minor ?? 0) + 1,
|
minor: (latest?.minor ?? 0) + 1,
|
||||||
comment: restoreComment({ major: version.major, minor: version.minor }),
|
comment: restoreComment({ major: version.major, minor: version.minor }),
|
||||||
createdById: userId,
|
createdById: userId,
|
||||||
|
|||||||
@@ -73,8 +73,14 @@ describe("canonicalJson", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("decideVersion", () => {
|
describe("decideVersion", () => {
|
||||||
it("legt für den allerersten Stand 1.0 an", () => {
|
it("legt für den allerersten Stand 0.1 an", () => {
|
||||||
expect(decideVersion(null, snap(), "u1", T0)).toEqual({ action: "create", major: 1, minor: 0 });
|
// 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", () => {
|
it("tut nichts, wenn sich inhaltlich nichts geändert hat", () => {
|
||||||
|
|||||||
@@ -69,8 +69,11 @@ export function decideVersion(
|
|||||||
userId: string,
|
userId: string,
|
||||||
now: Date
|
now: Date
|
||||||
): VersionDecision {
|
): VersionDecision {
|
||||||
// Allererster Stand: 1.0.
|
// Allererster Stand: 0.1. Ein Szenario laeuft solange in der 0er-Reihe (0.1, 0.2, ... 0.137),
|
||||||
if (!latest) return { action: "create", major: 1, minor: 0 };
|
// 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
|
// Nichts veraendert -- z. B. Dialog geoeffnet und unveraendert gespeichert. Ohne diese
|
||||||
// Pruefung sammelt die Historie Versionen, die nichts unterscheiden.
|
// Pruefung sammelt die Historie Versionen, die nichts unterscheiden.
|
||||||
|
|||||||
Reference in New Issue
Block a user