From b42f9b385fa50212fdc546e4fa38598e4dc88f7c Mon Sep 17 00:00:00 2001 From: kelle Date: Sat, 25 Jul 2026 12:16:07 +0200 Subject: [PATCH] Modul-Review 3: Struktur, Dashboard, CSV-Export und Grafiken 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 --- SPEZIFIKATION.md | 201 +++++++++----- .../migration.sql | 9 + prisma/schema.prisma | 2 +- src/app/api/plans/[planId]/dashboard/route.ts | 13 +- .../scenarios/[scenarioId]/export/route.ts | 13 +- src/app/globals.css | 32 ++- src/components/AllocationChart.tsx | 232 +++++++++++++--- src/components/AppShell.tsx | 66 ++++- src/components/Dashboard.tsx | 117 +++++--- src/components/LiveSimDialog.tsx | 8 +- src/components/MonteCarloDialog.tsx | 2 +- src/components/PlanViews.tsx | 258 +++++++++++++++--- src/components/SavedAnalysisView.tsx | 2 +- src/components/SparquoteChart.tsx | 6 +- src/components/WealthChart.tsx | 161 ++++++++--- src/lib/calculations.ts | 30 +- src/lib/csv.test.ts | 93 +++++++ src/lib/csv.ts | 221 +++++++++++++++ src/lib/migrations.test.ts | 3 +- src/lib/versioning-db.ts | 2 +- src/lib/versioning.test.ts | 10 +- src/lib/versioning.ts | 7 +- 22 files changed, 1199 insertions(+), 289 deletions(-) create mode 100644 prisma/migrations/20260724120000_version_zero_start/migration.sql create mode 100644 src/lib/csv.test.ts create mode 100644 src/lib/csv.ts diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index a47b2ba..d0f526c 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.30 | +| **Version** | 0.31 | | **Datum** | 2026-07-24 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `efcc04c` inkl. Tour-Korrekturen (Branch `main`) | +| **Codestand** | Arbeitsstand nach `96fdd00` inkl. Modul-Review 3 (Struktur & Grafiken) (Branch `main`) | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.31 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3 (Plan-/Szenario-Struktur, Dashboard, Grafiken).** (1) **Versionierung startet bei 0.1** statt 1.0 (Kap. 3.8): Ein Szenario läuft in der 0er-Reihe (0.1, 0.2, … 0.137), bis eine **Hauptversion** gesetzt wird – erst dann entsteht 1.0. Vorher begann jedes Szenario bereits bei 1.0, wodurch die Hauptversion ihre Bedeutung verlor. Die Szenario-Liste zeigt neu die **echte** Version statt «1.x», dazu eine Spalte **Phasen**. (2) **Plan-Dashboard:** Kacheln sind **anklickbar** und führen in ihren Bereich, neu inkl. **Berichte**; der Plan lässt sich über ein Stift-Symbol **umbenennen**; die Ist-Abweichung nennt das **Jahr** des jüngsten Ist-Datensatzes und ist bei einer positiven Abweichung **grün** statt rot. (3) **Szenario-Liste:** Ein Klick auf die **Zeile** öffnet die Matrix (der «Matrix»-Knopf entfällt), dazu je Zeile **Kopie** und **Löschen**; die Kopiervorlage ist damit frei wählbar und nicht mehr auf das Basisszenario festgelegt. Nach einer Löschung lädt die Liste neu (zeigte vorher den alten Stand). (4) **Seitenleiste:** Szenarien werden wieder **verschachtelt** dargestellt (Tiefe = Herkunftskette); «Effektive Werte», «Analysen» und «Berichte» stehen neu **bündig zum Knoten «Szenarien»** statt auf Höhe der einzelnen Szenarien. (5) **CSV-Export vollständig neu** (Kap. 3.6.5, neues Modul `csv.ts`): vier Blöcke – Kopf, Lebensphasen, **die ganze Matrix** (Elemente × Phasen mit Beginn/Ende und den Übergangs-Entscheiden im Klartext) und **Jahreswerte**; mit BOM, damit Excel die Umlaute erkennt. Vorher enthielt die Datei kein einziges finanzielles Element. (6) **Grafiken:** Ein **Szenario-Wähler** gilt neu für **alle drei** Grafiken (vorher nur der Vermögensverlauf, und der nur additiv). Der **Vergleichs-Fehler** ist behoben: `WealthChart` benutzte den Szenario-**Namen** als Datenschlüssel, wodurch sich gleichnamige Szenarien gegenseitig überschrieben (Legende zeigte beide, der Chart nur eine) – neu die **ID**; die stille Deckelung auf vier Serien entfällt. Die **Legende** ist eigenständig, erlaubt eine **freie Farbwahl je Serie** und erklärt den Linienstil (gestrichelt = Plan, durchgezogen = effektiv). Die **Vermögensaufteilung** ist neu eine **gestapelte Fläche über alle Planjahre** plus ein **Ring** für die relative Aufteilung zu einem wählbaren Zeitpunkt (vorher gestapelte Balken je Phase mit schräger Beschriftung). **Alle Diagrammfarben** kommen aus neuen Theme-Tokens (`--chart-1` … `--chart-6`, `--chart-grid`) statt fester Hex-Werte. (7) **Dokumentation nachgezogen:** Die Kapitel 2.1, 3.2.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.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. | @@ -127,11 +128,12 @@ Referenz: `prisma/schema.prisma` Zeilen 8–10, `src/lib/types.ts` Zeilen 38–4 ``` User - └── Plan (Behälter: nur Name — KEINE Finanzdaten) - └── Scenario[] (die berechenbare Einheit) - │ Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert + └── Plan (der HAUSHALT: Name, Haushaltsform, Startjahr) + ├── PlanPerson[] (Rolle, Name, Alter — gelten für ALLE Szenarien) + └── Scenario[] (die berechenbare Einheit = eine Planungsvariante) + │ szenario-eigen: Inflation, Cash-Startwert │ isBase = genau eines je Plan · parentScenarioId = Baum + Vergleichsbasis - ├── Person[] (1 bei SINGLE, 2 bei COUPLE — je Szenario eigen!) + ├── Person[] (nur das PENSIONSALTER je Rolle — szenario-eigen) ├── Phase[] (Kette 1..n; sourcePhaseId = Gegenstück in der Vorlage) └── FinancialElement[] (szenario-weit; sourceElementId = Gegenstück in der Vorlage) ├── ElementPhaseValue[] (Werte je Phase, JSON) @@ -146,14 +148,15 @@ und pro Phasenübergang einen Entscheid-Satz. Dadurch bleibt die Identität eine Vermögensgegenstands über die Zeit erhalten – Voraussetzung für die Fortschreibung (Carry) und die Vermögensaufteilungs-Grafik. -**(2) Das Grundprofil liegt am Szenario, nicht am Plan** (V6). Nur so lassen sich die -wertvollsten Szenario-Fragen abbilden – allen voran ein abweichendes **Pensionsalter** -(„Was, wenn ich mit 62 statt 65 aufhöre?"), das in `Person` steckt. Wäre das Profil geteilt, -wären Frühpensionierungs-Szenarien unmöglich. +**(2) Der Haushalt liegt am Plan, die Annahmen am Szenario** (V7, siehe +[9.30](#930-warum-der-haushalt-am-plan-hängt)). Haushaltsform, Personen (Name, Alter) und +Planstartjahr beschreiben den **Haushalt** – sie gelten für alle Szenarien. Unterscheiden sie +sich, ist es ein anderer **Plan**. Szenario-eigen sind die **Annahmen**: Inflation, +Cash-Startwert und – am wichtigsten – das **Pensionsalter** je Person. Nur deshalb sind +Frühpensionierungs-Szenarien überhaupt möglich („Was, wenn ich mit 62 statt 65 aufhöre?"). -Der **Plan** ist damit ein reiner Behälter: Er bündelt Szenarien und trägt den Eigentümer -(`userId`). Ownership von Szenario/Phase/Element läuft über die Kette -`Scenario → Plan → User`. +Der **Plan** trägt zudem den Eigentümer (`userId`). Ownership von Szenario/Phase/Element läuft +über die Kette `Scenario → Plan → User`. Referenz: `prisma/schema.prisma`. @@ -317,49 +320,59 @@ Der Dialog «Leer starten» fragt Name plus das vollständige Grundprofil: Konsistenzregel: `SINGLE` erfordert genau eine Person, `COUPLE` genau zwei (Person A und B). Verletzung → HTTP 400 mit Klartextmeldung. -Referenz: `src/components/PlanProfileFields.tsx` Zeilen 1012–1018, `src/app/api/plans/route.ts` -Zeilen 6–35. +Referenz: `src/components/PlanProfileFields.tsx`, `src/app/api/plans/route.ts`. Ein neu erstellter Plan hat **keine Phasen und keine Elemente**; `initialCash` ist 0. ### 3.2.2 Grundprofil ändern -Über „Einstellungen" in der Planansicht. Wichtig: Die Personen werden serverseitig in einer -Transaktion **gelöscht und neu angelegt** (`deleteMany` + `create`). Die Person-IDs ändern sich -dadurch. Da Elemente über `ownerRole` (nicht über `personId`) zugeordnet sind, bleibt die -Zuordnung erhalten. +Über **«Profil bearbeiten»** im Grundprofil-Block der Szenario-Ansicht (Panel «Szenario-Profil»). +Der Endpunkt ist `PATCH /api/scenarios/`: Er schreibt Haushaltsform, Personen (Name, +Alter) und Startjahr an den **Plan**, Inflation und Pensionsalter ans **Szenario** – die Trennung +aus V7 ([9.30](#930-warum-der-haushalt-am-plan-hängt)). Das **Pensionsalter** ist dort nur noch +Anzeige; verschoben wird es über «Pensionsalter anpassen» ([3.12](#312-pensionsalter-anpassen)), +weil es auf einer Phasengrenze liegt. + +Wichtig: Die Personen werden serverseitig in einer Transaktion **gelöscht und neu angelegt** +(`deleteMany` + `create`). Die Person-IDs ändern sich dadurch. Da Elemente über `ownerRole` +(nicht über `personId`) zugeordnet sind, bleibt die Zuordnung erhalten. Ein Wechsel von `COUPLE` auf `SINGLE` entfernt Person B. Elemente mit `ownerRole = PERSON_B` bleiben in der Datenbank bestehen, finden aber keinen Owner mehr – siehe [9.2](#92-verwaiste-person_b-elemente). -Referenz: `src/app/api/plans/[planId]/route.ts` Zeilen 55–70. +**Plan umbenennen:** über das Stift-Symbol neben dem Plannamen im Plan-Dashboard +(`PATCH /api/plans/`, nur das Feld `name`). + +Referenz: `src/app/api/scenarios/[scenarioId]/route.ts`, `src/app/api/plans/[planId]/route.ts`. ### 3.2.3 Cash-Anfangswert Klick auf die erste Zelle der Cash-Zeile öffnet den Dialog „Cash-Anfangswert". Wertebereich 0 bis 1'000'000'000, wird auf ganze Franken gerundet. -Referenz: `src/components/PlanView.tsx` Zeilen 1130–1156, `src/app/api/plans/[planId]/route.ts` Zeile 36. +Referenz: `src/components/PlanView.tsx`, `PATCH /api/scenarios/` (Feld `initialCash`). ### 3.2.4 Plan löschen -Aus der Übersichtskachel oder der Planansicht, mit Browser-`confirm()`. Löscht per Datenbank-Cascade -Personen, Phasen, Elemente und alle Werte. Szenarien, die auf diesen Plan als `parentPlanId` -zeigen, werden **nicht** gelöscht – ihre `parentPlanId` wird auf `NULL` gesetzt -(`onDelete: SetNull`). +Aus der Übersichtskachel oder der Seitenleiste, mit dem eigenen Bestätigungs-Dialog +(`useConfirm`, siehe [3.7.6](#376-sprache-und-ui-primitiven) – kein Browser-`confirm()` mehr). +Löscht per Datenbank-Cascade **alle Szenarien** des Plans und darüber Personen, Phasen, Elemente +und sämtliche Werte, dazu die Ist-Datensätze, gespeicherten Analysen und Berichte des Plans. -Referenz: `prisma/schema.prisma` Zeile 82. +Referenz: `prisma/schema.prisma`, `src/app/api/plans/[planId]/route.ts`. ### 3.2.5 Szenarien Beim Anlegen eines Plans entsteht **automatisch das Basisszenario** (`isBase = true`, -Name „Basisszenario"). Der beim Anlegen erfasste Profilteil (Haushaltsform, Personen, -Inflation) landet dort, der Name am Plan. +Name „Basisszenario"). Vom erfassten Profil landen **Haushaltsform, Personen und Startjahr am +Plan**, **Inflation und Pensionsalter am Basisszenario** (V7). Ein weiteres Szenario ist eine **vollständige Kopie eines beliebigen bestehenden Szenarios** -(nicht nur des Basisszenarios). Kopiert werden Grundprofil, alle Personen, alle Phasen, alle -Elemente sowie sämtliche Phasen- und Übergangswerte. +(nicht nur des Basisszenarios – die Vorlage ist in der Szenario-Liste je Zeile über «Kopie» +wählbar). Kopiert werden die szenario-eigenen Angaben (Inflation, Cash-Startwert, +Pensionsalter), alle Phasen, alle Elemente sowie sämtliche Phasen- und Übergangswerte. Der +Haushalt wird **nicht** kopiert – er liegt am Plan und gilt ohnehin für alle Szenarien. Gesetzt werden dabei: - `parentScenarioId` = das kopierte Szenario → ergibt den **Baum** in der Seitenleiste **und** @@ -404,11 +417,11 @@ Referenz: `src/lib/diff.ts`. ### 3.2.7 Planstart (Kalenderjahr) Das Grundprofil enthält das Feld **Planstart (Jahr)** – das Kalenderjahr, in dem Jahr 1 der -Planung liegt (`Scenario.startYear`). Es dient **ausschliesslich der Darstellung**: Zeitachse +Planung liegt (`Plan.startYear`, seit V7 plan-weit). Es dient **ausschliesslich der Darstellung**: Zeitachse und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unverändert in **relativen** Jahren ab Planbeginn – `startYear` fliesst in keine Formel ein. -Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Szenarien wurden per Migration darauf +Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Pläne wurden per Migration darauf gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr − 1)`. ### 3.2.8 Geführter Assistent und Beispielplan @@ -978,10 +991,13 @@ Referenz: `src/components/PlanView.tsx` Zeilen 701–769. ### 3.6.4 Analyse-Bereich „Grafiken" -Die Auswertungen liegen **nicht** unter der Matrix, sondern in einem eigenen Bereich: Der Button -**Grafiken** in der oberen Aktionsleiste (neben „Neues Szenario aus diesem" und -„Monte-Carlo-Simulation") öffnet sie als breiten Dialog. So bleibt die Matrix die ruhige -Hauptansicht. +Die Auswertungen liegen **nicht** unter der Matrix, sondern in einem eigenen Bereich: Die Kachel +**Grafiken** unter dem Menüpunkt **Analysen** ([3.10.2](#3102-analysen-vier-kacheln)) öffnet sie +als breiten Dialog. So bleibt die Matrix die ruhige Hauptansicht. + +**Szenario-Wahl:** Ganz oben steht ein Auswahlfeld, welches Szenario die Grafiken zeigen – es +gilt für **alle drei**. Vorher war der Bereich fest an das geöffnete Szenario gebunden, und nur +der Vermögensverlauf konnte weitere überlagern. **Kennzahl-Karten:** Endvermögen nominal und Endvermögen real. (Die frühere Karte „Geschätzter Nachlass" ist entfallen – sie war rechnerisch identisch mit dem nominalen Endvermögen und @@ -994,32 +1010,57 @@ ist grün (Sparquote) oder rot (Verzehr) eingefärbt. Der Keil zwischen roter un anschaulich „das, was die Inflation frisst". **Grafik 2 – Vermögensverlauf nach Alter** (`WealthChart`): Liniendiagramm über das Alter von -Person A. Je Szenario eine durchgezogene Linie (nominal) und eine gestrichelte (real). -Datenpunkte: **jedes Planjahr** (nicht nur die Phasengrenzen) – dadurch werden Verläufe +Person A. Datenpunkte: **jedes Planjahr** (nicht nur die Phasengrenzen) – dadurch werden Verläufe *innerhalb* einer Phase sichtbar, etwa das Abschmelzen im Kapitalverzehr. Grundlage ist -`YearPoint.wealthNominal/wealthReal` (4.10). Über Checkboxen lassen sich die **Geschwister- -Szenarien überlagern**; deren Daten werden bei Bedarf nachgeladen und im Client -zwischengespeichert. +`YearPoint.wealthNominal/wealthReal` (4.10). Über Checkboxen lassen sich weitere **Szenarien +überlagern**; deren Daten werden bei Bedarf nachgeladen und im Client zwischengespeichert. -**Grafik 3 – Vermögensaufteilung pro Phase**: Gestapeltes Balkendiagramm mit zwei Balken je Phase -(Beginn / Ende). Gestapelt werden alle Elemente der Kategorien PK, 3a, Immobilie, Sonstiges -Vermögen, die irgendwann einen positiven Wert haben. Stapelung nach `elementId` (nicht Name), -damit gleichnamige Elemente nicht kollidieren. Negative Werte werden auf 0 geklammert. +Der Datenschlüssel je Serie ist die **Szenario-ID**, nicht der Name. Bis 0.30 diente der Name als +Schlüssel – zwei gleichnamige Szenarien überschrieben sich dadurch gegenseitig, und die Legende +zeigte eine Linie an, die es im Diagramm gar nicht gab. Die **Legende** ist eigenständig (nicht +die von Recharts): Sie erlaubt je Serie eine **freie Farbwahl** und erklärt den Linienstil +(gestrichelt = reine Planwerte, durchgezogen = mit effektiven Werten). -Referenz: `src/components/Dashboard.tsx`, `src/components/WealthChart.tsx`, `src/components/SparquoteChart.tsx`. +**Grafik 3 – Vermögensaufteilung im Zeitverlauf**: Links eine **gestapelte Fläche** über alle +Planjahre, die Phasengrenzen als feine gestrichelte Senkrechte; rechts ein **Ring**, der die +**relative** Aufteilung zu einem wählbaren Zeitpunkt zeigt (Klick in die Fläche wählt das Jahr, +Vorgabe ist das Planende). Gestapelt werden alle Elemente der Kategorien PK, 3a, Immobilie, +Sonstiges Vermögen, die irgendwann einen positiven Wert haben. Stapelung nach `elementId` (nicht +Name), damit gleichnamige Elemente nicht kollidieren. Negative Werte werden auf 0 geklammert. + +Bis 0.30 waren das gestapelte Balken mit zwei Säulen je Phase und schräg gestellter +Beschriftung – ab drei Phasen kaum mehr lesbar, und die relative Aufteilung war gar nicht +ablesbar. Die Fläche zeigt den Verlauf statt zweier Stichproben je Phase; die Jahreswerte dafür +liegen seit 0.11 ohnehin vor (`ElementPhaseComputed.yearly`), es wird nichts neu gerechnet. + +Referenz: `src/components/Dashboard.tsx`, `src/components/WealthChart.tsx`, +`src/components/SparquoteChart.tsx`, `src/components/AllocationChart.tsx`. ### 3.6.5 CSV-Export -`GET /api/plans//export` liefert eine semikolon-getrennte CSV, eine Zeile pro Phase: +`GET /api/scenarios//export` liefert die **vollständige Matrix** als +semikolon-getrennte CSV. Bis 0.30 war das eine reine Phasen-Zusammenfassung – zehn Spalten, eine +Zeile je Phase, **kein einziges finanzielles Element**; was am Bildschirm stand, liess sich damit +nicht weiterverarbeiten. -``` -Phase;Typ;Dauer;Einkommen (Beginn);Ausgaben (Beginn);Quote (Beginn);Quote (Ende);Cash (Ende);Endvermoegen (nominal);Endvermoegen (real) -``` +Die Datei enthält **vier Blöcke** untereinander (Excel kommt damit zurecht, und es ist sofort +erkennbar, was zusammengehört): -Bei vorhandenem Ruin folgt eine Schlusszeile `Ruin: Kapital aufgebraucht mit Alter `. -Dateiname = Planname, nicht-alphanumerische Zeichen durch `_` ersetzt. +| Block | Inhalt | +|---|---| +| **Kopf** | Szenario, Haushaltsform, Personen (Alter/Pensionsalter), Planstart, Inflation, Cash-Anfangswert, Erzeugungszeitpunkt | +| **LEBENSPHASEN** | je Phase: Typ, Dauer, Altersspanne, Einkommen/Ausgaben, Quote (Beginn/Ende), Cash (Beginn/Ende), Vermögen (Beginn/Ende, nominal + real) | +| **MATRIX** | Zeilen = Cash + alle Elemente; je Phase **zwei** Spalten (Beginn/Ende), dazwischen je Übergang **eine** Spalte mit dem Entscheid im Klartext (z. B. «Kapitalbezug (Steuer 8 %)») | +| **JAHRESWERTE** | je Planjahr: Kalenderjahr, Alter, Vermögen nominal/real, Cash, Einkommen, Ausgaben, Sparquote | -Referenz: `src/lib/calculations.ts` Zeilen 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" @@ -1058,7 +1099,7 @@ Jede Element-Zeile und jeder Phasenkopf trägt oben rechts ein **Expand-Icon** ( | Reiter | Inhalt | |---|---| -| Übersicht | Typ, Dauer, Vermögen Beginn/Ende; Vermögensaufteilung als gestapelter Balken (Beginn und Ende) | +| Übersicht | Typ, Dauer, Vermögen Beginn/Ende; Vermögensaufteilung als Fläche über die Planjahre mit Ring für den gewählten Zeitpunkt | | Wasserfall | Vermögens- und Cash-Wasserfall ([4.14.2](#4142-die-beiden-wasserfälle)) | | Rechenweg | Quote, Spar-/Verzehrrate, Cash-Fortschreibung, Vermögen, Phasentyp, Cash-Übergang | @@ -1289,6 +1330,12 @@ die oberste. Animationen (150–220 ms) respektieren `prefers-reduced-motion`. je Farbschema abgestimmt) statt der Akzentfarbe – «hier fehlt eine Eingabe» und «hier kannst du klicken» sind damit unterscheidbar. +**Diagramm-Palette:** Seit 0.31 kommen auch die Farben aller Diagramme aus Tokens +(`--chart-1` … `--chart-6`, dazu `--chart-grid`) statt aus festen Hex-Werten – je Farbschema +gedämpft abgestimmt. Recharts bekommt die Variablen direkt als Attributwert (`var(--chart-1)`), +der Browser löst sie im SVG auf. Vorher stachen die Diagramme mit greller Standardpalette aus +dem Schema heraus und sahen im Dunkelmodus falsch aus. + ### 3.7.7 Inspector-Panel statt Modals Alle **Einzel-Bearbeitungen** – Phasenzelle, Übergangszelle, Cash-Übergang, Cash-Anfangswert, @@ -1387,6 +1434,12 @@ sie. Zwei Sicherungen ergänzen das: Eine **Hauptversion** wird nie zusammengefasst und nie nachträglich verändert; die nächste Änderung beginnt bei A.1. +**Ein Szenario startet bei 0.1.** Die Nebenversion zählt beliebig weit hoch (0.1, 0.2, … 0.137); +die **0** bedeutet «noch kein verabschiedeter Stand». Erst eine bewusst gesetzte **Hauptversion** +macht daraus **1.0**. Bis 0.30 begann jedes Szenario bereits bei 1.0 – damit war die Hauptversion +bedeutungslos, weil jeder Plan schon eine hatte. Bestehende Historien werden **nicht** +umnummeriert; nur der Startwert ist neu (Migration `20260724120000_version_zero_start`). + ### 3.8.2 Was die Historie zeigt Je Version: **wer** (Benutzername), **wann** (Zeitpunkt der letzten Änderung dieser Sitzung) @@ -1575,15 +1628,20 @@ Referenz: `src/lib/actuals.ts`, `src/lib/dataview.ts`, `src/components/ActualsDi ## 3.10 Navigation auf Plan-Ebene und gespeicherte Analysen -Die Seitenleiste ist zweistufig: Unter jedem **Plan** liegen drei leicht eingezogene -Unterpunkte. Ein Klick auf den **Plan-Namen** öffnet dessen Dashboard. +Die Seitenleiste ist zweistufig: Unter jedem **Plan** liegen vier Unterpunkte. Ein Klick auf den +**Plan-Namen** öffnet dessen Dashboard. | Ort | Führt zu | |---|---| -| Plan-Name | **Plan-Dashboard** (Kennzahlen) | -| Szenarien | **Szenario-Liste** – darunter bleibt der Szenario-Baum, dessen Einträge direkt in die Matrix führen | +| Plan-Name | **Plan-Dashboard** (Kennzahlen, Umbenennen) | +| Szenarien | **Szenario-Liste** – darunter der Szenario-Baum, **verschachtelt** nach Herkunft; seine Einträge führen direkt in die Matrix | | Effektive Werte | Liste + Wizard der Ist-Werte ([3.9](#39-effektive-werte-plan-ist-vergleich)) | | Analysen | Vier Werkzeug-Kacheln + Liste gespeicherter Analysen | +| Berichte | PDF-Berichte erzeugen und wieder herunterladen ([3.11](#311-pdf-berichte)) | + +Die vier Unterpunkte stehen **bündig zum Knoten «Szenarien»**; nur die einzelnen Szenarien +darunter sind eingerückt – und zwar je nach Tiefe ihrer Herkunftskette, sodass in der +Seitenleiste sichtbar ist, woraus ein Szenario entstanden ist. ### 3.10.1 Plan- vs. Szenario-Ebene der Kennzahlen @@ -1591,13 +1649,19 @@ Seit V7 ([9.30](#930-warum-der-haushalt-am-plan-hängt)) liegen Haushaltsform, P Startjahr am Plan; Endvermögen, Ruinalter, Phasen und Elemente sind dagegen **szenario-eigen**. Das Plan-Dashboard zeigt deshalb die Haushaltsdaten direkt, alle gerechneten Kennzahlen aber ausdrücklich als **«laut Basisszenario»** – das Basisszenario ist der kanonische Vertreter. -Falls Ist-Werte erfasst sind, weist es zusätzlich die **Abweichung** des Endvermögens gegenüber -dem Plan aus. +Die vier Kennzahl-Kacheln (Szenarien, Effektive Werte, Analysen, Berichte) sind **anklickbar** +und führen in ihren Bereich. Über ein **Stift-Symbol** neben dem Plannamen lässt sich der Plan +umbenennen. -Die **Szenario-Liste** zeigt je Szenario Name, aktuelle Hauptversion, Anzahl Elemente, das -Endvermögen und ob das Kapital reicht; das Basisszenario ist farblich hervorgehoben, und die -Herkunft (aus welchem Szenario kopiert) steht darunter. Zwei Aktionen je Zeile: **Historie** -und **Matrix**. +Falls Ist-Werte erfasst sind, weist das Dashboard die **Abweichung** des Endvermögens gegenüber +dem Plan aus – benannt mit dem **Jahr des jüngsten Ist-Datensatzes** («Mit den effektiven Werten +von 2032 …») und **grün**, wenn die Realität besser ist als der Plan, sonst rot. + +Die **Szenario-Liste** zeigt je Szenario Name, die **aktuelle Version** (z. B. «0.17»), Anzahl +**Phasen** und **Elemente**, das Endvermögen und ob das Kapital reicht; das Basisszenario ist +farblich hervorgehoben, und die Herkunft (aus welchem Szenario kopiert) steht darunter. Ein +Klick auf die **Zeile** öffnet die Matrix; je Zeile gibt es zusätzlich **Historie**, **Kopie** +(macht dieses Szenario zur Vorlage) und – ausser beim Basisszenario – **Löschen**. ### 3.10.2 Analysen: vier Kacheln @@ -3233,7 +3297,8 @@ PlanComputed ← an den Client geliefert | Datei | Verantwortung | |---|---| -| `calculations.ts` | Berechnungskern + CSV-Export. Keine I/O. | +| `calculations.ts` | Berechnungskern. Keine I/O. | +| `csv.ts` | CSV-Export der vollständigen Matrix (vier Blöcke, Kap. 3.6.5). Rein. | | `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` | | `types.ts` | Domänentypen für API und Berechnung | | `constants.ts` | Schweizer Systemparameter | @@ -3475,6 +3540,7 @@ sondern zu leeren Werten. | `20260720090000_actuals` | Tabelle `ActualsSet` (effektive Werte je Plan, Werte als JSONB, Cash separat) | | `20260720140000_plan_level_profile` | **V7**: Haushaltsform, Personen (Name/Alter) und Startjahr vom Szenario auf den Plan; neue Tabelle `PlanPerson`; `Person` behält nur das Pensionsalter. Datenübernahme aus dem Basisszenario. | | `20260721090000_reports` | Tabelle `Report`: gewählte Parameter, eingefrorenes Modell und die PDF-Datei als `BYTEA` | +| `20260724120000_version_zero_start` | `Scenario.currentMajor` startet bei **0** statt 1 – die Versionierung beginnt bei 0.1 (Kap. 3.8). Nur der Default; bestehende Zeilen bleiben | | `20260720160000_saved_analyses` | Tabelle `SavedAnalysis` (Eingaben + Ergebnis als JSONB, denormalisierte Kerndaten) | **Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um – dadurch @@ -3846,7 +3912,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `actuals.test.ts` | 24 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung, Wirkung über die Phasengrenze hinaus | | `dataview.test.ts` | 7 | beide Sichten in einem Zug, Rückfall ohne Ist-Werte, Abweichungsmessung | | `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen | -| `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien | +| `versioning.test.ts` | 23 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien | | `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus | | `report.test.ts` | 9 | Zusammenfassung, Basis-Angabe zu JEDER Kennzahl, Szenario-Deckelung, Plan/Ist-Block, Haftungsausschluss, gültige PDF-Datei | | `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung | @@ -3857,7 +3923,8 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) | | `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP | -| **Total** | **267** | | +| `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` | +| **Total** | **275** | | ## 8.2 Testfälle diff --git a/prisma/migrations/20260724120000_version_zero_start/migration.sql b/prisma/migrations/20260724120000_version_zero_start/migration.sql new file mode 100644 index 0000000..a5362a7 --- /dev/null +++ b/prisma/migrations/20260724120000_version_zero_start/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 24f1d81..befb32e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -224,7 +224,7 @@ model Scenario { updatedAt DateTime @updatedAt // Laufende Hauptversion. Die Nebenversionen zaehlen in ScenarioVersion. - currentMajor Int @default(1) + currentMajor Int @default(0) persons Person[] phases Phase[] diff --git a/src/app/api/plans/[planId]/dashboard/route.ts b/src/app/api/plans/[planId]/dashboard/route.ts index 9a9fe6f..f494600 100644 --- a/src/app/api/plans/[planId]/dashboard/route.ts +++ b/src/app/api/plans/[planId]/dashboard/route.ts @@ -19,7 +19,13 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ persons: { orderBy: { role: "asc" } }, scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], - include: { ...planInclude, _count: { select: { elements: true, versions: true } } }, + include: { + ...planInclude, + _count: { select: { elements: true, versions: true } }, + // Höchste Version je Szenario -- die Liste zeigt die echte Nummer (z. B. «0.17»), + // nicht nur die Hauptversion. + versions: { orderBy: [{ major: "desc" }, { minor: "desc" }], take: 1, select: { major: true, minor: true } }, + }, }, actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] }, _count: { select: { scenarios: true, actuals: true, analyses: true } }, @@ -71,13 +77,16 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ const pi = toPlanInput(s); const computed = computePlan(pi); const last = computed.phases[computed.phases.length - 1]; + const top = s.versions[0]; return { id: s.id, name: s.name, isBase: s.isBase, parentScenarioId: s.parentScenarioId, - currentMajor: s.currentMajor, + // Die tatsächliche aktuelle Version, nicht nur die Hauptversion. Ohne Historie noch keine. + version: top ? `${top.major}.${top.minor}` : null, elementCount: s._count.elements, + phaseCount: computed.phases.length, versionCount: s._count.versions, endNominal: last ? Math.round(last.endWealthNominal) : 0, endReal: last ? Math.round(last.endWealthReal) : 0, diff --git a/src/app/api/scenarios/[scenarioId]/export/route.ts b/src/app/api/scenarios/[scenarioId]/export/route.ts index 9ea8861..ca87991 100644 --- a/src/app/api/scenarios/[scenarioId]/export/route.ts +++ b/src/app/api/scenarios/[scenarioId]/export/route.ts @@ -1,7 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; import { toPlanInput, getOwnedScenario } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; -import { computePlan, planToCsv } from "@/lib/calculations"; +import { computePlan } from "@/lib/calculations"; +import { planToCsv } from "@/lib/csv"; + +// Umlaute und ss ausschreiben, alles Uebrige auf Unterstrich -- ein Dateiname mit Umlauten +// kommt je nach Betriebssystem verstuemmelt an. +function safeFileName(name: string): string { + const map: Record = { "ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss" }; + const t = name.replace(/[äöüÄÖÜß]/g, (c) => map[c]); + return t.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "") || "szenario"; +} export async function GET( _request: NextRequest, @@ -25,7 +34,7 @@ export async function GET( return new NextResponse(csv, { headers: { "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": `attachment; filename="${scenario.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`, + "Content-Disposition": `attachment; filename="${safeFileName(scenario.name)}.csv"`, }, }); } diff --git a/src/app/globals.css b/src/app/globals.css index e19ec92..433a557 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -39,6 +39,15 @@ --attention-fg: #ffffff; --attention-soft: #fef3c7; --attention-soft-fg: #92400e; + /* Diagramm-Palette: bewusst gedämpfte Töne, je Farbschema abgestimmt. Recharts bekommt + die Variablen direkt als Attributwert (var(--chart-1)) -- der Browser löst sie im SVG auf. */ + --chart-1: #5b5bd6; + --chart-2: #3b8ea5; + --chart-3: #4c9a6a; + --chart-4: #c98a2e; + --chart-5: #c25d6b; + --chart-6: #8b6bb1; + --chart-grid: #e4e4e7; } :root[data-theme="dark"] { @@ -71,6 +80,13 @@ --attention-fg: #1c1400; --attention-soft: rgba(245, 158, 11, 0.16); --attention-soft-fg: #fcd34d; + --chart-1: #818cf8; + --chart-2: #5eb3c9; + --chart-3: #6ec898; + --chart-4: #e0b25c; + --chart-5: #e08a9a; + --chart-6: #b39ae0; + --chart-grid: #3f3f46; } /* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundärton. */ @@ -90,7 +106,7 @@ --accent-soft: #fcebe2; --accent-soft-fg: #b24521; --danger: #c0392b; - --danger-soft: #fbeä7; + --danger-soft: #fbeae7; --success: #2e9e7b; --person-a: #e8663c; --diff: #b45309; @@ -104,6 +120,13 @@ --attention-fg: #ffffff; --attention-soft: #fdeed3; --attention-soft-fg: #8f5410; + --chart-1: #e8663c; + --chart-2: #4e8fa3; + --chart-3: #7ba05b; + --chart-4: #d99441; + --chart-5: #a9556b; + --chart-6: #8c6a9e; + --chart-grid: #eadfd2; } /* Ohne gespeicherte Wahl der Dunkel-OS-Einstellung folgen. */ @@ -138,6 +161,13 @@ --attention-fg: #1c1400; --attention-soft: rgba(245, 158, 11, 0.16); --attention-soft-fg: #fcd34d; + --chart-1: #818cf8; + --chart-2: #5eb3c9; + --chart-3: #6ec898; + --chart-4: #e0b25c; + --chart-5: #e08a9a; + --chart-6: #b39ae0; + --chart-grid: #3f3f46; } } diff --git a/src/components/AllocationChart.tsx b/src/components/AllocationChart.tsx index 635fad0..7de46ea 100644 --- a/src/components/AllocationChart.tsx +++ b/src/components/AllocationChart.tsx @@ -1,17 +1,54 @@ "use client"; -import { useMemo } from "react"; -import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { useMemo, useState } from "react"; +import { + Area, + AreaChart, + Cell, + Legend, + Pie, + PieChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; import { formatChf } from "@/lib/format"; import type { PlanComputed } from "@/lib/calculations"; -export const CHART_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"]; +// Diagramm-Palette aus den Theme-Tokens (globals.css) statt fester Hex-Werte -- so passt sie +// sich Hell/Dunkel/Warm an. Recharts reicht die Werte als SVG-Attribut durch, der Browser +// löst `var(...)` dort auf. +export const CHART_PALETTE = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", + "var(--chart-6)", +]; + +// Einheitliches Aussehen der Recharts-Tooltips (folgt dem Farbschema). +export const TOOLTIP_STYLE = { + background: "var(--surface)", + border: "1px solid var(--border)", + borderRadius: "0.5rem", + fontSize: 12, + color: "var(--fg)", +} as const; const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"]; -// Gestapelte Vermögensaufteilung je Phase (Beginn und Ende). Eigene Komponente, weil sie -// sowohl im Grafiken-Dialog als auch in der Live-Simulation gebraucht wird. -export function AllocationChart({ computed, height = 288 }: { computed: PlanComputed; height?: number }) { +// Gestapelte Vermögensaufteilung. +// +// Bis 0.30 waren das gestapelte Balken mit je zwei Säulen pro Phase (Beginn/Ende) und schräg +// gestellter Beschriftung -- schwer lesbar, sobald es mehr als zwei Phasen gab. Neu: +// * eine gestapelte FLÄCHE über alle Planjahre (zeigt den Verlauf statt zweier Stichproben), +// Phasengrenzen als feine senkrechte Linien, +// * daneben ein RING für ein wählbares Jahr, der die RELATIVE Aufteilung zeigt. +// Ein Klick in die Fläche wählt das Jahr des Rings. +export function AllocationChart({ computed, height = 300 }: { computed: PlanComputed; height?: number }) { // Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen // positiven Wert haben -- in Reihenfolge ihres ersten Auftretens. const assetEls = useMemo(() => { @@ -28,50 +65,159 @@ export function AllocationChart({ computed, height = 288 }: { computed: PlanComp return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name })); }, [computed]); - // Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende. - const barData = useMemo( - () => - computed.phases.flatMap((phase) => { - const beginn: Record = { label: `${phase.name} · Beginn` }; - const ende: Record = { label: `${phase.name} · Ende` }; - for (const el of phase.elements) { - if (!ASSET_CATS.includes(el.category)) continue; - beginn[el.elementId] = Math.max(0, Math.round(el.startValue)); - ende[el.elementId] = Math.max(0, Math.round(el.endValue)); + // Je Planjahr eine Zeile mit dem Wert jedes Elements. Die Jahreswerte liegen bereits in + // `ElementPhaseComputed.yearly` (seit 0.11) -- hier wird nur umsortiert, nichts gerechnet. + const areaData = useMemo(() => { + const byYear = new Map>(); + const ageOf = new Map(); + for (const phase of computed.phases) { + for (const el of phase.elements) { + if (!ASSET_CATS.includes(el.category)) continue; + for (const y of el.yearly) { + const row = byYear.get(y.year) ?? {}; + row[el.elementId] = Math.max(0, Math.round(y.value)); + byYear.set(y.year, row); + ageOf.set(y.year, y.age); } - return [beginn, ende]; - }), - [computed] - ); + } + } + return [...byYear.entries()] + .sort(([a], [b]) => a - b) + .map(([year, values]) => ({ year, age: ageOf.get(year) ?? 0, ...values })); + }, [computed]); + + // Phasengrenzen (kumulierte Dauer) für die Trennlinien. + const boundaryAges = useMemo(() => { + const out: number[] = []; + let acc = 0; + for (let i = 0; i < computed.phases.length - 1; i++) { + acc += computed.phases[i].durationYears; + const age = areaData.find((r) => r.year === acc)?.age; + if (typeof age === "number") out.push(age); + } + return out; + }, [computed, areaData]); + + // Gewähltes Jahr für den Ring -- Vorgabe: das letzte (Endzustand). + const [pickedYear, setPickedYear] = useState(null); + const ringRow = areaData.find((r) => r.year === pickedYear) ?? areaData[areaData.length - 1]; + + const ringData = useMemo(() => { + if (!ringRow) return []; + return assetEls + .map((el) => ({ name: el.name, value: Number(ringRow[el.id as keyof typeof ringRow] ?? 0) })) + .filter((d) => d.value > 0); + }, [assetEls, ringRow]); + const ringTotal = ringData.reduce((s, d) => s + d.value, 0); if (assetEls.length === 0) { return

Dieser Plan enthält keine Vermögenselemente.

; } return ( -
- - - - - Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} - /> - (typeof v === "number" ? formatChf(v) : v)} /> - - {assetEls.map((el, i) => ( - - ))} - - +
+ {/* Verlauf */} +
+
+ Verlauf über alle Planjahre · Klick wählt das Jahr für den Ring +
+
+ + { + const y = (e as { activePayload?: { payload?: { year?: number } }[] })?.activePayload?.[0]?.payload?.year; + if (typeof y === "number") setPickedYear(y); + }} + > + `${v} J.`} + stroke="var(--faint)" + /> + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} + /> + (typeof v === "number" ? formatChf(v) : v)} + labelFormatter={(v) => `Alter ${v}`} + /> + {boundaryAges.map((age) => ( + + ))} + {/* Markierung des Ring-Jahres. */} + {ringRow && } + {assetEls.map((el, i) => ( + + ))} + + + +
+
+ + {/* Relative Aufteilung im gewählten Jahr */} +
+
+ Aufteilung mit {ringRow?.age ?? "–"} Jahren + {pickedYear !== null && ( + + )} +
+
+ + + + {ringData.map((d, i) => ( + + ))} + + + typeof v === "number" + ? `${formatChf(v)} · ${ringTotal > 0 ? Math.round((v / ringTotal) * 100) : 0} %` + : v + } + /> + + +
+
+ Total {formatChf(ringTotal)} +
+
); } diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 11a91de..176d12c 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -117,6 +117,8 @@ function AppShellInner({ username }: { username: string }) { const [showPalette, setShowPalette] = useState(false); // Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus. const [specAnchor, setSpecAnchor] = useState(null); + // Erhoehen erzwingt ein Neuladen der Szenario-Liste (bleibt bei einer Loeschung montiert). + const [scenarioListKey, setScenarioListKey] = useState(0); // Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im // DOM der Szenario-Ansicht). Zwei Auslöser: @@ -276,6 +278,9 @@ function AppShellInner({ username }: { username: string }) { return; } await loadPlans(); + // Die Szenario-Liste bleibt beim Löschen aus der Seitenleiste montiert -- ohne dieses + // Signal zeigte sie den gelöschten Eintrag weiter. + setScenarioListKey((k) => k + 1); if (selectedScenarioId === s.id) setSelectedScenarioId(null); } @@ -384,7 +389,7 @@ function AppShellInner({ username }: { username: string }) { ); } // ========================================================================================= // Plan-Dashboard // ========================================================================================= -export function PlanDashboardView({ planId }: { planId: string }) { - const { data, error } = useDashboard(planId, 0); +export function PlanDashboardView({ + planId, + onNavigate, + onRenamed, +}: { + planId: string; + onNavigate: (tab: PlanTab) => void; + onRenamed: () => void; +}) { + const [reloadKey, setReloadKey] = useState(0); + const { data, error } = useDashboard(planId, reloadKey); + const [editing, setEditing] = useState(false); + const [draftName, setDraftName] = useState(""); + const [saving, setSaving] = useState(false); + const toast = useToast(); + if (error) return

{error}

; if (!data) return

Wird geladen…

; @@ -97,43 +153,134 @@ export function PlanDashboardView({ planId }: { planId: string }) { .map((p) => `${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")} (${p.age})`) .join(" · "); + // Jüngster erfasster Ist-Datensatz -- er bestimmt, gegen welchen Stand verglichen wird. + const latestActualYear = base && base.actualYears.length > 0 ? Math.max(...base.actualYears) : null; + const delta = base && base.actualEndNominal !== null ? base.actualEndNominal - base.endNominal : null; + + async function saveName() { + const name = draftName.trim(); + if (!name) return; + setSaving(true); + try { + await api.patch(`/api/plans/${planId}`, { name }); + setEditing(false); + setReloadKey((k) => k + 1); + onRenamed(); + toast("success", "Plan umbenannt."); + } catch (e) { + toast("error", e instanceof Error ? e.message : "Umbenennen fehlgeschlagen."); + } finally { + setSaving(false); + } + } + return (
-

{plan.name}

+ {editing ? ( +
+ 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" + /> + + +
+ ) : ( +
+

{plan.name}

+ +
+ )}

{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons} {plan.startYear ? ` · Planstart ${plan.startYear}` : ""}

-
- - - - + {/* Anklickbare Kacheln -- jede führt in ihren Bereich. */} +
+ onNavigate("scenarios")} + /> + onNavigate("actuals")} + /> + onNavigate("analyses")} + /> + onNavigate("reports")} + />
{base && ( -
-

+
+

Basisszenario «{base.name}» - alle Kennzahlen beziehen sich hierauf + alle Kennzahlen beziehen sich hierauf

-
+
+ {base.ruinAge !== null ? ( ) : ( - + )}
- {base.actualEndNominal !== null && ( -

- Mit den effektiven Werten liegt das Endvermögen (nominal) bei{" "} - {formatChf(base.actualEndNominal)} – eine Abweichung von{" "} - {formatChf(base.actualEndNominal - base.endNominal)} gegenüber dem Plan. + {base.actualEndNominal !== null && delta !== null && ( +

= 0 ? "border-success bg-success/10 text-fg" : "border-danger bg-danger-soft text-fg" + }`} + > + Mit den effektiven Werten{latestActualYear ? ` von ${latestActualYear}` : ""} liegt das Endvermögen + (nominal) bei {formatChf(base.actualEndNominal)} – eine Abweichung von{" "} + = 0 ? "text-success" : "text-danger"}> + {delta >= 0 ? "+" : ""} + {formatChf(delta)} + {" "} + gegenüber dem Plan.

)}
@@ -147,28 +294,36 @@ export function PlanDashboardView({ planId }: { planId: string }) { // ========================================================================================= export function ScenarioListView({ planId, + reloadKey = 0, onOpenMatrix, onOpenHistory, - onNew, + onCopyFrom, + onDelete, }: { planId: string; + reloadKey?: number; onOpenMatrix: (scenarioId: string) => void; onOpenHistory: (scenarioId: string) => void; - onNew: () => void; + // Kopiervorlage ist frei wählbar -- nicht mehr zwingend das Basisszenario. + onCopyFrom: (scenarioId: string) => void; + onDelete: (scenarioId: string) => void; }) { - const { data, error } = useDashboard(planId, 0); + const { data, error } = useDashboard(planId, reloadKey); if (error) return

{error}

; if (!data) return

Wird geladen…

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

Szenarien

- + {baseId && ( + + )}
@@ -177,6 +332,7 @@ export function ScenarioListView({ Szenario Version + Phasen Elemente Endvermögen (nom.) Kapital reicht @@ -185,11 +341,20 @@ export function ScenarioListView({ {data.scenarios.map((s) => ( - + // Die ganze Zeile öffnet die Matrix -- der frühere «Matrix»-Knopf entfällt. + onOpenMatrix(s.id)} + className={`cursor-pointer border-t border-border transition-colors hover:bg-accent-soft/40 ${ + s.isBase ? "bg-accent-soft/20" : "" + }`} + >
{s.name} - {s.isBase && Basis} + {s.isBase && ( + Basis + )}
{s.parentScenarioId && (
@@ -197,7 +362,8 @@ export function ScenarioListView({
)} - {s.currentMajor}.x + {s.version ?? "–"} + {s.phaseCount} {s.elementCount} {formatChf(s.endNominal)} @@ -207,10 +373,12 @@ export function ScenarioListView({ Planende )} - + {/* Aktionen: stopPropagation, sonst öffnet der Zeilenklick die Matrix. */} + e.stopPropagation()}>
+ {!s.isBase && ( + + )}
@@ -394,7 +574,7 @@ function FlipTile({ tile, onClick }: { tile: (typeof TILES)[number]; onClick: () onMouseEnter={() => setFlipped(true)} onMouseLeave={() => setFlipped(false)} // Touch hat kein Hover -- ein Antippen des Info-Bereichs klappt um, statt gleich zu starten. - className="group relative flex h-40 flex-col items-start justify-between overflow-hidden rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-colors hover:border-accent" + className="group relative flex h-40 flex-col items-start justify-between overflow-hidden rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md" > {!flipped ? ( <> diff --git a/src/components/SavedAnalysisView.tsx b/src/components/SavedAnalysisView.tsx index 3b8b62e..21f3657 100644 --- a/src/components/SavedAnalysisView.tsx +++ b/src/components/SavedAnalysisView.tsx @@ -139,7 +139,7 @@ function SavedChart({ chart }: { chart: NonNullable }) { for (const s of chart.series) row[s.label] = s.points.find((p) => p.x === x)?.y ?? (null as unknown as number); return row; }); - const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"]; + const PALETTE = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6)"]; return (
diff --git a/src/components/SparquoteChart.tsx b/src/components/SparquoteChart.tsx index 4d67a4e..4f59a86 100644 --- a/src/components/SparquoteChart.tsx +++ b/src/components/SparquoteChart.tsx @@ -14,9 +14,9 @@ import { import { formatChf } from "@/lib/format"; import type { PlanComputed } from "@/lib/calculations"; -const INCOME_COLOR = "#16a34a"; -const EXPENSE_COLOR = "#dc2626"; -const REAL_COLOR = "#9ca3af"; +const INCOME_COLOR = "var(--chart-3)"; +const EXPENSE_COLOR = "var(--chart-5)"; +const REAL_COLOR = "var(--faint)"; // Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Fläche // dazwischen ist die Spar-/Verzehrquote (grün = Sparen, rot = Verzehr). Reale Ausgaben als diff --git a/src/components/WealthChart.tsx b/src/components/WealthChart.tsx index 34e9ec6..124dd3a 100644 --- a/src/components/WealthChart.tsx +++ b/src/components/WealthChart.tsx @@ -1,19 +1,16 @@ "use client"; -import { - CartesianGrid, - Legend, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; +import { useEffect, useState } from "react"; +import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { TOOLTIP_STYLE } from "@/components/AllocationChart"; import { formatChf } from "@/lib/format"; import type { PlanComputed } from "@/lib/calculations"; export interface TimelineSeries { + // Eindeutiger Schlüssel der Serie. WICHTIG: Bis 0.30 diente der ANZEIGENAME als Datenschlüssel + // -- zwei Szenarien mit gleichem Namen überschrieben sich dadurch gegenseitig (die Legende + // zeigte beide, der Chart nur eine). Deshalb ein eigener, garantiert eindeutiger Schlüssel. + id: string; label: string; color: string; computed: PlanComputed; @@ -36,15 +33,39 @@ function pointsFor(computed: PlanComputed) { return pts; } +// `var(--chart-1)` in einen konkreten Hex-Wert auflösen -- das Farbwahl-Feld (input[type=color]) +// braucht einen echten Wert. Läuft nur im Browser und nur, wenn eine Variable übergeben wurde. +function resolveColor(color: string): string { + if (!color.startsWith("var(")) return color; + if (typeof window === "undefined") return "#888888"; + const name = color.slice(4, -1).trim(); + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || "#888888"; +} + // Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere // überlagerte Pläne für den Szenario-Vergleich. export function WealthChart({ series, metric = "nominal", + onColorChange, }: { series: TimelineSeries[]; metric?: "nominal" | "real"; + // Optional: erlaubt das Umfärben einer Serie über die Legende. + onColorChange?: (id: string, color: string) => void; }) { + // Aufgelöste Farben für die Farbwahl-Felder; nach dem ersten Rendern (und bei Theme-Wechsel). + const [resolved, setResolved] = useState>({}); + const colorKey = series.map((s) => `${s.id}:${s.color}`).join("|"); + useEffect(() => { + const next: Record = {}; + for (const s of series) next[s.id] = resolveColor(s.color); + // eslint-disable-next-line react-hooks/set-state-in-effect -- Farbauflösung braucht das DOM + setResolved(next); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [colorKey]); + if (series.length === 0 || series[0].computed.phases.length === 0) { return

Noch keine Phasen vorhanden.

; } @@ -56,47 +77,97 @@ export function WealthChart({ const row: Record = { age }; for (const s of withPoints) { const pt = s.points.find((p) => p.age === age); - row[s.label] = pt ? (metric === "real" ? pt.real : pt.nominal) : null; + row[s.id] = pt ? (metric === "real" ? pt.real : pt.nominal) : null; } return row; }); + const nameById = new Map(series.map((s) => [s.id, s.label])); + const hasDashed = series.some((s) => s.dashed); + return ( -
- - - - `${v} J.`} - /> - Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} - /> - (typeof v === "number" ? formatChf(v) : v)} - labelFormatter={(v) => `Alter ${v}`} - /> - - {withPoints.map((s) => ( - +
+ + + + `${v} J.`} /> - ))} - - + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} + /> + [typeof v === "number" ? formatChf(v) : v, nameById.get(String(key)) ?? key]} + labelFormatter={(v) => `Alter ${v}`} + /> + {withPoints.map((s) => ( + + ))} + + +
+ + {/* 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. */} +
+ {series.map((s) => ( + + {onColorChange ? ( + + ) : ( + + )} + {s.label} + + ))} + {hasDashed && ( + + gestrichelt = reine Planwerte · durchgezogen = mit effektiven Werten + + )} +
); } diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index a08735f..d9b8681 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -1826,32 +1826,4 @@ function retiresInPhase( return p.age + yearsBeforeNext >= retirementAge.get(personId)!; } -// CSV-Export (eine Zeile pro Lebensphase, Kernkennzahlen). -export function planToCsv(plan: PlanInput, computed: PlanComputed): string { - const header = [ - "Phase", - "Typ", - "Dauer", - "Einkommen (Beginn)", - "Ausgaben (Beginn)", - "Quote (Beginn)", - "Quote (Ende)", - "Cash (Ende)", - "Endvermögen (nominal)", - "Endvermögen (real)", - ]; - const rows = computed.phases.map((p) => [ - p.name, - p.type, - String(p.durationYears), - p.incomeStart.toFixed(0), - p.expenseStart.toFixed(0), - p.quotaStart.toFixed(0), - p.quotaEnd.toFixed(0), - p.cashEnd.toFixed(0), - p.endWealthNominal.toFixed(0), - p.endWealthReal.toFixed(0), - ]); - if (computed.ruinAge !== null) rows.push([`Ruin: Kapital aufgebraucht mit Alter ${computed.ruinAge}`]); - return [header, ...rows].map((r) => r.join(";")).join("\n"); -} +// Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary). diff --git a/src/lib/csv.test.ts b/src/lib/csv.test.ts new file mode 100644 index 0000000..7f85225 --- /dev/null +++ b/src/lib/csv.test.ts @@ -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"""'); + }); +}); diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..6961fbf --- /dev/null +++ b/src/lib/csv.ts @@ -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"); +} diff --git a/src/lib/migrations.test.ts b/src/lib/migrations.test.ts index 60f5b20..b981b09 100644 --- a/src/lib/migrations.test.ts +++ b/src/lib/migrations.test.ts @@ -97,7 +97,8 @@ describe("Datenbank-Migrationen", () => { WHERE table_name='Scenario' AND column_name='currentMajor'` ); expect(major.rows[0].is_nullable).toBe("NO"); - expect(major.rows[0].column_default).toContain("1"); + // Startwert 0: Ein Szenario laeuft in der 0er-Reihe, bis eine Hauptversion gesetzt wird. + expect(major.rows[0].column_default).toContain("0"); // A.B muss je Szenario eindeutig sein, sonst kollidieren zwei Sitzungen auf derselben // Nummer und die Historie wird mehrdeutig. diff --git a/src/lib/versioning-db.ts b/src/lib/versioning-db.ts index 34dc080..5e7bdb1 100644 --- a/src/lib/versioning-db.ts +++ b/src/lib/versioning-db.ts @@ -272,7 +272,7 @@ export async function restoreVersion( const created = await prisma.scenarioVersion.create({ data: { scenarioId, - major: latest?.major ?? 1, + major: latest?.major ?? 0, minor: (latest?.minor ?? 0) + 1, comment: restoreComment({ major: version.major, minor: version.minor }), createdById: userId, diff --git a/src/lib/versioning.test.ts b/src/lib/versioning.test.ts index db7fb35..27555cf 100644 --- a/src/lib/versioning.test.ts +++ b/src/lib/versioning.test.ts @@ -73,8 +73,14 @@ describe("canonicalJson", () => { }); describe("decideVersion", () => { - it("legt für den allerersten Stand 1.0 an", () => { - expect(decideVersion(null, snap(), "u1", T0)).toEqual({ action: "create", major: 1, minor: 0 }); + it("legt für den allerersten Stand 0.1 an", () => { + // Die 0er-Reihe bedeutet "noch kein verabschiedeter Stand" -- 1.0 entsteht erst durch eine + // bewusst gesetzte Hauptversion (SPEZIFIKATION 3.8). + expect(decideVersion(null, snap(), "u1", T0)).toEqual({ action: "create", major: 0, minor: 1 }); + }); + + it("macht aus der 0er-Reihe erst mit der Hauptversion eine 1.0", () => { + expect(nextMajor({ major: 0, minor: 17 })).toEqual({ major: 1, minor: 0 }); }); it("tut nichts, wenn sich inhaltlich nichts geändert hat", () => { diff --git a/src/lib/versioning.ts b/src/lib/versioning.ts index 54bff42..1731b54 100644 --- a/src/lib/versioning.ts +++ b/src/lib/versioning.ts @@ -69,8 +69,11 @@ export function decideVersion( userId: string, now: Date ): VersionDecision { - // Allererster Stand: 1.0. - if (!latest) return { action: "create", major: 1, minor: 0 }; + // Allererster Stand: 0.1. Ein Szenario laeuft solange in der 0er-Reihe (0.1, 0.2, ... 0.137), + // bis der Nutzer bewusst eine HAUPTVERSION setzt -- erst dann wird daraus 1.0. Die 0 sagt + // also: "noch kein verabschiedeter Stand". Bis 0.30 startete das Tool bei 1.0, wodurch die + // Hauptversion ihre Bedeutung verlor. + if (!latest) return { action: "create", major: 0, minor: 1 }; // Nichts veraendert -- z. B. Dialog geoeffnet und unveraendert gespeichert. Ohne diese // Pruefung sammelt die Historie Versionen, die nichts unterscheiden.