Compare commits

..

60 Commits

Author SHA1 Message Date
admGitAICDS a3c86fc559 Zeitachse als Zeitstrahl: Beschriftung alternierend, Startjahr markant, aktive Phase hervorgehoben
Deploy App / deploy (push) Successful in 1m11s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:20:12 +02:00
admGitAICDS 5842a84b20 Menue schiebt statt zu ueberlappen, Karte schmaler mit Seitenpfeilen, Zeitachse neu, Cash inline
Deploy App / deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:07:44 +02:00
admGitAICDS ff862ed030 Menueleiste als eine Komponente in zwei Breiten, Kartenansicht wieder erreichbar
Deploy App / deploy (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:47:28 +02:00
admGitAICDS f41c3614b5 Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:29:42 +02:00
admGitAICDS 113616cc1f Memo 2026081701: Kapitalverwendung mit waehlbarem Tilgungsziel, AHV automatisch, Farb- und Textkorrekturen
Deploy App / deploy (push) Successful in 1m18s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:04:45 +02:00
admGitAICDS f9d7ca07fd Stammdaten gewinnen in Phase 1, Bestaetigung je Zeile statt pauschal
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:41:07 +02:00
admGitAICDS ce987b9978 Ein Wert, ein Ort: Startwerte und Raten read-only, Verteil-Dialoge als einziger Eingabeort
Deploy App / deploy (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:17:39 +02:00
admGitAICDS 5ff9bc0709 Bestandsaufnahme: Anlegen schliesst den Dialog nicht mehr
Deploy App / deploy (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:17:34 +02:00
admGitAICDS f22b0a3f27 Uebersicht der offenen Punkte statt Assistent, Bestaetigung je Phasenzelle
Deploy App / deploy (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:02:15 +02:00
admGitAICDS 6ff144d7e1 Assistent auf zwei Schritte, Pensionsalter fix 65, Horizont abgeleitet
Deploy App / deploy (push) Successful in 1m57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:18:56 +02:00
admGitAICDS 2f6b788bd4 Bestandsaufnahme: Kategorienamen, Sammel-Speichern, Matrix-Spalte Start
Deploy App / deploy (push) Successful in 1m39s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:37:16 +02:00
admGitAICDS 42d5585969 Assistent als Herzstueck: Tests und Spezifikation 0.36
Deploy App / deploy (push) Successful in 2m2s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:33:46 +02:00
admGitAICDS c440063a93 Matrix-Steuerung eingebettet, Tour als Demo-Popup
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:28:56 +02:00
admGitAICDS 79fb2b9ca6 Szenario-Screen mit vier Kacheln, FPT-Assistent, Onboarding auf einen Knopf
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:23:13 +02:00
admGitAICDS 2f762175d2 Fundament: Element-Stammdaten, Fixpunkte, Horizont in Jahren, PK-Bezugsalter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:06:04 +02:00
admGitAICDS cc61a120ee Assistent: Ueberblicksschritt Pensionierung mit gerechneter Vorschau
Deploy App / deploy (push) Successful in 1m6s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:19:31 +02:00
admGitAICDS 0f656f4070 Pensionierung als Eigenschaft der Person: Treiber, Tests, Spezifikation 0.35
Deploy App / deploy (push) Successful in 1m57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:52:40 +02:00
admGitAICDS 1865db5de7 Pensionierungs-Bildschirm, Ampel mit drei Zustaenden, Planungshorizont
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:37:08 +02:00
admGitAICDS 9907fda6f4 Pensionierung: Entscheid ans Element, AHV-Vorbezug/Aufschub, PK-Kapitalquote
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:18:38 +02:00
admGitAICDS 7979fed7bd Kapitalbezug: Personenzuordnung, Herkunft und Filter fuer bezogene Guthaben
Deploy App / deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:33:47 +02:00
admGitAICDS 0953880a80 Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
Deploy App / deploy (push) Successful in 1m10s
Kapitalverwendung (Punkt C) am richtigen Ort:
- Prozent-Aufteilung des bezogenen Alterskapitals wandert vom Cash-Uebergang
  zum Bezugs-Entscheid der PK (nur bei Kapitalbezug) bzw. der Saeule 3a --
  mit zwei Guthaben liess sie sich vorher gar nicht getrennt beantworten
- Dialoge fuehren neu brutto -> Steuersatz -> netto -> Verteilung
- BUGFIX: Der zugeteilte Betrag erhoehte still den internen Bestand, deshalb
  zeigten Ziel-Element und "Kapital verteilen" eine 0. Er laeuft jetzt ueber
  Carry.capitalIn als Zusatzeinlage der Folgephase und ist ueberall sichtbar
- Saeule 3a ist am Pensions-Uebergang neu ein offener Entscheid

Phasendauer (gemeldeter Fehler):
- Die Folgephase gleicht eine geaenderte Dauer aus; Gesamtdauer bleibt gleich
- Vorher kappte das Tool nur die bearbeitete Phase -> Phase 2 ueberspannte
  danach die Pensionierung und die Invariante aus Punkt 44 kippte
- Rueckfrage vorher, Blockade wenn die Folgephase unter 1 Jahr fiele
- neue reine Funktion planDurationChange

Bedienung:
- Element-Zeile und Phasenkopf: Stift (umbenennen, beim Element inkl.
  Zuordnung), Papierkorb, Expand -- alle immer sichtbar
- PATCH /api/elements/<id> nimmt neu auch ownerRole
- Hilfetexte via Portal (wurden in scrollenden Dialogen abgeschnitten)
- Verteil-Dialoge: Zuordnung je Zeile, nach vom/ins Cash gruppiert,
  Vorbelegung mit dem EFFEKTIVEN Wert inkl. Vererbung (zeigte vorher 0)
- Matrix: gleiche Spaltenbreiten + horizontales Scrollen, "Alle auf-/
  zuklappen", Kategorie-Summe in der zugeklappten Zeile
- Phasen-Detailansicht nutzt die neue Aufteilungs-Grafik
- Uebersicht: "Leer starten" auch im leeren Zustand

Nebenbei: dritte verstuemmelte Hex-Farbe (#7c3aed) repariert, Phasen-Panel
nutzt den eigenen Bestaetigungs-Dialog statt window.confirm; mehrere veraltete
Referenzen und die buildCarryData-Tabelle in der Spez nachgezogen.

SPEZIFIKATION 0.33. 278 -> 288 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:09:03 +02:00
admGitAICDS 0761f6b3e2 Effektive Werte: Immobilien-Bugfix, Bearbeiten; Sidebar-Ebenen, Ring-Klick
Deploy App / deploy (push) Successful in 1m4s
Immobilien-Bugfix (gravierend):
- Der Ist-Wizard belegte den Immobilienwert mit dem EIGENKAPITAL vor
  (ElementYearPoint.value), waehrend Erfassung und Rechenkern den
  VERKEHRSWERT erwarten (propertyValue). Der Kern setzte die Zahl als
  Verkehrswert ein und liess die Hypothek stehen -> das Eigenkapital brach
  im Ist-Jahr um genau die Hypothek ein, meist ins Negative.
- Sichtbar als negative Gesamt-Abweichung trotz reiner Lohnerhoehung und als
  "wegbrechendes" Wohneigentum in der Vermoegensaufteilung.
- Feld ist neu als "Verkehrswert + Restschuld" beschriftet; 3 Regressionstests.

Ist-Datensaetze bearbeitbar:
- Klick auf die Zeile (oder "Bearbeiten") oeffnet den Satz erneut
- neuer Endpunkt PUT /api/plans/<id>/actuals/<setId>
- beim Bearbeiten ueberschreiben die Planwerte die erfassten Zahlen nicht

Ring-Klick in der Vermoegensaufteilung repariert:
- Recharts 3 reicht kein activePayload mehr durch (nur activeIndex) --
  der Handler feuerte nie, der Ring zeigte immer das Planende

Seitenleiste sauber dreistufig:
- Ebene 1 Plaene, Ebene 2 die vier Bereiche mit buendigen Symbolen,
  Ebene 3 nur die Szenarien (verschachtelt nach Herkunft)

Szenario-Liste zeigt neben der Version deren Kommentar.

SPEZIFIKATION 0.32 (neue Kapitel 3.9.6, 3.9.7). 275 -> 278 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:52:23 +02:00
admGitAICDS b42f9b385f Modul-Review 3: Struktur, Dashboard, CSV-Export und Grafiken
Deploy App / deploy (push) Successful in 1m59s
Versionierung:
- startet neu bei 0.1; erst eine gesetzte Hauptversion macht daraus 1.0
- Szenario-Liste zeigt die echte Version (z.B. 0.17) statt "1.x", plus
  neue Spalte "Phasen"
- Migration 20260724120000_version_zero_start (nur der Default)

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

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

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

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

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

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

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

SPEZIFIKATION 0.31. 267 -> 275 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:16:07 +02:00
admGitAICDS 96fdd00d5c Tour-Korrekturen (Spotlight, Schritte) + 3a-Schalter nach Schritt 5
Deploy App / deploy (push) Successful in 1m1s
Tour:
- Spotlight neu aus vier fixed-Abdunkelflaechen um die Bounding-Box statt
  box-shadow -- funktioniert jetzt unabhaengig von z-index (sticky
  Matrix-Koepfe blieben hell) und overflow (im Matrix-Scrollbereich blieb
  fast alles hell); folgt dem Ziel per requestAnimationFrame
- Schritte ueberarbeitet: Zeitachse + Endvermoegen neu erklaert; der
  vormalige "Analysen"-Schritt beschreibt jetzt die obere Funktions-Leiste;
  neuer Schritt fuer das linke Menue (Analysen/Berichte/Effektive Werte);
  unsichtbare Ziele werden uebersprungen

Wizard:
- 3a-Schalter "Selbststaendig ohne PK" von Schritt 4 nach Schritt 5
  (er betrifft die Einzahlung / Sparraten-Verteilung)

SPEZIFIKATION 0.30 (9.24 neu gefasst). 267 Tests unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:59:51 +02:00
admGitAICDS efcc04c645 Modul-Review 2 Feinschliff: Assistent, Tour, 3-Spalten-Ansicht
Deploy App / deploy (push) Successful in 1m8s
Sechs Punkte aus der Testrunde:

Assistent:
- Willkommens-Screen (Gesamtbild der 5 Schritte) + persistente Schritt-Leiste
- Rendite-Feld bei PK/3a/Wertschriften ergaenzt (war fest verdrahtet)
- Schritt 5: Hypothekarzinsen (falls nicht in Ausgaben) korrekt bei den
  Ausgaben dazugerechnet; "noch zu verteilen" prominent nach oben;
  Verteilung nach Gemeinsam/Person A/Person B gruppiert

Tour:
- Spotlight: Rest abgedunkelt (9999px box-shadow), staerkerer Puls
- Karte groesser + springt auf die gegenueberliegende Bildschirmhaelfte
- "Ueberspringen"-Knopf

Ansicht:
- Grundprofil + Zeitachse + Kennzahlen (Endvermoegen nom/real, Ruin) als
  ein kompakter 3-Spalten-Block (25/50/25%) statt zweier breiter Zeilen
- neue Modal-Groesse xwide

Kein Eingriff in den Rechenkern; 267 Tests unveraendert. SPEZIFIKATION 0.29.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:42:09 +02:00
admGitAICDS 52c48a9956 Modul-Review 2: Onboarding-Umbau, Tour, Assistenten-Redesign
Deploy App / deploy (push) Successful in 1m10s
Ergebnis der zweiten Test- und Review-Runde (Modul "Onboarding & Plan-Erstellung"):

Layout:
- Szenario-Ansicht neu geordnet: schlanke Funktions-Leiste oben (Historie,
  Tour, Neues Szenario, Rechenwege, CSV-Export, Loeschen, Diff-Badge), dann
  Naechste Schritte -> Grundprofil -> Zeitachse -> Anzeige -> Matrix
- Grafiken/Effektive Werte/Live-Sim/MC/Einflussfaktoren aus der Leiste
  entfernt (laufen ueber die eigenen Menuepunkte)
- Tour nach AppShell gehoben; startet neu bei JEDER Plan-Erstellung (F14)

Assistent:
- durchgehend Du-Form im Einzelmodus
- Schritt "Vorsorge & Vermoegen" nur noch Bestandswerte
- neuer Schritt "Sparen & Verteilen": Sparquote (Netto - Ausgaben) auf
  3a/Wertschriften/Amortisation/Tilgung verteilen, Rest bleibt Cash; PK
  separat mit Hinweis "vor Netto, reduziert Sparquote nicht"
- Immobilie: Wertsteigerung + Zins-in-Ausgaben-Schalter; Lohn 1% Default

Grosse Saeule 3a (Selbststaendige ohne PK):
- Schalter am 3a-Element + im Assistenten hebt Obergrenze 7258 -> 36288
- neue Konstante PILLAR_3A_MAX_SELF_EMPLOYED (2026-Wert zu verifizieren)
- neues Feld selfEmployed3a

Nebenbei: POST /plans liefert lesbare Fehlermeldung statt zod-Objekt.

Kein Eingriff in den Rechenkern; 267 Tests unveraendert. SPEZIFIKATION 0.28.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:57:56 +02:00
admGitAICDS 2bb243b896 Login-Rate-Limit je IP UND Benutzername
Deploy App / deploy (push) Successful in 1m6s
Der Login-Zaehler lief nur je IP -- Fehlversuche gegen ein Konto sperrten
dadurch alle Konten derselben IP mit (beim Dogfooding aufgefallen). Neu wird
je IP+Benutzername gezaehlt (normalisiert trim/lowercase); Brute-Force gegen
ein Konto bleibt gebremst, ohne die uebrigen zu treffen. Body wird dafuer vor
dem Limit-Check geparst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:55:37 +02:00
admGitAICDS b47d3d0a3e Modul-Review 1: Auth-Nachbesserungen (Sicherheit & UX)
Deploy App / deploy (push) Successful in 1m10s
Ergebnis der ersten Test- und Review-Runde zum Modul "Zugang & App-Rahmen":

- Login-Timing-Ausgleich: unbekannter Benutzer wird gegen Dummy-bcrypt-Hash
  geprueft -> Antwortzeit verraet nicht mehr, ob ein Name existiert
- Zurueck-Knopf nach Logout: pageshow-Waechter prueft die Session erneut und
  leitet die aus dem bfcache zurueckgeholte Ansicht auf /login
- Rate-Limiting (neues lib/rate-limit.ts): Login 10/15min, Registrierung
  5/h je IP, Passwortaenderung 10/15min je Benutzer; 429 + Retry-After
- Passwort-Dialog laeuft neu ueber Modal -> schliesst auf Esc (Fokus-Falle,
  aria-modal inklusive)
- Registrierungs-Fehler getrennt: nur belegter Name = 409 mit freundlicher
  Meldung, sonst 500 statt roher Prisma-Meldung

SPEZIFIKATION 0.27 (3.1.2/3/4, neues 3.1.6). 261 -> 267 Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:45:31 +02:00
admGitAICDS d6855ef1a0 Pensionsalter anpassen (Roadmap 44)
Deploy App / deploy (push) Successful in 1m1s
Das Pensionsalter laesst sich neu veraendern: Nicht das Alter wird gesetzt,
sondern die Phasengrenze verschoben -- Vorphase laenger, Folgephase kuerzer,
Gesamtdauer gleich. Faellt eine Phase dabei weg, werden die beiden Uebergaenge
nach Bestaetigung zusammengelegt.

- neues reines Modul lib/retirement.ts + POST /api/scenarios/<id>/retirement
- Pensionsalter als Tornado-Treiber und Live-Simulations-Regler
- AHV-Referenzalter 65: Rente ab 65 unabhaengig vom Pensionsalter; vor 65
  Beitrag als Nichterwerbstaetige(r) (neues Feld ahvContribution).
  AHV wird dafuer jahresweise statt phasenweise gerechnet.
- Punkt B: personenzugeordnetes Einkommen faellt bei Pensionierung auf 0
- Punkt A: Wiederkehr-Parameter werden live aus der Vorphase geerbt,
  sichtbar als "Aus Vorphase uebernehmen"
- Punkt C: Kapitalzufluss am Pensions-Uebergang per Quote auf Amortisation,
  Anlage und Cash verteilbar
- Fix: carry.flowBasis wurde vor der Jahresschleife berechnet, effektive
  Werte kamen deshalb nie in der Folgephase an

SPEZIFIKATION 0.26 (neue Kapitel 3.12, 4.4.7, 4.16). 221 -> 261 Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:24:40 +02:00
admGitAICDS c429624635 Fix: Server/Client-Grenze -- Uebergangsregeln nach lib verschoben
Deploy App / deploy (push) Successful in 1m5s
Ursache des 500 (danke fuer die Meldung): lib/decisions.ts importierte
isTransitionAnswered und isCashTransitionAnswered aus der CLIENT-
Komponente ElementDetail. Der PDF-Bericht laeuft serverseitig und brach
deshalb ab: "Attempted to call isCashTransitionAnswered() from the server
but it is on the client."

Die fuenf reinen Uebergangs-Regeln liegen jetzt in lib/transitions.ts;
ElementDetail reicht sie nur noch weiter, damit bestehende Importe
unveraendert bleiben.

Neuer Waechter-Test: kein Modul unter src/lib darf aus src/components
importieren. Gegen den echten Fehler verifiziert -- er schlaegt an.
Weder tsc noch der Build hatten das gemeldet, nur die Produktion.

225 Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:15:54 +02:00
admGitAICDS 8b50f8d4ae Bericht: vollstaendige Fehlerabsicherung + robustes Laden von pdfkit
Deploy App / deploy (push) Successful in 1m2s
Der gemeldete 500 kam OHNE JSON-Koerper -- der Client konnte deshalb nur
"Fehler 500" zeigen. Ursache: Der Absturz lag ausserhalb der bisherigen
try/catch-Bloecke. Jetzt liegt der GESAMTE Handler in einem try/catch,
jede Ursache wird protokolliert und im Klartext zurueckgegeben.

Zusaetzlich zwei Risiken entfernt:
- Die verschachtelte Abfrage (Plan -> Szenarien -> Plan -> Personen) war
  ein Ringbezug; Szenarien werden jetzt einzeln nachgeladen.
- pdfkit wird ueber einen Resolver geholt, der statischen Import,
  .default und createRequire durchprobiert -- unabhaengig davon, wie der
  Bundler CJS-Interop aufloest.

Neuer Test mit realistischem Plan (Immobilie, Schuld, AHV, PK, 3a,
Kopie-Szenario) -- laeuft lokal durch, schliesst die Datenform als
Ursache aus. 223 Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:09:21 +02:00
admGitAICDS e22f2cfa2b Fix: PDF-Bericht -- CJS-Interop, Leerseiten und sichtbare Fehler
Deploy App / deploy (push) Successful in 1m10s
1) pdfkit wird als externes CJS-Modul geladen. Je nach Interop kommt der
Konstruktor direkt oder unter .default an -- trifft man die falsche Form,
gelingt der Import, aber `new PDFDocument()` scheitert erst zur Laufzeit
(passt zum gemeldeten 500: unauth. Aufruf gab 401, die Erzeugung 500).
Beide Formen werden jetzt akzeptiert.

2) Die Fusszeile stand unterhalb des Satzspiegels -- pdfkit haengt dafuer
automatisch Seiten an. Ein Bericht mit 6 Inhaltsseiten wurde so auf 18
aufgeblaeht. Unterer Rand wird fuers Schreiben auf 0 gesetzt; ein Test
prueft jetzt die Seitenzahl im FERTIGEN PDF, nicht davor.

3) Rendern und Speichern melden ihre Ursache statt eines nackten 500.

Spezifikation unveraendert, 1 Test ergaenzt (221 -> 222).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:15:01 +02:00
admGitAICDS d9ef980edf PDF-Berichte (Roadmap 11)
Deploy App / deploy (push) Successful in 3m5s
Neuer Unterpunkt "Berichte" je Plan: Liste plus Assistent (Titel, Notiz,
nominal ODER real, Plan-/Ist-Daten, bis zu drei Szenarien, gespeicherte
Analysen). Layout immer gleich, Auswahl bestimmt nur die Bausteine.

Die PDF-Datei wird ALS DATEI abgelegt (BYTEA in Postgres, nicht im
Container-Dateisystem): Ein Bericht muss in drei Jahren byte-identisch
wieder herunterladbar sein -- eine Neuerzeugung koennte das nach
Aenderungen an Plan, Rechenkern oder Layout nicht garantieren.

Kennzahlen je Szenario inkl. offener Entscheide. Deren Zaehlung liegt neu
als reine Funktion in decisions.ts, die Matrix UND Bericht benutzen --
sonst nennen beide verschiedene Zahlen.

Zu jeder Kennzahl ihre Grundlage als Verweis; die vollstaendigen Annahmen
einmal je Szenario. Haftungsausschluss ist verpflichtend (per Test).

Technik: pdfkit in der Node-Runtime statt Headless-Browser;
@react-pdf/renderer bricht mit React 19. Als externes Paket deklariert,
weil pdfkit Font-Metriken ueber Dateipfade laedt.

Spezifikation 0.25 (3.11 neu), 9 Tests (212 -> 221).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:30:18 +02:00
admGitAICDS 489390a4a3 Effektive Werte als eingebettete Ansicht statt Pop-up
Deploy App / deploy (push) Successful in 1m4s
Klick auf "Effektive Werte" in der Sidebar oeffnet neu -- analog zu
"Szenarien" -- eine Liste im Hauptbereich statt eines Modals. Neuer
Plan-Tab "actuals"; ActualsDialog bekommt einen embedded-Modus (ohne
Overlay, Backdrop und Schliessen-Knopf). Der Modal-Weg ueber den
Matrix-Knopf bleibt bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:36:48 +02:00
admGitAICDS 74d1577bd8 Sidebar: Szenario-Baum standardmaessig eingeklappt, gleichmaessig eingerueckt
Deploy App / deploy (push) Successful in 1m5s
"Szenarien" bekommt ein Chevron: der Baum ist per Default zu und wird
aktiv aufgeklappt; das Label fuehrt weiterhin in die Szenario-Liste.

Der Baum wird flach dargestellt -- alle Szenarien gleich eingerueckt,
Basisszenario zuoberst. Die Herkunfts-Verschachtelung zeigt weiterhin
die Szenario-Liste (Spalte "aus ...").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:28:48 +02:00
admGitAICDS fb70781e5b Navigation auf Plan-Ebene und gespeicherte Analysen (Roadmap-Redesign)
Deploy App / deploy (push) Successful in 1m45s
Sidebar zweistufig: pro Plan die Unterpunkte Szenarien / Effektive Werte /
Analysen; Klick auf Plan-Name oeffnet ein Plan-Dashboard.

Plan-Dashboard: Kennzahlen, gerechnete Werte ausdruecklich "laut
Basisszenario", Ist-Abweichung falls erfasst.

Szenario-Liste: Version, Elementzahl, Endvermoegen, Ruinalter + Aktionen
Historie und Matrix. Baum in der Sidebar bleibt.

Analysen: vier umklappende Kacheln (auch per Antippen). Grafiken oeffnen
neu mit Auswahl EINER Grafik. Szenario-Vergleich zu den Grafiken,
CSV-Export auf die Matrix.

Gespeicherte Analysen: Grafik/MC/Einflussfaktoren als ZAHLEN einfrieren
(read-only, nichts wird neu gerechnet) -- druckfaehig fuer den spaeteren
PDF-Bericht, ohne finalWealthSorted. Einheitliche generische Ergebnisform.

Neue Tabelle SavedAnalysis, Endpunkte /analyses und /dashboard, Module
analyses.ts, Komponenten PlanViews/SavedAnalysisView/SaveAnalysisButton.
Kein Eingriff in den Rechenkern. Spezifikation 0.24 (3.10 und 9.30 neu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:21:02 +02:00
admGitAICDS ce5f83823f V7: Haushalt auf Plan-Ebene, Annahmen bleiben am Szenario
Deploy App / deploy (push) Successful in 1m52s
Haushaltsform, Personen (Name/Alter) und Startjahr wandern vom Szenario
auf den Plan. Das Pensionsalter bleibt szenario-eigen -- es ist der Kern
jedes Frueh-/Spaetpensionierungs-Szenarios.

Neue Tabelle PlanPerson; Person behaelt nur Rolle + Pensionsalter;
Plan bekommt householdType und startYear.

Der Rechenkern bleibt unberuehrt: toPlanInput fuegt beide Ebenen wieder
zu einem unveraenderten PlanInput zusammen. 43 Golden Tests unveraendert.

Nebeneffekt: Ein Ist-Satz trifft jetzt in ALLEN Szenarien dasselbe
Planjahr -- vorher war das nicht garantiert.

Wiederherstellen einer Version setzt nur noch Szenario-Eigenes zurueck.
Profil-Dialog kennzeichnet plan-weite vs. szenario-eigene Felder.

Zwei Tests spielen echte V6-Daten ein und pruefen die Uebernahme.
Spezifikation 0.23 (210 -> 212).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:53:12 +02:00
admGitAICDS 44c6c81f5d Fix: Cash-Vorbelegung im Ist-Wizard zeigte den Phasen-Endwert
Deploy App / deploy (push) Successful in 1m4s
Der Wizard las cashBridge.cashEnd -- also den Stand am Phasenende statt
am gewaehlten Stichtag. In einer Phase 2026-2036 erschien fuer 2031 der
Wert von 2036.

Ursache: computePlan wies den Cash-Bestand nur je PHASE aus. YearPoint
traegt neu ein Feld `cash` (Stand am Jahresende), analog zu
wealthNominal; der Wizard liest daraus.

Die Vorbelegung der Elemente war nie betroffen -- die stammte schon
immer aus dem Jahresverlauf.

Zwei Regressionstests (208 -> 210). Spezifikation 0.22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:53:59 +02:00
admGitAICDS e117e5a4e5 Effektive Werte: Plan-Ist-Vergleich (Roadmap 5)
Deploy App / deploy (push) Successful in 1m57s
Neuer Knopf auf Plan-Ebene: Liste plus Wizard in zwei Schritten. Ein
Ist-Satz haengt am PLAN, nicht am Szenario -- die Zuordnung laeuft ueber
die Herkunfts-Kette sourceElementId.

computePlan nimmt neu { actuals }: Die Werte schnappen in jedem erfassten
Jahr auf die Realitaet und laufen von dort planmaessig weiter. Luecken
fallen auf die Plandaten zurueck. Ohne die Option unveraendert -- die 43
Golden Tests laufen durch.

Der Sprung ist keine Rendite: eigene Brueckenposition actualsCorrection
in Vermoegens- und Cash-Bruecke, sonst ginge die Zerlegung nicht auf.

Matrix: Umschalter Plan/Effektiv, im Ist-Modus mit farbiger Abweichung
statt acht Zahlen je Zelle. Zeitachse: Marker je Jahr, juengster farbig.

Vier Analysewerkzeuge mit einheitlicher Leiste (nominal/real als
Einfachauswahl, Plan/Effektiv). MC: Zielbetrag dreht mit, Startjahr
abgeleitet statt eingebbar.

Neue Tabelle ActualsSet (gegen echtes Postgres verifiziert), Module
actuals.ts und dataview.ts. Spezifikation 0.21, 27 Tests (181 -> 208).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:35:07 +02:00
admGitAICDS 4343d50aa4 Raten ueber Lebensphasen uebernehmen + Rate in der Verlaufsgrafik
Deploy App / deploy (push) Successful in 1m6s
(1) Beim Aendern eines Ratenfelds fragt das Panel nach der Reichweite:
nur diese Phase (Vorgabe), diese + folgende, alle Phasen. Gilt fuer
expectedReturn, valueGrowth, interestRate und teuerungsausgleich.

Inline statt Modal -- das Zahlenfeld loest je Tastendruck aus. Die
Zielphasen behalten ihre uebrigen Werte (der Endpunkt ersetzt den ganzen
Satz; ein Kopieren des Entwurfs haette dort Betraege geloescht).

(2) ElementYearPoint fuehrt neu `rate` mit -- additiv, nur durchgereicht.
Verlaufsgrafik zeigt sie auf zweiter Y-Achse als Stufenlinie.

Golden Tests unveraendert. Spezifikation 0.20, 17 Tests (164 -> 181).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:36:05 +02:00
admGitAICDS d023534a03 Versionierung und Aenderungshistorie je Szenario
Deploy App / deploy (push) Successful in 1m48s
Version A.B: B automatisch je Bearbeitungssitzung (10-Minuten-Fenster,
unveraenderte Staende erzeugen keine), A manuell mit Pflichtkommentar.

Eine Version haelt den vollstaendigen Zustand als PlanInput-JSON -- dadurch
ist die Versionsauswahl in allen vier Analysewerkzeugen fast kostenlos,
bei Monte-Carlo je Szenario einzeln.

Wiederherstellen erhaelt die IDs (sonst verlieren Kind-Szenarien ihre
Diff-Basis) und legt den Stand selbst als neue Version an. Wo ein Bezug
trotzdem bricht, warnt der Dialog vorher namentlich.

Statischer Waechter-Test: jeder schreibende Endpunkt loest eine Version aus.
Migration gegen echtes Postgres verifiziert.

Spezifikation 0.19 (3.8 und 9.28 neu), 25 Tests (139 -> 164).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:09:10 +02:00
admGitAICDS d04e07fdfb Live-Simulation: Was-waere-wenn-Regler (Roadmap 22)
Deploy App / deploy (push) Successful in 54s
Zweispalter: links Regler, rechts waehlbare Grafik, oben Kennzahlen mit
Differenz zum unveraenderten Plan. Keine eigene Rechenlogik -- die Regler
nutzen dieselben Transformationen wie der Tornado.

Neu: applyElementDriver / tunableElements fuer einzeln regelbare
Element-Renditen; livesim.ts; AllocationChart aus dem Dashboard geloest.

computePlan misst 0.2 ms auf 60 Jahren -> synchron, ohne Debounce.
Spezifikation 0.18 (4.15 und 9.27 neu), 15 Tests (124 -> 139).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:22:49 +02:00
admGitAICDS 50e98122cf Monte-Carlo: zwei Welten, vier Faelle statt widerspruechlicher Modi
Deploy App / deploy (push) Successful in 1m1s
Zwei Laeufe (historische / geplante Renditen, gemeinsamer Seed), aus jeder
Verteilung beide Schwellen abgelesen. Fall 1 und Fall 3 stammen damit aus
derselben Verteilung -- ein tieferes Ziel kann nie unwahrscheinlicher sein.

Nullpunkt fuer das Urteil ist Fall 2 (nicht 50 %), Toleranzband +/- 5 pp.

Neu: MonteCarloResult.finalWealthSorted, probabilityAtLeast.
Spezifikation 0.17 (4.12.7 und 9.26 neu gefasst), 3 Tests (121 -> 124).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:46:50 +02:00
admGitAICDS 683d240518 Monte-Carlo: zwei Fragestellungen (Planung pruefen / Ziel pruefen / Beides)
Deploy App / deploy (push) Successful in 1m3s
Roadmap Nr. 46. Umschalter oben trennt die zwei Fragen; kein Eingriff in den
Rechenkern (reine Dialog-Logik + kleine montecarlo.ts-Erweiterung).

- "Planung pruefen" (Fall 1, wie bisher): wuerfelt um HISTORISCHE Renditen,
  prueft gegen den Planungs-Endbetrag (read-only) -> "wie realistisch ist
  meine Planung?"
- "Ziel pruefen" (Fall 2, neu): wuerfelt um die GEPLANTEN Werte aus dem Plan,
  prueft gegen einen manuellen Zielbetrag -> "erreiche ich mein Ziel?"; keine
  historischen Mittelwerte noetig, nur Streuung
- "Beides": beide Durchgaenge gleichzeitig, gemeinsamer Seed
- Deutungstexte je Fall, weich formuliert (Volatilitaets-Drag, siehe 9.26)
- Zielbetrag in Fall 2 = einer fuer alle Szenarien; Ruin/Faecher aus dem
  historischen Durchgang (ehrliches Risikobild)

montecarlo.ts: runMonteCarloMulti nimmt Inflation je Szenario
(inflationMeanFor); neuer Helfer plannedReturnOf (erste Phasenrendite,
Wertsteigerung bei Immobilien). Nebenbei die vom Umlaut-Sweep verstuemmelte
Hex-Farbe #7c3aed korrigiert.

8 Tests (119 -> 121). SPEZIFIKATION auf 0.16 (Kap. 4.12.7, 9.26 neu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:54:45 +02:00
admGitAICDS 1d046e9f7e Plan-Assistent: abschnittsbasierte Phasenplanung + Vermoegen pro Person
Deploy App / deploy (push) Successful in 58s
Rein an der Oberflaeche, keine Aenderung an Berechnung, Datenmodell oder API.

Schritt 2 (Lebensphasen) an den fixen Pensionierungszeitpunkten ausgerichtet:
- neues reines Modul phaseplan.ts (planSegments) leitet die Abschnitte ab:
  Erwerb (alle arbeiten) / Misch (eine pensioniert, eine arbeitet) / Pension
  (alle pensioniert), je mit kurzer Definition
- feste Abschnitte (durch Pensionierung begrenzt): beliebig viele Phasen mit
  +/Papierkorb und eigenem Namen; Live-Summe erzwingt exaktes Aufgehen,
  "Weiter" bis dahin gesperrt
- offener Pensions-Abschnitt: Dauer frei
- Anzahl Abschnitte wird abgeleitet (Einzel: 2, Paar unterschiedl. Ret: 3,
  bereits pensioniert: Misch/Pension zuerst)
- neue Zeitachse mit Pensionierungs-Flaggen; Phasen nummeriert und UNTER dem
  Balken beschriftet (kurze Phasen bleiben lesbar)
- behebt den Fehler, dass die Erwerbsphase beliebig ueber die Pensionierung
  hinaus gesetzt werden konnte

Schritt 4 (Vorsorge & Vermoegen) bei Paaren aufgeteilt in
Gemeinsam / Person A / Person B; PK und 3a je Person, Wertschriften/
Wohneigentum/Schulden je Bereich.

8 Tests (111 -> 119). SPEZIFIKATION auf 0.15 (Kap. 3.2.8 neu gefasst).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:32:43 +02:00
admGitAICDS 9252f7188d Phasenkopf-Ueberarbeitung: zweizeilige Werte, Verfuegbares Kapital, Verteil-Werkzeuge
Deploy App / deploy (push) Successful in 1m5s
Rein an der Oberflaeche und als neue Bearbeitungswerkzeuge -- keine Aenderung
an Berechnung, Datenmodell oder API. Beide Verteil-Dialoge schreiben nur
bestehende Felder ueber bestehende Endpunkte.

1) Zweizeilige Wertdarstellung im Modus "Beide": Realwert in Klammern in
   eigener Zeile UNTER dem nominalen Wert (Kopf + Matrix-Zellen), Pfeil auf
   beiden Zeilen. Dadurch schmalere Spalten und jede Kennzahl umbruchfrei.
2) "Sparquote" / "Verzehrquote" statt "Quote" / "Verzehr".
3) Neuer Kopf-Block "Verfuegbares Kapital" (ab Phase 2, nur wenn > 0):
   Topf, davon verteilt, Rest auf Cash -- vollstaendig aus der Cash-Bruecke
   abgeleitet (capitalPot).
4) Zwei Verteil-Popups mit Live-Vorschau (erneutes computePlan im Browser):
   - "Kapital verteilen": Zusatzeinlage (PK/3a/Vermoegen, Phasenwert) +
     Sonderamortisation/Sofort-Tilgung (Uebergangswert der Vorphase);
     Rest bleibt automatisch auf Cash, Ueberverteilung wird als Luecke gemeldet
   - "Sparquote/Bezug verteilen": jaehrliche Raten; zeigt Quote erstes Jahr,
     letztes Jahr UND absolut ueber die Phase; warnt, wenn die Quote sinkt
     (flache Rate wuerde spaeter Cash-Loch reissen). PK bewusst ausgeschlossen
     (Beitrag aus Bruttolohn, belastet Cash nicht).

Neues reines Modul distribution.ts (capitalPot, quotaSummary, applyPatches).
8 Tests (103 -> 111) -- u.a. residual-Kontrolle gegen die Cash-Bruecke und
Nachweis, dass die Quote ueber die Phase sinkt.

SPEZIFIKATION auf 0.14: neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3
ueberarbeitet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:30:36 +02:00
admGitAICDS c2fb82b0de UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
Deploy App / deploy (push) Successful in 1m1s
Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert
(103 Tests unveraendert gruen).

Paket A (Fundament):
- durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten,
  inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue)
- neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation,
  Bestaetigungs-Dialog statt window.confirm, Toasts statt alert,
  Skeleton-Loader, EmptyState
- eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent
- Micro-Interactions mit prefers-reduced-motion-Fallback

Paket B (Onboarding, Roadmap Nr. 10):
- gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person
  (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte
- Beispielplan mit einem Klick; Uebergaenge absichtlich offen
- interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar)
- abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente,
  fehlende Pensionsphase, Ruin -> Einflussfaktoren)

Paket C (Struktur):
- Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen;
  Matrix bleibt sichtbar, Zellklick wechselt den Inhalt
- Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht)
- Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert
- Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt
  SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen"
- Aktions-Icons ohne Hover sichtbar (Touch)

Paket D (Extras):
- Sparklines je Element-Zeile aus den 0.11-Verlaufswerten
- Befehls-Palette (Ctrl/Cmd+K)
- Ruin-Banner verlinkt auf die Einflussfaktoren

Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind
behoben -- npm run lint laeuft erstmals fehlerfrei.

SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24;
3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 07:40:42 +02:00
admGitAICDS e1f74fca95 Lesbare Wasserfaelle, Verkaufspreis-Abgleich, Erklaerung wirkungsloser Treiber
Deploy App / deploy (push) Successful in 59s
Die Wasserfall-Zahlen waren korrekt (residual exakt 0), die Darstellung nicht
lesbar. Neu als eigene liegende HTML/CSS-Darstellung statt Recharts:
- Verbindungslinien zwischen den Balken (ohne sie zerfaellt der Wasserfall in
  unverbundene Rechtecke)
- Wertbeschriftung an jedem Schritt
- Zwischenstand und Veraenderung optisch unterschieden
- Abschnitte "Am Uebergang" / "Innerhalb der Phase"
- aufklappbare Tabelle mit laufendem Zwischenstand
- Nullposten werden nicht gezeichnet
- Restposten neu als Fehlermeldung statt beilaeufiger Rundungsnotiz

Verkaufspreis einer Immobilie wird beim Wechsel auf "Verkaufen" mit dem
modellierten Verkehrswert vorbelegt (nur wenn noch keiner erfasst ist);
Verkehrswert und Abweichung werden ausgewiesen, ab 10 % rot abgesetzt. Die
beiden Groessen bleiben bewusst entkoppelt -- ein Verkauf unter Verkehrswert
ist ein realer Fall.

Tornado erklaert Nullbalken statt sie stumm zu zeigen. Wichtigster Fall: Wird
die Immobilie vor Planende verkauft, ist die Wertsteigerung nachweislich
wirkungslos, weil der Erloes am erfassten Verkaufspreis haengt und nicht am
Verkehrswert.

11 Tests ergaenzt (92 -> 103), darunter residual === 0 ueber sieben
Plankonstellationen. SPEZIFIKATION auf 0.12, neue Kapitel 3.5.8, 4.13.5,
4.14.2.1, 9.22. Keine Aenderung an der Berechnung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:21:37 +02:00
admGitAICDS 4791dccf93 Detailansichten, Wasserfaelle und vollstaendige Rechenweg-Offenlegung
Deploy App / deploy (push) Successful in 57s
Roadmap Nr. 43 (Detailansichten) und Nr. 41 (Berechnungslogiken offenlegen).

Systemparameter-Ansicht:
- SYSTEM_PARAMETERS in constants.ts: Wert, Bedeutung, Herleitung, Quelle,
  Stand -- aus derselben Datei, aus der gerechnet wird

Detailansichten (nur lesen, per Expand-Icon):
- Element: Verlauf ueber ALLE Planjahre (neu ElementPhaseComputed.yearly),
  bei Immobilien Verkehrswert/Restschuld/Eigenkapital getrennt
- Phase: Vermoegensaufteilung + zwei Wasserfaelle

Zwei getrennte Wasserfaelle (WealthBridge / CashBridge):
- Sparraten, Amortisationen und Investitionen sind UMBUCHUNGEN und erscheinen
  nur im Cash-Wasserfall -- als Vermoegensabgang gezeichnet wuerden sie einen
  Verlust vortaeuschen, den es nicht gibt
- PK-Beitraege dagegen sind ein echter Vermoegenszugang (belasten kein Cash),
  Verrentung ein echter Abgang (Kapital verlaesst die Bilanz)
- residual als Kontrollgroesse fuer die Vollstaendigkeit der Zerlegung

Rechenweg-Protokoll, vollstaendige Abdeckung:
- computePlan(plan, sample?, { explain }) protokolliert die Schritte, die es
  ohnehin ausfuehrt -- die Erklaerung IST die Rechnung, statt einer zweiten
  Formel-Implementierung im UI, die still abdriften koennte
- standardmaessig aus (Monte Carlo bleibt unberuehrt)
- Arithmetik nicht umgestellt: Zwischengroessen werden als Differenz
  abgeleitet, damit die 43 Golden Tests bitgleich bleiben
- jeder Trace verlinkt in die SPEZIFIKATION; ein Test prueft gegen die echte
  Datei, dass alle Verweise eine existierende Ueberschrift treffen

SPEZIFIKATION auf 0.11: neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21.
12 Tests ergaenzt (80 -> 92). Keine DB-Aenderung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:12:55 +02:00
admGitAICDS 1836cad7f3 SPEZIFIKATION: Version 1.0 -> 0.10
Deploy App / deploy (push) Successful in 59s
Die Versionsnummer ist ein Zaehler nach dem Punkt, keine Dezimalzahl:
auf 0.9 folgt 0.10. Der Pflegehinweis sagte bisher "um 0.1 erhoeht",
was ab hier arithmetisch nicht mehr passt -- entsprechend praezisiert,
damit die naechste Aktualisierung auf 0.11 geht und 1.0 einem echten
Meilenstein vorbehalten bleibt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:37:00 +02:00
admGitAICDS 3fe7a3978d Szenario-Vergleich in der Monte-Carlo-Simulation + Sensitivitaetsanalyse (Tornado)
Deploy App / deploy (push) Successful in 53s
Monte Carlo ueber mehrere Szenarien eines Plans in einem Lauf:
- Annahmen nur EINMAL je logischem Element (Zuordnung ueber sourceElementId,
  dieselbe Kette wie beim Diff) -- sonst vergleicht man die Eingaben statt
  der Szenarien
- gemeinsamer Seed fuer alle Szenarien (Common Random Numbers), damit
  Unterschiede strukturell und nicht zufaellig sind
- Zielbetrag bleibt szenario-eigen: die Erfolgswahrscheinlichkeit misst,
  wie oft ein Szenario sein EIGENES Versprechen haelt
- Vergleichstabelle + Median-Linien; bei einem Szenario unveraenderter Faecher

Sensitivitaetsanalyse (Roadmap Nr. 20), eigener Dialog "Einflussfaktoren":
- neues reines Modul sensitivity.ts, One-at-a-time ueber 7 Treiber
- Bandbreiten je Treiber pflichtig und ohne Default (die Balkenlaenge haengt
  direkt davon ab)
- Pensionsalter bewusst ausgeschlossen: nicht variierbar ohne Mitverschieben
  der Phasengrenzen (Begruendung in 9.18)

SPEZIFIKATION auf 1.0: neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18, 9.19 sowie
vier korrigierte Dokumentationsfehler (Kap. 1.2, 4.6.5, 5.2/5.3/5.5.2/8.1,
Glossar). 20 Tests ergaenzt (60 -> 80). Keine DB-Aenderung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-18 14:33:40 +02:00
admGitAICDS d203e504c0 UI-Umbau: Analyse-Bereich, Planstart, Zeitachse mit Phasen
Deploy App / deploy (push) Successful in 1m44s
Sieben Anpassungen aus dem Feedback:

- Grafiken liegen neu im eigenen Bereich "Grafiken" (Dialog, Button oben) statt
  unter der Matrix. Die Matrix bleibt die ruhige Hauptansicht.
- Kennzahl "Geschaetzter Nachlass" entfernt -- sie war rechnerisch identisch mit
  dem nominalen Endvermoegen und suggerierte eine Zusatzinformation, die es nicht gab.
- Monte-Carlo-Button nach oben zu den Szenario-Aktionen verschoben.
- Neues Profilfeld "Planstart (Jahr)" (Scenario.startYear + Migration). Rein fuer
  die Darstellung -- die Berechnung rechnet unveraendert in relativen Jahren.
  Bestehende Szenarien werden auf das laufende Jahr gesetzt.
- Zeitachse zeigt die Lebensphasen als Segmente (Breite = Dauer, Einfaerbung nach
  Phasentyp) inkl. Jahresspanne, plus Jahres-Beschriftung an den Enden.
- Lebensphase bearbeiten neu als Popup statt Panel unter der Tabelle -- konsistent
  zu allen anderen Eingaben; Speichern schliesst.
- Vermoegensverlauf ueber ALLE Jahre statt nur ueber die Phasengrenzen. Dafuer
  fuehrt computePlan das Vermoegen neu pro Jahr mit (YearPoint.wealthNominal/
  wealthReal) -- dieselbe Groesse, die schon die Ruin-Erkennung berechnet. Damit
  werden Verlaeufe INNERHALB einer Phase sichtbar (z. B. Kapitalverzehr).

Zwei Tests fuer das Jahres-Vermoegen (58 -> 60). Spezifikation auf v0.9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:40:31 +02:00
admGitAICDS a5d4868c58 Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
Deploy App / deploy (push) Successful in 1m43s
Groesste Umstrukturierung bisher. Der PLAN ist neu ein schlanker Behaelter ohne
Finanzdaten; die berechenbare Einheit ist das SZENARIO.

Modell:
- Jeder Plan bekommt beim Anlegen automatisch ein Basisszenario (isBase).
- Das Grundprofil liegt am Szenario, nicht am Plan -- nur so sind Szenarien mit
  abweichendem PENSIONSALTER moeglich (Fruehpensionierung), das in Person steckt.
- Neue Szenarien sind vollstaendige Kopien eines BELIEBIGEN Szenarios und haengen
  als Baum darunter (parentScenarioId); die Seitenleiste rueckt sie ein.
- Kopierte Phasen/Elemente tragen Herkunfts-Verweise (sourcePhaseId,
  sourceElementId). Ueber den Namen zu matchen waere fragil gewesen.

Abweichungs-Markierung (Diff gegen das Eltern-Szenario, live):
- geaendert = gelb, neu = gruen + Badge, entfernt = graue Geisterzeile.
- Markiert: Phasen-/Uebergangszellen, Element-Zeilen, Phasenkoepfe, Cash-Anfangswert,
  Cash-Uebergaenge, Grundprofil. Zaehler ueber der Matrix.
- Eigene Theme-Tokens fuer Hell/Dunkel/Warm -- ein fester Gelbwert waere im
  Dunkelschema unbrauchbar.

Charts vergleichen neu die Geschwister-Szenarien statt fremder Plaene.

Datenmodell/Migration:
- Neue Tabelle Plan; bisheriger Plan -> Scenario (IDs erhalten, damit alle
  Kind-Fremdschluessel gueltig bleiben); planId -> scenarioId in Person/Phase/
  FinancialElement. Bestehende Szenarien werden per rekursivem CTE demselben
  Behaelter zugeordnet, auch mehrfach verschachtelte.
- Migration VOR dem Deploy gegen echtes PostgreSQL verifiziert (PGlite, in-process),
  inkl. verschachtelter Szenarien und Cascade. Der Test ist als
  migrations.test.ts committet und sichert kuenftige Migrationen ab.

API neu unter /api/scenarios/*. 10 neue Tests (48 -> 58). Spezifikation auf v0.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:51:44 +02:00
admGitAICDS 71137f7cea Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A)
Deploy App / deploy (push) Successful in 57s
Button in der Planansicht -> Dialog: Erklaerung, Eingaben, Lauf, Ergebnis + Faecher.
Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet und die
Erfolgs-/Ruinwahrscheinlichkeit des Plans ausgewiesen.

Kern (computePlan-Nahtstelle):
- computePlan(plan, sample?) nimmt optional pro Jahr Inflation und pro Element/Jahr
  eine Rendite. Ohne sample bitgenau wie bisher (durch Golden Tests abgesichert).
- Dazu Inflation auf ein kumulatives Deflator-Array umgestellt (statt (1+i)^t), damit
  sie pro Jahr variieren kann. Deterministisch identisch.
- Reine Funktion ohne Server-Deps -> Simulation laeuft komplett im Browser, null
  Serverlast. ~10'000 Laeufe in ~1 s, Fortschritt alle 500 Laeufe (kein Freeze).

Statistik (montecarlo.ts):
- Fettschwaenzig (standardisierte Student-t, nu=5): Extremcrashs realistisch haeufig,
  eine Normalverteilung wuerde sie stark unterschaetzen.
- Gemeinsamer Marktschock (rho=0.7): riskante Anlagen fallen im Crash zusammen,
  nicht gegeneinander.
- Boeden: 0 % fuer PK/3a, -100 % sonst. Seedbar (reproduzierbar).
- Zwei Renditezahlen: geplante (Zielbalken) vs. historische (Streu-Mittelpunkt) --
  sonst waere P(>= geplantes Endvermoegen) immer ~50 %.

UI: Erklaerung, pro Element/Inflation historischer Oe (Pflicht, kein Default) +
Streuungsstufe (recherchierte sigma-Werte) + Anzahl Laeufe. Ergebnis: Ruin-/
Erfolgswahrscheinlichkeit, Faecher (10/50/90) + deterministische Linie.

7 MC-Tests inkl. deterministischer Aequivalenz, Vol-Drag, Reproduzierbarkeit, Boden,
Ruin (41 -> 48). Keine DB-Aenderung. Spezifikation auf v0.7 (Kap. 4.12, 9.15).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:47:18 +02:00
admGitAICDS 5bf35bd6da Teilverkauf (Sonstiges Vermoegen) + Sonderamortisation (Immobilie)
Deploy App / deploy (push) Successful in 1m1s
Roadmap Nr. 42: OTHER_ASSET am Uebergang neu mit Teilverkauf. Neben Halten und
Vollverkauf kann ein Betrag ins Cash fliessen, der Rest bleibt investiert und
waechst weiter. Der Teilverkauf erscheint als "Kapitalzufluss" im Phasenkopf
("zu investierendes Kapital"). Abgegrenzt zur laufenden Bezugsrate: der Teilverkauf
ist die einmalige Entnahme AM Uebergang, die Bezugsrate der laufende Verzehr IN der
Phase -- beide koexistieren.

Roadmap Nr. 15: REAL_ESTATE im Halten-Fall neu mit Sonderamortisation -- Einmaltilgung
der Hypothek aus dem Cash, am Restsaldo gekappt, bringt die Immobilie auf Augenhoehe
mit OTHER_DEBT (immediateRepayment). Erscheint als "Kapitalinvestition" im Phasenkopf.
Damit laesst sich die indirekte Amortisation via 3a MECHANISCH nachbilden (3a wachsen
lassen -> bei Pensionierung ins Cash -> Sonderamortisation).

Die Steuerwirkung der indirekten Amortisation (3a-Abzug, Zinsabzug) bleibt bewusst
zurueckgestellt mit dem Steuer-Buendel 13/14/23 (SPEZ 9.14).

Fuenf Regressionstests (36 -> 41). Spezifikation auf v0.6. Keine Verhaltensaenderung
fuer bestehende Plaene.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:02:44 +02:00
admGitAICDS a97de5b1ed Netto/Brutto-Klarstellung fuer die AHV + erweitertes Immobilien-Modul
Deploy App / deploy (push) Successful in 59s
Roadmap Nr. 9 (reduziert): Einkommen ist neu explizit als NETTOLOHN definiert
(Label + Hilfetext). Bisher stand nirgends, ob netto oder brutto gemeint ist -- fuer
den Cash-Fluss egal (beide Konventionen heben sich auf), aber seit der
einkommensabhaengigen AHV haengt eine Rente daran. Die AHV bemisst sich am
Bruttolohn, deshalb rechnet das Tool intern mit AHV_GROSS_FROM_NET_FACTOR = 1.12
hoch. Ohne das war die Rente um bis zu ~1'900/Jahr zu tief (Details: SPEZ 9.13).
Der Faktor ist hergeleitet und dokumentiert (AHV/IV/EO 5.3% + ALV 1.1% + NBU ~1% +
PK ~2-5% auf den koordinierten Lohn) -- fix vertretbar, weil das mdJE selbst ein
Karriere-Durchschnitt ist. Keine Aufschluesselung, kein sichtbares Feld (kommt mit
Roadmap Nr. 41 als erklaerte Konstante).

Roadmap Nr. 8: Immobilie neu mit Hypothekarzins (% der Restschuld, Zinsbetrag sinkt
mit der Amortisation, read-only "Beginn -> Ende") und Wertsteigerung.

WICHTIG: Die Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital.
1% von 1 Mio sind 10'000/Jahr, also 10% eines Eigenkapitals von 100'000 -- das ist
der Hebel. Auf dem EK gerechnet waeren es 1'000 (Beispiel: 304'622 statt 210'462).
Kaufpreis und Verkehrswert laufen deshalb getrennt; die Grundstueckgewinnsteuer
bemisst sich weiterhin am urspruenglichen Kaufpreis.

Doppelzaehlung: Schalter interestHandling auf der Immobilie, Default INCLUDED --
bestehende Plaene haben die Zinsen in den Ausgaben und aendern sich nicht.

Keine Steuerschaetzung (Begruendung: SPEZ 9.14). Sechs Regressionstests (30 -> 36).
Spezifikation auf v0.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:12:19 +02:00
admGitAICDS e901d23970 AHV einkommensabhaengig + Warnhinweis bei Aenderungen in frueheren Phasen
Deploy App / deploy (push) Successful in 59s
Roadmap Nr. 3: Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) ueber
das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente.

- Formel aus den amtlichen Randbedingungen hergeleitet und gegen die Tabelle
  318.117.1 verifiziert: 51/51 Zeilen exakt. Schwellen sind Vielfache von R0=1'260
  (12/36/72 x R0 = 15'120 / 45'360 / 90'720). Stuetzstellen als Golden Tests.
- Alles REAL gerechnet: die AHV wertet vergangene Einkommen auf UND indexiert die
  Schwellen -- real hebt sich das auf. Nominal wuerde die Rente systematisch zu hoch
  ausfallen (Beispiel: faelschlich Maximalrente, ~41'600 ueber 25 Rentenjahre).
- Pruefung der Beitragskarriere am Pensions-Uebergang; Zusatzfelder fuer die Jahre
  vor Planbeginn nur, wenn der Plan nicht bis Alter 21 zurueckreicht.
- Sonderfall "bei Planbeginn bereits pensioniert": Felder in der Phasenzelle.
- Ohne Pruefung gilt der geplante Durchschnitt (nicht 0) -- sonst waere die Rente
  still viel zu tief.
- 13. Altersrente: Jahresbetrag = Monatsrente x 13.

Roadmap Nr. 4: Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen
existieren -- Werte schreiben sich fort und wirken bis ans Planende durch.

Verhaltensaenderung fuer bestehende Plaene: siehe SPEZIFIKATION 9.10.
Zwoelf Regressionstests (18 -> 30). Spezifikation auf v0.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:02:42 +02:00
admGitAICDS 9f5bd754eb Einmalige Sonderein-/ausgaben am Cash-Uebergang (Roadmap Nr. 1)
Deploy App / deploy (push) Successful in 1m51s
Der Cash-Uebergang zwischen zwei Phasen ist neu ein eigener Entscheid:
1:1 uebernehmen / einmaliger Zufluss / einmalige Kosten / beides. Die Betraege
gehen direkt aufs Cash-Konto und bleiben aus der Spar-/Verzehrquote heraus.

- Zufluss NOMINAL erfasst, real angezeigt (wie Einkommen), optionaler Steuersatz
  (Default 0 %). Kosten REAL erfasst, nominal angezeigt (wie Ausgaben).
  Umrechnung ueber den Bestands-Deflator an der Phasengrenze.
- Entscheid startet unbeantwortet und zaehlt im "offen"-Badge mit; eine neue Phase
  erzeugt damit automatisch einen offenen Cash-Entscheid am neuen Uebergang.
- Eigene Kopf-Kennzahlen statt Vermischung mit Kapitalzufluss/-investitionen:
  eine Erbschaft ist kein Verkaufserloes, ein Poolbau keine Investition.
- Cash ist kein FinancialElement -> der Entscheid haengt als JSON an der Von-Phase
  (neue Spalte Phase.cashTransition + Migration). Szenario-Kopie nimmt ihn mit.

Fuenf Regressionstests ergaenzt (13 -> 18). Spezifikation auf v0.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 08:14:44 +02:00
admGitAICDS 87e6a5f564 Spezifikation v0.2 als lebendes Dokument + Ansicht in der App
Deploy App / deploy (push) Successful in 3m10s
Neue Funktionale/Technische Spezifikation (SPEZIFIKATION.md) mit
Aenderungshistorie; ersetzt die bisherigen FDD/TDD-Dokumente v1-v5.

In der App unter dem Menueintrag "SPEZIFIKATION" (Sidebar, unter den Plaenen)
lesbar: /api/spec liefert das Markdown, SpecView rendert es. Styling ueber die
bestehenden Farb-Tokens, folgt damit allen drei Schemata.

Die MD-Datei war ueber .dockerignore (*.md) vom Image ausgeschlossen -> Ausnahme
ergaenzt und Kopie in die Runner-Stage aufgenommen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:22:44 +02:00
admGitAICDS dfebbeb397 Drei Berechnungs-Fixes: Phaseninflation, Tilgungsraten, Vorbezugssteuer
- Phase.inflationRate ersatzlos entfernt (inkl. Migration). Die Inflation liegt
  seit V5 plan-weit; das Feld wurde von computePlan nie gelesen und war wirkungslos.
- Amortisation und Tilgung stoppen, sobald Hypothek bzw. Schuld abbezahlt sind:
  Hypothek/Restschuld sind neu laufende Salden, die Rate ist pro Jahr am Restsaldo
  gekappt. Belastet danach weder Cash noch Sparquote.
- Kapitalbezugssteuer greift neu auch bei PK-/3a-Vorbezuegen vor der Pensionierung.
  Der Bezug wird brutto dem Kapital entnommen, netto ins Cash gebucht.

plannedSaveRate ist neu die Rate des ersten Phasenjahres. Drei Regressionstests
ergaenzt (10 -> 13).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:22:34 +02:00
149 changed files with 36168 additions and 1886 deletions
+2
View File
@@ -9,3 +9,5 @@ npm-debug.log*
Info Dateien Info Dateien
.claude .claude
*.md *.md
# Die Spezifikation wird in der App unter /spezifikation ausgeliefert und muss ins Image.
!SPEZIFIKATION.md
+2
View File
@@ -21,6 +21,8 @@ COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.ts ./next.config.ts COPY --from=builder /app/next.config.ts ./next.config.ts
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
# Wird zur Laufzeit von /api/spec gelesen und in der App gerendert.
COPY --from=builder /app/SPEZIFIKATION.md ./SPEZIFIKATION.md
COPY docker-entrypoint.sh ./docker-entrypoint.sh COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh RUN chmod +x ./docker-entrypoint.sh
+5372
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -2,6 +2,11 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
// pdfkit lädt seine Font-Metriken (.afm) zur Laufzeit über Dateipfade. Gebündelt stimmt
// `__dirname` nicht mehr und die Dateien werden nicht gefunden -- der PDF-Bericht bräche
// erst in der Produktion. Als externes Paket wird es zur Laufzeit aus node_modules
// geladen (die der Docker-Build vollständig mitkopiert).
serverExternalPackages: ["pdfkit"],
}; };
export default nextConfig; export default nextConfig;
+1717 -3
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -17,16 +17,22 @@
"jose": "^6.2.3", "jose": "^6.2.3",
"lucide-react": "^1.24.0", "lucide-react": "^1.24.0",
"next": "16.2.10", "next": "16.2.10",
"pdfkit": "^0.19.1",
"pg": "^8.22.0", "pg": "^8.22.0",
"prisma": "^7.8.0", "prisma": "^7.8.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"recharts": "^3.9.2", "recharts": "^3.9.2",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@electric-sql/pglite": "^0.4.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/pdfkit": "^0.17.6",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -0,0 +1,3 @@
-- Die Inflation liegt seit dem V5-Modell plan-weit am Plan (Plan.inflationRateDefault).
-- Die Phasen-Ueberschreibung wurde von der Berechnung nie gelesen und wird nun entfernt.
ALTER TABLE "Phase" DROP COLUMN "inflationRate";
@@ -0,0 +1,4 @@
-- Einmalige Sonderein-/ausgaben (Roadmap Nr. 1): Entscheid fuer das Cash-Konto beim Uebergang
-- NACH dieser Phase. Cash ist kein FinancialElement und kann deshalb keinen
-- ElementTransitionValue tragen -- der Entscheid haengt darum direkt an der Von-Phase.
ALTER TABLE "Phase" ADD COLUMN "cashTransition" JSONB;
@@ -0,0 +1,86 @@
-- V6: Szenario-Hierarchie. Der bisherige "Plan" wird zum SZENARIO (IDs bleiben erhalten,
-- damit alle Kind-Datensaetze gueltig bleiben); darueber entsteht ein neuer, schlanker PLAN
-- als Behaelter. Bestehende Szenarien (bisher: Plan mit parentPlanId) werden als Baum unter
-- denselben Plan gehaengt wie ihr Ursprung.
-- 1) Bisherige Plan-Tabelle wird zum Szenario. Kind-FKs folgen dem Rename automatisch.
ALTER TABLE "Plan" RENAME TO "Scenario";
ALTER TABLE "Scenario" RENAME COLUMN "parentPlanId" TO "parentScenarioId";
ALTER TABLE "Scenario" RENAME CONSTRAINT "Plan_pkey" TO "Scenario_pkey";
ALTER TABLE "Scenario" RENAME CONSTRAINT "Plan_parentPlanId_fkey" TO "Scenario_parentScenarioId_fkey";
ALTER TABLE "Scenario" ADD COLUMN "isBase" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Scenario" ADD COLUMN "planId" TEXT;
-- 2) Neuer Behaelter "Plan".
CREATE TABLE "Plan" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Plan_pkey" PRIMARY KEY ("id")
);
-- 3) Je Wurzel-Szenario (bisher: eigenstaendiger Plan) einen Behaelter anlegen. Die Plan-ID
-- wird deterministisch aus der Szenario-ID abgeleitet, damit die Zuordnung ohne
-- Hilfstabelle moeglich ist.
INSERT INTO "Plan" ("id", "userId", "name", "createdAt", "updatedAt")
SELECT 'plan_' || s."id", s."userId", s."name", s."createdAt", CURRENT_TIMESTAMP
FROM "Scenario" s
WHERE s."parentScenarioId" IS NULL;
-- 4) Wurzel-Szenarien werden zum Basisszenario ihres Behaelters. Der bisherige Plan-Name
-- wandert auf den Behaelter (Schritt 3); das Szenario heisst neu "Basisszenario".
UPDATE "Scenario" s
SET "planId" = 'plan_' || s."id", "isBase" = true, "name" = 'Basisszenario'
WHERE s."parentScenarioId" IS NULL;
-- 5) Kind-Szenarien erben den Behaelter ihrer Wurzel (beliebig tief verschachtelt).
WITH RECURSIVE tree AS (
SELECT "id", "planId" FROM "Scenario" WHERE "parentScenarioId" IS NULL
UNION ALL
SELECT c."id", t."planId" FROM "Scenario" c JOIN tree t ON c."parentScenarioId" = t."id"
)
UPDATE "Scenario" s
SET "planId" = t."planId"
FROM tree t
WHERE s."id" = t."id" AND s."planId" IS NULL;
-- 6) Sicherheitsnetz: Szenarien, deren Elternteil fehlt (verwaist), werden eigenstaendig.
INSERT INTO "Plan" ("id", "userId", "name", "createdAt", "updatedAt")
SELECT 'plan_' || s."id", s."userId", s."name", s."createdAt", CURRENT_TIMESTAMP
FROM "Scenario" s
WHERE s."planId" IS NULL;
UPDATE "Scenario" s
SET "planId" = 'plan_' || s."id", "isBase" = true, "parentScenarioId" = NULL
WHERE s."planId" IS NULL;
-- 7) Beziehungen des Behaelters festziehen.
ALTER TABLE "Scenario" ALTER COLUMN "planId" SET NOT NULL;
ALTER TABLE "Plan" ADD CONSTRAINT "Plan_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Scenario" ADD CONSTRAINT "Scenario_planId_fkey"
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- 8) Eigentuemer haengt neu am Behaelter; die alten Felder am Szenario entfallen.
ALTER TABLE "Scenario" DROP CONSTRAINT "Plan_userId_fkey";
ALTER TABLE "Scenario" DROP COLUMN "userId";
ALTER TABLE "Scenario" DROP COLUMN "branchFromPhaseId";
-- 9) Kind-Tabellen: planId -> scenarioId (inkl. Constraint- und Index-Namen).
ALTER TABLE "Person" RENAME COLUMN "planId" TO "scenarioId";
ALTER TABLE "Person" RENAME CONSTRAINT "Person_planId_fkey" TO "Person_scenarioId_fkey";
ALTER INDEX "Person_planId_role_key" RENAME TO "Person_scenarioId_role_key";
ALTER TABLE "Phase" RENAME COLUMN "planId" TO "scenarioId";
ALTER TABLE "Phase" RENAME CONSTRAINT "Phase_planId_fkey" TO "Phase_scenarioId_fkey";
ALTER INDEX "Phase_planId_sequenceNumber_key" RENAME TO "Phase_scenarioId_sequenceNumber_key";
ALTER TABLE "FinancialElement" RENAME COLUMN "planId" TO "scenarioId";
ALTER TABLE "FinancialElement" RENAME CONSTRAINT "FinancialElement_planId_fkey" TO "FinancialElement_scenarioId_fkey";
-- 10) Herkunfts-Verweise fuer die Abweichungs-Markierung (Diff gegen das Eltern-Szenario).
ALTER TABLE "Phase" ADD COLUMN "sourcePhaseId" TEXT;
ALTER TABLE "FinancialElement" ADD COLUMN "sourceElementId" TEXT;
@@ -0,0 +1,6 @@
-- Kalenderjahr des Planbeginns (Jahr 1). Nur fuer die Darstellung (Zeitachse, Grafiken);
-- die Berechnung bleibt in relativen Jahren.
ALTER TABLE "Scenario" ADD COLUMN "startYear" INTEGER;
-- Bestehende Szenarien auf das laufende Jahr setzen, damit die Zeitachse sofort Jahre zeigt.
UPDATE "Scenario" SET "startYear" = EXTRACT(YEAR FROM CURRENT_DATE)::int WHERE "startYear" IS NULL;
@@ -0,0 +1,44 @@
-- Versionierung je Szenario (SPEZIFIKATION 3.8, 4.16).
--
-- Eine Version haelt den VOLLSTAENDIGEN Zustand des Szenarios als JSON (Form: PlanInput),
-- nicht eine Differenz. Grund: Alle Analysewerkzeuge nehmen ohnehin PlanInput entgegen --
-- damit lassen sich alte Staende ohne Umbau rechnen, anzeigen und wiederherstellen.
-- Laufende Hauptversion des Szenarios. Nebenversionen zaehlen in ScenarioVersion.
ALTER TABLE "Scenario" ADD COLUMN "currentMajor" INTEGER NOT NULL DEFAULT 1;
CREATE TABLE "ScenarioVersion" (
"id" TEXT NOT NULL,
"scenarioId" TEXT NOT NULL,
-- Version A.B: major wird manuell hochgezogen, minor je Bearbeitungssitzung.
"major" INTEGER NOT NULL,
"minor" INTEGER NOT NULL,
-- Pflicht bei Hauptversionen, sonst leer. Wiederherstellungen tragen hier ihre Herkunft.
"comment" TEXT,
"isMajor" BOOLEAN NOT NULL DEFAULT false,
-- Wer die Version erzeugt hat. Heute immer der Eigentuemer; vorbereitet auf spaetere
-- Freigabe an einen Finanzberater.
"createdById" TEXT NOT NULL,
-- Vollstaendiger Zustand (PlanInput als JSON).
"snapshot" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Wird bei Zusammenfassung innerhalb einer Bearbeitungssitzung mitgezogen.
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ScenarioVersion_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ScenarioVersion_scenarioId_major_minor_key"
ON "ScenarioVersion"("scenarioId", "major", "minor");
-- Die Historie wird immer neuestzuerst gelesen.
CREATE INDEX "ScenarioVersion_scenarioId_createdAt_idx"
ON "ScenarioVersion"("scenarioId", "createdAt");
ALTER TABLE "ScenarioVersion" ADD CONSTRAINT "ScenarioVersion_scenarioId_fkey"
FOREIGN KEY ("scenarioId") REFERENCES "Scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Beim Loeschen eines Benutzers verschwinden dessen Plaene ohnehin per Cascade; der
-- Verweis bleibt bewusst RESTRICT-frei, damit die Historie nie ein Loeschen blockiert.
ALTER TABLE "ScenarioVersion" ADD CONSTRAINT "ScenarioVersion_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,34 @@
-- Effektive (Ist-)Werte, Roadmap Nr. 5 (SPEZIFIKATION 3.9).
--
-- Ein Ist-Satz gehoert zum PLAN, nicht zum Szenario: Die Realitaet ist dieselbe, egal gegen
-- welches Szenario man sie haelt. Die Zuordnung auf die szenario-eigenen Element-IDs laeuft
-- ueber die Herkunfts-Kette (sourceElementId) in der Applikationsschicht.
CREATE TABLE "ActualsSet" (
"id" TEXT NOT NULL,
"planId" TEXT NOT NULL,
-- Exaktes Erfassungsdatum: erscheint in der Liste und auf der Zeitachse.
"recordedOn" DATE NOT NULL,
-- Kalenderjahr. NUR dieses geht in die Berechnung ein (der Rechenkern arbeitet in
-- ganzen Jahren ab Planbeginn).
"year" INTEGER NOT NULL,
"comment" TEXT,
-- Effektiver Cash-Bestand. Eigenes Feld, weil Cash kein FinancialElement ist.
"cash" DOUBLE PRECISION,
-- Werte je WURZEL-Element: { "<rootElementId>": { "value": 120000, "mortgage": 400000 } }
"values" JSONB NOT NULL DEFAULT '{}',
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ActualsSet_pkey" PRIMARY KEY ("id")
);
-- Die Saetze werden immer nach Jahr sortiert gelesen (aeltester zuerst fuer die Rechnung).
CREATE INDEX "ActualsSet_planId_year_idx" ON "ActualsSet"("planId", "year");
ALTER TABLE "ActualsSet" ADD CONSTRAINT "ActualsSet_planId_fkey"
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ActualsSet" ADD CONSTRAINT "ActualsSet_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,71 @@
-- V7: Haushaltsform, Personen (Name/Alter) und Planstartjahr wandern auf PLAN-Ebene.
--
-- Begruendung (SPEZIFIKATION 2.1, 9.30): Diese Angaben beschreiben den HAUSHALT, nicht eine
-- Planungsvariante. Unterscheiden sie sich, ist es ein anderer Plan -- kein anderes Szenario.
-- Solange sie am Szenario hingen, konnten zwei Szenarien desselben Plans verschiedene
-- Startjahre tragen; derselbe Ist-Satz waere dann je Szenario auf einem anderen Planjahr
-- gelandet.
--
-- Das PENSIONSALTER bleibt bewusst am Szenario -- es ist der Kern jedes
-- Frueh-/Spaetpensionierungs-Szenarios.
ALTER TABLE "Plan" ADD COLUMN "householdType" "HouseholdType";
ALTER TABLE "Plan" ADD COLUMN "startYear" INTEGER;
CREATE TABLE "PlanPerson" (
"id" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"role" "PersonRole" NOT NULL,
"name" TEXT,
"age" INTEGER NOT NULL,
CONSTRAINT "PlanPerson_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PlanPerson_planId_role_key" ON "PlanPerson"("planId", "role");
ALTER TABLE "PlanPerson" ADD CONSTRAINT "PlanPerson_planId_fkey"
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- --- Datenuebernahme aus dem BASISSZENARIO ------------------------------------------------
-- Das Basisszenario ist der kanonische Stand. Weichen Nebenszenarien ab, gewinnt die Basis.
UPDATE "Plan" p
SET "householdType" = s."householdType",
"startYear" = s."startYear"
FROM "Scenario" s
WHERE s."planId" = p."id" AND s."isBase" = true;
-- Sicherheitsnetz: Plaene ohne Basisszenario (sollte es nicht geben) nehmen irgendein Szenario.
UPDATE "Plan" p
SET "householdType" = s."householdType",
"startYear" = COALESCE(p."startYear", s."startYear")
FROM "Scenario" s
WHERE s."planId" = p."id" AND p."householdType" IS NULL;
INSERT INTO "PlanPerson" ("id", "planId", "role", "name", "age")
SELECT 'pp_' || pe."id", s."planId", pe."role", pe."name", pe."age"
FROM "Person" pe
JOIN "Scenario" s ON s."id" = pe."scenarioId"
WHERE s."isBase" = true
ON CONFLICT ("planId", "role") DO NOTHING;
-- Netz fuer Plaene, deren Basisszenario keine Personen traegt.
INSERT INTO "PlanPerson" ("id", "planId", "role", "name", "age")
SELECT DISTINCT ON (s."planId", pe."role") 'pp_' || pe."id", s."planId", pe."role", pe."name", pe."age"
FROM "Person" pe
JOIN "Scenario" s ON s."id" = pe."scenarioId"
ORDER BY s."planId", pe."role", s."isBase" DESC
ON CONFLICT ("planId", "role") DO NOTHING;
-- Ein Plan ohne Haushaltsform waere nicht rechenbar.
UPDATE "Plan" SET "householdType" = 'SINGLE' WHERE "householdType" IS NULL;
ALTER TABLE "Plan" ALTER COLUMN "householdType" SET NOT NULL;
-- --- Szenario-Ebene verschlanken ----------------------------------------------------------
-- Person traegt nur noch Rolle + Pensionsalter; Name und Alter kommen vom Plan.
ALTER TABLE "Person" DROP COLUMN "name";
ALTER TABLE "Person" DROP COLUMN "age";
ALTER TABLE "Scenario" DROP COLUMN "householdType";
ALTER TABLE "Scenario" DROP COLUMN "startYear";
@@ -0,0 +1,43 @@
-- Gespeicherte Analysen (SPEZIFIKATION 3.10).
--
-- Eingefroren werden EINGABEN und ERGEBNIS als Zahlen -- nicht als Bild. Beim Oeffnen wird
-- nichts neu gerechnet, die gespeicherten Werte werden nur gezeichnet. Zahlen statt Pixel,
-- weil der spaetere PDF-Bericht (Roadmap Nr. 11) daraus vektoriell und in Druckaufloesung
-- zeichnen kann; ein Bildschirm-Bitmap waere im Druck unscharf und im Format fix.
--
-- Bewusst NICHT gespeichert wird `finalWealthSorted` aus der Monte-Carlo-Simulation: ein
-- Eintrag je Lauf, bei 10'000 Laeufen also megabyteweise. Fuer die Anzeige genuegen die
-- abgelesenen Wahrscheinlichkeiten und die Baender.
CREATE TABLE "SavedAnalysis" (
"id" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"name" TEXT NOT NULL,
-- CHART | MONTE_CARLO | SENSITIVITY. Als Text statt Enum, damit spaetere Typen (etwa
-- ein Berichts-Zusammenzug) keine Schema-Migration brauchen.
"type" TEXT NOT NULL,
-- Kerndaten fuer die Liste, denormalisiert: So laesst sich die Uebersicht anzeigen, ohne
-- die (groesseren) JSON-Felder zu laden.
"scenarioName" TEXT,
"versionLabel" TEXT,
"metric" TEXT NOT NULL DEFAULT 'nominal',
"source" TEXT NOT NULL DEFAULT 'PLAN',
"summary" TEXT,
"inputs" JSONB NOT NULL DEFAULT '{}',
"result" JSONB NOT NULL DEFAULT '{}',
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SavedAnalysis_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "SavedAnalysis_planId_createdAt_idx" ON "SavedAnalysis"("planId", "createdAt");
ALTER TABLE "SavedAnalysis" ADD CONSTRAINT "SavedAnalysis_planId_fkey"
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SavedAnalysis" ADD CONSTRAINT "SavedAnalysis_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,38 @@
-- PDF-Berichte (Roadmap Nr. 11, SPEZIFIKATION 3.11).
--
-- Die fertige PDF-Datei wird ALS BYTES abgelegt, nicht nur ihre Definition. Grund ist der
-- Audit-Trail: Ein Bericht, den man heute erzeugt und verschickt, muss in drei Jahren
-- byte-identisch wieder herunterladbar sein -- auch wenn sich Plan, Rechenkern oder Layout
-- zwischenzeitlich geaendert haben. Eine Neuerzeugung koennte das nicht garantieren.
--
-- Der App-Container wird bei jedem Deploy neu gebaut; nur das DB-Volume ueberlebt. Deshalb
-- Postgres und nicht das Dateisystem.
CREATE TABLE "Report" (
"id" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"title" TEXT NOT NULL,
-- Gewaehlte Parameter (Metrik, Quelle, Szenarien, Analysen) -- fuer die Liste und
-- damit nachvollziehbar bleibt, wie der Bericht zustande kam.
"config" JSONB NOT NULL DEFAULT '{}',
-- Das eingefrorene Berichtsmodell (alle Zahlen). Erlaubt eine Zusammenfassung in der
-- Liste, ohne das PDF zu oeffnen.
"model" JSONB NOT NULL DEFAULT '{}',
-- Die Datei selbst.
"pdf" BYTEA NOT NULL,
"pdfBytes" INTEGER NOT NULL DEFAULT 0,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Report_planId_createdAt_idx" ON "Report"("planId", "createdAt");
ALTER TABLE "Report" ADD CONSTRAINT "Report_planId_fkey"
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Report" ADD CONSTRAINT "Report_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -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;
@@ -0,0 +1,18 @@
-- Pensionierung wird eine Eigenschaft der PERSON statt der Zeitachse (SPEZIFIKATION 3.13).
--
-- (1) `FinancialElement.retirementDecision` haelt den Bezugs-Entscheid von AHV, Pensionskasse
-- und Saeule 3a. Bisher lag er in `ElementTransitionValue` -- also am Schluessel
-- Element x Phasen-ID. Verschob sich das Pensionsalter, wanderte die Phasengrenze, und der
-- Entscheid musste verlustbehaftet von Grenze zu Grenze gerettet werden. Ohne Phasen-ID im
-- Schluessel ueberlebt er jede Zeitachsen-Aenderung.
--
-- (2) `Person.planningHorizonAge` macht das Planende explizit. Bisher ergab es sich
-- stillschweigend als Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt
-- verschieden weit rechnen und waren nicht vergleichbar. NULL bedeutet weiterhin
-- "aus den Phasendauern ableiten", der Wert ist also rueckwaertskompatibel.
--
-- Bewusst OHNE Datenmigration: Alte Entscheide in `ElementTransitionValue` bleiben liegen und
-- werden vom Rechenkern ignoriert. Die betroffenen Elemente erscheinen dadurch als
-- "Vorgabe ungeprueft" -- genau der Zustand, den das neue Drei-Zustands-Modell dafuer kennt.
ALTER TABLE "FinancialElement" ADD COLUMN "retirementDecision" JSONB;
ALTER TABLE "Person" ADD COLUMN "planningHorizonAge" INTEGER;
@@ -0,0 +1,19 @@
-- Umbau 0.36: Bestandsaufnahme vor der Zeitachse, Assistent als Herzstueck.
--
-- (1) `FinancialElement.baseData` haelt den Bestand bei PLANBEGINN und die Ausgangs-Annahmen.
-- Bisher lagen diese Werte in `ElementPhaseValue` der ersten Phase. Das war schon immer
-- schief -- ein Startwert ist nicht "phase-1-spezifisch", sondern der Stand am Anfang --
-- und es machte den ersten Schritt des Assistenten unmoeglich: Eine Bestandsaufnahme
-- braucht noch keine Lebensphasen. Zugleich ist es die Wurzel der Feld-Vererbung.
--
-- (2) `Scenario.planningHorizonYears` fuehrt den Horizont in JAHREN statt als Endalter je
-- Person. Eine Zahl statt zweier, die bei einem Paar auseinanderlaufen koennten; die
-- Endalter werden abgeleitet. Ersetzt `Person.planningHorizonAge` aus 0.35.
--
-- (3) `Scenario.assistantProgress` haelt die sieben Haken des FPT-Assistenten.
--
-- Bewusst OHNE Datenmigration (Plaene wurden vorgaengig geloescht).
ALTER TABLE "FinancialElement" ADD COLUMN "baseData" JSONB;
ALTER TABLE "Scenario" ADD COLUMN "planningHorizonYears" INTEGER;
ALTER TABLE "Scenario" ADD COLUMN "assistantProgress" JSONB;
ALTER TABLE "Person" DROP COLUMN IF EXISTS "planningHorizonAge";
@@ -0,0 +1,8 @@
-- Der Planungshorizont wird ABGELEITET statt erfasst (SPEZIFIKATION 3.14.5).
--
-- Dritter und letzter Anlauf: 0.35 fuehrte ihn als Endalter je Person, 0.36 als Jahreszahl am
-- Szenario -- beide Male eine zweite Wahrheit ueber dieselbe Sache. Wer die Lebensphasen
-- einzeln plant, hat den Horizont damit bereits bestimmt; ihn zusaetzlich zu speichern hiess
-- nur, zwei Zahlen synchron halten zu muessen. Er ist jetzt schlicht die Summe der
-- Phasendauern.
ALTER TABLE "Scenario" DROP COLUMN IF EXISTS "planningHorizonYears";
@@ -0,0 +1,18 @@
-- Der FPT-Assistent wird durch eine Uebersicht der OFFENEN PUNKTE ersetzt (SPEZIFIKATION 3.14).
--
-- Der Unterschied ist grundsaetzlich: Der Assistent war ein ABLAUF ("tu dies, dann das") und
-- funktionierte nur beim ersten Aufsetzen. Die Uebersicht ist ein ZUSTAND ("das ist noch
-- offen") und traegt bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
--
-- (1) `Phase.ratesConfirmed` haelt fest, ob die Spar-/Verzehrquote dieser Phase einmal bewusst
-- verteilt wurde. Was man nicht verteilt, sammelt sich still als Cash an -- rechnerisch
-- richtig, aber selten die Absicht. Der Haken unterscheidet "bewusst so gelassen" von
-- "noch nie angeschaut".
--
-- (2) `Scenario.assistantProgress` entfaellt mit dem Assistenten. Der Fortschritt wird nicht
-- mehr erklaert, sondern ABGELEITET -- aus dem, was tatsaechlich noch offen ist.
--
-- Die Bestaetigung der Phasenwerte je Element liegt als `confirmed` im vorhandenen JSON von
-- `ElementPhaseValue` und braucht deshalb keine Spalte.
ALTER TABLE "Phase" ADD COLUMN "ratesConfirmed" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Scenario" DROP COLUMN IF EXISTS "assistantProgress";
+224 -24
View File
@@ -1,13 +1,21 @@
// FPT (Financial Planning Tool) — Datenmodell. // FPT (Financial Planning Tool) — Datenmodell.
// Kernkonzept (Rework 07/2026): finanzielle Elemente leben auf PLAN-Ebene und sind //
// ueber alle Lebensphasen hinweg dieselbe Entitaet. Pro Element existiert je Lebensphase // Kernkonzept: finanzielle Elemente leben auf SZENARIO-Ebene und sind ueber alle
// ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz // Lebensphasen hinweg dieselbe Entitaet. Pro Element existiert je Lebensphase ein
// Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz
// (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON, // (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON,
// validiert und typisiert in der Applikationsschicht (lib/elements.ts). // validiert und typisiert in der Applikationsschicht (lib/elements.ts).
// //
// Rework 07/2026 (V3): Das Grundprofil (Haushaltsform, Personen, Inflation) wurde vom // Rework 07/2026 (V6, Szenario-Hierarchie): Der PLAN ist neu ein schlanker Behaelter und
// frueheren Household auf die PLAN-Ebene verschoben. Jeder Plan ist selbsttragend und // traegt selbst keine Finanzdaten. Die berechenbare Einheit ist das SZENARIO -- es traegt
// definiert seine eigenen Personen (Alter, Pensionsalter) und Inflationsannahme. // das Grundprofil (Haushaltsform, Personen, Inflation, Cash-Anfangswert), die Phasenkette
// und die Elemente. Jeder Plan hat genau ein Basisszenario (isBase) und beliebig viele
// weitere Szenarien, die als Baum (parentScenarioId) darunter haengen. Kopierte Phasen und
// Elemente tragen einen Herkunfts-Verweis (sourcePhaseId / sourceElementId) auf ihr
// Gegenstueck im Eltern-Szenario -- darauf beruht die Abweichungs-Markierung im UI.
//
// Namens-Hinweis: In der Berechnungsschicht heisst die berechenbare Einheit weiterhin
// `PlanInput` / `computePlan` (sie beschreibt eine Finanzplanung -- fachlich ein Szenario).
generator client { generator client {
provider = "prisma-client" provider = "prisma-client"
@@ -25,6 +33,10 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
plans Plan[] plans Plan[]
versions ScenarioVersion[]
actuals ActualsSet[]
analyses SavedAnalysis[]
reports Report[]
} }
enum HouseholdType { enum HouseholdType {
@@ -54,53 +66,226 @@ enum ElementCategory {
OTHER_DEBT OTHER_DEBT
} }
// Einzelperson eines Plans. retirementAge ist das (plan-eigene) Pensionsalter. // Einzelperson eines Szenarios. retirementAge ist das (szenario-eigene) Pensionsalter --
// dadurch sind Frueh-/Spaetpensionierungs-Szenarien moeglich.
// Szenario-EIGENE Angabe zu einer Person. Seit V7 nur noch das Pensionsalter -- es ist der
// Kern jedes Frueh-/Spaetpensionierungs-Szenarios. Name und Alter beschreiben den Haushalt
// und liegen deshalb am Plan (PlanPerson).
model Person { model Person {
id String @id @default(cuid())
scenarioId String
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
role PersonRole
// Alter, in dem die Person die Erwerbstaetigkeit aufgibt. Wird bei der Plan-Anlage nicht
// mehr abgefragt (Default 65) -- die Pensionsplanung im Assistenten legt es fest.
retirementAge Int
@@unique([scenarioId, role])
}
// Eine Person des HAUSHALTS. Gilt fuer alle Szenarien des Plans.
model PlanPerson {
id String @id @default(cuid()) id String @id @default(cuid())
planId String planId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
role PersonRole role PersonRole
name String? name String?
age Int age Int
retirementAge Int
@@unique([planId, role]) @@unique([planId, role])
} }
// Eine vollstaendige Phasenkette; selbsttragend inkl. Grundprofil (Haushaltsform, // Behaelter einer Planung. Traegt selbst KEINE Finanzdaten, nur den Namen und die Szenarien.
// Personen, Inflationsannahme). Kann Szenario eines anderen Plans sein (Deep-Copy).
model Plan { model Plan {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String name String
householdType HouseholdType
inflationRateDefault Float
initialCash Float @default(0)
parentPlanId String? // Seit V7 am Plan: Diese Angaben beschreiben den HAUSHALT, nicht eine Planungsvariante.
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull) // Unterscheiden sie sich, ist es ein anderer Plan -- kein anderes Szenario.
scenarios Plan[] @relation("PlanScenarios") householdType HouseholdType
branchFromPhaseId String? // Kalenderjahr, in dem die Planung beginnt (Jahr 1). Rein fuer die Darstellung --
// die Berechnung rechnet weiterhin in relativen Jahren ab Planbeginn.
startYear Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
scenarios Scenario[]
actuals ActualsSet[]
persons PlanPerson[]
analyses SavedAnalysis[]
reports Report[]
}
// Ein erzeugter PDF-Bericht (Roadmap Nr. 11). Die FERTIGE DATEI wird abgelegt, nicht nur
// ihre Definition: Ein Bericht, den man heute verschickt, muss in drei Jahren unveraendert
// wieder herunterladbar sein -- eine Neuerzeugung koennte das nach Aenderungen an Plan,
// Rechenkern oder Layout nicht garantieren.
model Report {
id String @id @default(cuid())
planId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
title String
// Gewaehlte Parameter und das eingefrorene Berichtsmodell (alle Zahlen).
config Json @default("{}")
model Json @default("{}")
pdf Bytes
pdfBytes Int @default(0)
createdById String
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([planId, createdAt])
}
// Eine festgehaltene Analyse (Roadmap Nr. 5 / Analysen-Ansicht). Haelt EINGABEN und ERGEBNIS
// als Zahlen fest -- read-only, es wird nichts neu gerechnet. Zahlen statt Bild, damit der
// spaetere PDF-Bericht daraus vektoriell zeichnen kann.
model SavedAnalysis {
id String @id @default(cuid())
planId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
name String
type String // CHART | MONTE_CARLO | SENSITIVITY
// Denormalisierte Kerndaten fuer die Liste.
scenarioName String?
versionLabel String?
metric String @default("nominal")
source String @default("PLAN")
summary String?
inputs Json @default("{}")
result Json @default("{}")
createdById String
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([planId, createdAt])
}
// Ein erfasster Stand der WIRKLICHKEIT zu einem Stichtag (Roadmap Nr. 5).
//
// Haengt am PLAN, nicht am Szenario: Das tatsaechliche PK-Guthaben am 18.8.2026 ist eine
// Zahl, unabhaengig davon, gegen welches Szenario man sie haelt. Die Zuordnung auf die
// szenario-eigenen Element-IDs erfolgt ueber die Herkunfts-Kette (lib/actuals.ts).
model ActualsSet {
id String @id @default(cuid())
planId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
// Exaktes Datum fuer Liste und Zeitachse; fuer die Rechnung zaehlt nur `year`.
recordedOn DateTime @db.Date
year Int
comment String?
// Effektiver Cash-Bestand (Cash ist kein FinancialElement).
cash Float?
// Werte je Wurzel-Element: Record<rootElementId, { value?, mortgage? }>
values Json @default("{}")
createdById String
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([planId, year])
}
// Die berechenbare Einheit: Grundprofil + Phasenkette + Elemente. Genau ein Szenario je
// Plan ist das Basisszenario (isBase); weitere haengen als Baum darunter (parentScenarioId).
model Scenario {
id String @id @default(cuid())
planId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
name String
isBase Boolean @default(false)
// Szenario, aus dem dieses kopiert wurde. Zugleich die Vergleichsbasis fuer den Diff
// (Basisszenario: null). Beim Loeschen des Elternteils bleibt der Baum flach erhalten.
parentScenarioId String?
parentScenario Scenario? @relation("ScenarioTree", fields: [parentScenarioId], references: [id], onDelete: SetNull)
children Scenario[] @relation("ScenarioTree")
// Szenario-eigene Annahmen. Haushaltsform, Personen und Startjahr liegen seit V7 am Plan.
inflationRateDefault Float
initialCash Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Laufende Hauptversion. Die Nebenversionen zaehlen in ScenarioVersion.
currentMajor Int @default(0)
persons Person[] persons Person[]
phases Phase[] phases Phase[]
elements FinancialElement[] elements FinancialElement[]
versions ScenarioVersion[]
}
// Ein festgehaltener Stand eines Szenarios (Version A.B). Haelt den VOLLSTAENDIGEN Zustand
// als JSON in der Form PlanInput -- nicht eine Differenz. Dadurch laesst sich jeder alte
// Stand ohne Umbau rechnen (computePlan), anzeigen und wiederherstellen.
model ScenarioVersion {
id String @id @default(cuid())
scenarioId String
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
major Int
minor Int
// Pflicht bei Hauptversionen; Wiederherstellungen tragen hier ihre Herkunft.
comment String?
isMajor Boolean @default(false)
createdById String
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
snapshot Json
createdAt DateTime @default(now())
// Wird bei Zusammenfassung innerhalb einer Bearbeitungssitzung mitgezogen.
updatedAt DateTime @updatedAt
@@unique([scenarioId, major, minor])
@@index([scenarioId, createdAt])
} }
// Ein Lebensabschnitt innerhalb eines Plans. Der Phasentyp (Erwerb/Pension/Mischung) // Ein Lebensabschnitt innerhalb eines Plans. Der Phasentyp (Erwerb/Pension/Mischung)
// wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations). // wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations).
// Die Inflation liegt seit V5 plan-weit am Plan (inflationRateDefault), nicht mehr hier.
model Phase { model Phase {
id String @id @default(cuid()) id String @id @default(cuid())
planId String scenarioId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
sequenceNumber Int sequenceNumber Int
name String name String
durationYears Int durationYears Int
inflationRate Float?
// Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs.
sourcePhaseId String?
// Entscheid fuer das Cash-Konto beim UEBERGANG NACH dieser Phase (JSON, validiert in
// lib/elements.ts): 1:1 uebernehmen oder einmaliger Zufluss/einmalige Kosten. Liegt hier
// und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist.
cashTransition Json?
// Wurde die Spar-/Verzehrquote dieser Phase einmal bewusst verteilt? Was man nicht verteilt,
// sammelt sich still als Cash an -- rechnerisch richtig, aber selten die Absicht. Der Haken
// unterscheidet "bewusst so gelassen" von "noch nie angeschaut".
ratesConfirmed Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -108,20 +293,35 @@ model Phase {
phaseValues ElementPhaseValue[] phaseValues ElementPhaseValue[]
transitionValues ElementTransitionValue[] @relation("TransitionFromPhase") transitionValues ElementTransitionValue[] @relation("TransitionFromPhase")
@@unique([planId, sequenceNumber]) @@unique([scenarioId, sequenceNumber])
} }
// Ein finanzielles Element (plan-weit): Kategorie + optionale Personenzuordnung. // Ein finanzielles Element (szenario-weit): Kategorie + optionale Personenzuordnung.
model FinancialElement { model FinancialElement {
id String @id @default(cuid()) id String @id @default(cuid())
planId String scenarioId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
category ElementCategory category ElementCategory
name String name String
ownerRole OwnerRole? ownerRole OwnerRole?
orderIndex Int @default(0) orderIndex Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs.
sourceElementId String?
// Pensionierungs-Entscheid (AHV/PK/3a). Bewusst OHNE Phasen-ID im Schluessel: So ueberlebt
// er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts.
retirementDecision Json?
// Stammdaten des Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen (Rendite,
// Wertsteigerung, Zins). Zwei Gruende dafuer, dass das nicht in Phase 1 liegt:
// 1. Ein Startwert ist nicht "phase-1-spezifisch", sondern schlicht der Stand am Anfang.
// 2. Elemente lassen sich damit erfassen, BEVOR es Lebensphasen gibt -- der erste Schritt
// des Assistenten ist eine Bestandsaufnahme und braucht noch keine Zeitachse.
// Zugleich die Wurzel der Feld-Vererbung: Phase 1 erbt von hier.
baseData Json?
phaseValues ElementPhaseValue[] phaseValues ElementPhaseValue[]
transitionValues ElementTransitionValue[] transitionValues ElementTransitionValue[]
} }
+8 -2
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { changeUserPassword } from "@/lib/users"; import { changeUserPassword } from "@/lib/users";
import { rateLimit, tooManyRequests } from "@/lib/rate-limit";
const changePasswordSchema = z.object({ const changePasswordSchema = z.object({
currentPassword: z.string().min(1), currentPassword: z.string().min(1),
@@ -14,10 +15,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
} }
// Bremse gegen das Durchprobieren des aktuellen Passworts: 10 Versuche je Benutzer und
// 15 Minuten (nach dem Auth-Check, damit der Schlüssel die echte Benutzer-ID ist).
const limited = rateLimit(`change-pw:${userId}`, 10, 15 * 60 * 1000);
if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds);
const body = await request.json(); const body = await request.json();
const parsed = changePasswordSchema.safeParse(body); const parsed = changePasswordSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe."; const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe.";
return NextResponse.json({ error: message }, { status: 400 }); return NextResponse.json({ error: message }, { status: 400 });
} }
@@ -25,7 +31,7 @@ export async function POST(request: NextRequest) {
await changeUserPassword(userId, parsed.data.currentPassword, parsed.data.newPassword); await changeUserPassword(userId, parsed.data.currentPassword, parsed.data.newPassword);
} catch (e) { } catch (e) {
return NextResponse.json( return NextResponse.json(
{ error: e instanceof Error ? e.message : "Passwort aendern fehlgeschlagen." }, { error: e instanceof Error ? e.message : "Passwort ändern fehlgeschlagen." },
{ status: 400 } { status: 400 }
); );
} }
+10
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth"; import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
import { verifyUserCredentials } from "@/lib/users"; import { verifyUserCredentials } from "@/lib/users";
import { clientIp, rateLimit, tooManyRequests } from "@/lib/rate-limit";
const loginSchema = z.object({ const loginSchema = z.object({
username: z.string().min(1), username: z.string().min(1),
@@ -15,6 +16,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Bitte Benutzername und Passwort angeben." }, { status: 400 }); return NextResponse.json({ error: "Bitte Benutzername und Passwort angeben." }, { status: 400 });
} }
// Bremse gegen Brute-Force: 10 Versuche je IP UND Benutzername in 15 Minuten. Bewusst nicht
// nur je IP -- sonst würden Fehlversuche gegen ein Konto alle anderen Konten derselben IP
// mitsperren (z. B. mehrere Testkonten hinter demselben Anschluss). Der Schlüssel wird
// normalisiert (trim/lowercase), damit sich die Bremse nicht per Gross-/Kleinschreibung
// umgehen lässt; die eigentliche Anmeldung prüft den Namen weiterhin exakt.
const rateKey = `login:${clientIp(request)}:${parsed.data.username.trim().toLowerCase()}`;
const limited = rateLimit(rateKey, 10, 15 * 60 * 1000);
if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds);
const user = await verifyUserCredentials(parsed.data.username, parsed.data.password); const user = await verifyUserCredentials(parsed.data.username, parsed.data.password);
if (!user) { if (!user) {
return NextResponse.json({ error: "Benutzername oder Passwort falsch." }, { status: 401 }); return NextResponse.json({ error: "Benutzername oder Passwort falsch." }, { status: 401 });
+14 -6
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth"; import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
import { registerUser, validateUsername } from "@/lib/users"; import { registerUser, UsernameTakenError, validateUsername } from "@/lib/users";
import { clientIp, rateLimit, tooManyRequests } from "@/lib/rate-limit";
const registerSchema = z.object({ const registerSchema = z.object({
username: z.string().min(1), username: z.string().min(1),
@@ -9,10 +10,14 @@ const registerSchema = z.object({
}); });
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
// Bremse gegen Massen-Registrierung: 5 Konten je IP und Stunde.
const limited = rateLimit(`register:${clientIp(request)}`, 5, 60 * 60 * 1000);
if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds);
const body = await request.json(); const body = await request.json();
const parsed = registerSchema.safeParse(body); const parsed = registerSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe."; const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe.";
return NextResponse.json({ error: message }, { status: 400 }); return NextResponse.json({ error: message }, { status: 400 });
} }
@@ -25,10 +30,13 @@ export async function POST(request: NextRequest) {
try { try {
user = await registerUser(parsed.data.username, parsed.data.password); user = await registerUser(parsed.data.username, parsed.data.password);
} catch (e) { } catch (e) {
return NextResponse.json( // Nur der "Name vergeben"-Fall ist ein 409 mit freundlicher Meldung; alles andere
{ error: e instanceof Error ? e.message : "Registrierung fehlgeschlagen." }, // (z. B. DB kurzzeitig weg) ist ein echter Serverfehler und darf nicht als Konflikt
{ status: 409 } // mit roher Prisma-Meldung durchgereicht werden.
); if (e instanceof UsernameTakenError) {
return NextResponse.json({ error: e.message }, { status: 409 });
}
return NextResponse.json({ error: "Registrierung fehlgeschlagen." }, { status: 500 });
} }
const token = await createSessionToken(user.id); const token = await createSessionToken(user.id);
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { phaseDataSchema } from "@/lib/elements";
// Stammdaten eines Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen.
//
// Bewusst ohne Phasenbezug. Zwei Gründe: Ein Startwert ist nicht «phase-1-spezifisch»,
// sondern schlicht der Stand am Anfang -- und Elemente lassen sich damit erfassen, BEVOR es
// Lebensphasen gibt. Genau das braucht der erste Schritt des Assistenten: eine
// Bestandsaufnahme, die noch keine Zeitachse voraussetzt.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ elementId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { elementId } = await params;
const element = await getOwnedElement(elementId, userId);
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
const parsed = phaseDataSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({
where: { id: elementId },
data: { baseData: parsed.data },
});
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries"; import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { phaseDataSchema } from "@/lib/elements"; import { phaseDataSchema } from "@/lib/elements";
// Speichert die Werte eines Elements innerhalb einer Lebensphase (Upsert). // Speichert die Werte eines Elements innerhalb einer Lebensphase (Upsert).
@@ -16,12 +17,12 @@ export async function PUT(
const element = await getOwnedElement(elementId, userId); const element = await getOwnedElement(elementId, userId);
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
const phase = await prisma.phase.findFirst({ where: { id: phaseId, planId: element.planId } }); const phase = await prisma.phase.findFirst({ where: { id: phaseId, scenarioId: element.scenarioId } });
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
const body = await request.json(); const body = await request.json();
const parsed = phaseDataSchema.safeParse(body); const parsed = phaseDataSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.elementPhaseValue.upsert({ await prisma.elementPhaseValue.upsert({
where: { elementId_phaseId: { elementId, phaseId } }, where: { elementId_phaseId: { elementId, phaseId } },
@@ -29,5 +30,6 @@ export async function PUT(
update: { data: parsed.data }, update: { data: parsed.data },
}); });
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { RETIREMENT_CATEGORIES, retirementDecisionSchema } from "@/lib/retirement-decision";
// Speichert den Pensionierungs-Entscheid eines Elements (AHV, PK, Säule 3a).
//
// Bewusst OHNE Phasenbezug in der Route: Der Entscheid gilt für die Pensionierung des
// Besitzers, wo immer die auf der Zeitachse gerade liegt. Genau das unterscheidet ihn vom
// Übergangs-Entscheid (`/transition/<fromPhaseId>`), der an einer konkreten Grenze hängt.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ elementId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { elementId } = await params;
const element = await getOwnedElement(elementId, userId);
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
if (!RETIREMENT_CATEGORIES.includes(element.category)) {
return NextResponse.json(
{ error: "Nur AHV, Pensionskasse und Säule 3a kennen einen Pensionierungs-Entscheid." },
{ status: 400 }
);
}
const body = await request.json();
const parsed = retirementDecisionSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({
where: { id: elementId },
data: { retirementDecision: parsed.data },
});
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
+25 -3
View File
@@ -3,8 +3,15 @@ import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries"; import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
const patchSchema = z.object({ name: z.string().min(1).max(120) }); // Name UND Zuordnung sind änderbar. Die Zuordnung läuft über `ownerRole` (nicht über eine
// Person-ID), deshalb überlebt sie auch ein Neuanlegen der Personen im Profil-Dialog.
const patchSchema = z.object({
name: z.string().min(1).max(120).optional(),
ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).optional(),
});
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
@@ -19,9 +26,23 @@ export async function PATCH(
const body = await request.json(); const body = await request.json();
const parsed = patchSchema.safeParse(body); const parsed = patchSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } }); // AHV, PK und 3a hängen am individuellen Pensionierungszeitpunkt und müssen deshalb einer
// Person gehören -- «Gemeinsam» wäre dort fachlich sinnlos.
const nextOwner = parsed.data.ownerRole;
if (nextOwner === "HOUSEHOLD" && PERSON_ONLY_CATEGORIES.includes(element.category)) {
return NextResponse.json(
{ error: "Diese Kategorie muss einer Person zugeordnet bleiben." },
{ status: 400 }
);
}
await prisma.financialElement.update({
where: { id: element.id },
data: { name: parsed.data.name ?? undefined, ownerRole: nextOwner ?? undefined },
});
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -37,5 +58,6 @@ export async function DELETE(
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
await prisma.financialElement.delete({ where: { id: element.id } }); await prisma.financialElement.delete({ where: { id: element.id } });
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries"; import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { transitionDataSchema } from "@/lib/elements"; import { transitionDataSchema } from "@/lib/elements";
// Speichert den Uebergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert). // Speichert den Übergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
export async function PUT( export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ elementId: string; fromPhaseId: string }> } { params }: { params: Promise<{ elementId: string; fromPhaseId: string }> }
@@ -16,12 +17,12 @@ export async function PUT(
const element = await getOwnedElement(elementId, userId); const element = await getOwnedElement(elementId, userId);
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, planId: element.planId } }); const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, scenarioId: element.scenarioId } });
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
const body = await request.json(); const body = await request.json();
const parsed = transitionDataSchema.safeParse(body); const parsed = transitionDataSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.elementTransitionValue.upsert({ await prisma.elementTransitionValue.upsert({
where: { elementId_fromPhaseId: { elementId, fromPhaseId } }, where: { elementId_fromPhaseId: { elementId, fromPhaseId } },
@@ -29,5 +30,6 @@ export async function PUT(
update: { data: parsed.data }, update: { data: parsed.data },
}); });
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedPhase } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { cashTransitionSchema } from "@/lib/elements";
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 übernehmen oder
// einmaliger Zufluss / einmalige Kosten (Roadmap Nr. 1). Cash ist kein FinancialElement,
// deshalb liegt der Entscheid direkt an der Von-Phase.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ phaseId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { phaseId } = await params;
const phase = await getOwnedPhase(phaseId, userId);
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
const body = await request.json();
const parsed = cashTransitionSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.phase.update({
where: { id: phase.id },
data: { cashTransition: parsed.data },
});
await touchScenario(phase.scenarioId, userId);
return NextResponse.json({ ok: true });
}
+34 -13
View File
@@ -1,14 +1,17 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries"; import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { maxPhaseDuration } from "@/lib/calculations"; import { maxPhaseDuration } from "@/lib/calculations";
import { planDurationChange } from "@/lib/phaseplan";
const updatePhaseSchema = z.object({ const updatePhaseSchema = z.object({
name: z.string().min(1).max(120).optional(), name: z.string().min(1).max(120).optional(),
durationYears: z.number().int().min(1).max(80).optional(), durationYears: z.number().int().min(1).max(80).optional(),
inflationRate: z.number().min(-20).max(50).nullable().optional(), // Die Spar-/Verzehrquote dieser Phase wurde bewusst verteilt (SPEZIFIKATION 3.14).
ratesConfirmed: z.boolean().optional(),
}); });
export async function PUT( export async function PUT(
@@ -24,35 +27,52 @@ export async function PUT(
const body = await request.json(); const body = await request.json();
const parsed = updatePhaseSchema.safeParse(body); const parsed = updatePhaseSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
let duration = parsed.data.durationYears; let duration = parsed.data.durationYears;
// Die Folgephase gleicht eine geänderte Dauer aus -- sonst verschieben sich alle späteren
// Phasengrenzen und eine davon überspannt eine Pensionierung (siehe lib/phaseplan.ts).
let neighbour: { id: string; durationYears: number } | null = null;
if (duration != null) { if (duration != null) {
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase). const scenario = await getOwnedScenario(existing.scenarioId, userId);
const plan = await getOwnedPlan(existing.planId, userId); if (scenario) {
if (plan) { const planInput = toPlanInput(scenario);
const planInput = toPlanInput(plan); // Dauer ans nächste Pensionsereignis kappen (Jahre vor dieser Phase).
const yearsBefore = planInput.phases const yearsBefore = planInput.phases
.filter((p) => p.sequenceNumber < existing.sequenceNumber) .filter((p) => p.sequenceNumber < existing.sequenceNumber)
.reduce((s, p) => s + p.durationYears, 0); .reduce((s, p) => s + p.durationYears, 0);
const cap = maxPhaseDuration(planInput.persons, yearsBefore); const cap = maxPhaseDuration(planInput.persons, yearsBefore);
if (cap != null) duration = Math.min(duration, cap); if (cap != null) duration = Math.min(duration, cap);
duration = Math.max(1, duration); duration = Math.max(1, duration);
const change = planDurationChange(planInput.phases, phaseId, duration);
if (!change.ok) return NextResponse.json({ error: change.error }, { status: 400 });
if (change.neighbourId && change.neighbourNewDuration != null) {
neighbour = { id: change.neighbourId, durationYears: change.neighbourNewDuration };
}
} }
} }
const phase = await prisma.phase.update({ const phase = await prisma.$transaction(async (tx) => {
const updated = await tx.phase.update({
where: { id: phaseId }, where: { id: phaseId },
data: { data: {
name: parsed.data.name ?? undefined, name: parsed.data.name ?? undefined,
durationYears: duration ?? undefined, durationYears: duration ?? undefined,
inflationRate: parsed.data.inflationRate === undefined ? undefined : parsed.data.inflationRate, ratesConfirmed: parsed.data.ratesConfirmed ?? undefined,
}, },
}); });
return NextResponse.json({ phase: { id: phase.id } }); if (neighbour) {
await tx.phase.update({ where: { id: neighbour.id }, data: { durationYears: neighbour.durationYears } });
}
return updated;
});
await touchScenario(existing.scenarioId, userId);
return NextResponse.json({ phase: { id: phase.id }, adjustedNeighbour: neighbour });
} }
// Nur die letzte Phase kann geloescht werden (Verkettung bleibt intakt). // Nur die letzte Phase kann gelöscht werden (Verkettung bleibt intakt).
export async function DELETE( export async function DELETE(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ phaseId: string }> } { params }: { params: Promise<{ phaseId: string }> }
@@ -65,12 +85,13 @@ export async function DELETE(
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
const later = await prisma.phase.findFirst({ const later = await prisma.phase.findFirst({
where: { planId: phase.planId, sequenceNumber: { gt: phase.sequenceNumber } }, where: { scenarioId: phase.scenarioId, sequenceNumber: { gt: phase.sequenceNumber } },
}); });
if (later) { if (later) {
return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 }); return NextResponse.json({ error: "Nur die letzte Phase kann gelöscht werden." }, { status: 400 });
} }
await prisma.phase.delete({ where: { id: phaseId } }); await prisma.phase.delete({ where: { id: phaseId } });
await touchScenario(phase.scenarioId, userId);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
const valueSchema = z.object({
value: z.number().min(-1_000_000_000).max(1_000_000_000).optional(),
mortgage: z.number().min(0).max(1_000_000_000).optional(),
});
const updateSchema = z.object({
recordedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Datum im Format JJJJ-MM-TT"),
comment: z.string().max(500).optional(),
cash: z.number().min(-1_000_000_000).max(1_000_000_000).nullable().optional(),
values: z.record(z.string(), valueSchema),
});
// Ändert einen bestehenden Ist-Satz. Wie beim Anlegen gilt: Ein Ist-Satz ist eine Beobachtung,
// keine Planänderung -- er erzeugt also KEINE Szenario-Version. Der ursprüngliche Erfasser
// (`createdById`) bleibt stehen; korrigiert wird der Datensatz selbst.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ planId: string; setId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, setId } = await params;
const existing = await prisma.actualsSet.findFirst({ where: { id: setId, planId, plan: { userId } } });
if (!existing) return NextResponse.json({ error: "Datensatz nicht gefunden." }, { status: 404 });
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
const first = parsed.error.issues[0];
return NextResponse.json({ error: first?.message ?? "Ungültige Eingabe." }, { status: 400 });
}
const { recordedOn, comment, cash, values } = parsed.data;
const updated = await prisma.actualsSet.update({
where: { id: setId },
data: {
recordedOn: new Date(`${recordedOn}T00:00:00.000Z`),
// Für die Berechnung zählt nur die Jahreszahl (siehe POST).
year: Number(recordedOn.slice(0, 4)),
comment: comment?.trim() || null,
cash: typeof cash === "number" ? cash : null,
values,
},
});
return NextResponse.json({ set: { id: updated.id, year: updated.year } });
}
// Löscht einen Ist-Satz. Ein Ist-Satz ist eine Beobachtung, keine Planänderung -- deshalb
// gibt es hier weder Versionierung noch Wiederherstellung.
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string; setId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, setId } = await params;
const set = await prisma.actualsSet.findFirst({
where: { id: setId, planId, plan: { userId } },
});
if (!set) return NextResponse.json({ error: "Datensatz nicht gefunden." }, { status: 404 });
await prisma.actualsSet.delete({ where: { id: setId } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
// Ein Ist-Wert je Wurzel-Element. Beide Felder optional: Wer eine Zahl nicht kennt, lässt sie
// weg -- die Lücke fällt in der Berechnung auf die Plandaten zurück.
const valueSchema = z.object({
value: z.number().min(-1_000_000_000).max(1_000_000_000).optional(),
mortgage: z.number().min(0).max(1_000_000_000).optional(),
});
const createSchema = z.object({
recordedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Datum im Format JJJJ-MM-TT"),
comment: z.string().max(500).optional(),
cash: z.number().min(-1_000_000_000).max(1_000_000_000).nullable().optional(),
values: z.record(z.string(), valueSchema),
});
async function ownedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({ where: { id: planId, userId } });
}
// Alle Ist-Sätze eines Plans, neueste zuerst.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const rows = await prisma.actualsSet.findMany({
where: { planId },
orderBy: [{ year: "desc" }, { recordedOn: "desc" }],
include: { createdBy: { select: { username: true } } },
});
return NextResponse.json({
sets: rows.map((r) => ({
id: r.id,
recordedOn: r.recordedOn.toISOString().slice(0, 10),
year: r.year,
comment: r.comment,
cash: r.cash,
values: r.values,
author: r.createdBy.username,
createdAt: r.createdAt,
})),
});
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { recordedOn, comment, cash, values } = parsed.data;
// Für die Berechnung zählt nur die Jahreszahl -- der Rechenkern arbeitet in ganzen Jahren
// ab Planbeginn. Das exakte Datum bleibt für Liste und Zeitachse erhalten.
const year = Number(recordedOn.slice(0, 4));
const created = await prisma.actualsSet.create({
data: {
planId,
recordedOn: new Date(`${recordedOn}T00:00:00.000Z`),
year,
comment: comment?.trim() || null,
cash: typeof cash === "number" ? cash : null,
values,
createdById: userId,
},
});
return NextResponse.json({ set: { id: created.id, year: created.year } }, { status: 201 });
}
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
// Eine einzelne gespeicherte Analyse MIT den Zahlen (Eingaben + Ergebnis) -- zum Öffnen der
// read-only Ansicht.
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string; analysisId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, analysisId } = await params;
const a = await prisma.savedAnalysis.findFirst({
where: { id: analysisId, planId, plan: { userId } },
});
if (!a) return NextResponse.json({ error: "Analyse nicht gefunden." }, { status: 404 });
return NextResponse.json({
analysis: {
id: a.id,
name: a.name,
type: a.type,
scenarioName: a.scenarioName,
versionLabel: a.versionLabel,
metric: a.metric,
source: a.source,
summary: a.summary,
inputs: a.inputs,
result: a.result,
createdAt: a.createdAt,
},
});
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string; analysisId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, analysisId } = await params;
const a = await prisma.savedAnalysis.findFirst({
where: { id: analysisId, planId, plan: { userId } },
});
if (!a) return NextResponse.json({ error: "Analyse nicht gefunden." }, { status: 404 });
await prisma.savedAnalysis.delete({ where: { id: analysisId } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
const createSchema = z.object({
name: z.string().min(1).max(160),
type: z.enum(["CHART", "MONTE_CARLO", "SENSITIVITY"]),
scenarioName: z.string().max(120).nullish(),
versionLabel: z.string().max(40).nullish(),
metric: z.string().max(20).default("nominal"),
source: z.string().max(20).default("PLAN"),
summary: z.string().max(400).nullish(),
// Eingaben und Ergebnis als Zahlen -- read-only festgehalten, nichts wird neu gerechnet.
inputs: z.record(z.string(), z.unknown()).default({}),
result: z.record(z.string(), z.unknown()).default({}),
});
async function ownedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({ where: { id: planId, userId } });
}
// Liste der gespeicherten Analysen, neueste zuerst -- OHNE die grossen JSON-Felder.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const rows = await prisma.savedAnalysis.findMany({
where: { planId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
type: true,
scenarioName: true,
versionLabel: true,
metric: true,
source: true,
summary: true,
createdAt: true,
createdBy: { select: { username: true } },
},
});
return NextResponse.json({
analyses: rows.map((r) => ({
id: r.id,
name: r.name,
type: r.type,
scenarioName: r.scenarioName,
versionLabel: r.versionLabel,
metric: r.metric,
source: r.source,
summary: r.summary,
createdAt: r.createdAt,
author: r.createdBy.username,
})),
});
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const d = parsed.data;
const created = await prisma.savedAnalysis.create({
data: {
planId,
name: d.name,
type: d.type,
scenarioName: d.scenarioName ?? null,
versionLabel: d.versionLabel ?? null,
metric: d.metric,
source: d.source,
summary: d.summary ?? null,
inputs: d.inputs as object,
result: d.result as object,
createdById: userId,
},
});
return NextResponse.json({ analysis: { id: created.id } }, { status: 201 });
}
@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { planInclude, toPlanInput } from "@/lib/queries";
import { computePlan } from "@/lib/calculations";
import { buildViews } from "@/lib/dataview";
// Kennzahlen für das Plan-Dashboard. Vieles ist SZENARIO-eigen (Endvermögen, Ruinalter,
// Phasen) -- dafür dient das BASISSZENARIO als kanonischer Vertreter; die Oberfläche
// beschriftet das entsprechend.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await prisma.plan.findFirst({
where: { id: planId, userId },
include: {
persons: { orderBy: { role: "asc" } },
scenarios: {
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
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, comment: true },
},
},
},
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
_count: { select: { scenarios: true, actuals: true, analyses: true } },
},
});
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const baseScenario = plan.scenarios.find((s) => s.isBase) ?? plan.scenarios[0];
// Kennzahlen des Basisszenarios (inkl. Ist-Sicht, falls erfasst).
let baseInfo: unknown = null;
if (baseScenario) {
const planInput = toPlanInput(baseScenario);
const origins = plan.scenarios.flatMap((s) => s.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId })));
const views = buildViews(
planInput,
plan.actuals.map((a) => ({
id: a.id,
recordedOn: a.recordedOn.toISOString().slice(0, 10),
year: a.year,
comment: a.comment,
cash: a.cash,
values: a.values as Record<string, { value?: number; mortgage?: number }>,
})),
origins
);
const last = views.plan.phases[views.plan.phases.length - 1];
const lastActual = views.actual ? views.actual.phases[views.actual.phases.length - 1] : null;
baseInfo = {
scenarioId: baseScenario.id,
name: baseScenario.name,
phaseCount: views.plan.phases.length,
endNominal: last ? Math.round(last.endWealthNominal) : 0,
endReal: last ? Math.round(last.endWealthReal) : 0,
ruinAge: views.plan.ruinAge,
// Abweichung des Endvermögens Ist gegenüber Plan -- die Zahl, für die das Monitoring da ist.
actualEndNominal: lastActual ? Math.round(lastActual.endWealthNominal) : null,
actualYears: views.actualYears,
};
}
// Laufen Szenarien in Angaben auseinander, die seit V7 plan-weit sein SOLLTEN? Kann nach
// der Migration alter Daten vorkommen. Nur ein Hinweis, kein Fehler.
const inconsistencies: string[] = [];
// Offene Übergangs-Entscheide je Szenario (Summe) -- die aktionierbarste Kennzahl.
const scenarioRows = plan.scenarios.map((s) => {
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,
// Die tatsächliche aktuelle Version, nicht nur die Hauptversion. Ohne Historie noch keine.
version: top ? `${top.major}.${top.minor}` : null,
// Kommentar der aktuellen Version -- gesetzt wird er bei Hauptversionen und beim
// Wiederherstellen; Nebenversionen haben in der Regel keinen.
versionComment: top?.comment ?? 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,
ruinAge: computed.ruinAge,
};
});
return NextResponse.json({
plan: {
id: plan.id,
name: plan.name,
householdType: plan.householdType,
startYear: plan.startYear,
persons: plan.persons.map((p) => ({ role: p.role, name: p.name, age: p.age })),
},
counts: {
scenarios: plan._count.scenarios,
actuals: plan._count.actuals,
analyses: plan._count.analyses,
},
base: baseInfo,
scenarios: scenarioRows,
inconsistencies,
});
}
@@ -1,31 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { computePlan, planToCsv } from "@/lib/calculations";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const { planId } = await params;
const plan = await getOwnedPlan(planId, userId);
if (!plan) {
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
}
const planInput = toPlanInput(plan);
const computed = computePlan(planInput);
const csv = planToCsv(planInput, computed);
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${plan.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`,
},
});
}
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
export const runtime = "nodejs";
// Liefert die GESPEICHERTE PDF-Datei. Sie wird nicht neu erzeugt -- das ist der Kern des
// Audit-Trails: derselbe Bericht ergibt in drei Jahren dieselben Bytes.
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string; reportId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, reportId } = await params;
const report = await prisma.report.findFirst({
where: { id: reportId, planId, plan: { userId } },
select: { title: true, pdf: true, createdAt: true },
});
if (!report) return NextResponse.json({ error: "Bericht nicht gefunden." }, { status: 404 });
const date = report.createdAt.toISOString().slice(0, 10);
const safe = report.title.replace(/[^\w\s.-]/g, "").trim().replace(/\s+/g, "_") || "Bericht";
return new NextResponse(new Uint8Array(report.pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${date}_${safe}.pdf"`,
"Content-Length": String(report.pdf.length),
},
});
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string; reportId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, reportId } = await params;
const report = await prisma.report.findFirst({ where: { id: reportId, planId, plan: { userId } } });
if (!report) return NextResponse.json({ error: "Bericht nicht gefunden." }, { status: 404 });
await prisma.report.delete({ where: { id: reportId } });
return NextResponse.json({ ok: true });
}
+171
View File
@@ -0,0 +1,171 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { planInclude, toPlanInput } from "@/lib/queries";
import { buildReport, MAX_REPORT_SCENARIOS, type ReportConfig } from "@/lib/report";
import { renderReportPdf } from "@/lib/report-pdf";
import type { ActualsSetInput } from "@/lib/actuals";
// pdfkit braucht die Node-Runtime (Streams, Buffer) -- nicht Edge.
export const runtime = "nodejs";
const createSchema = z.object({
title: z.string().min(1).max(160),
metric: z.enum(["nominal", "real"]),
source: z.enum(["PLAN", "ACTUAL"]),
scenarioIds: z.array(z.string()).min(1).max(MAX_REPORT_SCENARIOS),
analysisIds: z.array(z.string()).max(20).default([]),
comment: z.string().max(500).nullish(),
});
async function ownedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({ where: { id: planId, userId } });
}
// Liste der Berichte -- ohne die PDF-Bytes (die kommen erst beim Herunterladen).
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const rows = await prisma.report.findMany({
where: { planId },
orderBy: { createdAt: "desc" },
select: {
id: true,
title: true,
config: true,
pdfBytes: true,
createdAt: true,
createdBy: { select: { username: true } },
},
});
return NextResponse.json({
reports: rows.map((r) => ({
id: r.id,
title: r.title,
config: r.config,
pdfBytes: r.pdfBytes,
createdAt: r.createdAt,
author: r.createdBy.username,
})),
});
}
// Erzeugt den Bericht: rechnet, baut das Modell, rendert das PDF und legt beides ab.
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const cfg = parsed.data;
// Der GESAMTE weitere Ablauf liegt in einem try/catch. Ein unbehandelter Fehler ergäbe ein
// nacktes 500 ohne JSON-Körper -- der Client könnte dann nur «Fehler 500» melden, und die
// Ursache bliebe im Dunkeln.
try {
const plan = await prisma.plan.findFirst({
where: { id: planId, userId },
include: {
persons: { orderBy: { role: "asc" } },
scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], select: { id: true, name: true, isBase: true } },
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
},
});
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
// Szenarien einzeln nachladen statt über eine verschachtelte Abfrage: Plan -> Szenarien ->
// Plan -> Personen wäre ein Ringbezug, und ein paar Abfragen mehr sind hier belanglos.
const wanted = plan.scenarios
.filter((s) => cfg.scenarioIds.includes(s.id))
.sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
if (wanted.length === 0) {
return NextResponse.json({ error: "Kein gültiges Szenario ausgewählt." }, { status: 400 });
}
const chosen = (
await Promise.all(wanted.map((s) => prisma.scenario.findUnique({ where: { id: s.id }, include: planInclude })))
).filter((s): s is NonNullable<typeof s> => !!s);
if (chosen.length === 0) {
return NextResponse.json({ error: "Szenarien konnten nicht geladen werden." }, { status: 400 });
}
// Element-Herkunft aller Szenarien -- Grundlage der Ist-Zuordnung.
const allElements = await prisma.financialElement.findMany({
where: { scenario: { planId } },
select: { id: true, sourceElementId: true },
});
const analyses =
cfg.analysisIds.length === 0
? []
: await prisma.savedAnalysis.findMany({
where: { id: { in: cfg.analysisIds }, planId },
orderBy: { createdAt: "asc" },
select: { name: true, type: true, result: true },
});
const user = await prisma.user.findUnique({ where: { id: userId }, select: { username: true } });
const config: ReportConfig = {
title: cfg.title,
metric: cfg.metric,
source: cfg.source,
scenarioIds: chosen.map((s) => s.id),
analysisIds: cfg.analysisIds,
comment: cfg.comment ?? null,
};
const model = buildReport({
planName: plan.name,
author: user?.username ?? "unbekannt",
createdAt: new Date(),
config,
scenarios: chosen.map((s) => ({ name: s.name, isBase: s.isBase, plan: toPlanInput(s) })),
household: {
householdType: plan.householdType,
startYear: plan.startYear,
persons: plan.persons.map((p) => ({ name: p.name, age: p.age, role: p.role })),
},
actuals: plan.actuals.map(
(a): ActualsSetInput => ({
id: a.id,
recordedOn: a.recordedOn.toISOString().slice(0, 10),
year: a.year,
comment: a.comment,
cash: a.cash,
values: a.values as Record<string, { value?: number; mortgage?: number }>,
})
),
origins: allElements,
analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })),
});
const pdf = await renderReportPdf(model);
const created = await prisma.report.create({
data: {
planId,
title: cfg.title,
config: config as unknown as object,
model: JSON.parse(JSON.stringify(model)) as object,
pdf: new Uint8Array(pdf),
pdfBytes: pdf.length,
createdById: userId,
},
select: { id: true },
});
return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 });
} catch (err) {
// Jede Ursache landet hier -- Datenbank, Modellaufbau, Schriftdaten, Speichern.
console.error("[reports] Bericht fehlgeschlagen", err);
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
return NextResponse.json({ error: `Bericht fehlgeschlagen -- ${message}` }, { status: 500 });
}
}
+10 -67
View File
@@ -1,41 +1,12 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { planProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
export async function GET( // Der Plan ist nur noch der Behälter: er trägt den Namen; die Finanzdaten liegen im Szenario.
_request: NextRequest, const patchSchema = z.object({ name: z.string().min(1).max(120) });
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await getOwnedPlan(planId, userId); export async function PATCH(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const planInput = toPlanInput(plan);
const computed = computePlan(planInput);
return NextResponse.json({ plan: planInput, computed });
}
// Name/Cash-Anfangswert aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation).
const patchSchema = z.union([
planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
z.object({
name: z.string().min(1).max(120).optional(),
initialCash: z.number().min(0).max(1_000_000_000).optional(),
}),
]);
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params; const { planId } = await params;
@@ -43,50 +14,22 @@ export async function PATCH(
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } }); const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const body = await request.json(); const parsed = patchSchema.safeParse(await request.json());
const parsed = patchSchema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const data = parsed.data; const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } });
const hasProfile = "householdType" in data;
if (hasProfile) {
const error = validatePersonsForType(data);
if (error) return NextResponse.json({ error }, { status: 400 });
const updated = await prisma.$transaction(async (tx) => {
await tx.person.deleteMany({ where: { planId: plan.id } });
return tx.plan.update({
where: { id: plan.id },
data: {
name: data.name ?? undefined,
householdType: data.householdType,
inflationRateDefault: data.inflationRateDefault,
persons: { create: data.persons },
},
});
});
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
}
const updated = await prisma.plan.update({
where: { id: plan.id },
data: {
name: "name" in data ? data.name : undefined,
initialCash: "initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
},
});
return NextResponse.json({ plan: { id: updated.id, name: updated.name } }); return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
} }
export async function DELETE( // Löscht den Plan inkl. aller Szenarien (Cascade).
_request: NextRequest, export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params; const { planId } = await params;
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } }); const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
await prisma.plan.delete({ where: { id: plan.id } }); await prisma.plan.delete({ where: { id: plan.id } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -1,101 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
const scenarioSchema = z.object({
name: z.string().min(1).max(120),
branchFromPhaseId: z.string().min(1),
});
// Erstellt ein Szenario als Deep-Copy eines Plans bis zur Verzweigungsphase (inkl.).
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const body = await request.json();
const parsed = scenarioSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const source = await getOwnedPlan(planId, userId);
if (!source) return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 });
const branchPhase = source.phases.find((p) => p.id === parsed.data.branchFromPhaseId);
if (!branchPhase) return NextResponse.json({ error: "Verzweigungsphase nicht gefunden." }, { status: 404 });
const copiedPhases = source.phases
.filter((p) => p.sequenceNumber <= branchPhase.sequenceNumber)
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const copiedPhaseIds = new Set(copiedPhases.map((p) => p.id));
const newPlanId = await prisma.$transaction(async (tx) => {
const newPlan = await tx.plan.create({
data: {
userId,
name: parsed.data.name,
householdType: source.householdType,
inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash,
parentPlanId: source.id,
persons: {
create: source.persons.map((p) => ({ role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge })),
},
},
});
// Phasen kopieren (alte -> neue Id).
const phaseIdMap = new Map<string, string>();
let lastNewPhaseId = "";
for (const phase of copiedPhases) {
const created = await tx.phase.create({
data: {
planId: newPlan.id,
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
inflationRate: phase.inflationRate,
},
});
phaseIdMap.set(phase.id, created.id);
lastNewPhaseId = created.id;
}
// Elemente + deren Phasen-/Uebergangswerte kopieren.
for (const el of source.elements) {
const newEl = await tx.financialElement.create({
data: {
planId: newPlan.id,
category: el.category,
name: el.name,
ownerRole: el.ownerRole,
orderIndex: el.orderIndex,
},
});
for (const pv of el.phaseValues) {
const newPhaseId = phaseIdMap.get(pv.phaseId);
if (!newPhaseId) continue;
await tx.elementPhaseValue.create({
data: { elementId: newEl.id, phaseId: newPhaseId, data: pv.data as object },
});
}
for (const tv of el.transitionValues) {
if (!copiedPhaseIds.has(tv.fromPhaseId)) continue;
const newFromId = phaseIdMap.get(tv.fromPhaseId);
if (!newFromId) continue;
await tx.elementTransitionValue.create({
data: { elementId: newEl.id, fromPhaseId: newFromId, data: tv.data as object },
});
}
}
await tx.plan.update({ where: { id: newPlan.id }, data: { branchFromPhaseId: lastNewPhaseId } });
return newPlan.id;
});
return NextResponse.json({ planId: newPlanId }, { status: 201 });
}
+50 -23
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
const personSchema = z.object({ const personSchema = z.object({
role: z.enum(["PERSON_A", "PERSON_B"]), role: z.enum(["PERSON_A", "PERSON_B"]),
@@ -10,73 +11,99 @@ const personSchema = z.object({
retirementAge: z.number().int().min(30).max(100), retirementAge: z.number().int().min(30).max(100),
}); });
// Ein Plan traegt sein eigenes Grundprofil (Haushaltsform, Personen, Inflation). // Das Grundprofil liegt am SZENARIO (nicht am Plan) -- dadurch kann ein Szenario z. B. ein
export const planProfileSchema = z.object({ // anderes Pensionsalter tragen als das Basisszenario (Frühpensionierungs-Szenario).
export const scenarioProfileSchema = z.object({
householdType: z.enum(["SINGLE", "COUPLE"]), householdType: z.enum(["SINGLE", "COUPLE"]),
inflationRateDefault: z.number().min(-20).max(50), inflationRateDefault: z.number().min(-20).max(50),
startYear: z.number().int().min(1900).max(2200).nullish(),
persons: z.array(personSchema).min(1).max(2), persons: z.array(personSchema).min(1).max(2),
}); });
const createPlanSchema = z const createPlanSchema = z.object({ name: z.string().min(1).max(120) }).and(scenarioProfileSchema);
.object({ name: z.string().min(1).max(120) })
.and(planProfileSchema);
export function validatePersonsForType(data: z.infer<typeof planProfileSchema>): string | null { export function validatePersonsForType(data: z.infer<typeof scenarioProfileSchema>): string | null {
if (data.householdType === "SINGLE" && data.persons.length !== 1) { if (data.householdType === "SINGLE" && data.persons.length !== 1) {
return "Einzelperson-Plan benoetigt genau eine Person."; return "Einzelperson-Plan benötigt genau eine Person.";
} }
if (data.householdType === "COUPLE" && data.persons.length !== 2) { if (data.householdType === "COUPLE" && data.persons.length !== 2) {
return "Paar-Plan benoetigt genau zwei Personen (Person A und Person B)."; return "Paar-Plan benötigt genau zwei Personen (Person A und Person B).";
} }
return null; return null;
} }
// Liste der Pläne mit ihrem Szenario-Baum (nur Kopfdaten).
export async function GET() { export async function GET() {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) { if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const plans = await prisma.plan.findMany({ const plans = await prisma.plan.findMany({
where: { userId }, where: { userId },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
select: { select: {
id: true, id: true,
name: true, name: true,
parentPlanId: true,
branchFromPhaseId: true,
createdAt: true, createdAt: true,
phases: { householdType: true,
select: { id: true, name: true, sequenceNumber: true }, startYear: true,
orderBy: { sequenceNumber: "asc" }, persons: { select: { role: true, name: true, age: true }, orderBy: { role: "asc" } },
scenarios: {
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
select: { id: true, planId: true, name: true, isBase: true, parentScenarioId: true },
}, },
// Zähler für die Übersicht und das Plan-Dashboard -- ohne die Datensätze selbst zu laden.
_count: { select: { actuals: true, analyses: true } },
}, },
}); });
return NextResponse.json({ plans }); return NextResponse.json({ plans });
} }
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil trägt.
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) { if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const body = await request.json(); const body = await request.json();
const parsed = createPlanSchema.safeParse(body); const parsed = createPlanSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); // Lesbare Meldung statt des rohen zod-Objekts (sonst sieht der Nutzer einen JSON-Blob).
const first = parsed.error.issues[0];
return NextResponse.json(
{ error: first ? `${first.path.join(".") || "Eingabe"}: ${first.message}` : "Ungültige Eingabe." },
{ status: 400 }
);
} }
const error = validatePersonsForType(parsed.data); const error = validatePersonsForType(parsed.data);
if (error) return NextResponse.json({ error }, { status: 400 }); if (error) return NextResponse.json({ error }, { status: 400 });
// Haushaltsform, Personen und Startjahr am PLAN; das Pensionsalter je Szenario.
const plan = await prisma.plan.create({ const plan = await prisma.plan.create({
data: { data: {
userId, userId,
name: parsed.data.name, name: parsed.data.name,
householdType: parsed.data.householdType, householdType: parsed.data.householdType,
inflationRateDefault: parsed.data.inflationRateDefault, startYear: parsed.data.startYear ?? new Date().getFullYear(),
persons: { create: parsed.data.persons }, persons: {
create: parsed.data.persons.map((p) => ({ role: p.role, name: p.name ?? null, age: p.age })),
}, },
scenarios: {
create: {
name: "Basisszenario",
isBase: true,
inflationRateDefault: parsed.data.inflationRateDefault,
persons: {
create: parsed.data.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
},
},
},
},
include: { scenarios: true },
}); });
return NextResponse.json({ plan: { id: plan.id } }, { status: 201 }); // Das frisch angelegte Basisszenario startet mit Version 1.0.
await touchScenario(plan.scenarios[0].id, userId);
return NextResponse.json(
{ plan: { id: plan.id }, scenario: { id: plan.scenarios[0].id } },
{ status: 201 }
);
} }
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
const copySchema = z.object({ name: z.string().min(1).max(120) });
// Erstellt ein neues Szenario als vollständige Kopie eines bestehenden. Das Original wird
// zum Elternteil -- damit hängt der Baum in der Seitenleiste und der Diff hat seine Basis.
// Jede kopierte Phase/jedes kopierte Element trägt einen Herkunfts-Verweis auf sein
// Gegenstück im Original; darauf beruht die Abweichungs-Markierung im UI.
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const parsed = copySchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const source = await getOwnedScenario(scenarioId, userId);
if (!source) return NextResponse.json({ error: "Ursprungs-Szenario nicht gefunden." }, { status: 404 });
const newId = await prisma.$transaction(async (tx) => {
const created = await tx.scenario.create({
data: {
planId: source.planId,
name: parsed.data.name,
isBase: false,
parentScenarioId: source.id,
// Haushaltsform, Personen und Startjahr liegen am Plan und werden mitbenutzt, nicht
// kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter.
inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash,
persons: {
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
},
},
});
// Phasen kopieren (alte -> neue Id merken, für die Werte-Zuordnung).
const phaseIdMap = new Map<string, string>();
for (const phase of source.phases) {
const p = await tx.phase.create({
data: {
scenarioId: created.id,
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
cashTransition: phase.cashTransition ?? undefined,
sourcePhaseId: phase.id,
},
});
phaseIdMap.set(phase.id, p.id);
}
// Elemente inkl. Phasen- und Übergangswerten kopieren.
for (const el of source.elements) {
const newEl = await tx.financialElement.create({
data: {
scenarioId: created.id,
category: el.category,
name: el.name,
ownerRole: el.ownerRole,
orderIndex: el.orderIndex,
// Ohne das waere die Kopie eines Szenarios genau fuer den Zweck unbrauchbar, fuer
// den man sie am haeufigsten anlegt: ein anderes Pensionierungs-Szenario.
retirementDecision: el.retirementDecision ?? undefined,
baseData: el.baseData ?? undefined,
sourceElementId: el.id,
},
});
for (const pv of el.phaseValues) {
const phaseId = phaseIdMap.get(pv.phaseId);
if (!phaseId) continue;
await tx.elementPhaseValue.create({
data: { elementId: newEl.id, phaseId, data: pv.data as object },
});
}
for (const tv of el.transitionValues) {
const fromPhaseId = phaseIdMap.get(tv.fromPhaseId);
if (!fromPhaseId) continue;
await tx.elementTransitionValue.create({
data: { elementId: newEl.id, fromPhaseId, data: tv.data as object },
});
}
}
return created.id;
});
// Das kopierte Szenario startet mit einer eigenen Version 1.0.
await touchScenario(newId, userId);
return NextResponse.json({ scenarioId: newId }, { status: 201 });
}
@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOwnedPlan } from "@/lib/queries"; import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements"; import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
const createSchema = z.object({ const createSchema = z.object({
@@ -20,21 +21,21 @@ const createSchema = z.object({
ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).nullable().optional(), ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).nullable().optional(),
}); });
// Legt ein neues finanzielles Element (plan-weit) an. Personen-Pflicht je Kategorie. // Legt ein neues finanzielles Element (szenario-weit) an. Personen-Pflicht je Kategorie.
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ planId: string }> } { params }: { params: Promise<{ scenarioId: string }> }
) { ) {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params; const { scenarioId } = await params;
const plan = await getOwnedPlan(planId, userId); const scenario = await getOwnedScenario(scenarioId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const body = await request.json(); const body = await request.json();
const parsed = createSchema.safeParse(body); const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { category, name } = parsed.data; const { category, name } = parsed.data;
let ownerRole = parsed.data.ownerRole ?? null; let ownerRole = parsed.data.ownerRole ?? null;
@@ -51,13 +52,13 @@ export async function POST(
} }
const maxOrder = await prisma.financialElement.aggregate({ const maxOrder = await prisma.financialElement.aggregate({
where: { planId: plan.id }, where: { scenarioId: scenario.id },
_max: { orderIndex: true }, _max: { orderIndex: true },
}); });
const element = await prisma.financialElement.create({ const element = await prisma.financialElement.create({
data: { data: {
planId: plan.id, scenarioId: scenario.id,
category, category,
name, name,
ownerRole, ownerRole,
@@ -65,5 +66,6 @@ export async function POST(
}, },
}); });
await touchScenario(scenario.id, userId);
return NextResponse.json({ element: { id: element.id } }, { status: 201 }); return NextResponse.json({ element: { id: element.id } }, { status: 201 });
} }
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { toPlanInput, getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { planToCsv } from "@/lib/csv";
// Umlaute und ss ausschreiben, alles Uebrige auf Unterstrich -- ein Dateiname mit Umlauten
// kommt je nach Betriebssystem verstuemmelt an.
function safeFileName(name: string): string {
const map: Record<string, string> = { "ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss" };
const t = name.replace(/[äöüÄÖÜß]/g, (c) => map[c]);
return t.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "") || "szenario";
}
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ scenarioId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) {
return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
}
const planInput = toPlanInput(scenario);
const computed = computePlan(planInput);
const csv = planToCsv(planInput, computed);
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${safeFileName(scenario.name)}.csv"`,
},
});
}
@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOwnedPlan, toPlanInput } from "@/lib/queries"; import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { Prisma } from "@/generated/prisma/client"; import { Prisma } from "@/generated/prisma/client";
import { computePlan, maxPhaseDuration } from "@/lib/calculations"; import { computePlan, maxPhaseDuration } from "@/lib/calculations";
import { num, type PhaseData } from "@/lib/elements"; import { num, type PhaseData } from "@/lib/elements";
@@ -10,28 +11,27 @@ import { num, type PhaseData } from "@/lib/elements";
const createPhaseSchema = z.object({ const createPhaseSchema = z.object({
name: z.string().min(1).max(120).optional(), name: z.string().min(1).max(120).optional(),
durationYears: z.number().int().min(1).max(80).optional(), durationYears: z.number().int().min(1).max(80).optional(),
inflationRate: z.number().min(-20).max(50).nullable().optional(),
}); });
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste // Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans nächste
// Pensionsereignis gekappt. Fuer bestehende Elemente werden die editierbaren Felder // Pensionsereignis gekappt. Für bestehende Elemente werden die editierbaren Felder
// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben. // vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ planId: string }> } { params }: { params: Promise<{ scenarioId: string }> }
) { ) {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params; const { scenarioId } = await params;
const plan = await getOwnedPlan(planId, userId); const scenario = await getOwnedScenario(scenarioId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const parsed = createPhaseSchema.safeParse(body); const parsed = createPhaseSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const planInput = toPlanInput(plan); const planInput = toPlanInput(scenario);
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0); const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
const cap = maxPhaseDuration(planInput.persons, yearsBefore); const cap = maxPhaseDuration(planInput.persons, yearsBefore);
@@ -41,12 +41,12 @@ export async function POST(
const nextSequence = planInput.phases.length + 1; const nextSequence = planInput.phases.length + 1;
// Phasentyp der neuen Phase fuer den Default-Namen bestimmen. // Phasentyp der neuen Phase für den Default-Namen bestimmen.
const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge); const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge);
const defaultName = const defaultName =
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase"); parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortfuehren). // Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortführen).
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null; const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null;
const lastPhaseId = planInput.phases.at(-1)?.id; const lastPhaseId = planInput.phases.at(-1)?.id;
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null; const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null;
@@ -55,11 +55,10 @@ export async function POST(
const phase = await prisma.$transaction(async (tx) => { const phase = await prisma.$transaction(async (tx) => {
const created = await tx.phase.create({ const created = await tx.phase.create({
data: { data: {
planId: plan.id, scenarioId: scenario.id,
sequenceNumber: nextSequence, sequenceNumber: nextSequence,
name: defaultName, name: defaultName,
durationYears: duration, durationYears: duration,
inflationRate: parsed.data.inflationRate ?? undefined,
}, },
}); });
@@ -76,29 +75,32 @@ export async function POST(
return created; return created;
}); });
await touchScenario(scenario.id, userId);
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 }); return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
} }
// Vorbelegung fuer eine neue Phase: nur die editierbaren Felder werden uebernommen. Start- // Vorbelegung für eine neue Phase.
// bzw. Restwerte (PK/3a/Vermoegen/Hypothek/Schuld) werden in der Berechnung live aus der //
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert. // Seit Roadmap Nr. 44 (Punkt A) werden die WIEDERKEHR-Parameter (Raten, Beiträge, Amortisation,
// Wertsteigerung, Zinssatz) NICHT mehr als Kopie gespeichert, sondern bewusst leer gelassen:
// Die Berechnung übernimmt sie live aus der Vorphase, und im UI steht dafür sichtbar
// «Aus Vorphase übernehmen» -- angehakt. Der Unterschied zeigt sich, sobald man die Vorphase
// später ändert: Eine Kopie bliebe stehen, die Vererbung zieht mit.
//
// Start- bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden ohnehin live fortgeschrieben.
function buildCarryData(category: string, prev: PhaseData): PhaseData { function buildCarryData(category: string, prev: PhaseData): PhaseData {
switch (category) { switch (category) {
case "INCOME":
case "EXPENSE":
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird uebernommen.
return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {};
case "AHV": case "AHV":
// Ausfalljahre sind KEIN Wiederkehr-Parameter: Sie gelten für genau diese Phase, und
// ein geerbter Wert würde eine Lücke erfinden, die es nicht gibt.
return { gapYears: 0 }; return { gapYears: 0 };
case "PENSION_FUND":
case "PILLAR_3A":
case "OTHER_ASSET":
return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
case "REAL_ESTATE": case "REAL_ESTATE":
// purchasePrice + amortization bleiben; die Resthypothek wird live fortgeschrieben. // Der Kaufpreis ist eine Tatsache, keine Annahme (Basis der Grundstückgewinnsteuer);
return { purchasePrice: num(prev.purchasePrice), amortization: num(prev.amortization) }; // die Zins-Behandlung ist ein Schalter ohne Zahlenwert -- beide werden weiterhin kopiert.
case "OTHER_DEBT": return {
return { annualRepayment: num(prev.annualRepayment) }; purchasePrice: num(prev.purchasePrice),
interestHandling: prev.interestHandling ?? "INCLUDED",
};
default: default:
return {}; return {};
} }
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { Prisma } from "@/generated/prisma/client";
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
// Pensionsalter anpassen (Roadmap Nr. 44).
//
// Das ist bewusst KEIN einfaches PATCH auf `retirementAge`: Eine Pensionierung liegt immer
// auf einer Phasengrenze, also verschiebt jede Änderung zwei Phasendauern gleichzeitig --
// und kann eine Phase ganz entfallen lassen. Die Entscheidung darüber trifft `lib/retirement`
// (rein und getestet), hier wird sie nur noch geschrieben.
const bodySchema = z.object({
role: z.enum(["PERSON_A", "PERSON_B"]),
delta: z.number().int().min(-80).max(80),
// Muss der Aufrufer ausdrücklich setzen, wenn dabei eine Lebensphase wegfällt.
confirmMerge: z.boolean().optional(),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { role, delta, confirmMerge } = parsed.data;
const planInput = toPlanInput(scenario);
const boundary = retirementBoundaries(planInput).find((b) => b.role === role);
if (!boundary) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 });
if (boundary.blocked) return NextResponse.json({ error: boundary.blocked }, { status: 400 });
if (delta < (boundary.mergeDeltaDown ?? boundary.minDelta) || delta > (boundary.mergeDeltaUp ?? boundary.maxDelta)) {
return NextResponse.json(
{ error: "So weit lässt sich das Pensionsalter hier nicht verschieben. Passe zuerst die Lebensphasen an." },
{ status: 400 }
);
}
const result = shiftRetirement(planInput, role, delta);
if (!result) return NextResponse.json({ error: "Die Verschiebung ist hier nicht möglich." }, { status: 400 });
// Eine Zusammenlegung verändert den Plan inhaltlich (zwei Übergänge werden einer) und muss
// deshalb vom Aufrufer bestätigt sein. 409 = "verstanden, aber so nicht ohne Zustimmung".
if (result.removedPhaseId && !confirmMerge) {
const removed = planInput.phases.find((p) => p.id === result.removedPhaseId);
return NextResponse.json(
{
error: "Bestätigung erforderlich.",
needsMergeConfirmation: true,
removedPhaseId: result.removedPhaseId,
removedPhaseName: removed?.name ?? "",
mergedIntoPhaseId: result.mergedIntoPhaseId,
},
{ status: 409 }
);
}
await prisma.$transaction(async (tx) => {
await tx.person.updateMany({
where: { scenarioId: scenario.id, role },
data: { retirementAge: boundary.retirementAge + delta },
});
if (result.removedPhaseId) {
// Die Übergangsdaten der entfallenden Phase wurden in den Vorgänger gezogen; hier wird
// nur noch der zusammengeführte Stand geschrieben, bevor die Phase verschwindet.
if (result.mergedIntoPhaseId) {
const target = result.plan.phases.find((p) => p.id === result.mergedIntoPhaseId);
if (target) {
await tx.phase.update({
where: { id: target.id },
data: { cashTransition: (target.cashTransition ?? {}) as Prisma.InputJsonValue },
});
}
for (const e of result.plan.elements) {
const td = e.transitionValues?.[result.mergedIntoPhaseId];
if (!td) continue;
await tx.elementTransitionValue.upsert({
where: { elementId_fromPhaseId: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId } },
create: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId, data: td as Prisma.InputJsonValue },
update: { data: td as Prisma.InputJsonValue },
});
}
}
// Löscht die Phase samt ihrer Phasen- und Übergangswerte (Kaskade im Schema).
await tx.phase.delete({ where: { id: result.removedPhaseId } });
}
for (const p of result.plan.phases) {
await tx.phase.update({
where: { id: p.id },
data: { durationYears: p.durationYears, sequenceNumber: p.sequenceNumber },
});
}
});
await touchScenario(scenario.id, userId);
return NextResponse.json({
ok: true,
removedPhaseId: result.removedPhaseId,
retirementAge: boundary.retirementAge + delta,
});
}
+155
View File
@@ -0,0 +1,155 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { toPlanInput, getOwnedScenario, getOwnedScenarioWithMeta } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { computePlan } from "@/lib/calculations";
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
// Liefert das Szenario samt Berechnung -- und zusätzlich das ELTERN-Szenario als
// Vergleichsbasis, damit das UI die Abweichungen markieren kann (Basisszenario: null).
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenarioWithMeta(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const planInput = toPlanInput(scenario);
const computed = computePlan(planInput);
let base: ReturnType<typeof toPlanInput> | null = null;
if (scenario.parentScenarioId) {
const parent = await getOwnedScenario(scenario.parentScenarioId, userId);
if (parent) base = toPlanInput(parent);
}
// Ist-Werte des Plans plus die Element-Herkunft ALLER Szenarien: Nur damit lässt sich die
// auf Wurzel-IDs erfasste Realität auf dieses Szenario abbilden (siehe lib/actuals.ts).
// Die Ist-Rechnung selbst passiert im Browser -- computePlan ist rein.
const [actualsRows, siblings] = await Promise.all([
prisma.actualsSet.findMany({
where: { planId: scenario.planId },
orderBy: [{ year: "asc" }, { recordedOn: "asc" }],
}),
prisma.scenario.findMany({
where: { planId: scenario.planId },
select: { elements: { select: { id: true, sourceElementId: true } } },
}),
]);
return NextResponse.json({
plan: planInput,
computed,
base,
actuals: actualsRows.map((r) => ({
id: r.id,
recordedOn: r.recordedOn.toISOString().slice(0, 10),
year: r.year,
comment: r.comment,
cash: r.cash,
values: r.values,
})),
elementOrigins: siblings.flatMap((s) => s.elements),
meta: {
id: scenario.id,
planId: scenario.planId,
planName: scenario.plan.name,
name: scenario.name,
isBase: scenario.isBase,
parentScenarioId: scenario.parentScenarioId,
},
});
}
// Name/Cash-Anfangswert ODER das ganze Grundprofil (Haushaltsform/Personen/Inflation).
const patchSchema = z.union([
scenarioProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
z.object({
name: z.string().min(1).max(120).optional(),
initialCash: z.number().min(0).max(1_000_000_000).optional(),
}),
]);
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = patchSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const data = parsed.data;
if ("householdType" in data) {
const error = validatePersonsForType(data);
if (error) return NextResponse.json({ error }, { status: 400 });
// Haushaltsform, Personen (Name/Alter) und Startjahr gehen an den PLAN -- sie gelten
// fuer alle Szenarien. Am Szenario bleiben Inflation und die Pensionsalter.
const updated = await prisma.$transaction(async (tx) => {
await tx.plan.update({
where: { id: scenario.planId },
data: {
householdType: data.householdType,
startYear: data.startYear ?? undefined,
},
});
await tx.planPerson.deleteMany({ where: { planId: scenario.planId } });
await tx.planPerson.createMany({
data: data.persons.map((p) => ({
planId: scenario.planId,
role: p.role,
name: p.name ?? null,
age: p.age,
})),
});
await tx.person.deleteMany({ where: { scenarioId: scenario.id } });
return tx.scenario.update({
where: { id: scenario.id },
data: {
name: data.name ?? undefined,
inflationRateDefault: data.inflationRateDefault,
persons: {
create: data.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
},
},
});
});
await touchScenario(scenario.id, userId);
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
}
const updated = await prisma.scenario.update({
where: { id: scenario.id },
data: {
name: "name" in data ? data.name : undefined,
initialCash:
"initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
},
});
await touchScenario(scenario.id, userId);
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
}
// Löscht ein Szenario. Das Basisszenario kann nicht gelöscht werden (dafür den Plan löschen).
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
if (scenario.isBase) {
return NextResponse.json(
{ error: "Das Basisszenario kann nicht gelöscht werden. Löschen Sie stattdessen den Plan." },
{ status: 400 }
);
}
await prisma.scenario.delete({ where: { id: scenario.id } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { restoreImpact, restoreVersion } from "@/lib/versioning-db";
import type { PlanInput } from "@/lib/types";
async function ownedVersion(scenarioId: string, versionId: string, userId: string) {
return prisma.scenarioVersion.findFirst({
where: { id: versionId, scenarioId, scenario: { plan: { userId } } },
});
}
// Ein einzelner Stand: der Snapshot selbst, das daraus Gerechnete und -- als Vorwarnung für
// den Wiederherstellen-Knopf -- welche Kind-Szenarien dabei ihre Diff-Basis verlieren würden.
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId, versionId } = await params;
const version = await ownedVersion(scenarioId, versionId, userId);
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
const snapshot = version.snapshot as unknown as PlanInput;
return NextResponse.json({
version: {
id: version.id,
major: version.major,
minor: version.minor,
comment: version.comment,
isMajor: version.isMajor,
createdAt: version.createdAt,
},
plan: snapshot,
computed: computePlan(snapshot),
impact: await restoreImpact(scenarioId, snapshot),
});
}
// Setzt das Szenario auf diesen Stand zurück. Löscht KEINE Historie: Der wiederhergestellte
// Stand wird selbst als neue Version mit Herkunftsvermerk festgehalten.
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId, versionId } = await params;
const version = await ownedVersion(scenarioId, versionId, userId);
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
const ref = await restoreVersion(scenarioId, versionId, userId);
if (!ref) return NextResponse.json({ error: "Wiederherstellen fehlgeschlagen." }, { status: 500 });
return NextResponse.json({ version: ref });
}
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { createMajorVersion } from "@/lib/versioning-db";
import { isValidMajorComment } from "@/lib/versioning";
async function ownedScenario(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
}
// Änderungshistorie eines Szenarios, neueste zuerst. Ohne die Snapshots -- die sind je
// Version zig Kilobyte gross und werden erst beim Anzeigen einzeln geladen.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await ownedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const rows = await prisma.scenarioVersion.findMany({
where: { scenarioId },
orderBy: [{ major: "desc" }, { minor: "desc" }],
select: {
id: true,
major: true,
minor: true,
comment: true,
isMajor: true,
createdAt: true,
updatedAt: true,
createdBy: { select: { username: true } },
},
});
return NextResponse.json({
currentMajor: scenario.currentMajor,
versions: rows.map((r) => ({
id: r.id,
major: r.major,
minor: r.minor,
comment: r.comment,
isMajor: r.isMajor,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
author: r.createdBy.username,
})),
});
}
const majorSchema = z.object({ comment: z.string().min(3).max(500) });
// Legt den aktuellen Stand als HAUPTVERSION fest. Der Kommentar ist Pflicht -- eine
// Hauptversion ohne Begründung wäre nur eine Zahl.
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await ownedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = majorSchema.safeParse(await request.json());
if (!parsed.success || !isValidMajorComment(parsed.data.comment)) {
return NextResponse.json({ error: "Bitte einen Kommentar zur Hauptversion angeben." }, { status: 400 });
}
const ref = await createMajorVersion(scenarioId, userId, parsed.data.comment);
if (!ref) return NextResponse.json({ error: "Hauptversion konnte nicht angelegt werden." }, { status: 500 });
return NextResponse.json({ version: ref }, { status: 201 });
}
+20
View File
@@ -0,0 +1,20 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import { getCurrentUserId } from "@/lib/session";
// Liefert die Spezifikation (SPEZIFIKATION.md aus dem Projekt-Root) als Rohtext an die App.
// Die Datei liegt im Docker-Image neben package.json (siehe Dockerfile).
export async function GET() {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
try {
const markdown = await readFile(path.join(process.cwd(), "SPEZIFIKATION.md"), "utf8");
return NextResponse.json({ markdown });
} catch {
return NextResponse.json({ error: "Spezifikation nicht gefunden." }, { status: 404 });
}
}
+226 -3
View File
@@ -1,7 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
/* ------------------------------------------------------------------------- /* -------------------------------------------------------------------------
Semantische Farb-Tokens. Drei umschaltbare Schemata ueber data-theme am Semantische Farb-Tokens. Drei umschaltbare Schemata über data-theme am
<html>: "light", "dark", "warm". Ohne explizite Wahl folgt das Standard- <html>: "light", "dark", "warm". Ohne explizite Wahl folgt das Standard-
:root der OS-Einstellung (prefers-color-scheme). Umschaltung: lib/theme.ts. :root der OS-Einstellung (prefers-color-scheme). Umschaltung: lib/theme.ts.
------------------------------------------------------------------------- */ ------------------------------------------------------------------------- */
@@ -26,7 +26,28 @@
--danger-soft: #fef2f2; --danger-soft: #fef2f2;
--success: #059669; --success: #059669;
--person-a: #4f46e5; --person-a: #4f46e5;
--diff: #b45309;
--diff-soft: #fef3c7;
--diff-added: #15803d;
--diff-added-soft: #dcfce7;
--diff-removed: #71717a;
--diff-removed-soft: #f4f4f5;
--person-b: #0ea5e9; --person-b: #0ea5e9;
/* Attention: "hier fehlt eine Eingabe" -- bewusst getrennt vom Akzent, damit
"offen" und "klickbar" nicht dieselbe Farbe tragen. */
--attention: #d97706;
--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"] { :root[data-theme="dark"] {
@@ -48,10 +69,27 @@
--danger-soft: rgba(220, 38, 38, 0.16); --danger-soft: rgba(220, 38, 38, 0.16);
--success: #34d399; --success: #34d399;
--person-a: #818cf8; --person-a: #818cf8;
--diff: #fbbf24;
--diff-soft: rgba(251, 191, 36, 0.16);
--diff-added: #4ade80;
--diff-added-soft: rgba(74, 222, 128, 0.14);
--diff-removed: #a1a1aa;
--diff-removed-soft: rgba(161, 161, 170, 0.12);
--person-b: #38bdf8; --person-b: #38bdf8;
--attention: #f59e0b;
--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-Sekundaerton. */ /* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundärton. */
:root[data-theme="warm"] { :root[data-theme="warm"] {
--bg: #fbf7f2; --bg: #fbf7f2;
--surface: #fffdfa; --surface: #fffdfa;
@@ -71,7 +109,24 @@
--danger-soft: #fbeae7; --danger-soft: #fbeae7;
--success: #2e9e7b; --success: #2e9e7b;
--person-a: #e8663c; --person-a: #e8663c;
--diff: #b45309;
--diff-soft: #fbeccb;
--diff-added: #2e7d52;
--diff-added-soft: #dcf0e2;
--diff-removed: #8a7f72;
--diff-removed-soft: #f0e8dd;
--person-b: #f2a93b; --person-b: #f2a93b;
--attention: #c77414;
--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. */ /* Ohne gespeicherte Wahl der Dunkel-OS-Einstellung folgen. */
@@ -95,7 +150,24 @@
--danger-soft: rgba(220, 38, 38, 0.16); --danger-soft: rgba(220, 38, 38, 0.16);
--success: #34d399; --success: #34d399;
--person-a: #818cf8; --person-a: #818cf8;
--diff: #fbbf24;
--diff-soft: rgba(251, 191, 36, 0.16);
--diff-added: #4ade80;
--diff-added-soft: rgba(74, 222, 128, 0.14);
--diff-removed: #a1a1aa;
--diff-removed-soft: rgba(161, 161, 170, 0.12);
--person-b: #38bdf8; --person-b: #38bdf8;
--attention: #f59e0b;
--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;
} }
} }
@@ -117,6 +189,16 @@
--color-danger: var(--danger); --color-danger: var(--danger);
--color-danger-soft: var(--danger-soft); --color-danger-soft: var(--danger-soft);
--color-success: var(--success); --color-success: var(--success);
--color-diff: var(--diff);
--color-diff-soft: var(--diff-soft);
--color-diff-added: var(--diff-added);
--color-diff-added-soft: var(--diff-added-soft);
--color-diff-removed: var(--diff-removed);
--color-diff-removed-soft: var(--diff-removed-soft);
--color-attention: var(--attention);
--color-attention-fg: var(--attention-fg);
--color-attention-soft: var(--attention-soft);
--color-attention-soft-fg: var(--attention-soft-fg);
--color-person-a: var(--person-a); --color-person-a: var(--person-a);
--color-person-b: var(--person-b); --color-person-b: var(--person-b);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
@@ -126,5 +208,146 @@
body { body {
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
} }
/* -------------------------------------------------------------------------
Micro-Interactions: kurze, dezente Übergänge für Dialoge, Panels, Toasts.
150-220 ms -- schnell genug, um nie zu bremsen (Doherty-Schwelle).
------------------------------------------------------------------------- */
@keyframes ui-fade-kf {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes ui-pop-kf {
from { opacity: 0; transform: translateY(6px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes ui-slide-in-kf {
from { opacity: 0.4; transform: translateX(24px); }
to { opacity: 1; transform: translateX(0); }
}
.ui-fade { animation: ui-fade-kf 150ms ease-out; }
.ui-pop { animation: ui-pop-kf 180ms cubic-bezier(0.2, 0.9, 0.3, 1); }
.ui-slide-in { animation: ui-slide-in-kf 200ms cubic-bezier(0.2, 0.9, 0.3, 1); }
@media (prefers-reduced-motion: reduce) {
.ui-fade, .ui-pop, .ui-slide-in { animation: none; }
}
/* Hervorhebung des aktuellen Tour-Ziels. */
/* Tour-Spotlight: Der abgedunkelte Bereich entsteht aus vier fixed-Flächen UM das Ziel herum
(in Tour.tsx aus der Bounding-Box berechnet) -- das funktioniert unabhängig von z-index und
overflow. Hier nur der pulsierende Rahmen, der als eigene fixed-Fläche über dem Loch liegt. */
@keyframes tour-ring-kf {
0%, 100% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 85%, transparent), 0 0 14px 2px color-mix(in srgb, var(--accent) 40%, transparent);
}
50% {
box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 50%, transparent), 0 0 22px 6px color-mix(in srgb, var(--accent) 25%, transparent);
}
}
.tour-ring {
border: 2px solid var(--accent);
animation: tour-ring-kf 1.4s ease-in-out infinite;
}
/* -------------------------------------------------------------------------
Dokument-Darstellung für gerenderten Markdown (Spezifikation, /spezifikation).
Bewusst über die semantischen Tokens, damit alle drei Farbschemata greifen.
------------------------------------------------------------------------- */
.md-doc {
color: var(--fg);
line-height: 1.65;
font-size: 0.9rem;
max-width: 62rem;
overflow-wrap: break-word;
}
.md-doc > *:first-child { margin-top: 0; }
.md-doc > *:last-child { margin-bottom: 0; }
.md-doc h1,
.md-doc h2,
.md-doc h3,
.md-doc h4 {
color: var(--fg);
font-weight: 600;
line-height: 1.3;
scroll-margin-top: 1rem;
}
.md-doc h1 { font-size: 1.6rem; margin: 2.4rem 0 1rem; }
.md-doc h2 {
font-size: 1.25rem;
margin: 2.2rem 0 0.85rem;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--border);
}
.md-doc h3 { font-size: 1.05rem; margin: 1.7rem 0 0.6rem; }
.md-doc h4 { font-size: 0.95rem; margin: 1.3rem 0 0.5rem; color: var(--muted); }
.md-doc p { margin: 0.75rem 0; }
.md-doc strong { font-weight: 600; color: var(--fg); }
.md-doc em { font-style: italic; }
.md-doc a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
.md-doc a:hover { color: var(--accent-hover); }
.md-doc ul,
.md-doc ol { margin: 0.75rem 0; padding-left: 1.4rem; }
.md-doc ul { list-style: disc; }
.md-doc ol { list-style: decimal; }
.md-doc li { margin: 0.3rem 0; }
.md-doc li::marker { color: var(--faint); }
.md-doc code {
font-family: var(--font-mono), monospace;
font-size: 0.84em;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 0.25rem;
padding: 0.1em 0.35em;
}
.md-doc pre {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 0.6rem;
padding: 0.9rem 1rem;
margin: 1rem 0;
overflow-x: auto;
line-height: 1.5;
}
.md-doc pre code {
background: none;
border: none;
padding: 0;
font-size: 0.82rem;
white-space: pre;
}
/* Breite Tabellen scrollen in ihrem eigenen Container, die Seite bleibt ruhig. */
.md-doc .md-table-wrap { overflow-x: auto; margin: 1rem 0; }
.md-doc table { border-collapse: collapse; width: 100%; font-size: 0.82rem; }
.md-doc th,
.md-doc td {
border: 1px solid var(--border);
padding: 0.4rem 0.6rem;
text-align: left;
vertical-align: top;
}
.md-doc th { background: var(--surface-2); font-weight: 600; white-space: nowrap; }
.md-doc tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface-2) 45%, transparent); }
.md-doc blockquote {
margin: 1rem 0;
padding: 0.6rem 1rem;
border-left: 3px solid var(--accent);
background: var(--accent-soft);
color: var(--accent-soft-fg);
border-radius: 0 0.4rem 0.4rem 0;
}
.md-doc blockquote p { margin: 0.3rem 0; }
.md-doc hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; }
+1 -1
View File
@@ -14,7 +14,7 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "FPT — Financial Planning Tool", title: "FPT — Financial Planning Tool",
description: "Persoenliche Finanzplanung ueber Lebensabschnittsphasen (AICDS)", description: "Persönliche Finanzplanung über Lebensabschnittsphasen (AICDS)",
}; };
// Setzt data-theme aus localStorage noch vor dem ersten Paint (verhindert FOUC). // Setzt data-theme aus localStorage noch vor dem ersten Paint (verhindert FOUC).
+3 -3
View File
@@ -21,7 +21,7 @@ function LoginForm() {
setError(null); setError(null);
if (mode === "register" && password !== passwordConfirm) { if (mode === "register" && password !== passwordConfirm) {
setError("Die Passwoerter stimmen nicht ueberein."); setError("Die Passwörter stimmen nicht überein.");
return; return;
} }
@@ -59,7 +59,7 @@ function LoginForm() {
Financial Planning Tool Financial Planning Tool
</h1> </h1>
<p className="text-xs text-muted"> <p className="text-xs text-muted">
Persoenliche Finanzplanung ueber Lebensphasen Persönliche Finanzplanung über Lebensphasen
</p> </p>
</div> </div>
@@ -116,7 +116,7 @@ function LoginForm() {
<input <input
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
placeholder="Passwort bestaetigen" placeholder="Passwort bestätigen"
value={passwordConfirm} value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)} onChange={(e) => setPasswordConfirm(e.target.value)}
className={inputClass} className={inputClass}
+17 -2
View File
@@ -12,15 +12,30 @@ export default function Home() {
.get<{ user: { username: string } }>("/api/auth/me") .get<{ user: { username: string } }>("/api/auth/me")
.then((data) => setUsername(data.user.username)) .then((data) => setUsername(data.user.username))
.catch(() => { .catch(() => {
// Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um. // Session abgelaufen/ungültig -- Middleware leitet beim nächsten Request um.
window.location.href = "/login"; window.location.href = "/login";
}); });
}, []); }, []);
// Nach dem Abmelden führt der Zurück-Knopf zu dieser Seite aus dem Browser-Cache (bfcache)
// zurück -- OHNE neue Anfrage, also ohne dass Middleware oder Session-Prüfung greifen. Die
// eingefrorene Ansicht darf nicht stehen bleiben: Bei einer aus dem bfcache zurückgeholten
// Seite die Session erneut prüfen und bei fehlender Anmeldung sofort auf /login gehen.
useEffect(() => {
function onPageShow(e: PageTransitionEvent) {
if (!e.persisted) return;
api.get("/api/auth/me").catch(() => {
window.location.href = "/login";
});
}
window.addEventListener("pageshow", onPageShow);
return () => window.removeEventListener("pageshow", onPageShow);
}, []);
if (username === null) { if (username === null) {
return ( return (
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<p className="text-sm text-muted">Laedt</p> <p className="text-sm text-muted">Lädt</p>
</div> </div>
); );
} }
+580
View File
@@ -0,0 +1,580 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, CalendarClock, Pencil, Plus, Trash2, X } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client";
import { computePlan } from "@/lib/calculations";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements";
import { formatChf } from "@/lib/format";
import { actualKindOf, resolveActuals, toPlanYear, type ActualsSetInput } from "@/lib/actuals";
import { resolveRootElementId } from "@/lib/montecarlo";
import type { ElementCategory } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
interface StoredSet extends ActualsSetInput {
author: string;
}
// Ein Eingabefeld im Wizard -- je Wurzel-Element eines oder (bei Immobilien) zwei.
interface Row {
rootId: string;
name: string;
category: ElementCategory;
scenarioNames: string[];
planValue: number; // Vorbelegung aus dem Basisszenario für das gewählte Jahr
planMortgage?: number;
kind: ReturnType<typeof actualKindOf>;
}
const dt = (iso: string) =>
new Date(`${iso}T00:00:00Z`).toLocaleDateString("de-CH", { day: "2-digit", month: "long", year: "numeric" });
interface LoadedScenario {
id: string;
name: string;
isBase: boolean;
plan: PlanInput;
}
export function ActualsDialog({
planId,
planName,
scenarioMetas,
initial,
embedded = false,
onClose,
onChanged,
}: {
planId: string;
planName: string;
// Alle Szenarien dieses Plans (nur Kopfdaten) -- die Pläne werden hier nachgeladen.
scenarioMetas: { id: string; name: string; isBase: boolean }[];
// Das bereits geöffnete Szenario, damit der Dialog sofort etwas anzeigen kann.
initial: LoadedScenario;
// Eingebettet in den Hauptbereich (Plan-Ansicht) statt als Pop-up -- ohne Overlay,
// ohne Backdrop, ohne Schliessen-Knopf.
embedded?: boolean;
onClose: () => void;
onChanged: () => void;
}) {
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({ [initial.id]: initial }));
// Die übrigen Szenarien nachladen: Ein Ist-Satz gilt für ALLE, also müssen auch Elemente
// erscheinen, die es nur in einem Nebenszenario gibt.
useEffect(() => {
const missing = scenarioMetas.filter((m) => m.id !== initial.id);
if (missing.length === 0) return;
let cancelled = false;
(async () => {
try {
const entries = await Promise.all(
missing.map(async (m) => {
const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${m.id}`);
return [m.id, { id: m.id, name: m.name, isBase: m.isBase, plan: data.plan }] as const;
})
);
if (!cancelled) setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) }));
} catch {
// Fehlende Nebenszenarien sind verschmerzbar -- der Wizard zeigt dann weniger Zeilen.
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scenarioMetas.map((m) => m.id).join(","), initial.id]);
const scenarios = useMemo(
() => scenarioMetas.map((m) => loaded[m.id]).filter((s): s is LoadedScenario => !!s),
[scenarioMetas, loaded]
);
const [sets, setSets] = useState<StoredSet[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<"list" | "wizard">("list");
const [step, setStep] = useState<1 | 2>(1);
const [busy, setBusy] = useState(false);
const confirm = useConfirm();
const toast = useToast();
// Schritt 1
const [recordedOn, setRecordedOn] = useState(() => new Date().toISOString().slice(0, 10));
const [comment, setComment] = useState("");
// Schritt 2
const [values, setValues] = useState<Record<string, { value?: number; mortgage?: number }>>({});
const [cash, setCash] = useState<number>(0);
// null = neuer Datensatz; sonst die id des gerade bearbeiteten (dann PUT statt POST).
const [editingId, setEditingId] = useState<string | null>(null);
const base = scenarios.find((s) => s.isBase) ?? scenarios[0];
const year = Number(recordedOn.slice(0, 4));
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`);
if (!cancelled) setSets(data.sets);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [planId]);
// Herkunft aller Elemente über alle Szenarien -- Grundlage der Wurzel-Auflösung.
const origins = useMemo(
() => scenarios.flatMap((s) => s.plan.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId ?? null }))),
[scenarios]
);
// Alle Elemente ALLER Szenarien, zusammengefasst auf ihre Wurzel. Vorbelegt mit dem
// berechneten Stand des Basisszenarios im gewählten Jahr; fehlt das Element dort, wird es
// aus dem erstbesten Szenario geholt, das es kennt.
const rows: Row[] = useMemo(() => {
const sourceById = new Map(origins.map((o) => [o.id, o.sourceElementId]));
const byRoot = new Map<string, Row>();
const ordered = [base, ...scenarios.filter((s) => s.id !== base?.id)].filter(Boolean);
for (const sc of ordered) {
const planYear = toPlanYear(year, sc.plan.startYear);
const computed = computePlan(sc.plan);
for (const el of sc.plan.elements) {
const kind = actualKindOf(el.category);
const root = resolveRootElementId(el.id, sourceById);
// Jahresstand aus dem Verlauf: genau der Wert, den der Plan für dieses Jahr vorsieht.
const yearly = computed.phases
.flatMap((p) => p.elements.filter((e) => e.elementId === el.id).flatMap((e) => e.yearly))
.find((y) => y.year === planYear);
// Die AHV ist nur erfassbar, wenn die Rente zum Stichtag bereits läuft -- vorher gibt
// es keinen Stand, den man ablesen könnte.
if (el.category === "AHV" && !(yearly && yearly.value > 0)) continue;
const existing = byRoot.get(root);
if (existing) {
if (!existing.scenarioNames.includes(sc.name)) existing.scenarioNames.push(sc.name);
continue;
}
byRoot.set(root, {
rootId: root,
name: el.name,
category: el.category,
scenarioNames: [sc.name],
// Bei einer Immobilie ist `yearly.value` das EIGENKAPITAL (Verkehrswert Hypothek).
// Erfasst und gerechnet wird aber der VERKEHRSWERT -- er steht in `propertyValue`.
// Wurde hier bis 0.31 das Eigenkapital vorbelegt, setzte der Rechenkern es als
// Verkehrswert ein, während die Hypothek stehen blieb: Das Eigenkapital brach im
// Ist-Jahr schlagartig ein (typischerweise ins Negative).
planValue:
kind === "PROPERTY"
? Math.round(yearly?.propertyValue ?? 0)
: Math.round(Math.abs(yearly?.value ?? 0)),
planMortgage: kind === "PROPERTY" ? Math.round(yearly?.mortgage ?? 0) : undefined,
kind,
});
}
}
return [...byRoot.values()].sort((a, b) => {
const ca = CATEGORY_ORDER.indexOf(a.category);
const cb = CATEGORY_ORDER.indexOf(b.category);
return ca !== cb ? ca - cb : a.name.localeCompare(b.name, "de-CH");
});
}, [scenarios, base, origins, year]);
// Der geplante Cash-Bestand im gewählten Jahr -- Vorbelegung für das Cash-Feld.
const planCash = useMemo(() => {
if (!base) return 0;
const planYear = toPlanYear(year, base.plan.startYear);
if (planYear === null) return 0;
// Der Stand am Ende GENAU DIESES Jahres -- nicht am Ende der Phase. In einer Phase über
// zehn Jahre, in der sich das Cash-Konto füllt, sind das zwei völlig verschiedene Zahlen.
const computed = computePlan(base.plan);
return computed.yearly.find((y) => y.year === planYear)?.cash ?? 0;
}, [base, year]);
function startWizard() {
setEditingId(null);
setValues({});
setCash(planCash);
setComment("");
setRecordedOn(new Date().toISOString().slice(0, 10));
setStep(1);
setMode("wizard");
}
// Bestehenden Datensatz zum Bearbeiten oeffnen. Die gespeicherten Werte sind bereits auf
// WURZEL-Element-IDs erfasst -- genau die Form, mit der der Wizard arbeitet.
function startEdit(set: StoredSet) {
setEditingId(set.id);
setRecordedOn(set.recordedOn);
setComment(set.comment ?? "");
setValues({ ...set.values });
setCash(typeof set.cash === "number" ? set.cash : 0);
setStep(1);
setMode("wizard");
}
// Beim Wechsel auf Schritt 2 mit den Planwerten vorbelegen -- der Nutzer überschreibt nur,
// was tatsächlich abweicht.
function goToStep2() {
// Beim Bearbeiten stehen die erfassten Werte bereits -- sie duerfen nicht durch die
// Planwerte ersetzt werden. Nur Zeilen, fuer die nichts erfasst ist (z. B. ein spaeter
// hinzugekommenes Element), werden ergaenzt.
const prefill: Record<string, { value?: number; mortgage?: number }> = {};
for (const r of rows) {
if (editingId && values[r.rootId]) {
prefill[r.rootId] = values[r.rootId];
continue;
}
prefill[r.rootId] =
r.kind === "PROPERTY" ? { value: r.planValue, mortgage: r.planMortgage ?? 0 } : { value: r.planValue };
}
setValues(prefill);
if (!editingId) setCash(planCash);
setStep(2);
}
async function reload() {
const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`);
setSets(data.sets);
}
async function save() {
setBusy(true);
try {
const payload = { recordedOn, comment: comment.trim() || undefined, cash, values };
if (editingId) {
await api.put(`/api/plans/${planId}/actuals/${editingId}`, payload);
} else {
await api.post(`/api/plans/${planId}/actuals`, payload);
}
toast("success", `Effektive Werte für ${dt(recordedOn)} ${editingId ? "aktualisiert" : "erfasst"}.`);
await reload();
setEditingId(null);
setMode("list");
onChanged();
} catch (e) {
toast("error", e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setBusy(false);
}
}
async function remove(set: StoredSet) {
const ok = await confirm({
title: "Datensatz löschen?",
message: `Die effektiven Werte vom ${dt(set.recordedOn)} werden entfernt. Der Plan selbst bleibt unverändert.`,
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.delete(`/api/plans/${planId}/actuals/${set.id}`);
await reload();
onChanged();
toast("success", "Datensatz gelöscht.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
// Wie viele Elemente weichen ab? Kleine Orientierungshilfe in der Liste.
const deviationCount = (set: ActualsSetInput) => {
const resolved = resolveActuals([set], base?.plan ?? scenarios[0].plan, origins);
return resolved.length === 0 ? 0 : Object.keys(resolved[0].byElementId).length;
};
const setRow = (rootId: string, patch: { value?: number; mortgage?: number }) =>
setValues((prev) => ({ ...prev, [rootId]: { ...prev[rootId], ...patch } }));
const body = (
<>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<CalendarClock className="h-5 w-5 text-accent" /> Effektive Werte
</h2>
<p className="mt-0.5 text-xs text-muted">
Plan «{planName}». Was tatsächlich eingetreten ist der Plan selbst bleibt unverändert.
</p>
</div>
{!embedded && (
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
)}
</div>
{mode === "list" && (
<>
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Ein Datensatz hält fest, wie es an einem Stichtag <strong className="text-fg">wirklich</strong> aussah.
Die Berechnung läuft dann ein zweites Mal: gleiche Mechanik, aber ab jedem erfassten Jahr mit den
echten Zahlen. Werte, die du weglässt, laufen unverändert auf ihrer Planlinie weiter.
Ein Datensatz gilt für <strong className="text-fg">alle Szenarien</strong> dieses Plans die
Wirklichkeit ist dieselbe, egal wogegen man sie hält.
</div>
{error && <p className="text-xs text-danger">{error}</p>}
{!sets && !error && <p className="text-xs text-muted">Wird geladen</p>}
{sets && sets.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Noch keine effektiven Werte erfasst.
</p>
)}
{sets && sets.length > 0 && (
<div className="flex flex-col gap-2">
{sets.map((s, i) => (
<div
key={s.id}
onClick={() => startEdit(s)}
title="Zum Bearbeiten anklicken"
className={`flex cursor-pointer flex-wrap items-center gap-3 rounded-xl border p-3 transition-colors hover:border-accent ${
i === 0 ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-fg">
{dt(s.recordedOn)}
{i === 0 && (
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">
aktuellster
</span>
)}
</div>
<div className="text-[11px] text-muted">
rechnet ab {s.year} · {deviationCount(s)} Werte · {s.author}
{typeof s.cash === "number" && ` · Cash ${formatChf(s.cash)}`}
</div>
{s.comment && <div className="mt-0.5 truncate text-xs text-fg">«{s.comment}»</div>}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
startEdit(s);
}}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:bg-surface-2 hover:text-fg"
>
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void remove(s);
}}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" /> Löschen
</button>
</div>
))}
</div>
)}
<div>
<Button onClick={startWizard}>
<Plus className="h-4 w-4" /> Effektive Werte erfassen
</Button>
</div>
</>
)}
{mode === "wizard" && step === 1 && (
<>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">
{editingId ? "Bearbeiten" : "Erfassen"} · Schritt 1 von 2 · Stichtag
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 flex items-center text-xs font-medium text-muted">
Datum der Erfassung
<InfoBubble text="Das exakte Datum erscheint in der Liste und auf der Zeitachse. Für die Berechnung zählt nur die Jahreszahl der Rechenkern arbeitet in ganzen Jahren ab Planbeginn." />
</label>
<input
type="date"
value={recordedOn}
onChange={(e) => setRecordedOn(e.target.value)}
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Notiz (optional)</label>
<input
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="z. B. «nach Jahresabschluss»"
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
</div>
<p className="rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-muted">
Gerechnet wird ab dem Jahr <strong className="text-fg">{year}</strong>. Die Vorbelegung im nächsten
Schritt zeigt, was dein Plan für dieses Jahr vorsieht du überschreibst nur, was tatsächlich anders ist.
</p>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setMode("list")}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button onClick={goToStep2}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
</>
)}
{mode === "wizard" && step === 2 && (
<>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">
{editingId ? "Bearbeiten" : "Erfassen"} · Schritt 2 von 2 · Werte per {dt(recordedOn)}
</div>
<div className="max-h-[52vh] overflow-y-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 z-10 bg-surface-2">
<tr className="text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Element</th>
<th className="px-3 py-2 text-right font-semibold">Laut Plan {year}</th>
<th className="px-3 py-2 text-right font-semibold">Effektiv</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-border bg-surface-2/60">
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
Cash
</td>
</tr>
<tr className="border-t border-border">
<td className="px-3 py-2 font-medium text-fg">Cash-Konto</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(planCash)}</td>
<td className="px-3 py-2">
<NumInput value={cash} onChange={setCash} />
</td>
</tr>
{rows.map((r, i) => {
const header = i === 0 || rows[i - 1].category !== r.category ? r.category : null;
const v = values[r.rootId] ?? {};
return (
<FragmentRow
key={r.rootId}
header={header}
row={r}
value={v}
onChange={(patch) => setRow(r.rootId, patch)}
/>
);
})}
</tbody>
</table>
</div>
<p className="text-[11px] text-faint">
Einkommen und Ausgaben bitte als <strong>Jahresbetrag</strong> erfassen (nominal, wie tatsächlich
geflossen). Bei Immobilien zählt der Verkehrswert und die Restschuld getrennt. Was du auf dem
Planwert stehen lässt, wird als «keine Abweichung» gewertet.
</p>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setStep(1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button disabled={busy} onClick={save}>
{busy ? "Speichern…" : "Speichern"}
</Button>
</div>
</>
)}
</>
);
// Eingebettet: schlichter Container im Hauptbereich. Sonst: Pop-up mit Overlay.
if (embedded) {
return <div className="flex flex-col gap-4">{body}</div>;
}
return (
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
{body}
</div>
</div>
);
}
function FragmentRow({
header,
row,
value,
onChange,
}: {
header: ElementCategory | null;
row: Row;
value: { value?: number; mortgage?: number };
onChange: (patch: { value?: number; mortgage?: number }) => void;
}) {
return (
<>
{header && (
<tr className="border-t border-border bg-surface-2/60">
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
{CATEGORY_LABELS[header]}
</td>
</tr>
)}
<tr className="border-t border-border">
<td className="px-3 py-2">
<div className="font-medium text-fg">{row.name}</div>
{row.scenarioNames.length > 1 && (
<div className="text-[10px] text-faint">gilt für {row.scenarioNames.join(", ")}</div>
)}
{row.kind === "FLOW" && <div className="text-[10px] text-faint">Jahresbetrag</div>}
{/* Klarstellen, dass hier der Verkehrswert steht -- nicht das Eigenkapital, das die
Matrix zeigt. */}
{row.kind === "PROPERTY" && <div className="text-[10px] text-faint">Verkehrswert + Restschuld</div>}
</td>
<td className="px-3 py-2 text-right text-muted">
{formatChf(row.planValue)}
{row.kind === "PROPERTY" && (
<div className="text-[11px] text-faint">Hypothek {formatChf(row.planMortgage ?? 0)}</div>
)}
</td>
<td className="px-3 py-2">
<NumInput value={value.value ?? 0} onChange={(v) => onChange({ value: v })} />
{row.kind === "PROPERTY" && (
<div className="mt-1">
<NumInput
value={value.mortgage ?? 0}
onChange={(v) => onChange({ mortgage: v })}
label="Hypothek"
/>
</div>
)}
</td>
</tr>
</>
);
}
function NumInput({ value, onChange, label }: { value: number; onChange: (v: number) => void; label?: string }) {
return (
<div className="flex items-center justify-end gap-1">
{label && <span className="text-[10px] text-faint">{label}</span>}
<input
type="number"
value={Number.isFinite(value) ? value : 0}
onChange={(e) => onChange(Math.round(Number(e.target.value) || 0))}
className="w-32 rounded border border-border bg-surface px-2 py-1 text-right text-sm text-fg"
/>
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
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";
// 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.
//
// 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,
// Auf eine einzelne Lebensphase einschraenken (Detailansicht der Phase). Ohne Angabe wird
// der ganze Plan gezeigt.
onlyPhaseId,
}: {
computed: PlanComputed;
height?: number;
onlyPhaseId?: string;
}) {
const phases = useMemo(
() => (onlyPhaseId ? computed.phases.filter((p) => p.id === onlyPhaseId) : computed.phases),
[computed, onlyPhaseId]
);
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
const assetEls = useMemo(() => {
const info = new Map<string, { name: string; any: boolean }>();
for (const phase of phases) {
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
cur.name = el.name;
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
info.set(el.elementId, cur);
}
}
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
}, [phases]);
// Je Planjahr eine Zeile mit dem Wert jedes Elements. Die Jahreswerte liegen bereits in
// `ElementPhaseComputed.yearly` (seit 0.11) -- hier wird nur umsortiert, nichts gerechnet.
const areaData = useMemo(() => {
const byYear = new Map<number, Record<string, number>>();
const ageOf = new Map<number, number>();
for (const phase of 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 [...byYear.entries()]
.sort(([a], [b]) => a - b)
.map(([year, values]) => ({ year, age: ageOf.get(year) ?? 0, ...values }));
}, [phases]);
// Phasengrenzen (kumulierte Dauer) für die Trennlinien. Bei einer einzelnen Phase gibt es keine.
const boundaryAges = useMemo(() => {
if (onlyPhaseId) return [];
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, onlyPhaseId]);
// Gewähltes Jahr für den Ring -- Vorgabe: das letzte (Endzustand).
const [pickedYear, setPickedYear] = useState<number | null>(null);
const ringRow = areaData.find((r) => r.year === pickedYear) ?? areaData[areaData.length - 1];
const ringData = useMemo(() => {
if (!ringRow) return [];
return assetEls
.map((el) => ({ name: el.name, value: Number(ringRow[el.id as keyof typeof ringRow] ?? 0) }))
.filter((d) => d.value > 0);
}, [assetEls, ringRow]);
const ringTotal = ringData.reduce((s, d) => s + d.value, 0);
if (assetEls.length === 0) {
return <p className="text-sm text-muted">Dieser Plan enthält keine Vermögenselemente.</p>;
}
return (
<div className="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">
{onlyPhaseId ? "Verlauf in dieser Lebensphase" : "Verlauf über alle Planjahre"}{" "}
<span className="text-faint">· Klick wählt das Jahr für den Ring</span>
</div>
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={areaData}
margin={{ top: 8, right: 8, left: 8, bottom: 4 }}
style={{ cursor: "pointer" }}
// Recharts 3 reicht im Klick-Parameter NUR den aktiven Index durch -- das in
// Version 2 übliche `activePayload` gibt es nicht mehr. Über den Index in
// `areaData` kommt man an das Planjahr.
onClick={(state) => {
const raw = state?.activeIndex ?? state?.activeTooltipIndex;
const idx = typeof raw === "number" ? raw : Number(raw);
if (!Number.isInteger(idx) || idx < 0 || idx >= areaData.length) return;
setPickedYear(areaData[idx].year);
}}
>
<XAxis
dataKey="age"
type="number"
domain={["dataMin", "dataMax"]}
tick={{ fontSize: 11 }}
tickFormatter={(v) => `${v} J.`}
stroke="var(--faint)"
/>
<YAxis
tick={{ fontSize: 11 }}
stroke="var(--faint)"
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v) => (typeof v === "number" ? formatChf(v) : v)}
labelFormatter={(v) => `Alter ${v}`}
/>
{boundaryAges.map((age) => (
<ReferenceLine key={age} x={age} stroke="var(--border-strong)" strokeDasharray="3 3" />
))}
{/* Markierung des Ring-Jahres. */}
{ringRow && <ReferenceLine x={ringRow.age} stroke="var(--accent)" strokeWidth={1.5} />}
{assetEls.map((el, i) => (
<Area
key={el.id}
type="monotone"
dataKey={el.id}
name={el.name}
stackId="a"
stroke={CHART_PALETTE[i % CHART_PALETTE.length]}
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
fillOpacity={0.75}
isAnimationActive={false}
/>
))}
<Legend wrapperStyle={{ fontSize: 12 }} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Relative Aufteilung im gewählten Jahr */}
<div className="lg:w-72 lg:shrink-0">
<div className="mb-1 text-xs font-medium text-muted">
Aufteilung mit {ringRow?.age ?? ""} Jahren
{pickedYear !== null && (
<button
type="button"
onClick={() => setPickedYear(null)}
className="ml-2 text-[11px] font-normal text-accent hover:underline"
>
zurücksetzen
</button>
)}
</div>
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={ringData}
dataKey="value"
nameKey="name"
innerRadius="55%"
outerRadius="80%"
paddingAngle={1}
isAnimationActive={false}
>
{ringData.map((d, i) => (
<Cell key={d.name} fill={CHART_PALETTE[i % CHART_PALETTE.length]} />
))}
</Pie>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v) =>
typeof v === "number"
? `${formatChf(v)} · ${ringTotal > 0 ? Math.round((v / ringTotal) * 100) : 0} %`
: v
}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="-mt-2 text-center text-xs text-muted">
Total <strong className="text-fg">{formatChf(ringTotal)}</strong>
</div>
</div>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { useMemo, useState } from "react";
import { InfoBubble } from "@/components/InfoBubble";
import { computePlan } from "@/lib/calculations";
import {
resolveActuals,
latestPlanYear,
type ActualsSetInput,
type ElementOrigin,
type ResolvedActuals,
} from "@/lib/actuals";
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// In den Analysewerkzeugen ist nominal/real eine EINFACHauswahl, kein "beide".
//
// Grund: Der Vermögensverlauf zeichnet je Serie ohnehin schon eine Linie; mit "beide" wären
// es zwei, mit Plan/Ist vier und bei zwei Szenarien acht. Das Stilbudget wird stattdessen
// für Plan (gestrichelt) gegen Ist (durchgezogen) ausgegeben -- das ist der Vergleich, um
// den es geht (siehe SPEZIFIKATION 9.29).
export type Metric = "nominal" | "real";
export const METRIC_OPTIONS: { value: Metric; label: string }[] = [
{ value: "nominal", label: "Nominal" },
{ value: "real", label: "Real" },
];
export interface AnalysisBasis {
metric: Metric;
setMetric: (m: Metric) => void;
source: DataSource;
setSource: (s: DataSource) => void;
// Die für die gewählte Quelle massgebende Rechnung.
computed: PlanComputed;
// Immer die reine Plan-Rechnung -- Referenzlinie in den Grafiken.
planComputed: PlanComputed;
// Ist-Rechnung, null solange nichts erfasst ist.
actualComputed: PlanComputed | null;
hasActuals: boolean;
// Auf dieses Szenario aufgeloeste Ist-Saetze -- fuer Werkzeuge, die selbst rechnen.
resolvedActuals: ResolvedActuals[];
// Erstes Jahr, das nicht mehr durch Ist-Werte belegt ist (Monte-Carlo-Startpunkt).
simStartPlanYear: number;
simStartCalendarYear: number | null;
}
export function useAnalysisBasis(
plan: PlanInput,
actuals: ActualsSetInput[],
origins: ElementOrigin[],
initialMetric: Metric = "nominal"
): AnalysisBasis {
const [metric, setMetric] = useState<Metric>(initialMetric);
const [source, setSource] = useState<DataSource>("PLAN");
const resolved = useMemo(() => resolveActuals(actuals, plan, origins), [actuals, plan, origins]);
const planComputed = useMemo(() => computePlan(plan), [plan]);
const actualComputed = useMemo(
() => (resolved.length === 0 ? null : computePlan(plan, undefined, { actuals: resolved })),
[plan, resolved]
);
const hasActuals = actualComputed !== null;
const effectiveSource: DataSource = hasActuals ? source : "PLAN";
const latest = latestPlanYear(resolved);
return {
metric,
setMetric,
resolvedActuals: effectiveSource === "ACTUAL" ? resolved : [],
source: effectiveSource,
setSource,
computed: effectiveSource === "ACTUAL" && actualComputed ? actualComputed : planComputed,
planComputed,
actualComputed,
hasActuals,
// Im Ist-Modus beginnt die Simulation NACH dem jüngsten erfassten Jahr: Was erfasst ist,
// ist bekannt und darf nicht gewürfelt werden.
simStartPlanYear: effectiveSource === "ACTUAL" && latest !== null ? latest + 1 : 1,
simStartCalendarYear:
plan.startYear == null
? null
: plan.startYear + (effectiveSource === "ACTUAL" && latest !== null ? latest : 0),
};
}
// Einheitliche Leiste für alle vier Werkzeuge, damit die Bedienung überall dieselbe ist.
export function AnalysisBar({
basis,
onChange,
}: {
basis: AnalysisBasis;
// Wird nach jeder Umstellung gerufen -- die Werkzeuge verwerfen damit alte Ergebnisse.
onChange?: () => void;
}) {
const pick = (fn: () => void) => () => {
fn();
onChange?.();
};
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Werte</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{METRIC_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={pick(() => basis.setMetric(o.value))}
className={`rounded-md px-2 py-0.5 font-medium ${
basis.metric === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{o.label}
</button>
))}
</div>
<InfoBubble text="Nominal = Frankenbeträge im jeweiligen Jahr. Real = kaufkraftbereinigt auf den Planbeginn. In den Grafiken ist das bewusst eine Entweder-oder-Wahl: Beides gleichzeitig verdoppelt die Linien." />
</label>
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Grundlage</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{DATA_SOURCE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
disabled={o.value === "ACTUAL" && !basis.hasActuals}
onClick={pick(() => basis.setSource(o.value))}
title={
o.value === "ACTUAL" && !basis.hasActuals
? "Für diesen Plan sind noch keine effektiven Werte erfasst."
: o.label
}
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
basis.source === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{o.short}
</button>
))}
</div>
<InfoBubble text="Planzahlen rechnen rein mit deinen Annahmen. Effektiv rechnet dieselbe Mechanik, springt aber in jedem Jahr, für das du Ist-Werte erfasst hast, auf die Realität." />
</label>
</div>
);
}
+1148 -207
View File
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
"use client";
// Befehls-Palette (Cmd/Ctrl+K): schnelles Springen zwischen Plänen und Szenarien plus
// die wichtigsten Aktionen -- der Effizienz-Pfad für geübte Nutzer, ohne der Maus-
// Bedienung etwas wegzunehmen.
import { useEffect, useMemo, useRef, useState } from "react";
import { CornerDownLeft, Search } from "lucide-react";
import type { PlanListItem } from "@/lib/types";
export interface PaletteAction {
id: string;
label: string;
hint?: string;
run: () => void;
}
export function CommandPalette({
open,
onClose,
plans,
actions,
onOpenScenario,
}: {
open: boolean;
onClose: () => void;
plans: PlanListItem[];
actions: PaletteAction[];
onOpenScenario: (id: string) => void;
}) {
const [query, setQuery] = useState("");
const [index, setIndex] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const items = useMemo<PaletteAction[]>(() => {
const scenarioItems: PaletteAction[] = plans.flatMap((p) =>
p.scenarios.map((s) => ({
id: `s-${s.id}`,
label: `${p.name} · ${s.name}`,
hint: "Szenario öffnen",
run: () => onOpenScenario(s.id),
}))
);
const all = [...actions, ...scenarioItems];
const q = query.trim().toLowerCase();
if (!q) return all;
return all.filter((i) => i.label.toLowerCase().includes(q));
}, [plans, actions, query, onOpenScenario]);
useEffect(() => {
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset beim Öffnen
setQuery("");
setIndex(0);
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Auswahl folgt der Eingabe
setIndex(0);
}, [query]);
if (!open) return null;
function choose(item: PaletteAction | undefined) {
if (!item) return;
onClose();
item.run();
}
return (
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center bg-black/40 px-4 pt-[15vh]" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="ui-pop w-full max-w-lg overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl"
>
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-faint" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setIndex((i) => Math.min(i + 1, items.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
choose(items[index]);
} else if (e.key === "Escape") {
onClose();
}
}}
placeholder="Plan, Szenario oder Aktion suchen…"
className="w-full bg-transparent text-sm text-fg outline-none placeholder:text-faint"
/>
<kbd className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-faint">ESC</kbd>
</div>
<div className="max-h-80 overflow-y-auto py-1">
{items.length === 0 && <p className="px-4 py-3 text-sm text-muted">Nichts gefunden.</p>}
{items.slice(0, 40).map((item, i) => (
<button
key={item.id}
type="button"
onMouseEnter={() => setIndex(i)}
onClick={() => choose(item)}
className={`flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm ${
i === index ? "bg-accent-soft text-accent-soft-fg" : "text-fg"
}`}
>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<span className="flex items-center gap-1.5 text-[10px] text-faint">
{item.hint}
{i === index && <CornerDownLeft className="h-3 w-3" />}
</span>
</button>
))}
</div>
</div>
</div>
);
}
+229 -94
View File
@@ -1,8 +1,12 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { BarChart3, LineChart as LineChartIcon } from "lucide-react";
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react"; import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart"; import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { SparquoteChart } from "@/components/SparquoteChart"; import { SparquoteChart } from "@/components/SparquoteChart";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
@@ -10,24 +14,76 @@ import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations"; import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
interface PlanListItem { interface PlanListItem {
id: string; id: string;
name: string; name: string;
} }
type ChartId = "wealth" | "cashflow" | "allocation";
const CHART_OPTIONS: { id: ChartId; label: string }[] = [
{ id: "wealth", label: "Vermögensverlauf" },
{ id: "cashflow", label: "Einkommen vs. Ausgaben" },
{ id: "allocation", label: "Vermögensaufteilung" },
];
export function Dashboard({ export function Dashboard({
plan, plan: openedPlan,
computed, planId,
allPlans, computed: openedComputed,
siblings,
actuals = [],
origins = [],
}: { }: {
plan: PlanInput; plan: PlanInput;
planId: string;
computed: PlanComputed; computed: PlanComputed;
allPlans: PlanListItem[]; // Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
}) { }) {
// Eine Grafik zur Zeit -- man wählt zuerst, welche (Punkt 5 der Analysen-Ansicht).
const [chartId, setChartId] = useState<ChartId>("wealth");
// Welches Szenario die Grafik zeigt. Gilt für ALLE drei Grafiken -- vorher war das fest an
// das geöffnete Szenario gebunden, und nur der Vermögensverlauf konnte weitere dazunehmen.
const allScenarios = useMemo<PlanListItem[]>(
() => [{ id: openedPlan.id, name: openedPlan.name }, ...siblings],
[openedPlan.id, openedPlan.name, siblings]
);
const [primaryId, setPrimaryId] = useState(openedPlan.id);
const [loaded, setLoaded] = useState<Record<string, { plan: PlanInput; computed: PlanComputed }>>({});
useEffect(() => {
if (primaryId === openedPlan.id || loaded[primaryId]) return;
let cancelled = false;
(async () => {
const d = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/scenarios/${primaryId}`);
if (!cancelled) setLoaded((prev) => ({ ...prev, [primaryId]: { plan: d.plan, computed: d.computed } }));
})();
return () => {
cancelled = true;
};
}, [primaryId, openedPlan.id, loaded]);
const basePlan = primaryId === openedPlan.id ? openedPlan : (loaded[primaryId]?.plan ?? openedPlan);
const baseComputed = primaryId === openedPlan.id ? openedComputed : (loaded[primaryId]?.computed ?? openedComputed);
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(basePlan.id, basePlan);
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
const basis = useAnalysisBasis(plan, actuals, origins);
// Version UND Datenquelle bestimmen, was gezeigt wird. Beim Arbeitsstand mit Plandaten
// bleibt es beim bereits vom Server Gerechneten -- kein zweiter Lauf nötig.
const computed = useMemo(
() => (versionId === "current" && basis.source === "PLAN" ? baseComputed : 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)) {
@@ -36,143 +92,222 @@ export function Dashboard({
} }
setCompareIds((prev) => [...prev, id]); setCompareIds((prev) => [...prev, id]);
if (!compareData[id]) { if (!compareData[id]) {
const data = await api.get<{ computed: PlanComputed }>(`/api/plans/${id}`); const data = await api.get<{ computed: PlanComputed }>(`/api/scenarios/${id}`);
setCompareData((prev) => ({ ...prev, [id]: data.computed })); setCompareData((prev) => ({ ...prev, [id]: data.computed }));
} }
} }
// 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: PALETTE[0], computed }]; const result: TimelineSeries[] = [
{ id: plan.id, label: plan.name, color: colors[plan.id] ?? CHART_PALETTE[0], computed },
];
// Im Ist-Modus die reine Planlinie als gestrichelte Referenz daneben -- ohne Anker sagt
// eine Ist-Linie allein nichts aus.
if (basis.source === "ACTUAL") {
const id = `${plan.id}__plan`;
result.push({
id,
label: `${plan.name} (Plan)`,
color: colors[id] ?? CHART_PALETTE[0],
computed: basis.planComputed,
dashed: true,
});
}
compareIds.forEach((id, i) => { compareIds.forEach((id, i) => {
const c = compareData[id]; const c = compareData[id];
const name = allPlans.find((p) => p.id === id)?.name ?? id; const name = allScenarios.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: PALETTE[(i + 1) % PALETTE.length], computed: c }); if (c) {
result.push({ id, label: name, color: colors[id] ?? CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
}
}); });
return result; return result;
}, [plan.name, computed, compareIds, compareData, allPlans]); }, [plan.id, plan.name, computed, compareIds, compareData, allScenarios, basis.source, basis.planComputed, colors]);
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
const assetEls = useMemo(() => {
const info = new Map<string, { name: string; any: boolean }>();
for (const phase of computed.phases) {
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
cur.name = el.name;
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
info.set(el.elementId, cur);
}
}
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [computed]);
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende. Werte je Asset-Element.
const barData = useMemo(
() =>
computed.phases.flatMap((phase) => {
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
}
return [beginn, ende];
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[computed]
);
const otherPlans = allPlans.filter((p) => p.id !== plan.id);
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">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> {/* Szenario-Auswahl: gilt für alle drei Grafiken. */}
<StatCard label="Endvermoegen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} /> {showScenarioPicker && (
<StatCard label="Endvermoegen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} /> <div className="flex flex-wrap items-center gap-2">
<StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." /> <span className="text-xs font-medium text-muted">Szenario</span>
<select
value={primaryId}
onChange={(e) => setPrimaryId(e.target.value)}
className="rounded-lg border border-border bg-input px-2.5 py-1.5 text-sm text-fg focus:border-accent focus:outline-none"
>
{allScenarios.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
)}
<VersionBar scenarioId={basePlan.id} versionId={versionId} onChange={setVersionId} loading={versionLoading} />
<AnalysisBar basis={basis} />
{/* Grafikauswahl */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="inline-flex flex-wrap gap-0.5 rounded-lg border border-border bg-surface-2 p-0.5 text-xs">
{CHART_OPTIONS.map((o) => (
<button
key={o.id}
type="button"
onClick={() => setChartId(o.id)}
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
chartId === o.id ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{o.label}
</button>
))}
</div>
<SaveAnalysisButton
planId={planId}
type="CHART"
scenarioName={plan.name}
versionLabel={versionId === "current" ? null : versionId}
build={() => buildChartResult(chartId, computed, series, basis)}
/>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
</div>
{chartId === "cashflow" && (
<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 Flaeche zwischen Einkommen und nominalen Ausgaben ist die Spar- (gruen) bzw. Verzehrquote (rot). Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- bzw. Verzehrquote.
Die blasse Linie sind die realen Ausgaben &ndash; der Abstand zur nominalen Linie ist der Inflationsanteil. Die blasse Linie sind die realen Ausgaben &ndash; der Abstand zur nominalen Linie ist der Inflationsanteil.
</p> </p>
<SparquoteChart computed={computed} /> <SparquoteChart computed={computed} />
</section> </section>
)}
{chartId === "wealth" && (
<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">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg"> <h3 className="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" />
Vermoegensverlauf nach Alter Vermögensverlauf nach Alter
</h3> </h3>
<a
href={`/api/plans/${plan.id}/export`}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted hover:bg-surface-2"
>
<Download className="h-3.5 w-3.5" />
CSV-Export
</a>
</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">Vergleichen mit:</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 <input type="checkbox" checked={compareIds.includes(p.id)} onChange={() => toggleCompare(p.id)} />
type="checkbox"
checked={compareIds.includes(p.id)}
onChange={() => toggleCompare(p.id)}
/>
{p.name} {p.name}
</label> </label>
))} ))}
</div> </div>
)} )}
<WealthChart series={series} /> <WealthChart
series={series}
metric={basis.metric}
onColorChange={(id, color) => setColors((prev) => ({ ...prev, [id]: color }))}
/>
</section> </section>
)}
{chartId === "allocation" && (
<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" />
Vermoegensaufteilung pro Phase (Beginn &amp; 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 naechsten &ndash; die Aufteilung kann durch Umschichtung abweichen. die relative Aufteilung zu einem wählbaren Zeitpunkt.
</p> </p>
<div className="h-72 w-full"> <AllocationChart computed={computed} />
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{assetEls.map((el, i) => (
<Bar key={el.id} dataKey={el.id} name={el.name} stackId="a" fill={PALETTE[i % PALETTE.length]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
</section> </section>
)}
</div> </div>
); );
} }
// Momentaufnahme der gerade gezeigten Grafik als Zahlen (siehe analyses.ts).
function buildChartResult(
chartId: ChartId,
computed: PlanComputed,
series: TimelineSeries[],
basis: { metric: string; source: string },
): { metric?: string; source?: string; summary?: string | null; inputs: Record<string, unknown>; result: Record<string, unknown> } {
const label = CHART_OPTIONS.find((o) => o.id === chartId)!.label;
const common = {
metric: basis.metric,
source: basis.source,
summary: label,
inputs: { params: [{ label: "Grafik", value: label }, { label: "Grösse", value: basis.metric === "real" ? "real" : "nominal" }] },
};
if (chartId === "wealth") {
return {
...common,
result: {
params: [{ label: "Grösse", value: basis.metric === "real" ? "real" : "nominal" }],
chart: {
kind: "line" as const,
xLabel: "Alter",
yFormat: "chf" as const,
series: series.map((s) => ({
label: s.label,
dashed: s.dashed,
points: s.computed.yearly.map((y) => ({ x: y.age, y: basis.metric === "real" ? y.wealthReal : y.wealthNominal })),
})),
},
},
};
}
if (chartId === "cashflow") {
return {
...common,
result: {
params: common.inputs.params,
chart: {
kind: "line" as const,
xLabel: "Jahr",
yFormat: "chf" as const,
series: [
{ label: "Einkommen", color: "var(--chart-3)", points: computed.yearly.map((y) => ({ x: y.year, y: y.income })) },
{ label: "Ausgaben", color: "var(--chart-5)", points: computed.yearly.map((y) => ({ x: y.year, y: y.expenseNominal })) },
],
},
},
};
}
// Allokation: Endvermögen je Phase als Balken (kompakte, druckbare Zusammenfassung).
return {
...common,
result: {
params: common.inputs.params,
table: {
columns: ["Phase", "Vermögen Ende (nom.)", "real"],
rows: computed.phases.map((p) => [p.name, formatChf(p.endWealthNominal), formatChf(p.endWealthReal)]),
},
chart: {
kind: "bar" as const,
yFormat: "chf" as const,
series: [{ label: "Vermögen Phasenende", points: computed.phases.map((p, i) => ({ x: i + 1, y: p.endWealthNominal })) }],
},
},
};
}
function StatCard({ label, value, help }: { label: string; value: number; help?: string }) { function StatCard({ label, value, help }: { label: string; value: number; help?: string }) {
return ( return (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm"> <div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
+655
View File
@@ -0,0 +1,655 @@
"use client";
import { useMemo, useState } from "react";
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { BookOpen, X } from "lucide-react";
import { AllocationChart } from "@/components/AllocationChart";
import { formatChf } from "@/lib/format";
import { CATEGORY_LABELS } from "@/lib/elements";
import type { ElementCategory } from "@/lib/elements";
import type {
CashBridge,
ElementYearPoint,
PhaseComputed,
PlanComputed,
Trace,
TraceStep,
WealthBridge,
} from "@/lib/calculations";
// --- Wasserfall ---------------------------------------------------------------------------
// Bewusst NICHT mit Recharts, sondern als eigene HTML/CSS-Darstellung. Ein Wasserfall lebt
// von drei Dingen, die Recharts hier nicht hergibt: Verbindungslinien zwischen den Balken
// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhört),
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenständen
// und Veränderungen.
//
// Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend
// müssten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
interface WaterfallItem {
label: string;
value: number;
total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veränderung
section?: string; // optionale Abschnitts-Überschrift VOR diesem Eintrag
}
interface WaterfallRow {
label: string;
section?: string;
from: number;
to: number;
value: number;
running: number; // Stand NACH diesem Schritt
kind: "total" | "pos" | "neg";
}
function waterfallRows(items: WaterfallItem[]): WaterfallRow[] {
let running = 0;
return items.map((it) => {
if (it.total) {
running = it.value;
return { label: it.label, section: it.section, from: 0, to: it.value, value: it.value, running, kind: "total" as const };
}
const from = running;
running += it.value;
return {
label: it.label,
section: it.section,
from,
to: running,
value: it.value,
running,
kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg",
};
});
}
const WF_FILL = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" };
const ROW_H = 34;
const BAR_H = 20;
function Waterfall({ items }: { items: WaterfallItem[] }) {
const rows = useMemo(() => waterfallRows(items), [items]);
if (rows.length === 0) return null;
const lo = Math.min(0, ...rows.map((r) => Math.min(r.from, r.to)));
const hi = Math.max(0, ...rows.map((r) => Math.max(r.from, r.to)));
const span = hi - lo || 1;
const pos = (v: number) => ((v - lo) / span) * 100;
return (
<div>
<div className="overflow-hidden rounded-xl border border-border">
{rows.map((r, i) => {
const left = pos(Math.min(r.from, r.to));
const width = Math.max(0.4, Math.abs(pos(r.to) - pos(r.from)));
const isLast = i === rows.length - 1;
return (
<div key={`${r.label}-${i}`}>
{r.section && (
<div className="border-b border-border bg-surface-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-faint">
{r.section}
</div>
)}
<div className={`flex items-stretch ${r.kind === "total" ? "bg-surface-2" : ""}`}>
<div
className={`w-44 shrink-0 border-r border-border px-3 py-2 text-[11px] leading-tight ${
r.kind === "total" ? "font-semibold text-fg" : "text-muted"
}`}
>
{r.label}
</div>
<div className="relative min-w-0 flex-1" style={{ height: ROW_H }}>
{/* Nulllinie */}
<div
className="absolute top-0 h-full border-l border-dashed border-border"
style={{ left: `${pos(0)}%` }}
/>
{/* Balken */}
<div
className="absolute rounded-sm"
style={{
left: `${left}%`,
width: `${width}%`,
top: (ROW_H - BAR_H) / 2,
height: BAR_H,
backgroundColor: WF_FILL[r.kind],
opacity: r.kind === "total" ? 0.85 : 1,
}}
title={`${r.label}: ${formatChf(r.value)}`}
/>
{/* Verbindungslinie zum nächsten Balken: auf dem Stand NACH diesem Schritt */}
{!isLast && (
<div
className="absolute border-l border-dotted border-faint"
style={{
left: `${pos(r.to)}%`,
top: (ROW_H + BAR_H) / 2,
height: (ROW_H - BAR_H) / 2,
}}
/>
)}
</div>
<div
className={`w-28 shrink-0 border-l border-border px-3 py-2 text-right text-[11px] tabular-nums ${
r.kind === "total" ? "font-semibold text-fg" : r.kind === "neg" ? "text-danger" : "text-success"
}`}
>
{r.kind === "total" ? formatChf(r.value) : `${r.value >= 0 ? "+" : ""}${formatChf(Math.abs(r.value))}`}
</div>
</div>
</div>
);
})}
</div>
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Grössenordnungen
ist die Tabelle der Grafik überlegen. */}
<details className="mt-2">
<summary className="cursor-pointer text-[11px] text-muted hover:text-fg">Zahlen mit Zwischenstand anzeigen</summary>
<div className="mt-2 overflow-x-auto rounded-lg border border-border">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-surface-2 text-[10px] uppercase tracking-wide text-faint">
<th className="px-3 py-1.5 text-left font-semibold">Schritt</th>
<th className="px-3 py-1.5 text-right font-semibold">Betrag</th>
<th className="px-3 py-1.5 text-right font-semibold">Zwischenstand</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className={`border-t border-border ${r.kind === "total" ? "bg-surface-2 font-semibold" : ""}`}>
<td className="px-3 py-1.5 text-fg">{r.label}</td>
<td
className={`px-3 py-1.5 text-right tabular-nums ${
r.kind === "total" ? "text-faint" : r.kind === "neg" ? "text-danger" : "text-success"
}`}
>
{r.kind === "total" ? "—" : `${r.value >= 0 ? "+" : ""}${formatChf(Math.abs(r.value))}`}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-fg">{formatChf(r.running)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</div>
);
}
// Kontrollgrösse: Ist die Zerlegung vollständig, muss die Differenz zwischen Endwert und
// der Summe der Schritte 0 sein. Sichtbar machen statt verstecken -- ein Wasserfall, der
// nicht aufgeht, ist ein Fehler und kein Schönheitsproblem.
function ResidualNote({ residual }: { residual: number }) {
if (Math.abs(residual) <= 2) return null;
return (
<p className="mt-2 rounded-lg border border-danger bg-danger-soft px-3 py-2 text-[11px] text-danger">
<strong>Die Zerlegung geht nicht auf.</strong> Nicht zugeordnete Differenz: {formatChf(residual)}. Bitte melden
das ist ein Fehler in der Berechnung, nicht in der Darstellung.
</p>
);
}
function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
const items: WaterfallItem[] = [];
if (!isFirst) {
items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true, section: "Am Übergang in diese Phase" });
if (w.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: w.oneOffInflow });
if (w.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -w.oneOffOutflow });
if (w.transitionTax) items.push({ label: "Steuern am Übergang", value: -w.transitionTax });
if (w.pensionConversion) items.push({ label: "PK in Rente umgewandelt", value: -w.pensionConversion });
if (w.saleGainLoss) items.push({ label: "Verkaufsdifferenz", value: w.saleGainLoss });
}
items.push({
label: "Vermögen Phasenbeginn",
value: w.startWealth,
total: true,
section: isFirst ? undefined : "Innerhalb der Phase",
});
if (w.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: w.quotaTotal });
if (w.investmentReturn) items.push({ label: "Kapitalerträge", value: w.investmentReturn });
if (w.propertyAppreciation) items.push({ label: "Wertsteigerung Immobilie", value: w.propertyAppreciation });
if (w.pensionFundContribution) items.push({ label: "PK-Beiträge", value: w.pensionFundContribution });
items.push({ label: "Vermögen Phasenende", value: w.endWealth, total: true });
return items;
}
function cashItems(c: CashBridge, isFirst: boolean): WaterfallItem[] {
const items: WaterfallItem[] = [];
items.push({
label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase",
value: c.openingCash,
total: true,
section: isFirst ? undefined : "Am Übergang in diese Phase",
});
if (c.capitalInflow) items.push({ label: "Kapitalzufluss", value: c.capitalInflow });
if (c.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: c.oneOffInflow });
if (c.immediateRepay) items.push({ label: "Sofort-Tilgung", value: -c.immediateRepay });
if (c.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -c.oneOffOutflow });
if (c.investments) items.push({ label: "Investitionen", value: -c.investments });
items.push({ label: "Cash Phasenbeginn", value: c.cashStart, total: true, section: "Innerhalb der Phase" });
if (c.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: c.quotaTotal });
if (c.savingRates) items.push({ label: "Sparraten", value: -c.savingRates });
if (c.debtRates) items.push({ label: "Amort./Tilgung", value: -c.debtRates });
if (c.withdrawals) items.push({ label: "Bezugsraten", value: c.withdrawals });
items.push({ label: "Cash Phasenende", value: c.cashEnd, total: true });
return items;
}
// --- Rechenweg ----------------------------------------------------------------------------
function TraceStepRow({ step }: { step: TraceStep }) {
const unit = step.unit ?? "CHF";
const value =
unit === "" ? step.substituted ?? "" : unit === "%" ? `${step.result} %` : unit === "Jahre" ? `${step.result}` : formatChf(step.result);
return (
<tr className="border-t border-border align-top">
<td className="px-3 py-1.5 text-fg">{step.label}</td>
<td className="px-3 py-1.5 text-[11px] text-muted">
{step.formula && <div className="font-mono">{step.formula}</div>}
{step.substituted && unit !== "" && <div className="font-mono text-faint">{step.substituted}</div>}
{step.note && <div className="mt-0.5 italic text-faint">{step.note}</div>}
</td>
<td className="whitespace-nowrap px-3 py-1.5 text-right font-medium text-fg">{value}</td>
</tr>
);
}
export function TraceBlock({ trace, onOpenSpec }: { trace: Trace; onOpenSpec?: (anchor: string) => void }) {
return (
<div className="rounded-xl border border-border">
<div className="flex items-center justify-between gap-2 border-b border-border bg-surface-2 px-3 py-2">
<span className="text-xs font-semibold text-fg">{trace.title}</span>
{trace.specAnchor && onOpenSpec && (
<button
type="button"
onClick={() => onOpenSpec(trace.specAnchor!)}
className="flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-muted hover:bg-surface"
>
<BookOpen className="h-3 w-3" /> in der Spezifikation
</button>
)}
</div>
<table className="w-full border-collapse text-sm">
<tbody>
{trace.steps.map((s, i) => (
<TraceStepRow key={i} step={s} />
))}
</tbody>
</table>
</div>
);
}
// --- Gemeinsame Dialog-Huelle mit Reitern --------------------------------------------------
function DetailShell({
title,
subtitle,
tabs,
onClose,
}: {
title: string;
subtitle?: string;
tabs: { key: string; label: string; content: React.ReactNode }[];
onClose: () => void;
}) {
const [active, setActive] = useState(tabs[0]?.key);
const current = tabs.find((t) => t.key === active) ?? tabs[0];
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
{subtitle && <p className="text-xs text-muted">{subtitle}</p>}
</div>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
<div className="inline-flex w-fit rounded-lg border border-border bg-surface p-0.5 text-xs">
{tabs.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setActive(t.key)}
className={`rounded-md px-3 py-1 font-medium ${
current?.key === t.key ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{t.label}
</button>
))}
</div>
<div className="flex flex-col gap-4">{current?.content}</div>
<p className="text-[11px] text-faint">Nur-Lese-Ansicht. Werte werden hier nicht verändert.</p>
</div>
</div>
);
}
// --- Element-Detailansicht ----------------------------------------------------------------
const MULTI_SERIES: ElementCategory[] = ["REAL_ESTATE"];
export function ElementDetailDialog({
elementId,
name,
category,
computed,
onClose,
onOpenSpec,
}: {
elementId: string;
name: string;
category: ElementCategory;
computed: PlanComputed;
onClose: () => void;
onOpenSpec?: (anchor: string) => void;
}) {
// Verlauf über ALLE Phasen zusammensetzen.
const points = useMemo(() => {
const out: ElementYearPoint[] = [];
for (const ph of computed.phases) {
const ec = ph.elements.find((e) => e.elementId === elementId);
if (ec) out.push(...ec.yearly);
}
return out;
}, [computed, elementId]);
const perPhase = useMemo(
() =>
computed.phases
.map((ph) => ({ phase: ph, ec: ph.elements.find((e) => e.elementId === elementId) }))
.filter((x) => x.ec),
[computed, elementId]
);
const isFlow = category === "INCOME" || category === "EXPENSE";
const valueLabel = isFlow
? category === "INCOME"
? "Einkommen (nominal)"
: "Ausgaben (nominal)"
: category === "OTHER_DEBT"
? "Beitrag zum Vermögen"
: category === "REAL_ESTATE"
? "Eigenkapital"
: "Wert";
// Zweite Achse nur, wenn dieses Element überhaupt eine Rate trägt (AHV und Schulden nicht).
const rateLabel =
category === "REAL_ESTATE"
? "Wertsteigerung"
: isFlow
? category === "INCOME"
? "Lohnentwicklung"
: "Reale Mehrausgaben"
: "Erwartete Rendite";
const hasRate = points.some((p) => typeof p.rate === "number");
const verlauf = (
<>
{points.length === 0 ? (
<p className="text-sm text-muted">Für dieses Element gibt es in diesem Plan keinen Verlauf.</p>
) : (
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis yAxisId="chf" tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
{hasRate && (
<YAxis
yAxisId="rate"
orientation="right"
tick={{ fontSize: 11 }}
tickFormatter={(v) => `${v} %`}
// Etwas Luft nach oben und unten, damit eine konstante Rate nicht als
// Linie direkt auf der Achse klebt.
domain={([min, max]: readonly [number, number]) => [Math.min(0, min - 1), max + 1]}
/>
)}
<Tooltip
formatter={(v, n) =>
typeof v !== "number" ? [v, n] : n === rateLabel ? [`${v} %`, n] : [formatChf(v), n]
}
labelFormatter={(v) => `Alter ${v}`}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line yAxisId="chf" dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
{MULTI_SERIES.includes(category) && (
<>
<Line yAxisId="chf" dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} />
<Line yAxisId="chf" dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
</>
)}
{hasRate && (
// Stufenlinie, nicht interpoliert: Die Rate ist innerhalb einer Phase
// konstant und springt an der Phasengrenze. Eine weiche Kurve würde einen
// gleitenden Übergang suggerieren, den die Berechnung nicht macht.
<Line
yAxisId="rate"
type="stepAfter"
dataKey="rate"
name={rateLabel}
stroke="#d97706"
strokeWidth={1.5}
strokeDasharray="4 2"
dot={false}
connectNulls
isAnimationActive={false}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
)}
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Lebensphase</th>
<th className="px-3 py-2 text-right font-semibold">Beginn</th>
<th className="px-3 py-2 text-right font-semibold">Ende</th>
<th className="px-3 py-2 text-left font-semibold">Hinweis</th>
</tr>
</thead>
<tbody>
{perPhase.map(({ phase, ec }) => (
<tr key={phase.id} className="border-t border-border">
<td className="px-3 py-2 text-fg">{phase.name}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.startValue)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.endValue)}</td>
<td className="px-3 py-2 text-[11px] text-faint">{ec!.note ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
const rechenweg = (
<>
{perPhase.every(({ ec }) => !ec!.trace && !ec!.transitionTrace) && (
<p className="text-sm text-muted">Für dieses Element gibt es keinen eigenen Rechenweg.</p>
)}
{perPhase.map(({ phase, ec }) => (
<div key={phase.id} className="flex flex-col gap-2">
{(ec!.trace || ec!.transitionTrace) && (
<div className="text-xs font-semibold uppercase tracking-wide text-faint">{phase.name}</div>
)}
{ec!.trace && <TraceBlock trace={ec!.trace} onOpenSpec={onOpenSpec} />}
{ec!.transitionTrace && <TraceBlock trace={ec!.transitionTrace} onOpenSpec={onOpenSpec} />}
</div>
))}
</>
);
return (
<DetailShell
title={name}
subtitle={CATEGORY_LABELS[category]}
onClose={onClose}
tabs={[
{ key: "verlauf", label: "Verlauf", content: verlauf },
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
]}
/>
);
}
// --- Phasen-Detailansicht ------------------------------------------------------------------
const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
export function PhaseDetailDialog({
phase,
computed,
isFirst,
onClose,
onOpenSpec,
}: {
phase: PhaseComputed;
// Der ganze Plan -- die Aufteilungs-Grafik zeichnet daraus die Jahreswerte dieser Phase.
computed: PlanComputed;
isFirst: boolean;
onClose: () => void;
onOpenSpec?: (anchor: string) => void;
}) {
const allocEls = useMemo(
() => phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)),
[phase]
);
const übersicht = (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Kpi label="Typ" text={phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} />
<Kpi label="Dauer" text={`${phase.durationYears} Jahre`} />
<Kpi label="Vermögen Beginn" text={formatChf(phase.startWealthNominal)} />
<Kpi label="Vermögen Ende" text={formatChf(phase.endWealthNominal)} />
</div>
<section>
<h3 className="mb-1 text-xs font-semibold text-fg">Vermögensaufteilung</h3>
<p className="mb-2 text-[11px] text-muted">Zusammensetzung des Anlagevermögens über die Jahre dieser Phase; rechts die relative Aufteilung zu einem wählbaren Zeitpunkt.</p>
{allocEls.length === 0 ? (
<p className="text-sm text-muted">In dieser Phase gibt es kein Anlagevermögen.</p>
) : (
// Dieselbe Darstellung wie im Grafiken-Bereich, nur auf diese Phase eingeschränkt.
<AllocationChart computed={computed} onlyPhaseId={phase.id} height={240} />
)}
</section>
</>
);
const wasserfall = (
<>
<section>
<h3 className="mb-1 text-xs font-semibold text-fg">Woher kommt die Vermögensänderung?</h3>
<p className="mb-2 text-[11px] text-muted">
Nur <strong>echte</strong> Zu- und Abgänge. Sparraten, Amortisationen und Zusatzinvestitionen erscheinen hier
bewusst <strong>nicht</strong>: Sie verschieben Geld vom Cash in einen Vermögenswert, ohne das Vermögen zu
verändern als Balken gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt.
</p>
<Waterfall items={bridgeItems(phase.wealthBridge, isFirst)} />
<ResidualNote residual={phase.wealthBridge.residual} />
</section>
<section>
<h3 className="mb-1 text-xs font-semibold text-fg">Wohin ist das Cash geflossen?</h3>
<p className="mb-2 text-[11px] text-muted">
Hier erscheinen die Umbuchungen: Sparraten, Amortisationen und Investitionen verlassen das Cash-Konto, auch
wenn sie das Vermögen nicht mindern.
</p>
<Waterfall items={cashItems(phase.cashBridge, isFirst)} />
<ResidualNote residual={phase.cashBridge.residual} />
</section>
</>
);
const rechenweg = (
<>
{(phase.traces ?? []).length === 0 && <p className="text-sm text-muted">Kein Rechenweg verfügbar.</p>}
{(phase.traces ?? []).map((t, i) => (
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
))}
</>
);
return (
<DetailShell
title={phase.name}
subtitle={`Lebensphase ${phase.sequenceNumber} · ${phase.durationYears} Jahre`}
onClose={onClose}
tabs={[
{ key: "übersicht", label: "Übersicht", content: übersicht },
{ key: "wasserfall", label: "Wasserfall", content: wasserfall },
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
]}
/>
);
}
function Kpi({ label, text }: { label: string; text: string }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
<div className="text-[11px] text-muted">{label}</div>
<div className="text-sm font-semibold text-fg">{text}</div>
</div>
);
}
// --- Plan-weite Rechenwege (Deflatoren, AHV-Karriere, Ruinalter) ---------------------------
export function PlanTraceDialog({
computed,
onClose,
onOpenSpec,
}: {
computed: PlanComputed;
onClose: () => void;
onOpenSpec?: (anchor: string) => void;
}) {
return (
<DetailShell
title="Rechenwege dieses Szenarios"
subtitle="Plan-weite Grössen, die in alle Phasen hineinwirken"
onClose={onClose}
tabs={[
{
key: "plan",
label: "Plan-Ebene",
content: (
<>
{(computed.traces ?? []).map((t, i) => (
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
))}
</>
),
},
]}
/>
);
}
+689
View File
@@ -0,0 +1,689 @@
"use client";
// Zwei Verteil-Werkzeuge im Phasenkopf:
// 1. "Kapital verteilen" -- der beim Übergang zugeflossene Topf (Verkäufe, Bezüge,
// Erbschaft, Cash der Vorphase) auf Zusatzeinlagen, Sonderamortisation und Sofort-
// Tilgung verteilen; der Rest bleibt automatisch auf dem Cash.
// 2. "Sparquote verteilen" -- die laufende Spar- bzw. Verzehrquote auf jährliche Raten
// verteilen.
//
// Beide schreiben ausschliesslich BESTEHENDE Felder über die bestehenden Endpunkte. Die
// Live-Vorschau entsteht, indem der Plan mit den Entwurfswerten kopiert und erneut durch
// computePlan geschickt wird -- die angezeigte Wirkung ist dadurch per Konstruktion exakt
// die spätere, inklusive aller Kappungen.
import { useMemo, useState } from "react";
import { AlertTriangle, Coins, PiggyBank } from "lucide-react";
import { Button, Modal } from "@/components/ui";
import { MoneyField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { CarryWarning } from "@/components/ElementDetail";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import { computePlan } from "@/lib/calculations";
import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants";
import {
CATEGORY_LABELS,
inheritedPhaseValues,
num,
ownerLabel,
type PhaseData,
type TransitionData,
} from "@/lib/elements";
import {
applyPhasePatches,
applyTransitionPatches,
capitalPot,
quotaSummary,
type PhasePatch,
type TransitionPatch,
} from "@/lib/distribution";
import type { PlanInput } from "@/lib/types";
// --- gemeinsame Bausteine -----------------------------------------------------------------
function SummaryRow({
label,
value,
help,
strong,
tone,
}: {
label: string;
value: number;
help?: string;
strong?: boolean;
tone?: "danger" | "success" | "muted";
}) {
const color = tone === "danger" ? "text-danger" : tone === "success" ? "text-success" : strong ? "text-fg" : "text-muted";
return (
<div className="flex items-baseline justify-between gap-3 whitespace-nowrap">
<span className={`flex items-center text-xs ${strong ? "font-semibold text-fg" : "text-muted"}`}>
{label}
{help && <InfoBubble text={help} />}
</span>
<span className={`tabular-nums ${strong ? "text-sm font-semibold" : "text-xs"} ${color}`}>
{formatChf(value)}
</span>
</div>
);
}
// --- 1. Kapital verteilen -----------------------------------------------------------------
interface CapitalTarget {
elementId: string;
name: string;
owner: string;
category: string;
kind: "investment" | "amortization" | "repayment";
max: number | undefined; // Kappung (Restschuld); bei Investitionen unbegrenzt
hint: string;
}
export function CapitalDistributionDialog({
plan,
computed,
phaseId,
onClose,
onSaved,
}: {
plan: PlanInput;
computed: ReturnType<typeof computePlan>;
phaseId: string;
onClose: () => void;
onSaved: () => void;
}) {
const phaseIndex = computed.phases.findIndex((p) => p.id === phaseId);
const phase = computed.phases[phaseIndex];
const prevPhase = computed.phases[phaseIndex - 1];
// Ziele bestimmen. Zusatzeinlagen liegen in DIESER Phase, Sonderamortisation und
// Sofort-Tilgung dagegen am ÜBERGANG davor (also an der Vorphase) -- deshalb werden beim
// Speichern zwei verschiedene Endpunkte angesprochen.
const targets = useMemo<CapitalTarget[]>(() => {
const out: CapitalTarget[] = [];
for (const e of plan.elements) {
const ce = phase?.elements.find((x) => x.elementId === e.id);
const prevCe = prevPhase?.elements.find((x) => x.elementId === e.id);
if (!ce || ce.status !== "ACTIVE") continue;
// Die Rechnung ignoriert Einlagen in eine bezogene PK bzw. 3a. Solche Zeilen dürfen
// deshalb gar nicht erst erscheinen -- sonst trägt man Beträge ein, die folgenlos
// bleiben, und der Entscheid aus dem Übergang wirkt hier scheinbar nicht.
if (ce.acceptsCapital === false) continue;
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A" || e.category === "OTHER_ASSET") {
out.push({
elementId: e.id,
name: e.name,
owner: ownerLabel(plan.persons, e.ownerRole),
category: e.category,
kind: "investment",
max: undefined,
hint: "Zusatzeinlage aus dem verfügbaren Kapital erhöht den Startwert in dieser Phase.",
});
} else if (e.category === "REAL_ESTATE") {
const rest = prevCe?.mortgageEnd ?? 0;
// Beim Verkauf gibt es nichts mehr zu amortisieren.
const sold = e.transitionValues[prevPhase?.id ?? ""]?.decision === "SELL";
if (rest > 0 && !sold) {
out.push({
elementId: e.id,
name: e.name,
owner: ownerLabel(plan.persons, e.ownerRole),
category: e.category,
kind: "amortization",
max: rest,
hint: `Sonderamortisation: Einmaltilgung der Hypothek am Übergang. Maximal ${formatChf(rest)} (Resthypothek).`,
});
}
} else if (e.category === "OTHER_DEBT") {
const owed = prevCe ? Math.abs(Math.min(0, prevCe.endValue)) : 0;
if (owed > 0) {
out.push({
elementId: e.id,
name: e.name,
owner: ownerLabel(plan.persons, e.ownerRole),
category: e.category,
kind: "repayment",
max: owed,
hint: `Sofortige Tilgung am Übergang. Maximal ${formatChf(owed)} (Restschuld).`,
});
}
}
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase/prevPhase folgen phaseId
}, [plan.elements, phaseId]);
// Entwurf mit den aktuell gespeicherten Werten vorbelegen.
const [draft, setDraft] = useState<Record<string, number>>(() => {
const d: Record<string, number> = {};
for (const t of targets) {
const e = plan.elements.find((x) => x.id === t.elementId)!;
d[t.elementId] =
t.kind === "investment"
? Math.round(num(e.phaseValues[phaseId]?.additionalInvestment))
: t.kind === "amortization"
? Math.round(num(e.transitionValues[prevPhase?.id ?? ""]?.extraAmortization))
: Math.round(num(e.transitionValues[prevPhase?.id ?? ""]?.immediateRepayment));
}
return d;
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Live-Vorschau: Plan mit den Entwurfswerten neu rechnen.
const preview = useMemo(() => {
const phasePatches: PhasePatch[] = targets
.filter((t) => t.kind === "investment")
.map((t) => ({ elementId: t.elementId, field: "additionalInvestment" as const, value: draft[t.elementId] ?? 0 }));
const transPatches: TransitionPatch[] = targets
.filter((t) => t.kind !== "investment")
.map((t) => ({
elementId: t.elementId,
field: (t.kind === "amortization" ? "extraAmortization" : "immediateRepayment") as keyof TransitionData,
value: draft[t.elementId] ?? 0,
}));
let next = applyPhasePatches(plan, phaseId, phasePatches);
if (prevPhase) next = applyTransitionPatches(next, prevPhase.id, transPatches);
const c = computePlan(next);
return c.phases.find((p) => p.id === phaseId)!;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft, plan, phaseId, targets]);
const pot = capitalPot(preview);
const laterPhases = computed.phases.length - (phase?.sequenceNumber ?? 0);
async function save() {
setSaving(true);
setError(null);
try {
for (const t of targets) {
const e = plan.elements.find((x) => x.id === t.elementId)!;
const value = draft[t.elementId] ?? 0;
if (t.kind === "investment") {
// Bestehende Felder der Phase erhalten -- der Endpunkt ersetzt den ganzen Satz.
const merged: PhaseData = { ...(e.phaseValues[phaseId] ?? {}), additionalInvestment: value };
await api.put(`/api/elements/${e.id}/phase/${phaseId}`, merged);
} else if (prevPhase) {
const existing: TransitionData = e.transitionValues[prevPhase.id] ?? {};
const merged: TransitionData =
t.kind === "amortization"
? { ...existing, extraAmortization: value }
: { ...existing, immediateRepayment: value };
await api.put(`/api/elements/${e.id}/transition/${prevPhase.id}`, merged);
}
}
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
setSaving(false);
}
}
return (
<Modal
title="Kapital verteilen"
subtitle={phase ? `${phase.name} · verfügbares Kapital beim Übergang in diese Phase` : undefined}
onClose={onClose}
wide
>
{/* Herkunft des Topfs */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-faint">
<Coins className="h-4 w-4" /> Woher das Kapital kommt
</div>
<div className="flex flex-col gap-1">
<SummaryRow label="Cash-Ende der Vorphase" value={pot.fromPreviousCash} />
{pot.capitalInflow !== 0 && (
<SummaryRow label="Kapitalzufluss (Verkäufe, PK-/3a-Bezüge)" value={pot.capitalInflow} />
)}
{pot.oneOffInflow !== 0 && <SummaryRow label="Einmaliger Zufluss" value={pot.oneOffInflow} />}
{pot.oneOffOutflow !== 0 && <SummaryRow label="Einmalige Kosten" value={-pot.oneOffOutflow} tone="danger" />}
<div className="my-1 border-t border-border" />
<SummaryRow label="Verfügbares Kapital" value={pot.total} strong />
</div>
</div>
{targets.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
In dieser Phase gibt es keine Ziele, auf die sich Kapital verteilen lässt. Das Kapital bleibt vollständig
auf dem Cash-Konto.
</p>
) : (
<div className="flex flex-col gap-2">
{targets.map((t) => {
const pc = preview.elements.find((x) => x.elementId === t.elementId);
const sources = pc?.capitalFromTransferSources ?? [];
const fromTransfer = pc?.capitalFromTransfer ?? 0;
const manual = draft[t.elementId] ?? 0;
return (
<div key={t.elementId} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-fg">{t.name}</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span>
<span className="text-xs text-faint">
{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}
</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">
{t.kind === "investment"
? "Zusatzeinlage"
: t.kind === "amortization"
? "Sonderamortisation"
: "Sofortige Tilgung"}
</span>
<InfoBubble text={t.hint} />
</div>
{/* Was der Übergang bereits hierher geleitet hat. Ohne diese Zeile sieht man im
Feld eine 0 und hält die prozentuale Zuteilung für wirkungslos. */}
{fromTransfer > 0 && (
<div className="mb-2 rounded-lg border border-dashed border-border bg-surface-2 p-2">
<SummaryRow
label="Bereits aus Kapitalbezug zugeteilt"
value={fromTransfer}
help="Stammt aus der prozentualen Verteilung, die du beim Bezug der Pensionskasse bzw. der Säule 3a festgelegt hast. Der Betrag ist hier nicht editierbar ändere ihn dort."
/>
{sources.map((src) => (
<div key={src.name} className="flex justify-between gap-3 pl-3 text-[11px] text-faint">
<span>aus {src.name}</span>
<span className="tabular-nums">{formatChf(src.amount)}</span>
</div>
))}
</div>
)}
<MoneyField
label={fromTransfer > 0 ? "Zusätzlich aus dem Kapital (CHF)" : "Betrag (CHF)"}
value={manual}
max={t.max}
onChange={(v) => setDraft((prev) => ({ ...prev, [t.elementId]: v }))}
/>
{fromTransfer > 0 && (
<div className="mt-1 pl-1">
<SummaryRow label="Zusatzeinlage total" value={fromTransfer + manual} strong />
</div>
)}
</div>
);
})}
</div>
)}
{/* Live-Bilanz */}
<div
className={`rounded-xl border p-3 ${
pot.rest < 0 ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
}`}
>
<div className="flex flex-col gap-1">
<SummaryRow label="Verfügbares Kapital" value={pot.total} />
{pot.allocatedInvestments !== 0 && <SummaryRow label="davon investiert" value={-pot.allocatedInvestments} />}
{pot.allocatedRepayments !== 0 && (
<SummaryRow label="davon in Schulden getilgt" value={-pot.allocatedRepayments} />
)}
<div className="my-1 border-t border-border" />
<SummaryRow
label={pot.rest < 0 ? "Fehlbetrag (Cash wird negativ)" : "Rest bleibt auf dem Cash"}
value={pot.rest}
strong
tone={pot.rest < 0 ? "danger" : undefined}
help="Was du nicht verteilst, bleibt automatisch auf dem Cash-Konto dort steht es weiterhin zur Verfügung."
/>
</div>
{pot.rest < 0 && (
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Du verteilst mehr, als zur Verfügung steht. Das Cash-Konto startet negativ das Tool meldet die Phase
als Liquiditätslücke.
</p>
)}
</div>
{laterPhases > 0 && (
<div className="grid grid-cols-1">
<CarryWarning laterPhaseCount={laterPhases} />
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2 border-t border-border pt-3">
<Button disabled={saving} onClick={save}>
{saving ? "…" : "Verteilung speichern"}
</Button>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
</div>
</Modal>
);
}
// --- 2. Spar-/Verzehrquote verteilen ------------------------------------------------------
interface RateTarget {
elementId: string;
name: string;
category: string;
// Zuordnung als Klartext -- ohne sie sind zwei gleichnamige Elemente derselben Kategorie
// (z. B. "ETF" bei Person A und Person B) in der Liste nicht unterscheidbar.
owner: string;
field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment";
label: string;
// "out"/"in" = fliesst aus der bzw. in die Quote. "gross" = weder noch: der PK-Beitrag
// stammt aus dem BRUTTOLOHN und berührt das Cash-Konto nicht. Er steht seit 0.40 trotzdem
// hier, weil er sonst nirgends mehr erfassbar wäre -- aber in einem eigenen Block, damit
// niemand ihn für einen Teil der Quote hält.
direction: "out" | "in" | "gross";
hint: string;
// Obergrenze (Säule 3a). Ohne sie liesse sich ein steuerlich unzulässiger Betrag erfassen.
max?: number;
}
export function RateDistributionDialog({
plan,
computed,
phaseId,
onClose,
onSaved,
}: {
plan: PlanInput;
computed: ReturnType<typeof computePlan>;
phaseId: string;
onClose: () => void;
onSaved: () => void;
}) {
const phase = computed.phases.find((p) => p.id === phaseId)!;
// «Grosse Säule 3a» je Element: bestimmt die Obergrenze der Einzahlung. Der Schalter steht
// seit 0.40 hier statt in der Phasenzelle -- getrennt von dem Feld, dessen Grenze er
// festlegt, war er eine Einstellung ohne sichtbare Wirkung.
const [flags, setFlags] = useState<Record<string, boolean>>(() =>
Object.fromEntries(plan.elements.map((e) => [e.id, !!e.phaseValues[phaseId]?.selfEmployed3a]))
);
const targets = useMemo<RateTarget[]>(() => {
const out: RateTarget[] = [];
for (const e of plan.elements) {
const ce = phase?.elements.find((x) => x.elementId === e.id);
if (!ce || ce.status !== "ACTIVE") continue;
// Eine bezogene Säule 3a nimmt keine Einzahlung mehr auf; die Rechnung ignoriert sie.
// Die Zeile hier trotzdem anzubieten hiess, den Entscheid aus dem Übergang zu
// widersprechen -- inklusive eines geerbten Betrags, der wirkungslos weiterläuft.
if (ce.acceptsCapital === false) continue;
if (e.category === "PENSION_FUND") {
// Der PK-Beitrag ist NICHT Teil der Quote (er stammt aus dem Bruttolohn). Er steht in
// einem eigenen Block -- aber er steht hier, weil dies seit 0.40 der einzige Ort ist,
// an dem jährliche Beträge entstehen. Ohne ihn wäre er unerreichbar und fiele still
// auf 0, obwohl er das Endvermögen um sechsstellige Beträge bewegt. Eine bereits
// bezogene PK ist oben über `acceptsCapital` schon ausgeschieden.
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
label: "Jährliche Einzahlung", direction: "gross",
hint: "Arbeitnehmer- und Arbeitgeberbeiträge aus dem Bruttolohn. Sie sind im Nettolohn bereits abgezogen und schmälern die Sparquote deshalb NICHT.",
});
} else if (e.category === "PILLAR_3A") {
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
label: "Jährliche Einzahlung", direction: "out",
hint: "Fliesst jährlich vom Cash in die Säule 3a.",
max: flags[e.id] ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL,
});
} else if (e.category === "OTHER_ASSET") {
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
label: "Jährlicher Sparbeitrag", direction: "out",
hint: "Fliesst jährlich vom Cash ins Vermögen.",
});
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualWithdrawal",
label: "Jährliche Bezugsrate", direction: "in",
hint: "Entnahme aus dem Vermögen ins Cash. Wird jährlich am vorhandenen Bestand gekappt.",
});
} else if (e.category === "REAL_ESTATE") {
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "amortization",
label: "Amortisation pro Jahr", direction: "out",
hint: "Reduziert die Hypothek. Endet automatisch, sobald sie abbezahlt ist.",
});
} else if (e.category === "OTHER_DEBT") {
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualRepayment",
label: "Tilgung pro Jahr", direction: "out",
hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.",
});
}
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase folgt phaseId
}, [plan.elements, phaseId, flags]);
const key = (t: RateTarget) => `${t.elementId}:${t.field}`;
// Vorbelegung mit dem EFFEKTIVEN Wert -- also inklusive der Vererbung aus der Vorphase
// (Roadmap Nr. 44, Punkt A). Bis 0.32 wurde nur der roh gespeicherte Wert der Phase
// gelesen: Eine 3a-Einzahlung von 7'000, die aus Phase 1 geerbt wurde, erschien hier als 0,
// obwohl die Rechnung korrekt mit 7'000 lief.
const orderedPhaseIds = useMemo(
() => [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber).map((p) => p.id),
[plan.phases]
);
const [draft, setDraft] = useState<Record<string, number>>(() => {
const d: Record<string, number> = {};
for (const t of targets) {
const e = plan.elements.find((x) => x.id === t.elementId)!;
const own = (e.phaseValues[phaseId] ?? {})[t.field];
const inherited = inheritedPhaseValues(e.phaseValues, orderedPhaseIds, phaseId)[t.field];
d[key(t)] = Math.round(typeof own === "number" ? own : num(inherited));
}
return d;
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const preview = useMemo(() => {
const patches: PhasePatch[] = targets.map((t) => ({
elementId: t.elementId,
field: t.field,
value: draft[key(t)] ?? 0,
}));
return computePlan(applyPhasePatches(plan, phaseId, patches)).phases.find((p) => p.id === phaseId)!;
}, [draft, plan, phaseId, targets]);
const q = quotaSummary(preview);
const laterPhases = computed.phases.length - phase.sequenceNumber;
const titel = q.isConsumption ? "Verzehrquote verteilen" : "Sparquote verteilen";
async function save() {
setSaving(true);
setError(null);
try {
// Je Element EIN Aufruf, auch wenn mehrere Felder betroffen sind.
const byElement = new Map<string, PhaseData>();
for (const t of targets) {
const e = plan.elements.find((x) => x.id === t.elementId)!;
const merged = byElement.get(t.elementId) ?? { ...(e.phaseValues[phaseId] ?? {}) };
(merged as Record<string, number>)[t.field] = draft[key(t)] ?? 0;
if (t.category === "PILLAR_3A") merged.selfEmployed3a = !!flags[t.elementId];
byElement.set(t.elementId, merged);
}
for (const [elementId, data] of byElement) {
await api.put(`/api/elements/${elementId}/phase/${phaseId}`, data);
}
// Die Verteilung zählt eigens als offener Punkt: Man kann jede Zelle angeschaut und die
// Quote trotzdem nie verteilt haben -- dann bliebe alles still auf dem Cash-Konto.
await api.put(`/api/phases/${phaseId}`, { ratesConfirmed: true });
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
setSaving(false);
}
}
return (
<Modal title={titel} subtitle={`${phase.name} · ${phase.durationYears} Jahre`} onClose={onClose} wide>
{/* Die drei Bezugsgrössen */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-faint">
<PiggyBank className="h-4 w-4" /> {q.isConsumption ? "Deine Verzehrquote" : "Deine Sparquote"}
</div>
<div className="flex flex-col gap-1">
<SummaryRow
label="Erstes Phasenjahr"
value={q.start}
help="Einkommen minus nominale Ausgaben im ersten Jahr dieser Phase."
/>
<SummaryRow
label="Letztes Phasenjahr"
value={q.end}
help="Die Quote verändert sich über die Phase: Das Einkommen wächst mit der Lohnerhöhung, die real erfassten Ausgaben mit der Inflation."
/>
<div className="my-1 border-t border-border" />
<SummaryRow
label={q.isConsumption ? "Absolut über die ganze Phase (Bedarf)" : "Absolut über die ganze Phase"}
value={q.total}
strong
help="Summe über alle Phasenjahre der Betrag, der insgesamt zur Verfügung steht bzw. fehlt. Bewusst nominal summiert, genau wie das Cash-Konto rechnet."
/>
</div>
{!q.isConsumption && q.end < q.start && (
<p className="mt-2 text-[11px] text-muted">
Achtung: Die Quote <strong className="text-fg">sinkt</strong> von {formatChf(q.start)} auf{" "}
{formatChf(q.end)}. Eine flache Jahresrate über {formatChf(q.end)} lässt sich in den späteren Jahren
nicht mehr aus der laufenden Quote decken sie zehrt dann am Cash-Bestand.
</p>
)}
</div>
{targets.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
In dieser Phase gibt es keine Elemente mit jährlichen Raten. Die ganze Quote läuft aufs Cash-Konto.
</p>
) : (
// Bewusst nach RICHTUNG gruppiert: erst alles, was das Cash verlässt, dann alles, was
// hineinfliesst, zuletzt der Bruttolohn-Block. Gemischt war die Liste schwer zu lesen.
<div className="flex flex-col gap-4">
{(["out", "in", "gross"] as const).map((dir) => {
const rows = targets.filter((t) => t.direction === dir);
if (rows.length === 0) return null;
return (
<div key={dir} className="flex flex-col gap-2">
<div
className={`flex items-center gap-2 text-xs font-semibold uppercase tracking-wide ${
dir === "in" ? "text-success" : "text-muted"
}`}
>
{dir === "in"
? "Ins Cash (Entnahmen)"
: dir === "gross"
? "Aus dem Bruttolohn (ausserhalb der Quote)"
: "Vom Cash (Einzahlungen und Tilgungen)"}
</div>
{dir === "gross" && (
<p className="rounded-lg bg-surface-2 px-3 py-2 text-[11px] leading-relaxed text-muted">
Diese Beiträge sind im Nettolohn <strong className="text-fg">bereits abgezogen</strong>. Sie
schmälern die Sparquote nicht und tauchen in der Bilanz unten deshalb nicht auf sie stehen
hier, weil sie das Vorsorgekapital gleichwohl jährlich erhöhen.
</p>
)}
{rows.map((t) => (
<div key={key(t)} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-fg">{t.name}</span>
<span className="text-xs text-faint">
{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}
</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span>
<InfoBubble text={t.hint} />
</div>
{/* Das Häkchen steht ÜBER dem Betrag, weil es dessen Obergrenze bestimmt. */}
{t.category === "PILLAR_3A" && (
<label className="mb-2 flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={!!flags[t.elementId]}
onChange={(e) => setFlags((prev) => ({ ...prev, [t.elementId]: e.target.checked }))}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble
text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`}
/>
</label>
)}
<MoneyField
label={`${t.label} (CHF/Jahr)`}
help={
t.max !== undefined
? `Höchstens CHF ${t.max.toLocaleString("de-CH")} (2026) und höchstens die Sparquote.`
: undefined
}
value={draft[key(t)] ?? 0}
max={t.max}
onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))}
/>
</div>
))}
</div>
);
})}
</div>
)}
{/* Live-Bilanz über die ganze Phase */}
<div
className={`rounded-xl border p-3 ${
preview.cashNegative ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
}`}
>
<div className="flex flex-col gap-1">
<SummaryRow label={q.isConsumption ? "Absoluter Bedarf" : "Absolut verfügbar"} value={q.total} />
{q.allocatedOut !== 0 && (
<SummaryRow
label="davon in Raten verplant"
value={-q.allocatedOut}
help="Summe aller jährlichen Raten über die Phasenjahre inklusive der automatischen Kappung, sobald eine Schuld abbezahlt ist."
/>
)}
{q.allocatedIn !== 0 && (
<SummaryRow
label="Bezugsraten ins Cash"
value={q.allocatedIn}
tone="success"
help="Tatsächlich entnommener Betrag am vorhandenen Bestand gekappt."
/>
)}
<div className="my-1 border-t border-border" />
<SummaryRow
label={q.netToCash < 0 ? "Zehrt am Cash-Bestand" : "Geht aufs Cash-Konto"}
value={q.netToCash}
strong
tone={q.netToCash < 0 ? "danger" : undefined}
help="Was nicht in Raten verplant ist, landet auf dem Cash-Konto. Ein negativer Wert bedeutet, dass der Cash-Bestand in dieser Phase abnimmt."
/>
<SummaryRow label="Cash am Phasenende" value={preview.cashEnd} tone={preview.cashEnd < 0 ? "danger" : undefined} />
</div>
{preview.cashNegative && (
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Mit dieser Verteilung fällt das Cash-Konto in dieser Phase unter 0 das ist eine Liquiditätslücke.
</p>
)}
</div>
{laterPhases > 0 && (
<div className="grid grid-cols-1">
<CarryWarning laterPhaseCount={laterPhases} />
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2 border-t border-border pt-3">
<Button disabled={saving} onClick={save}>
{saving ? "…" : "Verteilung speichern"}
</Button>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
</div>
</Modal>
);
}
File diff suppressed because it is too large Load Diff
+101 -3
View File
@@ -51,6 +51,53 @@ export function NumberField({
); );
} }
// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und hätte
// damit immer einen Default -- genau das soll bei Annahmen vermieden werden, siehe
// SPEZIFIKATION 9.15/9.18: ein stiller Default wird nicht hinterfragt). Der Wert wird als
// String geführt; die Validierung liegt beim Aufrufer.
export function RequiredNumberField({
label,
help,
value,
onChange,
step = 0.1,
suffix,
placeholder = "Pflicht",
}: {
label: string;
help?: string;
value: string;
onChange: (value: string) => void;
step?: number;
suffix?: string;
placeholder?: string;
}) {
const empty = value.trim() === "";
return (
<div>
<FieldLabel label={label} help={help} />
<div className="relative">
<input
type="number"
step={step}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
className={`${baseInputClass} ${suffix ? "pr-8" : ""} ${
empty ? "border-danger focus:border-danger" : ""
}`}
/>
{suffix && (
<span className="pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-xs text-faint">
{suffix}
</span>
)}
</div>
{empty && <p className="mt-1 text-[11px] text-danger">Pflichtfeld bitte ausfüllen.</p>}
</div>
);
}
// Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an, // Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an,
// akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet // akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet
// Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...). // Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...).
@@ -101,7 +148,7 @@ export function MoneyInput({
} }
// Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die // Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
// Schrittweite mit der Haltedauer waechst (immer schneller). // Schrittweite mit der Haltedauer wächst (immer schneller).
function startHold(sign: number) { function startHold(sign: number) {
stepBy(sign); stepBy(sign);
const start = Date.now(); const start = Date.now();
@@ -130,7 +177,7 @@ export function MoneyInput({
onFocus={() => { onFocus={() => {
setFocused(true); setFocused(true);
const current = Math.round(value || 0); const current = Math.round(value || 0);
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn ueberschreiben kann. // Default-0 sofort leeren; sonst Wert markieren, damit man ihn überschreiben kann.
setText(current === 0 ? "" : String(current)); setText(current === 0 ? "" : String(current));
requestAnimationFrame(() => inputRef.current?.select()); requestAnimationFrame(() => inputRef.current?.select());
}} }}
@@ -153,7 +200,7 @@ export function MoneyInput({
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
aria-label="Erhoehen" aria-label="Erhöhen"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
startHold(1); startHold(1);
@@ -273,3 +320,54 @@ export function SelectField<T extends string>({
</div> </div>
); );
} }
// --- "Aus Vorphase uebernehmen" (Roadmap Nr. 44, Punkt A) --------------------------------
//
// Wiederkehr-Parameter (Raten, Beitraege, Amortisation) gelten weiter, bis man sie bewusst
// aendert. Bisher war das unsichtbar: Beim Anlegen einer Phase wurde der Wert KOPIERT, und
// spaetere Aenderungen an der Vorphase kamen deshalb nie an. Jetzt entscheidet ein sichtbares
// Haekchen: angehakt = Feld leer lassen und live erben; abgehakt = eigener Wert fuer diese
// Phase. In der ersten Phase gibt es nichts zu erben -- dort erscheint das Haekchen nicht.
export function InheritableField({
label,
help,
canInherit,
isInheriting,
inheritedDisplay,
onToggle,
children,
}: {
label: string;
help?: string;
canInherit: boolean;
isInheriting: boolean;
inheritedDisplay: string;
onToggle: (inherit: boolean) => void;
children: React.ReactNode;
}) {
return (
<div>
{isInheriting ? (
<>
<FieldLabel label={label} help={help} />
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
{inheritedDisplay} <span className="text-xs text-faint">(aus der Vorphase)</span>
</div>
</>
) : (
children
)}
{canInherit && (
<label className="mt-1 flex cursor-pointer items-center gap-1.5 text-xs text-muted">
<input
type="checkbox"
checked={isInheriting}
onChange={(e) => onToggle(e.target.checked)}
className="h-3.5 w-3.5 accent-accent"
/>
Aus Vorphase übernehmen
</label>
)}
</div>
);
}
+74 -5
View File
@@ -1,27 +1,96 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Info } from "lucide-react"; import { Info } from "lucide-react";
// Hilfetext-Bubble.
//
// Der Text wird über ein PORTAL an den <body> gehängt und `fixed` positioniert. Vorher lag er
// als `absolute` Kind im Fluss -- in einem scrollenden Dialog (Verteil-Dialoge, Assistent)
// schnitt dessen `overflow` die Box ab, teilweise nach wenigen Zeilen. Fix positioniert
// bezieht sich auf den Viewport und entkommt damit jedem Overflow-Container.
//
// Zusätzlich: Die Box klappt nach OBEN, wenn unten kein Platz mehr ist, und wird an den
// Bildschirmrändern geklemmt, damit sie nie halb ausserhalb steht.
const WIDTH = 256; // entspricht w-64
const MARGIN = 8;
export function InfoBubble({ text }: { text: string }) { export function InfoBubble({ text }: { text: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
// Position aus der Lage des Knopfes berechnen -- erst wenn geöffnet, und erneut, sobald die
// tatsächliche Höhe der Box bekannt ist (für das Hochklappen).
useEffect(() => {
if (!open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Position gilt nur im offenen Zustand
setPos(null);
return;
}
const place = () => {
const btn = btnRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
const height = boxRef.current?.offsetHeight ?? 0;
const spaceBelow = window.innerHeight - r.bottom;
const above = height > 0 && spaceBelow < height + MARGIN && r.top > height + MARGIN;
const left = Math.min(
Math.max(MARGIN, r.left + r.width / 2 - WIDTH / 2),
Math.max(MARGIN, window.innerWidth - WIDTH - MARGIN)
);
setPos({ top: above ? r.top - height - 6 : r.bottom + 6, left });
};
place();
// Nach dem ersten Zeichnen erneut -- dann steht die Höhe fest.
const raf = requestAnimationFrame(place);
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
}, [open]);
return ( return (
<span className="relative inline-flex align-middle ml-1"> <span className="relative inline-flex align-middle ml-1">
<button <button
ref={btnRef}
type="button" type="button"
aria-label="Hilfe anzeigen" aria-label="Hilfe anzeigen"
onMouseEnter={() => setOpen(true)} onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)} onMouseLeave={() => setOpen(false)}
onClick={() => setOpen((o) => !o)} onClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent-soft text-accent-soft-fg hover:opacity-80" className="flex h-4 w-4 items-center justify-center rounded-full bg-accent-soft text-accent-soft-fg hover:opacity-80"
> >
<Info className="h-2.5 w-2.5" strokeWidth={2.5} /> <Info className="h-2.5 w-2.5" strokeWidth={2.5} />
</button> </button>
{open && ( {open &&
<span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg"> typeof document !== "undefined" &&
createPortal(
<div
ref={boxRef}
role="tooltip"
style={{
position: "fixed",
top: pos?.top ?? -9999,
left: pos?.left ?? -9999,
width: WIDTH,
// Vor der ersten Messung unsichtbar, damit die Box nicht kurz aufblitzt.
visibility: pos ? "visible" : "hidden",
}}
className="pointer-events-none z-[60] rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg"
>
{text} {text}
</span> </div>,
document.body
)} )}
</span> </span>
); );
+627
View File
@@ -0,0 +1,627 @@
"use client";
// Die Bestandsaufnahme: alles erfassen, was heute da ist.
//
// Der einzige Sammel-Dialog, der geblieben ist. Er arbeitet ausschliesslich über die
// bestehenden Endpunkte -- was er schreibt, hätte man auch einzeln über die Matrix erfassen
// können; er nimmt einem nur das Suchen ab.
//
// Erfasst werden NUR Tatsachen: Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen,
// Lohnentwicklung und Teuerung stehen bewusst nicht hier -- sie sind Annahmen, und eine
// Annahme gilt immer nur für einen Zeitraum. Sie werden in den Phasenzellen der Matrix
// gesetzt und dort bestätigt (SPEZIFIKATION 3.14).
import { useState } from "react";
import { Check, Plus, Trash2 } from "lucide-react";
import { api } from "@/lib/api-client";
import { Button, Modal, useConfirm } from "@/components/ui";
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
type Scope = "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
interface Kind {
key: string;
category: string;
// Beschriftung des Knopfs. Bewusst der KATEGORIENAME und keine erfundene Umgangssprache:
// Die Matrix, die Rechenwege und der PDF-Bericht sprechen dieselbe Sprache -- wer hier eine
// andere Vokabel lernt, sucht sie spaeter vergeblich. Die Alltagssprache steht in `hint`.
label: string;
// Vorschlag fuer den Namen des angelegten Elements. Hier darf es konkret werden: Die
// Kategorie ist "Ausgaben", die Zeile heisst "Lebenshaltung".
defaultName: string;
hint: string;
personOnly?: boolean;
// SCHRITT 1 -- der Bestand von heute. Nur Zahlen, die man nachschlagen kann: Kontostand,
// Guthaben, Kaufpreis, Restschuld.
baseFields: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
// SCHRITT 2 -- die Annahmen fuer EINE Lebensphase. Alles, was pro Jahr geschieht und sich
// von Phase zu Phase aendern darf: Renditen, Lohnentwicklung, Wertsteigerung, Zins.
//
// Die Trennung ist der Kern: Ein Guthaben ist eine Tatsache, eine Rendite eine Annahme --
// und Annahmen gelten immer nur fuer einen Zeitraum. Bis 0.37 standen beide zusammen in der
// Bestandsaufnahme, wodurch eine Annahme wie eine Tatsache aussah.
phaseFields?: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
}
const KINDS: Kind[] = [
{
key: "income",
category: "INCOME",
label: "Einkommen",
defaultName: "Lohn",
hint: "Dein Nettolohn das, was auf dem Konto ankommt.",
personOnly: true,
baseFields: (v, set) => (
<MoneyField
label="Nettolohn pro Jahr"
help="Was tatsächlich aufs Konto kommt, nach allen Abzügen. FPT rechnet für die AHV intern auf den Bruttolohn hoch."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
),
phaseFields: (v, set) => (
<NumberField
label="Jährliche Lohnerhöhung (%)"
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
step={0.5}
value={num(v.teuerungsausgleich, 1)}
onChange={(x) => set({ teuerungsausgleich: x })}
/>
),
},
{
key: "expense",
category: "EXPENSE",
label: "Ausgaben",
defaultName: "Lebenshaltung",
hint: "Alles, was du im Jahr ausgibst Wohnen, Essen, Versicherungen, Freizeit.",
baseFields: (v, set) => (
<MoneyField
label="Ausgaben pro Jahr"
help="In heutiger Kaufkraft. Die Teuerung rechnet FPT selbst dazu."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
),
phaseFields: (v, set) => (
<NumberField
label="Jährliche Teuerung (%)"
help="Leer lassen heisst: die Inflationsannahme des Szenarios. Eine eigene Zahl nur, wenn du für diese Ausgaben etwas anderes erwartest."
step={0.25}
value={num(v.teuerungsausgleich)}
onChange={(x) => set({ teuerungsausgleich: x })}
/>
),
},
{
key: "pk",
category: "PENSION_FUND",
label: "Pensionskasse",
defaultName: "Pensionskasse",
hint: "Dein Altersguthaben laut PK-Ausweis.",
personOnly: true,
baseFields: (v, set) => (
<MoneyField
label="Altersguthaben heute"
help="Steht auf deinem Vorsorgeausweis."
value={num(v.currentValue)}
onChange={(x) => set({ currentValue: x })}
/>
),
phaseFields: (v, set) => (
<>
<NumberField
label="Verzinsung (%/Jahr)"
help="Der BVG-Mindestzins liegt aktuell tief; viele Kassen verzinsen etwas darüber."
step={0.25}
value={num(v.expectedReturn, 1.25)}
onChange={(x) => set({ expectedReturn: x })}
/>
{/* Der PK-Beitrag steht hier und nicht bei der Sparquote: Er stammt aus dem
Bruttolohn und belastet das Cash-Konto nicht -- aus der Quote liesse er sich
gar nicht verteilen (SPEZIFIKATION 4.6.3). */}
<MoneyField
label="Jährlicher Beitrag (Arbeitnehmer + Arbeitgeber)"
help="Die Altersgutschrift laut Vorsorgeausweis. Sie kommt aus dem Bruttolohn und schmälert deine Sparquote deshalb nicht."
value={num(v.annualContribution)}
onChange={(x) => set({ annualContribution: x })}
/>
</>
),
},
{
key: "p3a",
category: "PILLAR_3A",
label: "Säule 3a",
defaultName: "Säule 3a",
hint: "Ein Konto oder Depot der gebundenen Vorsorge. Mehrere Konten legst du einzeln an.",
personOnly: true,
baseFields: (v, set) => (
<MoneyField label="Guthaben heute" value={num(v.currentValue)} onChange={(x) => set({ currentValue: x })} />
),
phaseFields: (v, set) => (
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
step={0.5}
value={num(v.expectedReturn, 2)}
onChange={(x) => set({ expectedReturn: x })}
/>
),
},
{
key: "etf",
category: "OTHER_ASSET",
label: "Sonstiges Vermögen",
defaultName: "Wertschriften",
hint: "Depot, ETF, Sparkonto alles freie Vermögen ausser dem Cash-Konto.",
baseFields: (v, set) => (
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
),
phaseFields: (v, set) => (
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
step={0.5}
value={num(v.expectedReturn, 4)}
onChange={(x) => set({ expectedReturn: x })}
/>
),
},
{
key: "re",
category: "REAL_ESTATE",
label: "Immobilie",
defaultName: "Wohneigentum",
hint: "Haus oder Wohnung samt Hypothek.",
baseFields: (v, set) => (
<>
<MoneyField
label="Kaufpreis"
help="Der ursprüngliche Kaufpreis Grundlage der Grundstückgewinnsteuer bei einem späteren Verkauf."
value={num(v.purchasePrice)}
onChange={(x) => set({ purchasePrice: x })}
/>
<MoneyField label="Hypothek heute" value={num(v.mortgage)} onChange={(x) => set({ mortgage: x })} />
</>
),
phaseFields: (v, set) => (
<>
<NumberField
label="Hypothekarzins (%)"
step={0.25}
value={num(v.interestRate, 2)}
onChange={(x) => set({ interestRate: x })}
/>
<NumberField
label="Wertsteigerung (%/Jahr)"
help="Auf den Wert der Liegenschaft, nicht auf dein Eigenkapital dort wirkt sie gehebelt."
step={0.25}
value={num(v.valueGrowth, 1)}
onChange={(x) => set({ valueGrowth: x })}
/>
<div className="sm:col-span-2">
<SelectField
label="Sind die Hypothekarzinsen in deinen Ausgaben schon enthalten?"
help="Wenn ja, rechnet FPT sie nicht noch einmal dazu sonst zählten sie doppelt."
value={v.interestHandling ?? "INCLUDED"}
onChange={(x: "INCLUDED" | "ADD") => set({ interestHandling: x })}
options={[
{ value: "INCLUDED", label: "Ja, sind in der Lebenshaltung enthalten" },
{ value: "ADD", label: "Nein, bitte zusätzlich rechnen" },
]}
/>
</div>
</>
),
},
{
key: "debt",
category: "OTHER_DEBT",
label: "Sonstige Schulden",
defaultName: "Schulden",
hint: "Kredite ausserhalb der Hypothek.",
baseFields: (v, set) => (
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
),
},
];
// Der Entwurf des GANZEN Schritts, nicht der einzelnen Karte.
//
// Zwei Gruende dafuer, dass er hier oben liegt und nicht in der Karte: Beim Wechsel des
// Reiters verschwinden die Karten des vorigen Bereichs vom Bildschirm -- mit ihrem eigenen
// Zustand waeren alle getippten Zahlen still weg. Und die Summe "Vermoegen heute" laesst sich
// nur berechnen, wenn alle Entwuerfe an EINEM Ort liegen.
export interface InventoryDraft {
base: Record<string, PhaseData>;
names: Record<string, string>;
// Cash ist kein Element, sondern eine Eigenschaft des Szenarios (`Scenario.initialCash`) --
// deshalb kein Plus-Knopf, sondern ein festes Feld. Es fehlte bis 0.39 ganz: Die Summe
// «Vermögen heute» rechnete es bereits mit, erfassen konnte man es hier aber nirgends.
// `null` = unberührt (dann wird beim Speichern auch nichts geschrieben).
cash: number | null;
}
export function emptyInventoryDraft(): InventoryDraft {
return { base: {}, names: {}, cash: null };
}
// Bestand eines Elements aus Stammdaten -- dieselbe Rechnung wie in der Kachel «Kennzahlen»,
// hier nur auf dem Entwurf statt auf dem gespeicherten Stand.
function wealthOf(category: string, d: PhaseData): number {
if (category === "OTHER_ASSET") return num(d.startValue);
if (category === "PENSION_FUND" || category === "PILLAR_3A") return num(d.currentValue);
if (category === "REAL_ESTATE") return num(d.purchasePrice) - num(d.mortgage);
if (category === "OTHER_DEBT") return -num(d.startValue);
return 0;
}
// Die Stammdaten-Felder einer Kategorie. Eigene Komponente, weil sie an ZWEI Orten
// erscheinen: im Assistenten-Schritt 1 (als Liste) und in der Matrix-Spalte «Start» (einzeln).
// Zwei Implementierungen derselben Felder liefen garantiert auseinander.
export function BaseFields({
category,
values,
set,
}: {
category: string;
values: PhaseData;
set: (patch: Partial<PhaseData>) => void;
}) {
const kind = KINDS.find((k) => k.category === category);
return <>{kind?.baseFields(values, set)}</>;
}
export function InventoryFields({
plan,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const scopes: Scope[] =
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
const [page, setPage] = useState(0);
const scope = scopes[page];
const cash = draft.cash ?? Math.round(plan.initialCash || 0);
// Live-Summe aus dem ENTWURF: Sie soll sich beim Tippen bewegen, nicht erst beim Speichern.
const today = plan.elements.reduce(
(sum, e) => sum + wealthOf(e.category, draft.base[e.id] ?? e.baseData ?? {}),
cash
);
return (
<div className="flex flex-col gap-4">
{/* Cash steht ausserhalb der Reiter: Es ist kein Element und gehört keiner Person --
es ist der Topf, aus dem alles fliesst und in den alles zurückfliesst. */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<MoneyField
label="Cash heute (Konto- und Sparguthaben)"
help="Alles, was sofort verfügbar ist. Aus diesem Topf zahlt FPT die Ausgaben; was du sparst und nicht zuteilst, bleibt hier liegen."
value={cash}
onChange={(x) => setDraft((d) => ({ ...d, cash: x }))}
/>
</div>
{scopes.length > 1 && (
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
{scopes.map((sc, i) => (
<button
key={sc}
type="button"
onClick={() => setPage(i)}
className={`flex-1 rounded-md px-2 py-1 font-medium ${
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
}`}
>
{sc === "HOUSEHOLD" ? "Gemeinsam" : ownerLabel(plan.persons, sc)}
</button>
))}
</div>
)}
<ScopeElements plan={plan} scope={scope} draft={draft} setDraft={setDraft} onChanged={onChanged} />
{/* Die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt -- und die erste
Rueckmeldung auf die Bestandsaufnahme. */}
<div className="sticky bottom-0 flex items-baseline justify-between gap-3 border-t border-border bg-surface px-1 pt-3">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Vermögen heute</span>
<span className="text-lg font-semibold tabular-nums text-fg">{formatChf(today)}</span>
</div>
</div>
);
}
function ScopeElements({
plan,
scope,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
scope: Scope;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const kinds = KINDS.filter((k) => (scope === "HOUSEHOLD" ? !k.personOnly : true));
const mine = plan.elements.filter((e) => (e.ownerRole ?? "HOUSEHOLD") === scope);
// Das Element entsteht SOFORT -- es braucht eine Id, an der die Werte haengen. Nur die
// Zahlen werden bis zum Abschluss des Schritts zurueckgehalten.
async function add(kind: Kind) {
setBusy(kind.key);
setError(null);
try {
const suffix =
plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : "";
await api.post(`/api/scenarios/${plan.id}/elements`, {
category: kind.category,
name: `${kind.defaultName}${suffix}`,
ownerRole: scope,
});
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
} finally {
setBusy(null);
}
}
return (
<div className="flex flex-col gap-3">
{/* Der Knopf traegt den Kategorienamen, die Erklaerung steht darunter -- so lernt man
beim ersten Kontakt die Vokabel, die auch die Matrix verwendet. */}
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
{kinds.map((k) => (
<button
key={k.key}
type="button"
disabled={busy === k.key}
onClick={() => add(k)}
className="flex items-start gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-1.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/20"
>
<Plus className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
<span className="min-w-0">
<span className="block text-xs font-semibold text-fg">{k.label}</span>
<span className="block text-[11px] leading-snug text-muted">{k.hint}</span>
</span>
</button>
))}
</div>
{mine.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Noch nichts erfasst. Wähle oben, was zutrifft du kannst jederzeit weitere Positionen ergänzen.
</p>
) : (
<div className="flex flex-col gap-2">
{mine.map((el) => (
<BaseDataCard
key={el.id}
plan={plan}
elementId={el.id}
draft={draft}
setDraft={setDraft}
onChanged={onChanged}
/>
))}
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
function BaseDataCard({
plan,
elementId,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
elementId: string;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const el = plan.elements.find((x) => x.id === elementId)!;
const kind = KINDS.find((k) => k.category === el.category);
const values = draft.base[el.id] ?? el.baseData ?? {};
const name = draft.names[el.id] ?? el.name;
const set = (patch: Partial<PhaseData>) =>
setDraft((d) => ({ ...d, base: { ...d.base, [el.id]: { ...(d.base[el.id] ?? el.baseData ?? {}), ...patch } } }));
async function remove() {
await api.delete(`/api/elements/${el.id}`);
setDraft((d) => {
const base = { ...d.base };
const names = { ...d.names };
delete base[el.id];
delete names[el.id];
return { ...d, base, names };
});
onChanged();
}
return (
<div className="rounded-xl border border-border p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
{CATEGORY_LABELS[el.category]}
</span>
<button
type="button"
onClick={remove}
className="ml-auto rounded p-1 text-faint hover:bg-danger-soft hover:text-danger"
aria-label="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<TextField
label="Bezeichnung"
value={name}
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
/>
</div>
{kind ? (
kind.baseFields(values, set)
) : (
// Die AHV ist die einzige Kategorie ohne Bestand: Es gibt kein Guthaben zum
// Nachschlagen, nur eine Beitragskarriere. Eine leere Karte sähe nach einem Fehler
// aus -- deshalb steht hier, warum nichts zu tun ist.
<p className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Hier gibt es nichts zu erfassen. Die AHV-Rente entsteht aus deiner Beitragskarriere FPT leitet sie
aus den Einkommen dieses Plans ab. Beim Übergang in die Pension kannst du sie prüfen und um
Beitragslücken vor Planbeginn ergänzen.
</p>
)}
</div>
</div>
);
}
// Schreibt den gesammelten Entwurf. Wird vom Schritt-Dialog beim Abschliessen aufgerufen --
// es gibt bewusst keinen Speichern-Knopf je Karte mehr.
export async function saveInventoryDraft(plan: PlanInput, draft: InventoryDraft): Promise<void> {
for (const el of plan.elements) {
const nextName = draft.names[el.id]?.trim();
if (nextName && nextName !== el.name) {
await api.patch(`/api/elements/${el.id}`, { name: nextName });
}
const base = draft.base[el.id];
if (base) await api.put(`/api/elements/${el.id}/base`, base);
}
// Cash hängt am Szenario, nicht an einem Element -- eigener Endpunkt.
if (draft.cash !== null && draft.cash !== plan.initialCash) {
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: draft.cash });
}
// Die AHV entsteht am Schluss von selbst -- je Person eine.
//
// Sie hat als einzige Kategorie KEINEN Bestand: Es gibt kein Guthaben, das man nachschlagen
// könnte, nur eine Beitragskarriere. Ein Plus-Knopf mit einer leeren Karte darunter wäre
// deshalb sinnlos gewesen. Weglassen ist aber keine Option: In der Schweiz hat jede Person
// eine AHV, und ohne das Element rechnet der Plan ab der Pensionierung mit einer Rente von 0.
//
// IDEMPOTENT: Wer die Bestandsaufnahme später korrigiert und erneut abschliesst, bekommt
// kein zweites Element -- geprüft wird je Person, nicht global.
for (const person of plan.persons) {
const exists = plan.elements.some((e) => e.category === "AHV" && e.ownerRole === person.role);
if (exists) continue;
const name = person.name?.trim() ? `AHV ${person.name.trim()}` : "AHV";
await api.post(`/api/scenarios/${plan.id}/elements`, {
category: "AHV",
name,
ownerRole: person.role,
});
}
}
// --- Der Dialog drumherum -------------------------------------------------------------------
//
// Gespeichert wird EINMAL, beim Abschliessen. Ein Speichern-Knopf je Element hätte bedeutet,
// dass ein Reiter-Wechsel die getippten Zahlen verwirft -- deshalb liegt der Entwurf hier
// oben und nicht in den einzelnen Karten.
export function InventoryDialog({
plan,
onClose,
onChanged,
onSaved,
}: {
plan: PlanInput;
onClose: () => void;
// Der Plan hat sich geaendert (Element angelegt oder geloescht) -- neu laden, aber den
// Dialog OFFEN lassen. Getrennt von onSaved, denn das schliesst ihn.
onChanged: () => void;
onSaved: () => void;
}) {
const [draft, setDraft] = useState<InventoryDraft>(emptyInventoryDraft);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const dirty =
Object.keys(draft.base).length > 0 ||
Object.keys(draft.names).length > 0 ||
(draft.cash !== null && draft.cash !== plan.initialCash);
async function closeGuarded() {
if (!dirty) return onClose();
const ok = await confirm({
title: "Eingaben verwerfen?",
message:
"Du hast Angaben erfasst, die noch nicht gespeichert sind. Schliesst du jetzt, gehen sie verloren. " +
"Mit «Bestandsaufnahme abschliessen» werden sie übernommen.",
confirmLabel: "Verwerfen",
danger: true,
});
if (ok) onClose();
}
async function save() {
setSaving(true);
setError(null);
try {
await saveInventoryDraft(plan, draft);
setDraft(emptyInventoryDraft());
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
setSaving(false);
}
}
return (
<Modal
title="Bestandsaufnahme"
subtitle="Alles, was du heute hast Einkommen, Ausgaben, Guthaben, Immobilien, Schulden"
onClose={closeGuarded}
xwide
>
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Hier stehen nur Zahlen, die du <strong className="text-fg">nachschlagen</strong> kannst. Renditen,
Lohnentwicklung und Teuerung kommen später sie sind Annahmen, und eine Annahme gilt immer nur für
einen bestimmten Zeitraum. Du triffst sie in den Lebensphasen.
</div>
<InventoryFields
plan={plan}
draft={draft}
setDraft={(fn) => setDraft((d) => fn(d))}
onChanged={onChanged}
/>
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" onClick={closeGuarded}>
Abbrechen
</Button>
<Button disabled={saving} onClick={save}>
<Check className="h-4 w-4" /> {saving ? "Wird gespeichert…" : "Bestandsaufnahme abschliessen"}
</Button>
</div>
</div>
</Modal>
);
}
+376
View File
@@ -0,0 +1,376 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, RotateCcw, SlidersHorizontal, X } from "lucide-react";
import { AllocationChart } from "@/components/AllocationChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { WealthChart } from "@/components/WealthChart";
import { InfoBubble } from "@/components/InfoBubble";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { formatChf } from "@/lib/format";
import {
buildSliders,
describeSettings,
isNeutral,
kpisOf,
runLive,
type SliderDef,
type SliderValues,
} from "@/lib/livesim";
import { UNIT_SUFFIX } from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
type ChartId = "wealth" | "allocation" | "cashflow";
const CHARTS: { id: ChartId; label: string; hint: string }[] = [
{
id: "wealth",
label: "Vermögensverlauf",
hint: "Gesamtvermögen über das Alter. Die blasse Linie ist dein unveränderter Plan.",
},
{
id: "allocation",
label: "Vermögensaufteilung",
hint: "Wie sich das Vermögen zu Beginn und am Ende jeder Phase auf die Anlagen verteilt.",
},
{
id: "cashflow",
label: "Einkommen vs. Ausgaben",
hint: "Die Fläche dazwischen ist die Spar- bzw. Verzehrquote.",
},
];
const BASE_COLOR = "var(--faint)";
const LIVE_COLOR = "var(--chart-1)";
export function LiveSimDialog({
plan: currentPlan,
actuals = [],
origins = [],
onClose,
}: {
plan: PlanInput;
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
onClose: () => void;
}) {
// Geregelt wird wahlweise am Arbeitsstand oder an einer festgehaltenen Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
const basis = useAnalysisBasis(plan, actuals, origins);
const [expandReturns, setExpandReturns] = useState(false);
const [values, setValues] = useState<SliderValues>({});
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
const [bounds, setBounds] = useState<Record<string, [number, number]>>({});
const [chart, setChart] = useState<ChartId>("wealth");
const [showRanges, setShowRanges] = useState(false);
const sliders = useMemo(() => {
const base = buildSliders(plan, expandReturns);
return base.map((s) => {
const b = bounds[s.key];
return b ? { ...s, min: b[0], max: b[1] } : s;
});
}, [plan, expandReturns, bounds]);
// Referenz: der unveränderte Plan. Wird nur neu gerechnet, wenn sich der Plan ändert.
const base = useMemo(() => {
const computed = basis.computed;
return { computed, kpis: kpisOf(computed) };
}, [basis.computed]);
// Der Kern: bei JEDER Reglerbewegung synchron neu rechnen (~0.2 ms, siehe livesim.ts).
const live = useMemo(
() => runLive(plan, sliders, values, basis.resolvedActuals),
[plan, sliders, values, basis.resolvedActuals]
);
const neutral = isNeutral(sliders, values);
const settings = describeSettings(sliders, values);
function setValue(key: string, v: number) {
setValues((prev) => ({ ...prev, [key]: v }));
}
function reset() {
setValues({});
}
// Beim Auf-/Zuklappen die Rendite-Regler zurücksetzen: Sammel- und Element-Regler bewegen
// dieselben Werte, ein Übertrag würde doppelt zählen.
function toggleExpand() {
setValues((prev) => {
const next: SliderValues = {};
for (const [k, v] of Object.entries(prev)) {
if (k === "d:returns" || k === "d:propertyGrowth" || k.startsWith("e:")) continue;
next[k] = v;
}
return next;
});
setExpandReturns((v) => !v);
}
return (
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="ui-pop flex w-full max-w-6xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<SlidersHorizontal className="h-5 w-5 text-accent" /> Live-Simulation
</h2>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={(id) => {
setVersionId(id);
setValues({});
}}
loading={versionLoading}
/>
<AnalysisBar basis={basis} onChange={() => setValues({})} />
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
gespeichert</strong> dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
Szenario-Kopie. Die Regler benutzen dieselben Umrechnungen wie die Einflussfaktoren-Analyse.
</p>
{/* Kennzahlenleiste: die eigentliche Antwort. Die Grafik zeigt WANN, das hier WIE VIEL. */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DeltaCard
label={basis.metric === "real" ? "Endvermögen (real)" : "Endvermögen (nominal)"}
base={basis.metric === "real" ? base.kpis.endReal : base.kpis.endNominal}
live={basis.metric === "real" ? live.kpis.endReal : live.kpis.endNominal}
/>
<DeltaCard
label={basis.metric === "real" ? "Endvermögen (nominal)" : "Endvermögen (real)"}
base={basis.metric === "real" ? base.kpis.endNominal : base.kpis.endReal}
live={basis.metric === "real" ? live.kpis.endNominal : live.kpis.endReal}
/>
<RuinCard baseAge={base.kpis.ruinAge} liveAge={live.kpis.ruinAge} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr]">
{/* --- Links: Regler --- */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Parameter
<InfoBubble text="Es erscheinen nur Regler, die in diesem Plan überhaupt etwas bewegen. Die Standardbereiche sind Vorschläge du kannst beide Enden anpassen." />
</span>
<button
type="button"
onClick={reset}
disabled={neutral}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
<RotateCcw className="h-3 w-3" /> Zurücksetzen
</button>
</div>
<button
type="button"
onClick={toggleExpand}
className="flex items-center gap-1.5 self-start rounded-lg border border-dashed border-border px-2.5 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{expandReturns ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{expandReturns ? "Renditen wieder zusammenfassen" : "Renditen einzeln aufschlüsseln"}
</button>
<label className="flex items-center gap-1.5 text-[11px] text-muted">
<input type="checkbox" checked={showRanges} onChange={() => setShowRanges((v) => !v)} />
Bereiche anpassen
</label>
<div className="flex max-h-[46vh] flex-col gap-3 overflow-y-auto pr-1">
{sliders.map((s) => (
<Slider
key={s.key}
def={s}
value={values[s.key] ?? s.neutral}
showRange={showRanges}
onChange={(v) => setValue(s.key, v)}
onBounds={(min, max) => setBounds((prev) => ({ ...prev, [s.key]: [min, max] }))}
/>
))}
{sliders.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
Dieser Plan enthält noch keine Elemente, an denen sich etwas regeln liesse.
</p>
)}
</div>
{settings.length > 0 && (
<div className="rounded-lg border border-border bg-surface-2 p-2.5">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-faint">
Aktive Einstellung
</div>
<p className="text-[11px] leading-relaxed text-muted">{settings.join(" · ")}</p>
</div>
)}
</div>
{/* --- Rechts: Grafik --- */}
<div className="flex min-w-0 flex-col gap-2">
<div className="inline-flex flex-wrap gap-0.5 self-start rounded-lg border border-border bg-surface-2 p-0.5 text-xs">
{CHARTS.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setChart(c.id)}
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
chart === c.id ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{c.label}
</button>
))}
</div>
<p className="text-[11px] text-muted">{CHARTS.find((c) => c.id === chart)!.hint}</p>
<div className="min-w-0 rounded-xl border border-border bg-surface p-3">
{chart === "wealth" && (
<WealthChart
series={[
// Referenz zuerst, damit die Live-Linie darüber liegt.
{ id: "base", label: "Dein Plan", color: BASE_COLOR, computed: base.computed },
{ id: "live", label: "Simuliert", color: LIVE_COLOR, computed: live.computed },
]}
/>
)}
{chart === "allocation" && <AllocationChart computed={live.computed} />}
{chart === "cashflow" && <SparquoteChart computed={live.computed} />}
</div>
{chart !== "wealth" && (
<p className="text-[11px] text-faint">
Diese Grafik zeigt nur den simulierten Stand. Die Gegenüberstellung mit deinem
unveränderten Plan liefert der Vermögensverlauf und die Zahlen oben.
</p>
)}
</div>
</div>
</div>
</div>
);
}
// Ein Regler. Die Zahl steht bewusst neben dem Schieber und ist auch direkt eingebbar --
// mit der Maus trifft man 5.2 % nicht zuverlässig.
function Slider({
def,
value,
showRange,
onChange,
onBounds,
}: {
def: SliderDef;
value: number;
showRange: boolean;
onChange: (v: number) => void;
onBounds: (min: number, max: number) => void;
}) {
const suffix = UNIT_SUFFIX[def.unit];
const touched = value !== def.neutral;
return (
<div className={`rounded-lg border p-2.5 ${touched ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"}`}>
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center text-xs font-medium text-fg">
<span className="truncate">{def.label}</span>
<InfoBubble text={def.help} />
</span>
<span className="flex shrink-0 items-center gap-1">
<input
type="number"
value={value}
step={def.step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-16 rounded border border-border bg-surface px-1 py-0.5 text-right text-xs text-fg"
/>
<span className="text-[10px] text-faint">{suffix}</span>
</span>
</div>
<input
type="range"
min={def.min}
max={def.max}
step={def.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-[var(--accent)]"
/>
{showRange ? (
<div className="mt-1 flex items-center gap-1.5 text-[10px] text-faint">
<input
type="number"
value={def.min}
step={def.step}
onChange={(e) => onBounds(Number(e.target.value), def.max)}
className="w-14 rounded border border-border bg-surface px-1 py-0.5 text-right text-fg"
/>
<span>bis</span>
<input
type="number"
value={def.max}
step={def.step}
onChange={(e) => onBounds(def.min, Number(e.target.value))}
className="w-14 rounded border border-border bg-surface px-1 py-0.5 text-right text-fg"
/>
<span className="ml-auto">neutral: {def.neutral} {suffix}</span>
</div>
) : (
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
<span>{def.min}</span>
<span>{def.max}</span>
</div>
)}
</div>
);
}
function DeltaCard({ label, base, live }: { label: string; base: number; live: number }) {
const diff = live - base;
const tone = diff === 0 ? "text-fg" : diff > 0 ? "text-success" : "text-danger";
return (
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">{label}</div>
<div className="mt-0.5 text-lg font-semibold text-fg">{formatChf(live)}</div>
<div className="text-[11px] text-muted">
Plan: {formatChf(base)}
{diff !== 0 && (
<span className={`ml-1.5 font-semibold ${tone}`}>
{diff > 0 ? "+" : ""}
{formatChf(Math.abs(diff))}
</span>
)}
</div>
</div>
);
}
// Ein gekippter Plan ist die wichtigste Einzelinformation -- einer Verlaufslinie sieht man
// nicht zuverlässig an, dass das Kapital zwischendurch unter null gefallen ist.
function RuinCard({ baseAge, liveAge }: { baseAge: number | null; liveAge: number | null }) {
const broken = liveAge !== null;
return (
<div className={`rounded-xl border p-3 ${broken ? "border-danger bg-danger-soft" : "border-border bg-surface-2"}`}>
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">Kapital reicht</div>
<div className={`mt-0.5 text-lg font-semibold ${broken ? "text-danger" : "text-success"}`}>
{broken ? `aufgebraucht mit ${liveAge}` : "bis Planende"}
</div>
<div className="text-[11px] text-muted">
{baseAge === null ? "Plan: reicht bis Planende" : `Plan: aufgebraucht mit ${baseAge}`}
</div>
</div>
);
}
+907
View File
@@ -0,0 +1,907 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Area, CartesianGrid, ComposedChart, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Dices, X } from "lucide-react";
import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { computePlan } from "@/lib/calculations";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import {
buildElementGroups,
defaultVolatilityLevel,
floorFor,
paramsForScenario,
plannedReturnOf,
probabilityAtLeast,
runMonteCarloMulti,
INFLATION_VOLATILITY_LEVELS,
RETURN_BEARING,
RETURN_VOLATILITY_LEVELS,
type ElementGroup,
type ElementMcParams,
type InflationVolatilityLevel,
type ReturnVolatilityLevel,
type ScenarioMcResult,
type ScenarioRunInput,
} from "@/lib/montecarlo";
import { CATEGORY_LABELS } from "@/lib/elements";
import type { PlanInput, ScenarioMeta } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
// Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt.
const PALETTE = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6)"];
// Ergebnis je Szenario: ZWEI Welten (historische vs. geplante Renditen), aus jeder werden
// ZWEI Schwellen abgelesen (Plan-Endbetrag und Zielbetrag) -- die vier Fälle aus 4.12.7.
interface Outcome {
scenarioId: string;
name: string;
planEnd: number;
target: number;
f1: number; // historische Welt, Schwelle Plan-Endbetrag -> Realismus der Planung
f2: number; // geplante Welt, Schwelle Plan-Endbetrag -> Nullpunkt für das Urteil
f3: number; // historische Welt, Schwelle Zielbetrag -> Ziel, wenn der Markt wie früher läuft
f4: number; // geplante Welt, Schwelle Zielbetrag -> Ziel, wenn der Markt wie geplant läuft
medianHist: number;
medianPlan: number;
ruin: number; // aus der historischen Welt (das ehrliche Risikobild)
runs: number;
}
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
{ value: "sehr_niedrig", label: "Sehr niedrig" },
{ value: "niedrig", label: "Niedrig" },
{ value: "moderat", label: "Moderat" },
{ value: "hoch", label: "Hoch" },
{ value: "sehr_hoch", label: "Sehr hoch" },
{ value: "manuell", label: "Manuell" },
];
const INFLATION_LEVEL_OPTIONS: { value: InflationVolatilityLevel; label: string }[] = [
{ value: "sehr_niedrig", label: "Sehr niedrig" },
{ value: "niedrig", label: "Niedrig" },
{ value: "manuell", label: "Manuell" },
];
const RETURN_HELP =
"Wie stark die Jahresrendite schwankt. Sehr niedrig ≈ Staatsanleihen/Geldmarkt · Niedrig ≈ Immobilien, defensive Mischportfolios · Moderat ≈ breit diversifizierte Aktien-ETFs/Fonds · Hoch ≈ Einzelaktien, Branchen-/Schwellenländerfonds · Sehr hoch ≈ Kryptowährungen, hochspekulative Anlagen.";
const INFLATION_HELP =
"Wie stark die Inflation schwankt. Für die Schweiz ist sie historisch sehr stabil (Sehr niedrig ≈ 1 %). Höhere Stufen wären Hyperinflations-Annahmen.";
interface ElementDraft {
mean: string;
level: ReturnVolatilityLevel;
manualSigma: string;
}
interface LoadedScenario {
id: string;
name: string;
plan: PlanInput;
computed: PlanComputed;
}
function returnSigma(el: ElementDraft): number {
return el.level === "manuell" ? Number(el.manualSigma) || 0 : RETURN_VOLATILITY_LEVELS[el.level];
}
function inflationSigma(level: InflationVolatilityLevel, manual: string): number {
return level === "manuell" ? Number(manual) || 0 : INFLATION_VOLATILITY_LEVELS[level];
}
function orderedPhaseIds(plan: PlanInput): string[] {
return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber).map((p) => p.id);
}
// Deterministische Planungslinie eines Szenarios (Alterspunkte = Phasengrenzen).
function detPointsOf(plan: PlanInput, computed: PlanComputed): { age: number; det: number }[] {
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
if (computed.phases.length === 0) return [];
const pts = [{ age: startAge, det: computed.phases[0].startWealthNominal }];
let acc = 0;
for (const ph of computed.phases) {
acc += ph.durationYears;
pts.push({ age: startAge + acc, det: ph.endWealthNominal });
}
return pts;
}
export function MonteCarloDialog({
plan,
computed,
meta,
scenarios,
actuals = [],
origins = [],
onClose,
}: {
plan: PlanInput;
computed: PlanComputed;
meta: ScenarioMeta;
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
// Alle Szenarien dieses Plans (auch nicht ausgewählte) -- sie werden geladen, damit sich
// die Herkunfts-Kette der Elemente auch über übersprungene Zwischen-Szenarien auflöst.
scenarios: ScenarioMeta[];
onClose: () => void;
}) {
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({
[meta.id]: { id: meta.id, name: meta.name, plan, computed },
}));
const basis = useAnalysisBasis(plan, actuals, origins);
const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id));
const [loadError, setLoadError] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
// EIN Zielbetrag für alle Szenarien, vorbelegt mit dem Plan-Endbetrag des offenen Szenarios.
const [manualTarget, setManualTarget] = useState<number>(Math.max(0, computed.nachlass));
const [runs, setRuns] = useState(1000);
const [inflMean, setInflMean] = useState("");
const [inflLevel, setInflLevel] = useState<InflationVolatilityLevel>("sehr_niedrig");
const [inflManual, setInflManual] = useState("1");
const [drafts, setDrafts] = useState<Record<string, ElementDraft>>({});
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
// Gewaehlter Stand je Szenario (Default: Arbeitsstand) und die dazu geladenen Snapshots.
const [versionByScenario, setVersionByScenario] = useState<Record<string, string>>({});
const [versionPlans, setVersionPlans] = useState<Record<string, PlanInput>>({});
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null);
// Fächer/Bänder stammen aus der historischen Welt.
const [fanRes, setFanRes] = useState<ScenarioMcResult[] | null>(null);
const scenarioKey = scenarios.map((s) => s.id).join(",");
useEffect(() => {
const missing = scenarios.filter((s) => s.id !== meta.id);
if (missing.length === 0) return;
let cancelled = false;
(async () => {
try {
const entries = await Promise.all(
missing.map(async (s) => {
const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/scenarios/${s.id}`);
return [s.id, { id: s.id, name: s.name, plan: data.plan, computed: data.computed }] as const;
})
);
if (cancelled) return;
setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) }));
} catch (e) {
if (!cancelled) setLoadError(e instanceof Error ? e.message : "Szenarien konnten nicht geladen werden.");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scenarioKey, meta.id]);
async function chooseVersion(scenarioId: string, versionId: string) {
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: versionId }));
clearResults();
if (versionId === CURRENT) return;
const key = `${scenarioId}:${versionId}`;
if (versionPlans[key]) return;
try {
const loadedPlan = await loadVersionPlan(scenarioId, versionId);
if (loadedPlan) setVersionPlans((prev) => ({ ...prev, [key]: loadedPlan }));
} catch {
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: CURRENT }));
}
}
const allLoaded = useMemo(
() => scenarios.map((s) => loaded[s.id]).filter((s): s is LoadedScenario => !!s),
[scenarios, loaded]
);
// Ersetzt je Szenario den Arbeitsstand durch den gewaehlten Snapshot. Der Snapshot ist
// bereits ein PlanInput, das Gerechnete entsteht lokal.
const resolved = useMemo(() => {
const out: Record<string, LoadedScenario> = {};
for (const sc of allLoaded) {
const vid = versionByScenario[sc.id] ?? CURRENT;
const snap = vid === CURRENT ? null : versionPlans[`${sc.id}:${vid}`];
out[sc.id] = snap ? { ...sc, plan: snap, computed: computePlan(snap) } : sc;
}
return out;
}, [allLoaded, versionByScenario, versionPlans]);
const resolvedList = useMemo(() => Object.values(resolved), [resolved]);
const groups = useMemo(() => buildElementGroups(resolvedList, selectedIds), [resolvedList, selectedIds]);
function draftFor(g: ElementGroup): ElementDraft {
return drafts[g.rootId] ?? { mean: "", level: defaultVolatilityLevel(g.category), manualSigma: "10" };
}
function setDraft(rootId: string, group: ElementGroup, patch: Partial<ElementDraft>) {
setDrafts((prev) => ({ ...prev, [rootId]: { ...draftFor(group), ...prev[rootId], ...patch } }));
clearResults();
}
function clearResults() {
setOutcomes(null);
setFanRes(null);
}
function toggleScenario(id: string) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
clearResults();
}
const selected = selectedIds.map((id) => resolved[id]).filter((s): s is LoadedScenario => !!s);
const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category)));
const missingHist = inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === "");
const missing = missingHist || manualTarget <= 0;
const canRun = !missing && selected.length > 0 && anyReturnBearing && !loading;
// Immer zwei Durchgänge: historische Welt und geplante Welt.
const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length) * 2) / 6000));
async function run() {
setRunning(true);
clearResults();
setProgress({ index: 0, count: selected.length * 2, fraction: 0 });
try {
// Streuung (Sigma) je logischem Element -- in BEIDEN Welten dieselbe. Nur der
// Mittelpunkt unterscheidet sie.
const sigmaByRoot: Record<string, number> = Object.fromEntries(
groups.map((g) => [g.rootId, returnSigma(draftFor(g))])
);
const floorByRoot: Record<string, number> = Object.fromEntries(groups.map((g) => [g.rootId, floorFor(g.category)]));
const rootByMember = new Map<string, string>();
for (const g of groups) for (const id of g.memberIds) rootByMember.set(id, g.rootId);
// Gemeinsamer Seed für beide Welten und alle Szenarien.
const seed = (Math.random() * 2 ** 32) >>> 0;
const inflSig = inflationSigma(inflLevel, inflManual);
const inputs: ScenarioRunInput[] = selected.map((s) => ({
scenarioId: s.id,
name: s.name,
plan: s.plan,
// Der mitgegebene Zielbetrag ist nur ein Vorgabewert; die vier Fälle werden
// anschliessend direkt aus der Verteilung abgelesen.
target: manualTarget,
}));
const onProg = (pass: number) => (index: number, _count: number, fraction: number) =>
setProgress({ index: pass * selected.length + index, count: selected.length * 2, fraction });
// --- Welt A: HISTORISCHE Renditen (das realistische Bild) ---
const paramByRoot: Record<string, ElementMcParams> = Object.fromEntries(
groups.map((g) => [g.rootId, { mean: Number(draftFor(g).mean) || 0, sigma: sigmaByRoot[g.rootId], floor: floorByRoot[g.rootId] }])
);
const hist = await runMonteCarloMulti(
inputs,
{
runs,
inflationMeanFor: () => Number(inflMean) || 0,
inflationSigma: inflSig,
seed,
elementsFor: (p) => paramsForScenario(p, groups, paramByRoot),
},
onProg(0)
);
// --- Welt B: GEPLANTE Renditen (die Welt der eigenen Annahmen) ---
const planned = await runMonteCarloMulti(
inputs,
{
runs,
inflationMeanFor: (p) => p.inflationRateDefault,
inflationSigma: inflSig,
seed,
elementsFor: (p) => {
const ids = orderedPhaseIds(p);
const out: Record<string, ElementMcParams> = {};
for (const e of p.elements) {
if (!RETURN_BEARING.includes(e.category)) continue;
const root = rootByMember.get(e.id);
const sigma = root ? sigmaByRoot[root] : returnSigma({ mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" });
out[e.id] = { mean: plannedReturnOf(e, ids), sigma, floor: floorFor(e.category) };
}
return out;
},
},
onProg(1)
);
// Vier Fälle aus zwei Verteilungen ablesen. Weil Fall 1 und 3 aus DERSELBEN Verteilung
// stammen, gilt zwingend: tieferes Ziel -> nie kleinere Wahrscheinlichkeit.
const out: Outcome[] = selected.map((s) => {
const h = hist.find((r) => r.scenarioId === s.id)!;
const b = planned.find((r) => r.scenarioId === s.id)!;
const planEnd = Math.max(0, s.computed.nachlass);
return {
scenarioId: s.id,
name: s.name,
planEnd,
target: manualTarget,
f1: probabilityAtLeast(h.finalWealthSorted, planEnd),
f2: probabilityAtLeast(b.finalWealthSorted, planEnd),
f3: probabilityAtLeast(h.finalWealthSorted, manualTarget),
f4: probabilityAtLeast(b.finalWealthSorted, manualTarget),
medianHist: h.finalWealthMedian,
medianPlan: b.finalWealthMedian,
ruin: h.ruinProbability,
runs: h.runs,
};
});
setOutcomes(out);
setFanRes(hist);
} finally {
setRunning(false);
}
}
const overallProgress = progress.count > 0 ? (progress.index + progress.fraction) / progress.count : 0;
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<Dices className="h-5 w-5 text-accent" /> Monte-Carlo-Simulation
</h2>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
<AnalysisBar basis={basis} onChange={clearResults} />
{/* Startpunkt: bewusst abgeleitet statt eingebbar. Ein frei gesetztes Jahr würde
Jahre als sicher behandeln, die nie erfasst wurden. */}
<p className="text-[11px] text-muted">
{basis.source === "ACTUAL" && basis.simStartCalendarYear
? `Simuliert ab ${basis.simStartCalendarYear}. Die Jahre davor sind durch deine effektiven Werte belegt und werden nicht gewürfelt.`
: plan.startYear
? `Simuliert ab ${plan.startYear} (Planbeginn).`
: "Simuliert ab Planbeginn."}
</p>
{/* Erklärung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
pro Jahr. Real schwanken beide. Die Simulation würfelt <strong className="text-fg">viele tausend mögliche
Verläufe</strong> und zeigt, wie oft ein Betrag erreicht wird.
</p>
<p className="mb-2">
<strong className="text-fg">Zwei Welten, ein Lauf:</strong> Gerechnet wird einmal um die{" "}
<strong className="text-fg">historischen</strong> Renditen (wie der Markt bisher lief) und einmal um deine{" "}
<strong className="text-fg">geplanten</strong> Werte (wie du rechnest). Aus beiden Verteilungen werden
derselbe Plan-Endbetrag und dein Zielbetrag abgelesen deshalb können sich die Zahlen nicht widersprechen.
</p>
<p>
<strong className="text-fg">Verteilung:</strong> Renditen werden mit «fetten Rändern» gezogen (Extremcrashs so
häufig wie in der Realität, nicht wie in der Glockenkurve), und alle riskanten Anlagen fallen in einem
Marktschock gemeinsam. Alle Szenarien laufen mit demselben Zufalls-Seed, damit Unterschiede nicht blosses
Rauschen sind. <em>Die Prozentzahl gilt immer nur relativ zu deinen Annahmen.</em>
</p>
</div>
{/* Szenario-Auswahl */}
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Szenarien
<InfoBubble text="Wähle, für welche Szenarien dieses Plans gerechnet werden soll. Streuung und historische Renditen werden nur einmal je logischem Element erfasst und für alle Szenarien verwendet." />
</div>
{loading && <p className="text-xs text-muted">Szenarien werden geladen</p>}
{loadError && <p className="text-xs text-danger">{loadError}</p>}
<div className="flex flex-col gap-2">
{scenarios.map((s) => {
const isLoaded = !!loaded[s.id];
const checked = selectedIds.includes(s.id);
const nachlass = loaded[s.id]?.computed.nachlass;
return (
<div
key={s.id}
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"
}`}
>
<label className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<input type="checkbox" checked={checked} disabled={!isLoaded} onChange={() => toggleScenario(s.id)} />
<span className="truncate font-medium text-fg">{s.name}</span>
{s.isBase && <span className="rounded bg-surface px-1.5 text-[10px] text-muted">Basis</span>}
{!isLoaded && <span className="text-[11px] text-faint">lädt</span>}
</label>
{checked && (
<VersionSelect
compact
scenarioId={s.id}
value={versionByScenario[s.id] ?? CURRENT}
onChange={(vid) => void chooseVersion(s.id, vid)}
/>
)}
{checked && typeof nachlass === "number" && (
<span className="text-[11px] text-muted" title="Planungs-Endbetrag dieses Szenarios (read-only)">
Planungs-Endbetrag: <strong className="text-fg">{formatChf(Math.max(0, nachlass))}</strong>
</span>
)}
</div>
);
})}
</div>
{selected.length === 0 && <p className="mt-2 text-xs text-danger">Bitte mindestens ein Szenario auswählen.</p>}
</div>
{/* Zielbetrag */}
<div className="sm:max-w-xs">
<MoneyField
// Das Feld dreht mit der gewählten Grösse mit -- sonst prüft man einen nominalen
// Zielbetrag gegen ein reales Endvermögen und vergleicht Äpfel mit Birnen.
label={
basis.metric === "real"
? "Zielbetrag (Endvermögen, REAL)"
: "Zielbetrag (Endvermögen, NOMINAL)"
}
help={
basis.metric === "real"
? "Dein frei gewähltes Ziel in HEUTIGER Kaufkraft. Geprüft wird gegen das reale Endvermögen."
: "Dein frei gewähltes Ziel in Franken des Zieljahres (nominal). Geprüft wird gegen das nominale Endvermögen."
}
value={manualTarget}
onChange={(v) => {
setManualTarget(v);
clearResults();
}}
/>
</div>
{/* Inflation */}
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Inflation (Plan-Ebene)</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<RequiredNumberField
label="Ø Inflation letzte 20 J. (%)"
help={`Der Mittelpunkt der historischen Welt. Die geplante Welt würfelt um deine Planannahme von ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
value={inflMean}
onChange={(v) => {
setInflMean(v);
clearResults();
}}
/>
<SelectField
label="Streuung"
help={INFLATION_HELP}
value={inflLevel}
onChange={(v: InflationVolatilityLevel) => {
setInflLevel(v);
clearResults();
}}
options={INFLATION_LEVEL_OPTIONS}
/>
{inflLevel === "manuell" ? (
<NumberField label="Standardabw. (%)" step={0.1} value={Number(inflManual) || 0} onChange={(v) => { setInflManual(String(v)); clearResults(); }} />
) : (
<ReadOnlySigma value={INFLATION_VOLATILITY_LEVELS[inflLevel]} />
)}
</div>
</div>
{/* Renditetragende Elemente */}
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Renditetragende Elemente
{selected.length > 1 && (
<InfoBubble text="Ein Element, das in mehreren Szenarien vorkommt, erscheint hier nur einmal erkannt über seine Herkunft beim Kopieren des Szenarios." />
)}
</div>
{groups.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
Die ausgewählten Szenarien haben keine renditetragenden Elemente (PK, 3a, Sonstiges Vermögen, Immobilie).
</p>
)}
<div className="flex flex-col gap-3">
{groups.map((g) => {
const d = draftFor(g);
const partial = g.scenarioIds.length < selected.length;
const planned = plannedReturnOf(
(loaded[g.scenarioIds[0]]?.plan.elements ?? []).find((e) => g.memberIds.includes(e.id)) ??
({ category: g.category, phaseValues: {} } as never),
orderedPhaseIds(loaded[g.scenarioIds[0]]?.plan ?? plan)
);
return (
<div key={g.rootId} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-fg">{g.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[g.category]}</span>
{partial && (
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] text-muted">
nur in: {g.scenarioIds.map((id) => loaded[id]?.name ?? id).join(", ")}
</span>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<RequiredNumberField
label="Ø Rendite letzte 20 J. (%)"
help={`Der Mittelpunkt der historischen Welt die realistische Rendite dieser Anlage. Deine Planung rechnet mit ${planned} %; damit läuft die geplante Welt.`}
value={d.mean}
onChange={(v) => setDraft(g.rootId, g, { mean: v })}
/>
<SelectField
label="Streuung"
help={RETURN_HELP}
value={d.level}
onChange={(v: ReturnVolatilityLevel) => setDraft(g.rootId, g, { level: v })}
options={RETURN_LEVEL_OPTIONS}
/>
{d.level === "manuell" ? (
<NumberField label="Standardabw. (%)" step={0.5} value={Number(d.manualSigma) || 0} onChange={(v) => setDraft(g.rootId, g, { manualSigma: String(v) })} />
) : (
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[d.level]} />
)}
</div>
{(g.category === "PENSION_FUND" || g.category === "PILLAR_3A") && (
<p className="mt-2 text-[11px] text-faint">Boden 0 %: {CATEGORY_LABELS[g.category]} schreibt keine negative Rendite gut.</p>
)}
</div>
);
})}
</div>
</div>
{/* Lauf-Parameter */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<SelectField
label="Anzahl Simulationen"
value={String(runs)}
onChange={(v: string) => { setRuns(Number(v)); clearResults(); }}
options={[
{ value: "1000", label: "1'000 (schnell)" },
{ value: "5000", label: "5'000" },
{ value: "10000", label: "10'000 (genau)" },
]}
/>
<p className="mt-1 text-[11px] text-faint">
{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}
2 Welten · geschätzt ~{estSeconds} s
</p>
</div>
</div>
{missing && (
<p className="text-xs text-danger">
{missingHist
? "Bitte alle Pflichtfelder (historische Ø-Werte) ausfüllen."
: "Bitte einen Zielbetrag grösser 0 angeben."}
</p>
)}
<div className="flex items-center gap-3">
<button
type="button"
disabled={!canRun || running}
onClick={run}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{running ? `Berechne… ${Math.round(overallProgress * 100)} %` : "Berechnung jetzt durchführen"}
</button>
{running && (
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
<div className="h-full bg-accent transition-all" style={{ width: `${overallProgress * 100}%` }} />
</div>
)}
</div>
{outcomes && fanRes && (
<>
<div className="flex justify-end">
<SaveAnalysisButton
planId={meta.planId}
type="MONTE_CARLO"
scenarioName={outcomes.length === 1 ? outcomes[0].name : `${outcomes.length} Szenarien`}
versionLabel={null}
build={() => buildMcResult(outcomes, fanRes, manualTarget, basis)}
/>
</div>
<MonteCarloResults
outcomes={outcomes}
fanRes={fanRes}
detPoints={
fanRes.length === 1 && loaded[fanRes[0].scenarioId]
? detPointsOf(loaded[fanRes[0].scenarioId].plan, loaded[fanRes[0].scenarioId].computed)
: []
}
/>
</>
)}
</div>
</div>
);
}
// Baut die generische, druckfähige Momentaufnahme des MC-Ergebnisses (siehe analyses.ts).
// Bewusst OHNE finalWealthSorted -- das sind zehntausende Zahlen je Lauf.
function buildMcResult(
outcomes: Outcome[],
fanRes: ScenarioMcResult[],
target: number,
basis: { metric: string; source: string }
): { metric?: string; source?: string; scenarioName?: string | null; summary?: string | null; inputs: Record<string, unknown>; result: Record<string, unknown> } {
const single = outcomes.length === 1;
const table = {
columns: ["Szenario", "Planung hist. (Plan)", "Ziel hist. (Plan)", "Ruin", "Median"],
rows: outcomes.map((o) => [
o.name,
`${Math.round(o.f1 * 100)} % (${Math.round(o.f2 * 100)} %)`,
`${Math.round(o.f3 * 100)} % (${Math.round(o.f4 * 100)} %)`,
`${Math.round(o.ruin * 100)} %`,
formatChf(o.medianHist),
]),
};
// Fächer nur beim Einzelszenario; bei mehreren die Median-Linien.
const chart = {
kind: "line" as const,
xLabel: "Alter",
yFormat: "chf" as const,
series: fanRes.map((r, i) => ({
label: r.name,
color: PALETTE[i % PALETTE.length],
points: r.bands.map((b) => ({ x: b.age, y: b.p50 })),
})),
};
return {
metric: basis.metric,
source: basis.source,
scenarioName: single ? outcomes[0].name : `${outcomes.length} Szenarien`,
summary: single ? `Realismus ${Math.round(outcomes[0].f1 * 100)} %` : null,
inputs: {
params: [
{ label: "Zielbetrag", value: formatChf(target) },
{ label: "Läufe/Szenario", value: outcomes[0]?.runs.toLocaleString("de-CH") ?? "" },
{ label: "Grösse", value: basis.metric === "real" ? "real" : "nominal" },
{ label: "Grundlage", value: basis.source === "ACTUAL" ? "effektiv" : "Plan" },
],
},
result: {
params: [
{ label: "Zielbetrag", value: formatChf(target) },
{ label: "Läufe/Szenario", value: outcomes[0]?.runs.toLocaleString("de-CH") ?? "" },
],
metrics: single
? [
{ label: "Realismus der Planung", value: `${Math.round(outcomes[0].f1 * 100)} %` },
{ label: "Ziel erreicht (historisch)", value: `${Math.round(outcomes[0].f3 * 100)} %` },
{ label: "Median (historisch)", value: formatChf(outcomes[0].medianHist) },
]
: [],
table,
chart,
},
};
}
function ReadOnlySigma({ value }: { value: number }) {
return (
<div>
<label className="mb-1 flex items-center text-xs font-medium text-muted">
Standardabweichung
<InfoBubble text="Die hinter der gewählten Streuungsstufe hinterlegte Standardabweichung. Nur bei Manuell selbst eingebbar." />
</label>
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
{value} %
</div>
</div>
);
}
// Toleranzband für das Urteil, in Prozentpunkten.
const VERDICT_BAND = 5;
// Urteil über die Planung. Der Nullpunkt ist NICHT 50 %, sondern Fall 2: die Wahrscheinlichkeit,
// den Plan-Endbetrag in der eigenen geplanten Welt zu erreichen. Die liegt wegen des
// Volatilitäts-Drags systematisch unter 50 % (bei hoher Streuung deutlich). Verglichen wird
// also Fall 1 gegen Fall 2 -- beides derselbe Schwellwert, nur andere Renditeannahme.
function planVerdict(f1: number, f2: number): { text: string; tone: "danger" | "success" | "muted" } {
const p1 = Math.round(f1 * 100);
const diff = (f1 - f2) * 100;
if (diff > VERDICT_BAND)
return {
text: `Deine Planung ist zurückhaltend gerechnet: Mit den historischen Renditen wird dein geplanter Endbetrag in ${p1} % der Fälle erreicht häufiger als mit deinen eigenen Annahmen.`,
tone: "success",
};
if (diff < -VERDICT_BAND)
return {
text: `Deine Planung ist tendenziell zu optimistisch: Mit den historischen Renditen wird dein geplanter Endbetrag nur in ${p1} % der Fälle erreicht seltener als mit deinen eigenen Annahmen.`,
tone: "danger",
};
return {
text: `Deine Planung ist realistisch gerechnet: Mit den historischen Renditen wird dein geplanter Endbetrag in ${p1} % der Fälle erreicht praktisch gleich häufig wie mit deinen eigenen Annahmen.`,
tone: "muted",
};
}
// Untergeordnetes Fall-3/4-Paar: dieselbe Zielschwelle, einmal in der geplanten und einmal in der
// historischen Welt. Die Formulierung dreht automatisch, je nachdem welche Welt besser dasteht.
function targetSentence(f3: number, f4: number): string {
const p3 = Math.round(f3 * 100);
const p4 = Math.round(f4 * 100);
const planWord = f4 < f3 ? "schlecht" : "gut";
const histWord = f4 < f3 ? "gut" : "zurückhaltend";
return (
`Wenn der Markt so «${planWord}» läuft, wie du es in deiner Planung vorgibst, liegt die Wahrscheinlichkeit, ` +
`deinen Zielbetrag zu erreichen, bei ${p4} %. Läuft er so «${histWord}» wie in der Vergangenheit, liegt sie bei ${p3} %.`
);
}
function VerdictCard({ title, prob, verdict }: { title: string; prob: number; verdict: { text: string; tone: "danger" | "success" | "muted" } }) {
const color = verdict.tone === "danger" ? "text-danger" : verdict.tone === "success" ? "text-success" : "text-fg";
const border = verdict.tone === "danger" ? "border-danger bg-danger-soft" : "border-border bg-surface-2";
return (
<div className={`rounded-xl border p-4 ${border}`}>
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">{title}</span>
<span className={`text-3xl font-semibold ${color}`}>{Math.round(prob * 100)} %</span>
</div>
<p className="mt-1 text-xs text-muted">{verdict.text}</p>
</div>
);
}
function MonteCarloResults({
outcomes,
fanRes,
detPoints,
}: {
outcomes: Outcome[];
fanRes: ScenarioMcResult[]; // Fächer/Bänder aus der historischen Welt
detPoints: { age: number; det: number }[];
}) {
const single = fanRes.length === 1;
const singleData = useMemo(() => {
if (!single) return [];
return fanRes[0].bands.map((b) => ({
age: b.age,
band: [b.p10, b.p90] as [number, number],
median: b.p50,
det: detPoints.find((d) => d.age === b.age)?.det ?? null,
}));
}, [fanRes, single, detPoints]);
const compareData = useMemo(() => {
if (single) return [];
const ages = Array.from(new Set(fanRes.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b);
return ages.map((age) => {
const row: Record<string, number | null> = { age };
for (const r of fanRes) row[r.scenarioId] = r.bands.find((b) => b.age === age)?.p50 ?? null;
return row;
});
}, [fanRes, single]);
const one = outcomes[0];
return (
<div className="flex flex-col gap-4 border-t border-border pt-4">
{/* Deutung -- nur bei einem einzelnen Szenario; bei mehreren zählt die Tabelle. */}
{single && (
<div className="flex flex-col gap-3">
{/* Prominent: Fall 1 */}
<VerdictCard title="Realismus der Planung" prob={one.f1} verdict={planVerdict(one.f1, one.f2)} />
{/* Untergeordnet: Fall 3 und 4 als Paar */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-1 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Dein Zielbetrag {formatChf(one.target)}
<InfoBubble text="Dieselbe Zielschwelle in beiden Welten. Fall 3 stammt aus derselben Verteilung wie das Urteil oben liegt dein Ziel unter dem Plan-Endbetrag, kann seine Wahrscheinlichkeit deshalb nie tiefer sein." />
</div>
<p className="text-xs leading-relaxed text-muted">{targetSentence(one.f3, one.f4)}</p>
</div>
{/* Kleingedruckt: Fall 2 als Referenz plus typischer Ausgang je Welt */}
<p className="text-[11px] leading-relaxed text-faint">
Referenz: Mit deinen eigenen Planannahmen und Schwankung wird der Plan-Endbetrag von{" "}
{formatChf(one.planEnd)} in {Math.round(one.f2 * 100)} % der Fälle erreicht das ist der Nullpunkt für
das Urteil oben, nicht 50 % (Schwankung frisst Rendite). Typischer Ausgang (Median):{" "}
{formatChf(one.medianHist)} historisch, {formatChf(one.medianPlan)} nach Plan.
</p>
</div>
)}
{/* Vergleichstabelle */}
<div>
<div className="mb-1 flex items-center text-xs font-semibold text-fg">
Ergebnis je Szenario
<InfoBubble text="«Planung» = P(Plan-Endbetrag erreicht) in der historischen Welt (Fall 1), in Klammern derselbe Schwellwert in der geplanten Welt (Fall 2). «Ziel» = P(Zielbetrag erreicht), historisch (Fall 3) und nach Plan (Fall 4). Ruin, Bandbreite und Median stammen aus der historischen Welt." />
</div>
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
<th className="px-3 py-2 text-right font-semibold">Planung hist. (Plan)</th>
<th className="px-3 py-2 text-right font-semibold">Ziel hist. (Plan)</th>
<th className="px-3 py-2 text-right font-semibold">Ruin</th>
<th className="px-3 py-2 text-right font-semibold">Pessim. (10 %)</th>
<th className="px-3 py-2 text-right font-semibold">Median</th>
<th className="px-3 py-2 text-right font-semibold">Optim. (90 %)</th>
</tr>
</thead>
<tbody>
{outcomes.map((o, i) => {
const fan = fanRes.find((r) => r.scenarioId === o.scenarioId)!;
return (
<tr key={o.scenarioId} className="border-t border-border">
<td className="px-3 py-2">
<span className="flex items-center gap-2 font-medium text-fg">
{!single && <span className="inline-block h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: PALETTE[i % PALETTE.length] }} />}
{o.name}
</span>
</td>
<td className="px-3 py-2 text-right font-semibold text-success">
{Math.round(o.f1 * 100)} % <span className="font-normal text-faint">({Math.round(o.f2 * 100)} %)</span>
</td>
<td className="px-3 py-2 text-right font-semibold text-accent">
{Math.round(o.f3 * 100)} % <span className="font-normal text-faint">({Math.round(o.f4 * 100)} %)</span>
</td>
<td className="px-3 py-2 text-right font-semibold text-danger">{Math.round(o.ruin * 100)} %</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(fan.finalWealthP10)}</td>
<td className="px-3 py-2 text-right text-fg">{formatChf(fan.finalWealthMedian)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(fan.finalWealthP90)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="mt-1 text-[11px] text-faint">
{one.runs.toLocaleString("de-CH")} Läufe je Szenario und Welt · gemeinsamer Zufalls-Seed · Zielbetrag{" "}
{formatChf(one.target)}
</p>
</div>
{/* Fächer */}
<div>
<div className="mb-1 text-xs font-semibold text-fg">
{single ? "Vermögensfächer nach Alter (nominal)" : "Vermögensverlauf im Median nach Alter (nominal)"}
{" · historische Renditen"}
</div>
<p className="mb-2 text-[11px] text-muted">
{single
? "Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der Median. Die gestrichelte Linie ist deine deterministische Planung sie liegt meist leicht über dem Median (Schwankung frisst Rendite)."
: "Je Szenario die mittlere Entwicklung (Median). Wähle ein einzelnes Szenario, um den vollen Fächer zu sehen."}
</p>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
{single ? (
<ComposedChart data={singleData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]} labelFormatter={(v) => `Alter ${v}`} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area dataKey="band" name="1090 % Band" stroke="none" fill="var(--accent)" fillOpacity={0.16} isAnimationActive={false} />
<Line dataKey="median" name="Median" stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line dataKey="det" name="Planung (deterministisch)" stroke="var(--muted)" strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls isAnimationActive={false} />
</ComposedChart>
) : (
<LineChart data={compareData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]} labelFormatter={(v) => `Alter ${v}`} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{fanRes.map((r, i) => (
<Line key={r.scenarioId} dataKey={r.scenarioId} name={r.name} stroke={PALETTE[i % PALETTE.length]} strokeWidth={2} dot={false} connectNulls isAnimationActive={false} />
))}
</LineChart>
)}
</ResponsiveContainer>
</div>
</div>
</div>
);
}
+732
View File
@@ -0,0 +1,732 @@
"use client";
// Die Kartenansicht: eine Karte je Lebensphase (SPEZIFIKATION 3.15).
//
// Sie loest das Grundproblem der Matrix, nicht dessen Symptome. Die Matrix stellt Phasen
// NEBENEINANDER -- gut zum Vergleichen, schlecht zum Arbeiten: Ab der vierten Phase scrollt
// man horizontal, und das rechte Bearbeitungs-Panel legte sich ausgerechnet ueber die Spalte,
// zu der man gerade gescrollt hatte. Eine Karte zeigt genau eine Phase, ueber die ganze
// Breite, und getippt wird in der Karte selbst. Horizontales Scrollen gibt es nicht mehr.
//
// Die Karte erzaehlt die Phase als ZEITABLAUF in drei Abschnitten:
// 1. Eintritt -- was beim Wechsel in diese Phase geschah (Uebergang aus der Vorphase).
// Bei der ersten Phase stattdessen der Bestand aus der Bestandsaufnahme.
// 2. Laufend -- was in diesen Jahren gilt: die Elemente mit ihren Annahmen.
// 3. Ergebnis -- was am Ende dasteht.
//
// Der Uebergang gehoert bewusst zur FOLGEphase und nicht ans Ende der Vorphase: Das dabei
// frei werdende Kapital wird in der Folgephase verteilt (seit 0.41 auch in der Anzeige, Kap.
// 3.12.5). Der Preis ist, dass man den Hausverkauf gedanklich am Ende von Phase N trifft, ihn
// aber auf Karte N+1 findet -- bewusst in Kauf genommen, weil dort die Folgen stehen.
//
// Die Matrix bleibt als VERGLEICHSANSICHT bestehen (Diff, Plan/Ist, Verlauf ueber alle
// Phasen) -- dasselbe Datenmaterial, zwei Sichten.
import { useEffect, useState } from "react";
import {
AlertTriangle,
ChevronDown,
ChevronLeft,
ChevronRight,
Coins,
Maximize2,
Pencil,
PiggyBank,
Plus,
Trash2,
Wallet,
} from "lucide-react";
import { Button } from "@/components/ui";
import {
CashTransitionFields,
ElementDetail,
cashTransitionSummary,
isCashTransitionAnswered,
withCashTransitionDefaults,
type CellContext,
} from "@/components/ElementDetail";
import { api } from "@/lib/api-client";
import { CATEGORY_LABELS, ownerLabel, type CashTransitionData, type PhaseData } from "@/lib/elements";
import { capitalPot } from "@/lib/distribution";
import { formatChf } from "@/lib/format";
import { unconfirmedCells } from "@/lib/review";
import type { PhaseComputed, PlanComputed, ElementPhaseComputed } from "@/lib/calculations";
import type { ElementInput, PlanInput } from "@/lib/types";
import type { PhaseRef } from "@/lib/ratefields";
export type ValueMode = "nominal" | "both" | "real";
// Alles, was die Karte an ihre Umgebung zurueckmeldet. Bewusst ein flaches Buendel statt
// vieler Einzel-Props: Die Karte ist eine Ansicht, keine Datenhaltung.
export interface CardActions {
onEditPhase: (phaseId: string) => void;
onDeletePhase: (phaseId: string, name: string) => void;
onAddPhase: () => void;
onAddElement: () => void;
onDeleteElement: (elementId: string, name: string) => void;
onDistributeRates: (phaseId: string) => void;
onDistributeCapital: (phaseId: string) => void;
onCashTransition: (fromPhaseId: string) => void;
onOpenPhaseDetail: (phaseId: string) => void;
onChanged: () => void;
}
interface Props {
plan: PlanInput;
computed: PlanComputed;
mode: ValueMode;
activePhaseId: string | null;
onSelectPhase: (phaseId: string) => void;
phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext;
transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext;
// Elemente, die am Uebergang NACH einer Phase ueberhaupt etwas zu entscheiden haben.
transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[];
cashTransitionFor: (phaseId: string) => CashTransitionData;
actions: CardActions;
}
const real = (v: number, d: number) => Math.round(v / (d || 1));
function val(v: number, deflator: number, mode: ValueMode): string {
if (mode === "real") return formatChf(real(v, deflator));
if (mode === "both") return `${formatChf(v)} (${formatChf(real(v, deflator))})`;
return formatChf(v);
}
export function PhaseCards({
plan,
computed,
mode,
activePhaseId,
onSelectPhase,
phaseContext,
transitionContext,
transitionElements,
cashTransitionFor,
actions,
}: Props) {
const phases = computed.phases;
const index = Math.max(0, phases.findIndex((p) => p.id === activePhaseId));
const phase = phases[index];
// Pfeiltasten. Der dritte Weg neben Zeitachse und den Knoepfen unten -- wer eine Phase nach
// der anderen durchgeht, will die Hand nicht an der Maus haben. Eingabefelder duerfen davon
// nicht betroffen sein, sonst springt die Karte beim Bearbeiten eines Betrags weg.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const next = e.key === "ArrowLeft" ? index - 1 : index + 1;
if (next < 0 || next >= phases.length) return;
e.preventDefault();
onSelectPhase(phases[next].id);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [index, phases, onSelectPhase]);
if (!phase) return null;
return (
<div className="flex flex-col gap-3">
{/* Die Karte laeuft NICHT ueber die volle Breite. Eine Zeile aus 1300 px liest sich
schlecht -- das Auge verliert beim Zeilenwechsel den Anschluss, und Beschriftung und
Wert driften so weit auseinander, dass man sie nicht mehr zusammenbringt. Links und
rechts bleibt Luft, und die traegt die beiden grossen Pfeile. */}
<div className="flex items-stretch justify-center gap-3">
<SideArrow
dir="left"
label={phases[index - 1]?.name}
disabled={index === 0}
onClick={() => index > 0 && onSelectPhase(phases[index - 1].id)}
/>
<div className="min-w-0 flex-1" style={{ maxWidth: "46rem" }}>
<PhaseCard
key={phase.id}
plan={plan}
computed={computed}
phase={phase}
previous={phases[index - 1]}
mode={mode}
phaseContext={phaseContext}
transitionContext={transitionContext}
transitionElements={transitionElements}
cashTransitionFor={cashTransitionFor}
actions={actions}
/>
</div>
<SideArrow
dir="right"
label={phases[index + 1]?.name}
disabled={index === phases.length - 1}
onClick={() => index < phases.length - 1 && onSelectPhase(phases[index + 1].id)}
/>
</div>
<div className="flex items-center justify-center gap-2 text-xs text-muted">
<button
type="button"
disabled={index === 0}
onClick={() => onSelectPhase(phases[index - 1].id)}
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2 disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
{phases[index - 1]?.name ?? "Anfang"}
</button>
<span className="px-2 tabular-nums">
Phase {index + 1} von {phases.length}
</span>
{index === phases.length - 1 ? (
<button
type="button"
onClick={actions.onAddPhase}
className="flex items-center gap-1 rounded-lg border border-dashed border-accent px-2.5 py-1.5 font-medium text-accent transition-colors hover:bg-accent hover:text-accent-fg"
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
) : (
<button
type="button"
onClick={() => onSelectPhase(phases[index + 1].id)}
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2"
>
{phases[index + 1].name}
<ChevronRight className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
// --- Die Karte -----------------------------------------------------------------------------
function PhaseCard({
plan,
computed,
phase,
previous,
mode,
phaseContext,
transitionContext,
transitionElements,
cashTransitionFor,
actions,
}: {
plan: PlanInput;
computed: PlanComputed;
phase: PhaseComputed;
previous: PhaseComputed | undefined;
mode: ValueMode;
phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext;
transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext;
transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[];
cashTransitionFor: (phaseId: string) => CashTransitionData;
actions: CardActions;
}) {
// Welche Zeilen sind aufgeklappt? Beim Wechsel der Karte faengt es wieder bei zu an --
// die Karte wird ueber `key` neu montiert.
const [open, setOpen] = useState<Record<string, boolean>>({});
const rows = phase.elements.filter((e) => e.status !== "SOLD" || e.startValue !== 0);
const openCount = unconfirmedCells(plan, computed, phase.id).length;
const ratesOpen = plan.phases.find((p) => p.id === phase.id)?.ratesConfirmed !== true;
const pot = capitalPot(phase);
const capitalOpen = pot.total > 0 && pot.allocatedInvestments + pot.allocatedRepayments === 0;
const transEls = previous ? transitionElements(previous, phase) : [];
const ct = previous ? cashTransitionFor(previous.id) : null;
const cashOpen = ct ? !isCashTransitionAnswered(ct) : false;
// Ein Knopf fuer alle Zeilen der Karte. Bei acht Elementen ist das der Unterschied zwischen
// «einmal durchsehen» und acht Klicks.
const allKeys = [...transEls.map((e) => `t:${e.id}`), ...rows.map((e) => `p:${e.elementId}`)];
const anyOpen = allKeys.some((k) => open[k]);
const toggleAll = () =>
setOpen(anyOpen ? {} : Object.fromEntries(allKeys.map((k) => [k, true])));
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd;
const years =
plan.startYear != null
? (() => {
const before = computed.phases
.slice(0, computed.phases.findIndex((p) => p.id === phase.id))
.reduce((s, p) => s + p.durationYears, 0);
return `${plan.startYear + before}${plan.startYear + before + phase.durationYears - 1}`;
})()
: null;
return (
<article className="ui-fade overflow-hidden rounded-2xl border border-border bg-surface shadow-sm">
{/* Kopf: ruhig und gross. Er beantwortet «wo bin ich» in einem Blick. */}
<header className="flex flex-wrap items-start gap-3 px-5 py-4">
<div className="min-w-0">
<h2 className="truncate text-lg font-semibold text-fg">{phase.name}</h2>
<p className="mt-0.5 text-xs text-muted">
{years ? `${years} · ` : ""}
{phase.durationYears} {phase.durationYears === 1 ? "Jahr" : "Jahre"}
{phase.persons.length > 0 ? " · " : ""}
{phase.persons
.map((p) => `${ownerLabel(plan.persons, p.role)} ${p.startAge}${p.endAge}`)
.join(", ")}
</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-1.5">
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0) > 0 && (
<span className="flex items-center gap-1 rounded-full bg-attention-soft px-2 py-0.5 text-[11px] font-semibold text-attention-soft-fg">
<AlertTriangle className="h-3 w-3" />
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0)} offen
</span>
)}
{allKeys.length > 0 && (
<button
type="button"
onClick={toggleAll}
className="rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{anyOpen ? "Alle zuklappen" : "Alle aufklappen"}
</button>
)}
<IconBtn label="Bezeichnung und Dauer ändern" onClick={() => actions.onEditPhase(phase.id)}>
<Pencil className="h-4 w-4" />
</IconBtn>
<IconBtn label="Detailansicht" onClick={() => actions.onOpenPhaseDetail(phase.id)}>
<Maximize2 className="h-4 w-4" />
</IconBtn>
{phase.sequenceNumber === computed.phases.length && (
<IconBtn label="Lebensphase löschen" danger onClick={() => actions.onDeletePhase(phase.id, phase.name)}>
<Trash2 className="h-4 w-4" />
</IconBtn>
)}
</div>
</header>
{/* 1 · EINTRITT */}
<Section
step={1}
title={previous ? "Eintritt" : "Start"}
hint={
previous
? `Was beim Wechsel aus «${previous.name}» geschah`
: "Der Bestand bei Planbeginn erfasst in der Bestandsaufnahme"
}
>
{!previous ? (
<p className="px-1 py-2 text-xs text-muted">
Diese Phase beginnt mit dem heutigen Stand. Startwerte änderst du in der
Bestandsaufnahme oder über die Zeile selbst.
</p>
) : (
<>
{ct && (
// Cash verhielt sich bis 0.42.1 als einziger Eintritts-Punkt anders: Ein Klick
// oeffnete das rechte Panel statt aufzuklappen. Auf einer Karte, die genau davon
// wegwollte, war das ein Fremdkoerper.
<Row
open={!!open["cash"]}
onToggle={() => setOpen((pr) => ({ ...pr, cash: !pr.cash }))}
icon={<Wallet className="h-4 w-4" />}
name="Cash"
meta="einmalige Sonderein-/ausgaben"
attention={cashOpen}
right={<span className="tabular-nums">{cashTransitionSummary(ct)}</span>}
>
<CashTransitionEditor
fromPhaseId={previous.id}
initial={ct}
deflatorEnd={previous.cumulativeInflationEnd}
onSaved={actions.onChanged}
/>
</Row>
)}
{transEls.length === 0 && !cashOpen && (
<p className="px-1 py-2 text-xs text-muted">An diesem Übergang gibt es nichts zu entscheiden.</p>
)}
{transEls.map((el) => {
const key = `t:${el.id}`;
const ctx = transitionContext(previous, phase, el);
return (
<Row
key={key}
open={!!open[key]}
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
icon={<Coins className="h-4 w-4" />}
name={el.name}
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
right={null}
>
<ElementDetail
compact
element={el}
context={ctx}
phaseData={el.phaseValues[phase.id] ?? {}}
transitionData={el.transitionValues[previous.id] ?? {}}
onSaved={actions.onChanged}
onDeleteElement={() => actions.onDeleteElement(el.id, el.name)}
/>
</Row>
);
})}
<div className="pt-1">
<QuotaButton
label="Kapital verteilen"
open={capitalOpen}
hidden={!pot.total && !pot.allocatedInvestments && !pot.allocatedRepayments}
sub={`${formatChf(pot.total)} verfügbar · ${formatChf(pot.rest)} bleibt Cash`}
onClick={() => actions.onDistributeCapital(phase.id)}
/>
</div>
</>
)}
</Section>
{/* 2 · LAUFEND */}
<Section
step={2}
title="Laufend"
hint={`Was in diesen ${phase.durationYears} Jahren gilt`}
action={
<button
type="button"
onClick={actions.onAddElement}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
<Plus className="h-3.5 w-3.5" /> Element
</button>
}
>
{rows.length === 0 && (
<p className="px-1 py-2 text-xs text-muted">Noch keine Elemente erfasst.</p>
)}
{rows.map((ce) => {
const el = plan.elements.find((x) => x.id === ce.elementId);
if (!el) return null;
const key = `p:${el.id}`;
const isOpen = unconfirmedCells(plan, computed, phase.id).includes(el.id);
return (
<Row
key={key}
open={!!open[key]}
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
icon={<PiggyBank className="h-4 w-4" />}
name={el.name}
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
attention={isOpen}
right={<CellValue ce={ce} dS={dS} dE={dE} mode={mode} />}
>
<ElementDetail
compact
element={el}
context={phaseContext(phase, el)}
phaseData={el.phaseValues[phase.id] ?? {}}
transitionData={{}}
allPhases={plan.phases.map<PhaseRef>((p) => ({ id: p.id, name: p.name, sequenceNumber: p.sequenceNumber }))}
phaseDataByPhase={Object.fromEntries(
plan.phases.map((p) => [p.id, (el.phaseValues[p.id] ?? {}) as PhaseData])
)}
onSaved={actions.onChanged}
onDeleteElement={() => actions.onDeleteElement(el.id, el.name)}
/>
</Row>
);
})}
<div className="pt-1">
<QuotaButton
label={phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
open={ratesOpen}
sub={`${phase.isConsumption ? "Verzehrquote" : "Sparquote"} ${val(phase.quotaStart, dS, mode)}${val(
phase.quotaEnd,
phase.flowDeflatorEnd,
mode
)}`}
onClick={() => actions.onDistributeRates(phase.id)}
/>
</div>
</Section>
{/* 3 · ERGEBNIS */}
<Section step={3} title="Ergebnis" hint="Stand am Ende dieser Phase" tone="result">
<div className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
<Figure label="Vermögen" from={val(phase.startWealthNominal, dS, mode)} to={val(phase.endWealthNominal, dE, mode)} strong />
<Figure
label="Cash"
from={val(phase.cashStart, dS, mode)}
to={val(phase.cashEnd, dE, mode)}
danger={phase.cashNegative}
/>
</div>
{phase.cashNegative && (
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Das Cash-Konto fällt in dieser Phase unter 0 eine Liquiditätslücke.
</p>
)}
</Section>
</article>
);
}
// --- Bausteine -----------------------------------------------------------------------------
function IconBtn({
label,
onClick,
danger,
children,
}: {
label: string;
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`rounded-lg p-1.5 text-faint transition-colors ${
danger ? "hover:bg-danger-soft hover:text-danger" : "hover:bg-surface-2 hover:text-fg"
}`}
>
{children}
</button>
);
}
// Ein Akt der Karte. Die drei sind bewusst deutlich voneinander abgesetzt: ein getoenter
// Kopfstreifen ueber die volle Breite, eine Nummer im Kreis, darunter der Inhalt. Beim ersten
// Anlauf trennte sie nur eine Haarlinie -- man las die Karte als eine lange Liste statt als
// drei Schritte.
function Section({
step,
title,
hint,
action,
tone = "plain",
children,
}: {
step: number;
title: string;
hint: string;
action?: React.ReactNode;
// "plain" = weisser Inhalt, "result" = durchgehend getoent (der Schlusspunkt).
tone?: "plain" | "result";
children: React.ReactNode;
}) {
return (
<section className={`border-t-2 border-border ${tone === "result" ? "bg-surface-2" : ""}`}>
<div className={`flex items-center gap-2 px-5 py-2 ${tone === "result" ? "" : "bg-surface-2/70"}`}>
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold ${
tone === "result" ? "bg-accent text-accent-fg" : "bg-surface text-muted ring-1 ring-border"
}`}
>
{step}
</span>
<span className="text-xs font-semibold uppercase tracking-wide text-fg">{title}</span>
<span className="min-w-0 truncate text-[11px] text-faint">{hint}</span>
{action && <span className="ml-auto shrink-0">{action}</span>}
</div>
<div className="px-5 py-2.5">{children}</div>
</section>
);
}
// Der grosse Pfeil neben der Karte. Er sitzt in der Luft, die durch die begrenzte Kartenbreite
// ohnehin entsteht -- ein Bedienelement, das keinen zusaetzlichen Platz kostet.
function SideArrow({
dir,
label,
disabled,
onClick,
}: {
dir: "left" | "right";
label?: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-label={dir === "left" ? `Zurück zu ${label ?? "vorheriger Phase"}` : `Weiter zu ${label ?? "nächster Phase"}`}
title={label}
className="hidden w-12 shrink-0 items-center justify-center rounded-2xl border border-transparent text-faint transition-colors hover:border-border hover:bg-surface hover:text-accent disabled:pointer-events-none disabled:opacity-0 lg:flex"
>
{dir === "left" ? <ChevronLeft className="h-7 w-7" /> : <ChevronRight className="h-7 w-7" />}
</button>
);
}
// Eine aufklappbare Zeile. Zugeklappt ist sie eine Zusammenfassung, aufgeklappt der ganze
// Editor -- und zwar IN der Karte. Kein Panel legt sich mehr ueber das, was man bearbeitet.
function Row({
open,
onToggle,
icon,
name,
meta,
right,
attention,
children,
}: {
open: boolean;
onToggle: () => void;
icon: React.ReactNode;
name: string;
meta: string;
right: React.ReactNode;
attention?: boolean;
children: React.ReactNode;
}) {
return (
<div className={`border-b border-border last:border-b-0 ${open ? "bg-surface-2/40" : ""}`}>
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center gap-2.5 py-2 text-left transition-colors hover:bg-surface-2/60"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${attention ? "bg-attention" : "bg-success"}`} />
<span className={attention ? "text-attention" : "text-faint"}>{icon}</span>
<span className="min-w-0">
<span className="block truncate text-sm text-fg">{name}</span>
<span className="block truncate text-[11px] text-faint">{meta}</span>
</span>
<span className="ml-auto shrink-0 text-xs text-muted">{right}</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-faint transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && <div className="pb-4 pl-6 pr-1">{children}</div>}
</div>
);
}
// Der Cash-Uebergang in der Karte. Dieselben Felder wie im Panel (`CashTransitionFields`),
// nur mit eigenem Speichern -- eine zweite Implementierung der Felder waere garantiert
// auseinandergelaufen.
function CashTransitionEditor({
fromPhaseId,
initial,
deflatorEnd,
onSaved,
}: {
fromPhaseId: string;
initial: CashTransitionData;
deflatorEnd: number;
onSaved: () => void;
}) {
// Vorbelegen, damit ein blosses Speichern den sichtbaren Default (1:1) festhaelt.
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initial));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.put(`/api/phases/${fromPhaseId}/cash-transition`, ct);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<CashTransitionFields ct={ct} setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={deflatorEnd} />
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<div>
<Button disabled={saving} onClick={save}>
{saving ? "Speichern…" : "Speichern"}
</Button>
</div>
</div>
);
}
function CellValue({
ce,
dS,
dE,
mode,
}: {
ce: ElementPhaseComputed;
dS: number;
dE: number;
mode: ValueMode;
}) {
if (ce.summary) return <span className="tabular-nums">{ce.summary}</span>;
if (ce.note) return <span>{ce.note}</span>;
return (
<span className="whitespace-nowrap tabular-nums">
{val(ce.startValue, dS, mode)} <span className="text-faint"></span> {val(ce.endValue, dE, mode)}
</span>
);
}
function Figure({
label,
from,
to,
strong,
danger,
}: {
label: string;
from: string;
to: string;
strong?: boolean;
danger?: boolean;
}) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-border/60 py-1 last:border-b-0">
<span className="text-xs text-muted">{label}</span>
<span
className={`whitespace-nowrap tabular-nums ${strong ? "text-sm font-semibold" : "text-xs"} ${
danger ? "text-danger" : "text-fg"
}`}
>
{from} <span className="text-faint"></span> {to}
</span>
</div>
);
}
// Der Weg in einen Verteil-Dialog. Gruen mit Haken, sobald erledigt -- dieselbe Sprache wie
// im Phasenkopf der Matrix (SPEZIFIKATION 3.6.9).
function QuotaButton({
label,
sub,
open,
hidden,
onClick,
}: {
label: string;
sub: string;
open: boolean;
hidden?: boolean;
onClick: () => void;
}) {
if (hidden) return null;
return (
<Button variant={open ? "primary" : "ghost"} onClick={onClick} className="w-full justify-between">
<span className="flex flex-col items-start">
<span>
{label}
{open ? "" : " ✓"}
</span>
<span className="text-[11px] font-normal opacity-80">{sub}</span>
</span>
<ChevronRight className="h-4 w-4" />
</Button>
);
}
+62 -16
View File
@@ -3,19 +3,28 @@
import { useState } from "react"; import { useState } from "react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { NumberField, TextField } from "@/components/FormField"; import { NumberField, TextField } from "@/components/FormField";
import { CarryWarning } from "@/components/ElementDetail";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { planDurationChange } from "@/lib/phaseplan";
import type { PhaseInput } from "@/lib/types"; import type { PhaseInput } from "@/lib/types";
export function PhaseDetail({ export function PhaseDetail({
phase, phase,
allPhases,
maxDurationYears, maxDurationYears,
isLast, isLast,
laterPhaseCount,
onSaved, onSaved,
onDeleted, onDeleted,
}: { }: {
phase: PhaseInput; phase: PhaseInput;
// Alle Phasen des Szenarios -- nötig, um die Auswirkung einer geänderten Dauer auf die
// Folgephase VORHER zu zeigen (siehe planDurationChange).
allPhases: PhaseInput[];
maxDurationYears: number | null; maxDurationYears: number | null;
isLast: boolean; isLast: boolean;
laterPhaseCount: number;
onSaved: () => void; onSaved: () => void;
onDeleted: () => void; onDeleted: () => void;
}) { }) {
@@ -23,10 +32,32 @@ export function PhaseDetail({
const [durationYears, setDurationYears] = useState(phase.durationYears); const [durationYears, setDurationYears] = useState(phase.durationYears);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const toast = useToast();
const cap = maxDurationYears; const cap = maxDurationYears;
// Vorschau: Was passiert mit der Folgephase? Wird live berechnet, damit die Auswirkung
// sichtbar ist, BEVOR gespeichert wird.
const change = planDurationChange(allPhases, phase.id, durationYears);
async function save() { async function save() {
// Verschiebt sich eine Folgephase, wird das vorher bestätigt -- sonst ändert ein Klick
// stillschweigend zwei Phasen.
if (change.ok && change.neighbourId && change.delta !== 0) {
const jahre = Math.abs(change.delta);
const ok = await confirm({
title: "Folgephase wird angepasst",
message:
`Wenn du «${phase.name}» um ${jahre} Jahr${jahre === 1 ? "" : "e"} ` +
`${change.delta > 0 ? "verlängerst" : "verkürzst"}, wird «${change.neighbourName}» automatisch auf ` +
`${change.neighbourNewDuration} Jahr${change.neighbourNewDuration === 1 ? "" : "e"} ` +
`${change.delta > 0 ? "gekürzt" : "verlängert"} die Gesamtdauer des Plans bleibt gleich. ` +
`Ohne diesen Ausgleich würde eine spätere Lebensphase eine Pensionierung überspannen.`,
confirmLabel: "Fortfahren",
});
if (!ok) return;
}
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
@@ -40,55 +71,70 @@ export function PhaseDetail({
} }
async function remove() { async function remove() {
if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return; const ok = await confirm({
title: "Lebensphase löschen?",
message: `«${phase.name}» wird mit allen erfassten Werten und Übergangs-Entscheiden entfernt.`,
confirmLabel: "Endgültig löschen",
danger: true,
});
if (!ok) return;
try { try {
await api.delete(`/api/phases/${phase.id}`); await api.delete(`/api/phases/${phase.id}`);
onDeleted(); onDeleted();
} catch (e) { } catch (e) {
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen."); toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
} }
} }
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">
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg"> <div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">Lebensphase</div>
Lebensphase
</div>
{isLast && ( {isLast && (
<button <button
type="button" type="button"
onClick={remove} onClick={remove}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger" className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
> >
<Trash2 className="h-3.5 w-3.5" /> Phase loeschen <Trash2 className="h-3.5 w-3.5" /> Phase löschen
</button> </button>
)} )}
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* Eine geänderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermögen). */}
<CarryWarning laterPhaseCount={laterPhaseCount} />
<TextField label="Bezeichnung" value={name} onChange={setName} /> <TextField label="Bezeichnung" value={name} onChange={setName} />
<NumberField <NumberField
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`} label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
help={cap != null ? "Die Dauer ist ans naechste Pensionsereignis gekappt." : undefined} help={cap != null ? "Die Dauer ist ans nächste Pensionsereignis gekappt." : undefined}
value={durationYears} value={durationYears}
min={1} min={1}
max={cap ?? undefined} max={cap ?? undefined}
onChange={(v) => setDurationYears(cap != null ? Math.min(v, cap) : v)} onChange={(v) => setDurationYears(cap != null ? Math.min(v, cap) : v)}
/> />
</div> </div>
<p className="text-xs text-faint">Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen gesetzt.</p>
{/* Auswirkung auf die Folgephase -- sichtbar, bevor gespeichert wird. */}
{change.error && (
<p className="rounded-lg border border-danger bg-danger-soft px-3 py-2 text-xs text-danger">{change.error}</p>
)}
{change.ok && change.neighbourId && change.delta !== 0 && (
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
«{change.neighbourName}» wird dadurch auf <strong>{change.neighbourNewDuration} Jahre</strong>{" "}
{change.delta > 0 ? "gekürzt" : "verlängert"} die Gesamtdauer des Plans bleibt gleich.
</p>
)}
<p className="text-xs text-faint">
Die Inflationsrate gilt für das ganze Szenario und wird im Grundprofil gesetzt.
</p>
{error && <p className="text-sm text-danger">{error}</p>} {error && <p className="text-sm text-danger">{error}</p>}
<div> <div>
<button <Button disabled={saving || !!change.error} onClick={save}>
type="button" {saving ? "Speichern…" : "Speichern"}
disabled={saving} </Button>
onClick={save}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{saving ? "Speichern..." : "Speichern"}
</button>
</div> </div>
</div> </div>
); );
+44 -19
View File
@@ -1,11 +1,13 @@
"use client"; "use client";
import { NumberField, SelectField, TextField } from "@/components/FormField"; import { NumberField, SelectField, TextField } from "@/components/FormField";
import { AHV_REFERENCE_AGE } from "@/lib/constants";
import type { HouseholdType, PersonRole } from "@/lib/types"; import type { HouseholdType, PersonRole } from "@/lib/types";
export interface ProfileDraft { export interface ProfileDraft {
householdType: HouseholdType; householdType: HouseholdType;
inflationRateDefault: number; inflationRateDefault: number;
startYear: number;
persons: { role: PersonRole; name: string; age: number; retirementAge: number }[]; persons: { role: PersonRole; name: string; age: number; retirementAge: number }[];
} }
@@ -13,18 +15,29 @@ export function emptyProfileDraft(): ProfileDraft {
return { return {
householdType: "SINGLE", householdType: "SINGLE",
inflationRateDefault: 1.5, inflationRateDefault: 1.5,
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: 65 }], startYear: new Date().getFullYear(),
// Das Pensionsalter wird beim Anlegen nicht gefragt: Es ist der erste Entscheid der
// Pensionsplanung. Bis dahin gilt das Referenzalter.
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }],
}; };
} }
// Gemeinsame Formularfelder fuer das Grundprofil eines Plans (Haushaltsform, Personen, // Gemeinsame Formularfelder für das Grundprofil eines Plans (Haushaltsform, Personen,
// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet. // Startjahr, Inflation). Wird beim Plan-Erstellen und in den Grundeinstellungen verwendet.
//
// Das PENSIONSALTER fehlt hier seit 0.36 bewusst. Es ist kein Stammdatum, sondern der erste
// Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze. Es wird deshalb dort
// gesetzt («Pensionsplanung» je Person) und hier höchstens angezeigt.
export function PlanProfileFields({ export function PlanProfileFields({
draft, draft,
onChange, onChange,
// Blendet den Hinweis aus, dass diese Angaben plan-weit gelten -- beim Anlegen eines Plans
// gibt es noch keine anderen Szenarien, die betroffen sein könnten.
hideScopeNote = false,
}: { }: {
draft: ProfileDraft; draft: ProfileDraft;
onChange: (next: ProfileDraft) => void; onChange: (next: ProfileDraft) => void;
hideScopeNote?: boolean;
}) { }) {
function setType(type: HouseholdType) { function setType(type: HouseholdType) {
if (type === "SINGLE") { if (type === "SINGLE") {
@@ -32,7 +45,7 @@ export function PlanProfileFields({
} else { } else {
const persons = const persons =
draft.persons.length < 2 draft.persons.length < 2
? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: 65 }] ? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }]
: draft.persons; : draft.persons;
onChange({ ...draft, householdType: type, persons }); onChange({ ...draft, householdType: type, persons });
} }
@@ -47,9 +60,19 @@ export function PlanProfileFields({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Seit V7 beschreiben Haushaltsform, Personen und Startjahr den PLAN und gelten für
ALLE Szenarien. Das muss sichtbar sein -- sonst ändert man beim Bearbeiten eines
Nebenszenarios unbemerkt auch alle anderen. */}
{!hideScopeNote && (
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
Haushaltsform, Personen und Planstart gelten für <strong>alle Szenarien</strong> dieses
Plans. Unterscheiden sie sich, ist es ein anderer Plan.
</p>
)}
<SelectField <SelectField
label="Haushaltsform" label="Haushaltsform (plan-weit)"
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen." help="Planst du alleine oder gemeinsam mit einer Partnerin / einem Partner?"
value={draft.householdType} value={draft.householdType}
onChange={setType} onChange={setType}
options={[ options={[
@@ -61,37 +84,39 @@ export function PlanProfileFields({
{draft.persons.map((person, index) => ( {draft.persons.map((person, index) => (
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-surface-2 p-3"> <div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-surface-2 p-3">
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-faint"> <div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-faint">
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"} {draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Deine Angaben"}
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<TextField <TextField
label="Name (optional)" label="Name (optional, plan-weit)"
value={person.name} value={person.name}
placeholder={person.role === "PERSON_A" ? "Person A" : "Person B"} placeholder={person.role === "PERSON_A" ? "Person A" : "Person B"}
onChange={(v) => updatePerson(index, { name: v })} onChange={(v) => updatePerson(index, { name: v })}
/> />
</div> </div>
<NumberField <NumberField
label="Aktuelles Alter" label="Aktuelles Alter (plan-weit)"
value={person.age} value={person.age}
min={0} min={0}
max={120} max={120}
onChange={(v) => updatePerson(index, { age: Math.round(v) })} onChange={(v) => updatePerson(index, { age: Math.round(v) })}
/> />
<NumberField {/* Das Pensionsalter steht bewusst NICHT hier -- siehe Kommentar oben. */}
label="Pensionierungsalter"
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension)."
value={person.retirementAge}
min={30}
max={100}
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
/>
</div> </div>
))} ))}
<NumberField <NumberField
label="Erwartete Inflationsrate (%)" label="Planstart (Jahr, plan-weit)"
help="Langfristige Annahme zur jaehrlichen Teuerung. Kann pro Lebensphase individuell ueberschrieben werden." help="Kalenderjahr, in dem die Planung beginnt (Jahr 1). Wird auf der Zeitachse und in den Grafiken angezeigt; die Berechnung selbst rechnet in Jahren ab Planbeginn."
value={draft.startYear}
min={1900}
max={2200}
onChange={(v) => onChange({ ...draft, startYear: Math.round(v) })}
/>
<NumberField
label="Erwartete Inflationsrate (%, nur dieses Szenario)"
help="Langfristige Annahme zur jährlichen Teuerung. Gilt plan-weit für alle Lebensphasen."
value={draft.inflationRateDefault} value={draft.inflationRateDefault}
step={0.1} step={0.1}
onChange={(v) => onChange({ ...draft, inflationRateDefault: v })} onChange={(v) => onChange({ ...draft, inflationRateDefault: v })}
+2284 -405
View File
File diff suppressed because it is too large Load Diff
+606
View File
@@ -0,0 +1,606 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
BarChart3,
CalendarClock,
Check,
Dices,
Eye,
FileSpreadsheet,
GitBranch,
History,
Layers,
LineChart as LineChartIcon,
Pencil,
Plus,
SlidersHorizontal,
Tornado,
Trash2,
X,
} from "lucide-react";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
import type { PersonRole } from "@/lib/types";
export type PlanTab = "dashboard" | "scenarios" | "actuals" | "analyses" | "reports";
// --- Typen der Dashboard-Antwort ---
interface DashboardResponse {
plan: {
id: string;
name: string;
householdType: "SINGLE" | "COUPLE";
startYear: number | null;
persons: { role: PersonRole; name: string | null; age: number }[];
};
counts: { scenarios: number; actuals: number; analyses: number };
base: {
scenarioId: string;
name: string;
phaseCount: number;
endNominal: number;
endReal: number;
ruinAge: number | null;
actualEndNominal: number | null;
actualYears: number[];
} | null;
scenarios: {
id: string;
name: string;
isBase: boolean;
parentScenarioId: string | null;
version: string | null;
versionComment: string | null;
elementCount: number;
phaseCount: number;
versionCount: number;
endNominal: number;
endReal: number;
ruinAge: number | null;
}[];
}
function useDashboard(planId: string, reloadKey: number) {
const [data, setData] = useState<DashboardResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const d = await api.get<DashboardResponse>(`/api/plans/${planId}/dashboard`);
if (!cancelled) setData(d);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [planId, reloadKey]);
return { data, error };
}
// Anklickbare Kennzahl-Kachel. Ohne `onClick` bleibt sie eine reine Anzeige.
function Stat({
label,
value,
hint,
tone,
icon: Icon,
onClick,
}: {
label: string;
value: string;
hint?: string;
tone?: "danger" | "success";
icon?: typeof Layers;
onClick?: () => void;
}) {
const toneClass = tone === "danger" ? "text-danger" : tone === "success" ? "text-success" : "text-fg";
const body = (
<>
<div className="flex items-center gap-1.5 text-xs text-muted">
{Icon && <Icon className="h-3.5 w-3.5 text-accent" />}
{label}
</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${toneClass}`}>{value}</div>
{hint && <div className="mt-0.5 text-[11px] text-faint">{hint}</div>}
</>
);
if (!onClick) {
return <div className="rounded-2xl border border-border bg-surface p-4 shadow-sm">{body}</div>;
}
return (
<button
type="button"
onClick={onClick}
className="group rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
>
{body}
<div className="mt-1 text-[11px] font-medium text-accent opacity-0 transition-opacity group-hover:opacity-100">
Öffnen
</div>
</button>
);
}
// =========================================================================================
// Plan-Dashboard
// =========================================================================================
export function PlanDashboardView({
planId,
onNavigate,
onRenamed,
}: {
planId: string;
onNavigate: (tab: PlanTab) => void;
onRenamed: () => void;
}) {
const [reloadKey, setReloadKey] = useState(0);
const { data, error } = useDashboard(planId, reloadKey);
const [editing, setEditing] = useState(false);
const [draftName, setDraftName] = useState("");
const [saving, setSaving] = useState(false);
const toast = useToast();
if (error) return <p className="text-sm text-danger">{error}</p>;
if (!data) return <p className="text-sm text-muted">Wird geladen</p>;
const { plan, counts, base } = data;
const persons = plan.persons
.map((p) => `${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")} (${p.age})`)
.join(" · ");
// Jüngster erfasster Ist-Datensatz -- er bestimmt, gegen welchen Stand verglichen wird.
const latestActualYear = base && base.actualYears.length > 0 ? Math.max(...base.actualYears) : null;
const delta = base && base.actualEndNominal !== null ? base.actualEndNominal - base.endNominal : null;
async function saveName() {
const name = draftName.trim();
if (!name) return;
setSaving(true);
try {
await api.patch(`/api/plans/${planId}`, { name });
setEditing(false);
setReloadKey((k) => k + 1);
onRenamed();
toast("success", "Plan umbenannt.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Umbenennen fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-6">
<div>
{editing ? (
<div className="flex flex-wrap items-center gap-2">
<input
autoFocus
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveName();
if (e.key === "Escape") setEditing(false);
}}
maxLength={120}
className="rounded-lg border border-border bg-input px-3 py-1.5 text-lg font-semibold text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
/>
<Button size="sm" onClick={saveName} disabled={saving}>
<Check className="h-4 w-4" /> Speichern
</Button>
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="group flex items-center gap-2">
<h2 className="text-lg font-semibold text-fg">{plan.name}</h2>
<button
type="button"
aria-label="Plan umbenennen"
title="Plan umbenennen"
onClick={() => {
setDraftName(plan.name);
setEditing(true);
}}
className="rounded-md p-1 text-faint opacity-60 transition-opacity hover:bg-surface-2 hover:text-fg group-hover:opacity-100"
>
<Pencil className="h-4 w-4" />
</button>
</div>
)}
<p className="text-sm text-muted">
{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons}
{plan.startYear ? ` · Planstart ${plan.startYear}` : ""}
</p>
</div>
{/* Anklickbare Kacheln -- jede führt in ihren Bereich. */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Stat
label="Szenarien"
value={String(counts.scenarios)}
hint="Varianten dieser Planung"
icon={Layers}
onClick={() => onNavigate("scenarios")}
/>
<Stat
label="Effektive Werte"
value={String(counts.actuals)}
hint="erfasste Ist-Datensätze"
icon={CalendarClock}
onClick={() => onNavigate("actuals")}
/>
<Stat
label="Gespeicherte Analysen"
value={String(counts.analyses)}
hint="Grafiken, Simulationen, Tornados"
icon={BarChart3}
onClick={() => onNavigate("analyses")}
/>
<Stat
label="Berichte"
value="PDF"
hint="erzeugen und wieder herunterladen"
icon={FileSpreadsheet}
onClick={() => onNavigate("reports")}
/>
</div>
{base && (
<div className="rounded-2xl border border-border bg-surface p-5 shadow-sm">
<h3 className="mb-3 flex flex-wrap items-baseline gap-2 text-sm font-semibold text-fg">
Basisszenario «{base.name}»
<span className="text-[11px] font-normal text-faint">alle Kennzahlen beziehen sich hierauf</span>
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Stat label="Endvermögen (nominal)" value={formatChf(base.endNominal)} />
<Stat label="Endvermögen (real)" value={formatChf(base.endReal)} />
<Stat label="Lebensphasen" value={String(base.phaseCount)} />
{base.ruinAge !== null ? (
<Stat label="Kapital reicht" value={`bis Alter ${base.ruinAge}`} tone="danger" hint="danach aufgebraucht" />
) : (
<Stat label="Kapital reicht" value="bis Planende" tone="success" />
)}
</div>
{base.actualEndNominal !== null && delta !== null && (
<p
className={`mt-4 rounded-xl border px-3 py-2 text-xs ${
delta >= 0 ? "border-success bg-success/10 text-fg" : "border-danger bg-danger-soft text-fg"
}`}
>
Mit den effektiven Werten{latestActualYear ? ` von ${latestActualYear}` : ""} liegt das Endvermögen
(nominal) bei <strong>{formatChf(base.actualEndNominal)}</strong> eine Abweichung von{" "}
<strong className={delta >= 0 ? "text-success" : "text-danger"}>
{delta >= 0 ? "+" : ""}
{formatChf(delta)}
</strong>{" "}
gegenüber dem Plan.
</p>
)}
</div>
)}
</div>
);
}
// =========================================================================================
// Szenario-Liste
// =========================================================================================
export function ScenarioListView({
planId,
reloadKey = 0,
onOpenMatrix,
onOpenHistory,
onCopyFrom,
onDelete,
}: {
planId: string;
reloadKey?: number;
onOpenMatrix: (scenarioId: string) => void;
onOpenHistory: (scenarioId: string) => void;
// Kopiervorlage ist frei wählbar -- nicht mehr zwingend das Basisszenario.
onCopyFrom: (scenarioId: string) => void;
onDelete: (scenarioId: string) => void;
}) {
const { data, error } = useDashboard(planId, reloadKey);
if (error) return <p className="text-sm text-danger">{error}</p>;
if (!data) return <p className="text-sm text-muted">Wird geladen</p>;
const nameById = new Map(data.scenarios.map((s) => [s.id, s.name]));
const baseId = data.scenarios.find((s) => s.isBase)?.id ?? data.scenarios[0]?.id;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-fg">Szenarien</h2>
{baseId && (
<Button onClick={() => onCopyFrom(baseId)}>
<Plus className="h-4 w-4" /> Neues Szenario
</Button>
)}
</div>
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
<th className="px-3 py-2 text-right font-semibold">Version</th>
<th className="px-3 py-2 text-right font-semibold">Phasen</th>
<th className="px-3 py-2 text-right font-semibold">Elemente</th>
<th className="px-3 py-2 text-right font-semibold">Endvermögen (nom.)</th>
<th className="px-3 py-2 text-right font-semibold">Kapital reicht</th>
<th className="px-3 py-2 text-right font-semibold">Aktionen</th>
</tr>
</thead>
<tbody>
{data.scenarios.map((s) => (
// Die ganze Zeile öffnet die Matrix -- der frühere «Matrix»-Knopf entfällt.
<tr
key={s.id}
onClick={() => onOpenMatrix(s.id)}
className={`cursor-pointer border-t border-border transition-colors hover:bg-accent-soft/40 ${
s.isBase ? "bg-accent-soft/20" : ""
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span className="font-medium text-fg">{s.name}</span>
{s.isBase && (
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>
)}
</div>
{s.parentScenarioId && (
<div className="flex items-center gap-1 text-[11px] text-faint">
<GitBranch className="h-3 w-3" /> aus {nameById.get(s.parentScenarioId) ?? "…"}
</div>
)}
</td>
<td className="px-3 py-2 text-right">
<div className="tabular-nums text-muted">{s.version ?? ""}</div>
{s.versionComment && (
<div className="max-w-48 truncate text-[11px] text-faint" title={s.versionComment}>
«{s.versionComment}»
</div>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.phaseCount}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.elementCount}</td>
<td className="px-3 py-2 text-right tabular-nums text-fg">{formatChf(s.endNominal)}</td>
<td className="px-3 py-2 text-right">
{s.ruinAge !== null ? (
<span className="text-danger">Alter {s.ruinAge}</span>
) : (
<span className="text-success">Planende</span>
)}
</td>
{/* Aktionen: stopPropagation, sonst öffnet der Zeilenklick die Matrix. */}
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
title="Änderungshistorie"
onClick={() => onOpenHistory(s.id)}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
>
<History className="h-3.5 w-3.5" /> Historie
</button>
<button
type="button"
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"
>
<Plus className="h-3.5 w-3.5" /> Kopie
</button>
{!s.isBase && (
<button
type="button"
aria-label="Szenario löschen"
title="Szenario löschen"
onClick={() => onDelete(s.id)}
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// =========================================================================================
// Analysen: vier Kacheln + Liste der gespeicherten
// =========================================================================================
export type ToolKind = "CHART" | "MONTE_CARLO" | "SENSITIVITY" | "LIVESIM";
const TILES: { kind: ToolKind; title: string; icon: typeof BarChart3; blurb: string }[] = [
{
kind: "CHART",
title: "Grafiken",
icon: LineChartIcon,
blurb:
"Vermögensverlauf, Einkommen vs. Ausgaben oder Vermögensaufteilung wähle eine Grafik und ob nominal/real und Plan/effektiv gezeigt werden.",
},
{
kind: "LIVESIM",
title: "Live-Simulation",
icon: SlidersHorizontal,
blurb: "Dreh an Reglern (Rendite, Ausgaben, Inflation …) und sieh die Wirkung sofort ohne etwas zu speichern.",
},
{
kind: "MONTE_CARLO",
title: "Monte-Carlo",
icon: Dices,
blurb: "Tausende Zufallspfade zeigen, wie wahrscheinlich deine Planung hält und dein Ziel erreicht wird.",
},
{
kind: "SENSITIVITY",
title: "Einflussfaktoren",
icon: Tornado,
blurb: "Welche deiner Annahmen entscheidet überhaupt über das Ergebnis? Ein Tornado ordnet sie nach Wirkung.",
},
];
function typeIcon(t: AnalysisType) {
return t === "MONTE_CARLO" ? Dices : t === "SENSITIVITY" ? Tornado : BarChart3;
}
export function AnalysesView({
planId,
onLaunch,
onOpenSaved,
}: {
planId: string;
onLaunch: (kind: ToolKind) => void;
onOpenSaved: (id: string) => void;
}) {
const [saved, setSaved] = useState<SavedAnalysisMeta[] | null>(null);
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const toast = useToast();
const load = useCallback(async () => {
try {
const d = await api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`);
setSaved(d.analyses);
} catch (e) {
setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
}, [planId]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const d = await api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`);
if (!cancelled) setSaved(d.analyses);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [planId]);
async function remove(a: SavedAnalysisMeta) {
const ok = await confirm({ title: "Analyse löschen?", message: `«${a.name}» wird entfernt.`, confirmLabel: "Löschen", danger: true });
if (!ok) return;
try {
await api.delete(`/api/plans/${planId}/analyses/${a.id}`);
await load();
toast("success", "Analyse gelöscht.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
const dt = (iso: string) =>
new Date(iso).toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" });
return (
<div className="flex flex-col gap-6">
<h2 className="text-lg font-semibold text-fg">Analysen</h2>
{/* Vier Kacheln, die beim Darüberfahren (oder Antippen) umklappen. */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{TILES.map((t) => (
<FlipTile key={t.kind} tile={t} onClick={() => onLaunch(t.kind)} />
))}
</div>
<div>
<h3 className="mb-2 text-sm font-semibold text-fg">Gespeicherte Analysen</h3>
{error && <p className="text-xs text-danger">{error}</p>}
{!saved && !error && <p className="text-xs text-muted">Wird geladen</p>}
{saved && saved.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Noch nichts gespeichert. In jedem Werkzeug oben kannst du ein Ergebnis festhalten es erscheint dann hier.
</p>
)}
{saved && saved.length > 0 && (
<div className="flex flex-col gap-2">
{saved.map((a) => {
const Icon = typeIcon(a.type);
return (
<div key={a.id} className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2 p-3">
<Icon className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-fg">{a.name}</div>
<div className="text-[11px] text-muted">
{ANALYSIS_TYPE_LABEL[a.type]} · {dt(a.createdAt)}
{a.scenarioName ? ` · ${a.scenarioName}${a.versionLabel ? ` ${a.versionLabel}` : ""}` : ""} ·{" "}
{a.metric === "real" ? "real" : "nominal"} · {sourceLabel(a.source)}
{a.summary ? ` · ${a.summary}` : ""}
</div>
</div>
<button
type="button"
onClick={() => onOpenSaved(a.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"
>
<Eye className="h-3.5 w-3.5" /> Anzeigen
</button>
<button
type="button"
onClick={() => remove(a)}
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>
)}
</div>
</div>
);
}
function FlipTile({ tile, onClick }: { tile: (typeof TILES)[number]; onClick: () => void }) {
const [flipped, setFlipped] = useState(false);
const Icon = tile.icon;
return (
<button
type="button"
onClick={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-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
>
{!flipped ? (
<>
<Icon className="h-8 w-8 text-accent" />
<div>
<div className="text-sm font-semibold text-fg">{tile.title}</div>
<div className="text-[11px] text-faint">Zum Starten klicken</div>
</div>
</>
) : (
<div className="flex h-full flex-col">
<div className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-accent">
<Icon className="h-4 w-4" /> {tile.title}
</div>
<p className="text-[11px] leading-relaxed text-muted">{tile.blurb}</p>
<span className="mt-auto text-[11px] font-medium text-accent">Starten </span>
</div>
)}
</button>
);
}
+14 -19
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { KeyRound, LogOut, Palette, UserCircle2 } from "lucide-react"; import { KeyRound, LogOut, Palette, UserCircle2 } from "lucide-react";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { Button, Modal } from "@/components/ui";
import { getEffectiveTheme, setTheme, THEMES, type Theme } from "@/lib/theme"; import { getEffectiveTheme, setTheme, THEMES, type Theme } from "@/lib/theme";
export function ProfileMenu({ username }: { username: string }) { export function ProfileMenu({ username }: { username: string }) {
@@ -12,6 +13,7 @@ export function ProfileMenu({ username }: { username: string }) {
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Übernahme aus localStorage
setThemeState(getEffectiveTheme()); setThemeState(getEffectiveTheme());
}, []); }, []);
@@ -74,7 +76,7 @@ export function ProfileMenu({ username }: { username: string }) {
<MenuItem <MenuItem
icon={<KeyRound className="h-4 w-4" />} icon={<KeyRound className="h-4 w-4" />}
label="Passwort aendern" label="Passwort ändern"
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
setShowPasswordDialog(true); setShowPasswordDialog(true);
@@ -121,7 +123,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
if (newPassword !== newPasswordConfirm) { if (newPassword !== newPasswordConfirm) {
setError("Die neuen Passwoerter stimmen nicht ueberein."); setError("Die neuen Passwörter stimmen nicht überein.");
return; return;
} }
setSaving(true); setSaving(true);
@@ -130,7 +132,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
setDone(true); setDone(true);
setTimeout(onClose, 1200); setTimeout(onClose, 1200);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Passwort aendern fehlgeschlagen."); setError(err instanceof Error ? err.message : "Passwort ändern fehlgeschlagen.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -139,28 +141,21 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
const inputClass = const inputClass =
"w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"; "w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
// Über die zentrale Modal-Komponente: bringt Esc, Fokus-Falle und aria-modal mit
// (SPEZIFIKATION 3.7.6) -- der Dialog war zuvor von Hand gebaut und ignorierte Esc.
return ( return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}> <Modal title="Passwort ändern" onClose={onClose}>
<form <form onSubmit={handleSubmit} className="flex flex-col gap-3">
onSubmit={handleSubmit}
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-fg">Passwort aendern</h2>
<input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className={inputClass} /> <input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className={inputClass} />
<input type="password" placeholder="Neues Passwort" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className={inputClass} /> <input type="password" placeholder="Neues Passwort" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className={inputClass} />
<input type="password" placeholder="Neues Passwort bestaetigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} /> <input type="password" placeholder="Neues Passwort bestätigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
{error && <p className="text-sm text-danger">{error}</p>} {error && <p className="text-sm text-danger">{error}</p>}
{done && <p className="text-sm text-success">Passwort geaendert.</p>} {done && <p className="text-sm text-success">Passwort geändert.</p>}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<button type="submit" disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"> <Button type="submit" disabled={saving}>{saving ? "..." : "Speichern"}</Button>
{saving ? "..." : "Speichern"} <Button type="button" variant="secondary" onClick={onClose}>Abbrechen</Button>
</button>
<button type="button" onClick={onClose} className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2">
Abbrechen
</button>
</div> </div>
</form> </form>
</div> </Modal>
); );
} }
+322
View File
@@ -0,0 +1,322 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, Download, FileText, Plus, Trash2 } from "lucide-react";
import { Button, useConfirm, useToast } from "@/components/ui";
import { InfoBubble } from "@/components/InfoBubble";
import { api } from "@/lib/api-client";
import { ANALYSIS_TYPE_LABEL, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
import { MAX_REPORT_SCENARIOS, type ReportMetric, type ReportSource } from "@/lib/report";
interface StoredReport {
id: string;
title: string;
config: { metric?: string; source?: string; scenarioIds?: string[]; analysisIds?: string[]; comment?: string | null };
pdfBytes: number;
createdAt: string;
author: string;
}
const dt = (iso: string) =>
new Date(iso).toLocaleString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
export function ReportsView({
planId,
scenarios,
hasActuals,
}: {
planId: string;
scenarios: { id: string; name: string; isBase: boolean }[];
hasActuals: boolean;
}) {
const [reports, setReports] = useState<StoredReport[] | null>(null);
const [analyses, setAnalyses] = useState<SavedAnalysisMeta[]>([]);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<"list" | "new">("list");
const [busy, setBusy] = useState(false);
const confirm = useConfirm();
const toast = useToast();
// Formular
const [title, setTitle] = useState("");
const [comment, setComment] = useState("");
const [metric, setMetric] = useState<ReportMetric>("nominal");
const [source, setSource] = useState<ReportSource>("PLAN");
const [scenarioIds, setScenarioIds] = useState<string[]>([]);
const [analysisIds, setAnalysisIds] = useState<string[]>([]);
const load = useCallback(async () => {
const [r, a] = await Promise.all([
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
]);
setReports(r.reports);
setAnalyses(a.analyses);
}, [planId]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [r, a] = await Promise.all([
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
]);
if (cancelled) return;
setReports(r.reports);
setAnalyses(a.analyses);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [planId]);
function startNew() {
const base = scenarios.find((s) => s.isBase) ?? scenarios[0];
setTitle(`Finanzplanung ${new Date().getFullYear()}`);
setComment("");
setMetric("nominal");
setSource("PLAN");
setScenarioIds(base ? [base.id] : []);
setAnalysisIds([]);
setMode("new");
}
function toggleScenario(id: string) {
setScenarioIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : prev.length >= MAX_REPORT_SCENARIOS ? prev : [...prev, id]
);
}
async function create() {
setBusy(true);
try {
await api.post(`/api/plans/${planId}/reports`, {
title: title.trim() || "Finanzplanung",
metric,
source,
scenarioIds,
analysisIds,
comment: comment.trim() || undefined,
});
await load();
setMode("list");
toast("success", "Bericht erstellt.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Bericht konnte nicht erstellt werden.");
} finally {
setBusy(false);
}
}
async function remove(r: StoredReport) {
const ok = await confirm({
title: "Bericht löschen?",
message: `«${r.title}» wird endgültig entfernt. Die Datei lässt sich danach nicht mehr herunterladen.`,
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.delete(`/api/plans/${planId}/reports/${r.id}`);
await load();
toast("success", "Bericht gelöscht.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
if (mode === "new") {
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold text-fg">Neuer Bericht</h2>
<p className="text-sm text-muted">
Der Bericht wird als PDF erzeugt und abgelegt. Er hält den Stand von heute fest spätere Änderungen am
Plan verändern ihn nicht mehr.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-muted">Titel</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Notiz auf dem Deckblatt (optional)</label>
<input
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="z. B. «Stand nach Beratungsgespräch»"
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Werte</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{(["nominal", "real"] as ReportMetric[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMetric(m)}
className={`rounded-md px-2 py-0.5 font-medium ${metric === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"}`}
>
{m === "real" ? "Real" : "Nominal"}
</button>
))}
</div>
<InfoBubble text="Der Bericht führt durchgängig EINE Leitgrösse. Beides nebeneinander würde jede Tabellenspalte verdoppeln." />
</label>
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Grundlage</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{(["PLAN", "ACTUAL"] as ReportSource[]).map((s) => (
<button
key={s}
type="button"
disabled={s === "ACTUAL" && !hasActuals}
onClick={() => setSource(s)}
title={s === "ACTUAL" && !hasActuals ? "Für diesen Plan sind keine effektiven Werte erfasst." : undefined}
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
source === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{s === "ACTUAL" ? "Effektiv" : "Plan"}
</button>
))}
</div>
</label>
</div>
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Szenarien (max. {MAX_REPORT_SCENARIOS})
<InfoBubble text="Mehr als drei Szenarien machen die Vergleichstabelle unlesbar. Das Basisszenario führt den Bericht an und liefert die Zusammenfassung." />
</div>
<div className="flex flex-col gap-2">
{scenarios.map((s) => {
const checked = scenarioIds.includes(s.id);
const full = !checked && scenarioIds.length >= MAX_REPORT_SCENARIOS;
return (
<label
key={s.id}
className={`flex items-center gap-2 rounded-xl border p-3 text-sm ${
checked ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
} ${full ? "opacity-40" : ""}`}
>
<input type="checkbox" checked={checked} disabled={full} onChange={() => toggleScenario(s.id)} />
<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>}
</label>
);
})}
</div>
</div>
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Gespeicherte Analysen einbinden
<InfoBubble text="Übernommen werden die beim Speichern eingefrorenen Zahlen es wird nichts neu gerechnet." />
</div>
{analyses.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
Noch keine Analysen gespeichert. Unter «Analysen» kannst du Ergebnisse festhalten.
</p>
) : (
<div className="flex flex-col gap-1.5">
{analyses.map((a) => (
<label key={a.id} className="flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs">
<input
type="checkbox"
checked={analysisIds.includes(a.id)}
onChange={() =>
setAnalysisIds((prev) => (prev.includes(a.id) ? prev.filter((x) => x !== a.id) : [...prev, a.id]))
}
/>
<span className="font-medium text-fg">{a.name}</span>
<span className="text-faint">{ANALYSIS_TYPE_LABEL[a.type as AnalysisType]}</span>
</label>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setMode("list")}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button disabled={busy || scenarioIds.length === 0} onClick={create}>
{busy ? "Wird erstellt…" : "Bericht erstellen"}
</Button>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-fg">Berichte</h2>
<Button onClick={startNew} disabled={scenarios.length === 0}>
<Plus className="h-4 w-4" /> Neuer Bericht
</Button>
</div>
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Ein Bericht ist ein <strong className="text-fg">festes Dokument</strong>: Die erzeugte PDF-Datei wird abgelegt
und lässt sich jederzeit unverändert wieder herunterladen auch wenn du den Plan danach weiterentwickelst.
</p>
{error && <p className="text-xs text-danger">{error}</p>}
{!reports && !error && <p className="text-xs text-muted">Wird geladen</p>}
{reports && reports.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Noch kein Bericht erstellt.
</p>
)}
{reports && reports.length > 0 && (
<div className="flex flex-col gap-2">
{reports.map((r) => (
<div key={r.id} className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2 p-3">
<FileText className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-fg">{r.title}</div>
<div className="text-[11px] text-muted">
{dt(r.createdAt)} · {r.author} · {r.config.metric === "real" ? "real" : "nominal"} ·{" "}
{r.config.source === "ACTUAL" ? "effektiv" : "Plan"} · {(r.config.scenarioIds ?? []).length} Szenario
{(r.config.scenarioIds ?? []).length === 1 ? "" : "s"} · {Math.round(r.pdfBytes / 1024)} KB
</div>
</div>
<a
href={`/api/plans/${planId}/reports/${r.id}`}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface hover:text-fg"
>
<Download className="h-3.5 w-3.5" /> PDF
</a>
<button
type="button"
onClick={() => remove(r)}
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>
)}
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { useMemo, useState } from "react";
import { api } from "@/lib/api-client";
import { limitsText, retirementBoundaries, shiftRetirement, sortedPhases } from "@/lib/retirement";
import { Button, useConfirm } from "@/components/ui";
import type { PlanInput } from "@/lib/types";
// Pensionsalter anpassen (Roadmap Nr. 44).
//
// Warum eine eigene Bedienung statt eines Zahlenfeldes im Profil: Eine Pensionierung liegt
// immer auf einer Phasengrenze. Ein frei änderbares Alter würde diese Grenze zerreissen --
// die Phasen blieben, wo sie sind, und der Phasentyp (Erwerb/Pension) käme mitten in einer
// Phase ins Rutschen. Hier wird stattdessen die GRENZE verschoben: Die Phase davor wird
// länger, die danach kürzer, die Gesamtdauer des Plans bleibt gleich.
function personLabel(plan: PlanInput, role: string): string {
const p = plan.persons.find((x) => x.role === role);
if (p?.name) return p.name;
if (plan.householdType === "SINGLE") return "Du";
return role === "PERSON_A" ? "Person A" : "Person B";
}
export function RetirementAdjuster({ plan, onSaved }: { plan: PlanInput; onSaved: () => void }) {
const confirm = useConfirm();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const boundaries = useMemo(() => retirementBoundaries(plan), [plan]);
async function shift(role: "PERSON_A" | "PERSON_B", delta: number) {
setError(null);
const preview = shiftRetirement(plan, role, delta);
if (!preview) {
setError("So weit lässt sich das Pensionsalter hier nicht verschieben.");
return;
}
// Zusammenlegung: Die entfallende Phase nimmt einen ganzen Übergang mit. Das muss VORHER
// klar sein -- nachher liesse es sich nur durch erneutes Erfassen rückgängig machen.
if (preview.removedPhaseId) {
const removed = sortedPhases(plan).find((p) => p.id === preview.removedPhaseId);
const target = sortedPhases(plan).find((p) => p.id === preview.mergedIntoPhaseId);
const ok = await confirm({
title: "Lebensphase fällt weg",
message:
`Bei dieser Verschiebung entfällt die Lebensphase «${removed?.name ?? ""}» vollständig. ` +
(target
? `Ihre Übergangs-Entscheide werden in den Übergang nach «${target.name}» gezogen: Bereits ` +
`getroffene Entscheide bleiben, leere Felder werden von dort ergänzt, und einmalige ` +
`Cash-Beträge werden addiert. `
: "") +
"Fortfahren?",
confirmLabel: "Phase zusammenlegen",
danger: true,
});
if (!ok) return;
}
setBusy(role);
try {
await api.post(`/api/scenarios/${plan.id}/retirement`, { role, delta, confirmMerge: true });
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Anpassung fehlgeschlagen.");
} finally {
setBusy(null);
}
}
return (
<div className="flex flex-col gap-3">
{boundaries.map((b) => {
const label = personLabel(plan, b.role);
const canMergeDown = b.mergeDeltaDown !== null;
const canMergeUp = b.mergeDeltaUp !== null;
return (
<div key={b.role} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">{label}</div>
<div className="text-lg font-semibold text-fg">
Pension mit {b.retirementAge}
{b.blocked ? "" : <span className="ml-2 text-xs font-normal text-muted">Planjahr {b.planYear}</span>}
</div>
</div>
{!b.blocked && (
<div className="flex items-center gap-1">
<Button
variant="secondary"
disabled={busy !== null || (b.minDelta === 0 && !canMergeDown)}
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", -1)}
>
1 Jahr
</Button>
<Button
variant="secondary"
disabled={busy !== null || (b.maxDelta === 0 && !canMergeUp)}
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", 1)}
>
+1 Jahr
</Button>
</div>
)}
</div>
{/* Hilfebox: Was ist möglich -- und warum nicht mehr. Ohne diese Erklärung wirkt
ein deaktivierter Knopf wie ein Fehler. */}
<p className="mt-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
{limitsText(b, label)}
</p>
{!b.blocked && (b.minDelta < -1 || b.maxDelta > 1) && (
<div className="mt-2 flex flex-wrap gap-1">
{[-5, -3, -2, 2, 3, 5]
.filter((d) => d >= (b.mergeDeltaDown ?? b.minDelta) && d <= (b.mergeDeltaUp ?? b.maxDelta))
.map((d) => (
<button
key={d}
type="button"
disabled={busy !== null}
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", d)}
className="rounded-full border border-border px-2 py-0.5 text-xs text-muted hover:border-accent hover:text-fg disabled:opacity-50"
>
{d > 0 ? "+" : ""}
{d} {b.retirementAge + d}
</button>
))}
</div>
)}
</div>
);
})}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
+540
View File
@@ -0,0 +1,540 @@
"use client";
// Die drei Säulen-Bausteine des Pensionierungs-Entscheids.
//
// Eigenes Modul, weil sie an ZWEI Orten erscheinen: im Pensionierungs-Bildschirm (alle
// beieinander) und in der Matrix-Zelle am Pensions-Übergang (je einer). Das ist kein
// Duplikat, sondern zwei Ansichten auf dasselbe Objekt -- deshalb auch genau eine
// Implementierung. Wären es zwei, liefen sie garantiert auseinander.
import { useState } from "react";
import { AlertTriangle, Check, ChevronDown, ChevronRight, Info } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { MoneyField, NumberField, SelectField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { ownerLabel } from "@/lib/elements";
import {
AHV_DEFER_MAX_MONTHS,
AHV_EARLY_MAX_MONTHS,
AHV_REFERENCE_AGE,
PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PK_BUYIN_BLOCKING_YEARS,
} from "@/lib/constants";
import { ahvDrawLabel, withRetirementDefaults, type AhvDraw, type RetirementDecision } from "@/lib/retirement-decision";
import type { RetirementPersonSummary } from "@/lib/calculations";
import type { ElementInput, PlanInput } from "@/lib/types";
export type PatchFn = (elementId: string, p: Partial<RetirementDecision>) => void;
// Dispatcher: rendert den passenden Baustein zur Kategorie. Wird von der Matrix-Zelle
// genutzt, die immer genau ein Element vor sich hat.
export function RetirementFields({
plan,
el,
patch,
summary,
defaultOpen,
// Der bereits mit Vorgaben aufgefüllte Entscheid. Der Pensionierungs-Bildschirm reicht hier
// seinen Entwurf herein (ungespeicherte Änderungen); die Matrix-Zelle lässt ihn weg und
// bekommt den gespeicherten Stand.
rd: rdOverride,
}: {
plan: PlanInput;
el: ElementInput;
patch: PatchFn;
summary?: RetirementPersonSummary;
defaultOpen?: boolean;
rd?: RetirementDecision;
}) {
const person = plan.persons.find((p) => p.role === el.ownerRole);
const retirementAge = person?.retirementAge ?? AHV_REFERENCE_AGE;
const rd = rdOverride ?? withRetirementDefaults(el.category, retirementAge, el.retirementDecision);
const siblings = plan.elements
.filter((x) => x.category === "PILLAR_3A" && x.ownerRole === el.ownerRole && x.id !== el.id)
.map((x) => withRetirementDefaults("PILLAR_3A", retirementAge, x.retirementDecision));
if (el.category === "AHV") return <AhvBlock el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
if (el.category === "PENSION_FUND")
return <PkBlock plan={plan} el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
if (el.category === "PILLAR_3A")
return (
<Pillar3aBlock
plan={plan}
el={el}
rd={rd}
patch={patch}
retirementAge={retirementAge}
siblings={siblings}
defaultOpen={defaultOpen}
/>
);
return null;
}
// --- Säulen-Blöcke -------------------------------------------------------------------------
export function Pillar({
title,
subtitle,
confirmed,
onConfirm,
children,
defaultOpen,
}: {
title: string;
subtitle: string;
confirmed: boolean;
onConfirm: (v: boolean) => void;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen ?? false);
return (
<div className="rounded-xl border border-border">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 px-3 py-2 text-left"
>
{open ? <ChevronDown className="h-4 w-4 text-faint" /> : <ChevronRight className="h-4 w-4 text-faint" />}
<span className="text-sm font-semibold text-fg">{title}</span>
<span className="flex-1 truncate text-xs text-muted">{subtitle}</span>
{/* Dieselbe Vokabel wie bei den Phasenzellen (SPEZIFIKATION 3.14.2): «angeschaut».
Es ist derselbe Gedanke -- dort auf den Werten der Vorphase, hier auf einer Vorgabe
des Tools -- und zwei Wörter für eine Sache haben nur verwirrt. */}
{confirmed ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success">
<Check className="h-3.5 w-3.5" /> angeschaut
</span>
) : (
<span className="flex items-center gap-1 whitespace-nowrap rounded-full border border-border px-2 py-0.5 text-[11px] text-muted">
<Info className="h-3.5 w-3.5" /> Vorgabe ungeprüft
</span>
)}
</button>
{open && (
<div className="border-t border-border px-3 py-3">
<div className="grid gap-3 sm:grid-cols-2">{children}</div>
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => onConfirm(e.target.checked)}
className="mt-0.5"
/>
<span>
<strong className="text-fg">Angeschaut.</strong> FPT muss hier etwas rechnen, auch bevor du entschieden
hast deshalb gibt es eine Vorgabe (volle Rente, Bezug im Pensionsjahr). Der Haken hält fest, dass du
sie gesehen und für richtig befunden hast. <strong className="text-fg">Er ändert keine Zahl.</strong>{" "}
Ohne ihn rechnet FPT genauso weiter, weist die Vorgabe aber als «ungeprüft» aus, damit sie nicht
unbemerkt durchgeht.
</span>
</label>
</div>
)}
</div>
);
}
export function AhvBlock({
el,
rd,
patch,
summary,
defaultOpen,
}: {
el: ElementInput;
rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
summary?: RetirementPersonSummary;
defaultOpen?: boolean;
}) {
const draw = rd.ahvDraw ?? "REFERENCE";
const months = rd.ahvMonths ?? 0;
const maxMonths = draw === "EARLY" ? AHV_EARLY_MAX_MONTHS : AHV_DEFER_MAX_MONTHS;
return (
<Pillar
title="AHV"
subtitle={`${ahvDrawLabel(rd)} · ${formatChf(summary?.ahvAnnual ?? 0)} / Jahr`}
confirmed={rd.confirmed === true}
onConfirm={(v) => patch(el.id, { confirmed: v })}
defaultOpen={defaultOpen}
>
<SelectField
label="Bezug"
help={`Referenzalter ist ${AHV_REFERENCE_AGE}. Ein Vorbezug kürzt die Rente lebenslang um 6,8 % pro Jahr, ein Aufschub erhöht sie (nach 5 Jahren um 31,5 %).`}
value={draw}
onChange={(v: AhvDraw) =>
patch(el.id, { ahvDraw: v, ahvMonths: v === "REFERENCE" ? 0 : months || 12 })
}
options={[
{ value: "REFERENCE", label: `Ab Referenzalter (${AHV_REFERENCE_AGE})` },
{ value: "EARLY", label: "Vorbeziehen" },
{ value: "DEFERRED", label: "Aufschieben" },
]}
/>
{draw !== "REFERENCE" && (
<NumberField
label={draw === "EARLY" ? "Vorbezug (Monate)" : "Aufschub (Monate)"}
help={
draw === "EARLY"
? `Höchstens ${AHV_EARLY_MAX_MONTHS} Monate (3 Jahre), seit AHV 21 monatsgenau.`
: `Mindestens 12, höchstens ${AHV_DEFER_MAX_MONTHS} Monate (5 Jahre).`
}
value={months}
min={draw === "EARLY" ? 1 : 12}
max={maxMonths}
step={1}
onChange={(v) => patch(el.id, { ahvMonths: Math.max(0, Math.min(maxMonths, Math.round(v))) })}
/>
)}
{draw !== "REFERENCE" && summary && (
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Rente ab Alter <strong className="text-fg">{summary.ahvFromAge}</strong>:{" "}
<strong className="text-fg">{formatChf(summary.ahvAnnual)}</strong> pro Jahr. Die Beitragspflicht endet davon
unabhängig erst mit {AHV_REFERENCE_AGE} wer vorher aufhört zu arbeiten, zahlt bis dahin als
Nichterwerbstätige(r) weiter.
</div>
)}
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Grundlage der Schätzung</p>
<div className="grid gap-3 sm:grid-cols-2">
<MoneyField
label="Durchschnittseinkommen vor Planbeginn (brutto, heutige Kaufkraft)"
help="Aus der AHV-Rentenvorausberechnung. Bleibt das Feld leer, schätzt das Tool den Wert aus dem geplanten Durchschnitt das ist besser als 0, aber ungenauer als dein echter Auszug."
value={rd.avgIncomeBefore ?? 0}
onChange={(v) => patch(el.id, { avgIncomeBefore: v })}
/>
<NumberField
label="Beitragslücken vor Planbeginn (Jahre)"
help="Jahre ohne AHV-Beitrag, etwa durch Auslandaufenthalt. Jedes fehlende Jahr kürzt die Rente um rund 1/44."
value={rd.gapYearsBefore ?? 0}
min={0}
max={50}
step={1}
onChange={(v) => patch(el.id, { gapYearsBefore: Math.max(0, Math.round(v)) })}
/>
</div>
</div>
</Pillar>
);
}
export function PkBlock({
plan,
el,
rd,
patch,
summary,
defaultOpen,
}: {
plan: PlanInput;
el: ElementInput;
rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
summary?: RetirementPersonSummary;
defaultOpen?: boolean;
}) {
const share = rd.capitalSharePct ?? 0;
return (
<Pillar
title={`Pensionskasse · ${el.name}`}
subtitle={
share === 0
? `Volle Rente · ${formatChf(summary?.pkPensionAnnual ?? 0)} / Jahr`
: share === 100
? "Volles Kapital"
: `${100 - share} % Rente / ${share} % Kapital`
}
confirmed={rd.confirmed === true}
onConfirm={(v) => patch(el.id, { confirmed: v })}
defaultOpen={defaultOpen}
>
<div className="sm:col-span-2">
<label className="mb-1 flex items-center text-xs font-medium text-muted">
Kapitalanteil
<InfoBubble text="0 % = volle Rente, 100 % = volles Kapital, alles dazwischen ist die Kombination. Bewusst eine Quote und kein Frankenbetrag: Verschiebst du das Pensionsalter, ändert sich das Guthaben die Quote skaliert mit, ein fixer Betrag würde still ein anderes Verhältnis bedeuten." />
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={100}
step={5}
value={share}
onChange={(e) => patch(el.id, { capitalSharePct: Number(e.target.value) })}
// KEIN appearance-none: Damit zeichnet der Browser die Schiene nicht mehr selbst,
// und accent-color greift nicht -- uebrig blieb der blosse Knopf. Im warmen Schema
// war die Schiene dadurch unsichtbar.
// Gleiche Schreibweise wie der Regler der Live-Simulation, der immer funktioniert
// hat: keine erzwungene Hoehe, kein appearance-none.
className="flex-1 cursor-pointer accent-[var(--accent)]"
/>
<span className="w-28 whitespace-nowrap text-right text-sm tabular-nums text-fg">
{share} % Kapital
</span>
</div>
<div className="mt-1 flex justify-between text-[11px] text-faint">
<span>volle Rente</span>
<span>volles Kapital</span>
</div>
</div>
{/* Bei 100 % Kapital wird nichts verrentet -- dann ist der Satz gegenstandslos, genau wie
die Kapitalbezugssteuer bei 0 % Kapital. */}
{share < 100 && (
<NumberField
label="Umwandlungssatz (%)"
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
value={rd.conversionRate ?? 0}
step={0.1}
min={0}
max={20}
onChange={(v) => patch(el.id, { conversionRate: v })}
/>
)}
{share > 0 && (
<NumberField
label="Kapitalbezugssteuer (%)"
help="Pauschalsatz. Die tatsächliche Steuer ist kantonal und progressiv und alle Kapitalbezüge desselben Jahres werden zusammengezählt, bei Ehepaaren auch die des Partners. Wer staffelt, zahlt weniger."
value={rd.capitalTaxRate ?? 0}
step={0.5}
min={0}
max={100}
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
/>
)}
{share > 0 && (
<>
<div className="sm:col-span-2">
<label className="flex cursor-pointer items-start gap-2 text-xs text-muted">
<input
type="checkbox"
checked={rd.recentBuyIn === true}
onChange={(e) => patch(el.id, { recentBuyIn: e.target.checked })}
className="mt-0.5"
/>
<span>
In den letzten {PK_BUYIN_BLOCKING_YEARS} Jahren in die Pensionskasse eingekauft
</span>
</label>
{rd.recentBuyIn && (
// attention-fg ist WEISS -- gedacht fuer vollflaechigen Attention-Grund. Auf
// 10 % Deckkraft war der Text im warmen und im hellen Schema unlesbar.
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein Kapitalbezug innerhalb von {PK_BUYIN_BLOCKING_YEARS} Jahren nach einem Einkauf lässt den
Steuerabzug für diesen Einkauf nachträglich entfallen (Art. 79b Abs. 3 BVG). Das Tool rechnet diesen
Effekt nicht prüfe die Fristen mit deiner Kasse.
</p>
)}
</div>
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
</>
)}
</Pillar>
);
}
export function Pillar3aBlock({
plan,
el,
rd,
patch,
retirementAge,
siblings,
defaultOpen,
}: {
plan: PlanInput;
el: ElementInput;
rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
retirementAge: number;
siblings: RetirementDecision[];
defaultOpen?: boolean;
}) {
const age = rd.withdrawalAge ?? retirementAge;
const clash = siblings.some((s) => (s.withdrawalAge ?? retirementAge) === age);
return (
<Pillar
title={`Säule 3a · ${el.name}`}
subtitle={`Bezug mit ${age}`}
confirmed={rd.confirmed === true}
onConfirm={(v) => patch(el.id, { confirmed: v })}
defaultOpen={defaultOpen}
>
<NumberField
label="Bezugsalter"
help={`Frühestens ${PILLAR_3A_MIN_WITHDRAWAL_AGE}, spätestens ${PILLAR_3A_MAX_WITHDRAWAL_AGE} und nach dem Referenzalter nur, solange du erwerbstätig bleibst. Der Bezug erfolgt an der ersten Phasengrenze bei oder nach diesem Alter.`}
value={age}
min={PILLAR_3A_MIN_WITHDRAWAL_AGE}
max={PILLAR_3A_MAX_WITHDRAWAL_AGE}
step={1}
onChange={(v) => patch(el.id, { withdrawalAge: Math.round(v) })}
/>
<NumberField
label="Kapitalbezugssteuer (%)"
help="Pauschalsatz. Alle Kapitalbezüge desselben Jahres werden zusammengezählt deshalb lohnt sich das Staffeln über mehrere Jahre und mehrere Konten."
value={rd.capitalTaxRate ?? 0}
step={0.5}
min={0}
max={100}
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
/>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Ein 3a-Konto lässt sich bei der Pensionierung nur <strong className="text-fg">ganz</strong> auflösen. Gestaffelt
wird über mehrere Konten mit unterschiedlichen Bezugsjahren.
{age > AHV_REFERENCE_AGE && (
<>
{" "}
Ein Bezug nach {AHV_REFERENCE_AGE} setzt voraus, dass du weiterhin erwerbstätig bist.
</>
)}
</div>
{clash && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt ein
anderes Bezugsjahr senkt die Progression.
</p>
)}
{/* Die Frage stellt sich hier sogar zwingender als bei der PK: Ein 3a-Konto wird IMMER
vollständig bezogen, es fliesst also in jedem Fall ein grosser Betrag. Bis 0.40 gab
es die Maske nur bei der Pensionskasse -- das 3a-Kapital landete stumm auf dem Cash. */}
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
</Pillar>
);
}
// Eine Zeile der Kapitalverwendung: Quote plus, sobald sie > 0 ist, das Ziel. Eigene Komponente
// auf Modulebene -- innerhalb von CapitalUse deklariert wuerde sie bei jedem Tastendruck neu
// erzeugt und verloere ihren Zustand (der React Compiler weist das zu Recht zurueck).
function CapitalUseRow({
title,
help,
value,
max,
onChange,
targets,
targetValue,
onTarget,
targetLabel,
missing,
label,
}: {
title: string;
help: string;
value: number;
max: number;
onChange: (v: number) => void;
targets: ElementInput[];
targetValue?: string;
onTarget: (id: string) => void;
targetLabel: string;
missing: string;
label: (e: ElementInput) => string;
}) {
return (
<div className="rounded-lg border border-border p-3">
<div className="grid gap-3 sm:grid-cols-2">
<NumberField label={title} help={help} value={value} step={5} min={0} max={max} onChange={onChange} />
{value > 0 && targets.length > 0 && (
<SelectField
label={targetLabel}
value={targetValue ?? targets[0].id}
onChange={(v: string) => onTarget(v)}
options={targets.map((t) => ({ value: t.id, label: label(t) }))}
/>
)}
{value > 0 && targets.length === 0 && (
<p className="flex items-start gap-2 self-end text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{missing}
</p>
)}
</div>
</div>
);
}
// Verwendung des bezogenen Kapitals (Punkt C). Dieselbe Frage wie in der Matrix-Zelle -- hier
// nur an dem Ort, an dem man ohnehin über den Bezug nachdenkt.
//
// Drei gleichrangige Zeilen: Schuldentilgung, Investition, Cash. Cash ist rechnerisch der
// REST, wird aber wie eine Wahl dargestellt und nicht wie eine Fussnote -- «ich lasse es
// liegen und entscheide später» ist ein legitimer Entscheid. Alle drei frei eintippbar zu
// machen hiesse, dass beim Tippen still eine der anderen Zahlen wandert; das überrascht mehr,
// als es hilft.
function CapitalUse({
rd,
elementId,
patch,
plan,
}: {
rd: RetirementDecision;
elementId: string;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
plan: PlanInput;
}) {
const amort = Math.max(0, Math.min(100, rd.capitalUseAmortizationPct ?? 0));
const invest = Math.max(0, Math.min(100 - amort, rd.capitalUseInvestPct ?? 0));
const cash = Math.max(0, 100 - amort - invest);
// Tilgungs-Ziele: Hypotheken UND sonstige Schulden. Wer einen Konsumkredit zu 6 % neben
// einer Hypothek zu 1,5 % trägt, tilgt zuerst den Kredit.
const debtTargets = plan.elements.filter((e) => e.category === "REAL_ESTATE" || e.category === "OTHER_DEBT");
const investTargets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
const label = (e: ElementInput) => `${e.name} · ${ownerLabel(plan.persons, e.ownerRole)}`;
return (
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst das bezogene Kapital?
</p>
<div className="flex flex-col gap-2">
<CapitalUseRow
label={label}
title="Schuldentilgung (%)"
help="Einmalige Tilgung. Am Restsaldo gekappt ist die Schuld kleiner als der Anteil, bleibt der Rest Cash."
value={amort}
max={100}
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
targets={debtTargets}
targetValue={rd.capitalUseDebtTargetElementId}
onTarget={(v) => patch(elementId, { capitalUseDebtTargetElementId: v })}
targetLabel="Welche Schuld?"
missing="Es gibt weder Immobilie noch Schuld in diesem Szenario der Anteil bliebe auf dem Cash-Konto."
/>
<CapitalUseRow
label={label}
title="Investition (%)"
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
value={invest}
max={100 - amort}
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
targets={investTargets}
targetValue={rd.capitalUseTargetElementId}
onTarget={(v) => patch(elementId, { capitalUseTargetElementId: v })}
targetLabel="Wohin investieren?"
missing="Es gibt kein Element «Sonstiges Vermögen» der Anteil bliebe auf dem Cash-Konto."
/>
{/* Cash ist der Rest, aber als vollwertige Zeile dargestellt. */}
<div className="rounded-lg border border-dashed border-border bg-surface-2 p-3">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs font-medium text-muted">Cash (%)</span>
<span className="text-sm font-semibold tabular-nums text-fg">{cash} %</span>
</div>
<p className="mt-1 text-[11px] text-faint">
Was du nicht zuteilst, bleibt auf dem Cash-Konto unverzinst, aber jederzeit verfügbar.
</p>
</div>
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
// Die Übersicht der offenen Punkte -- oben rechts, wo bis 0.38 der Assistent stand.
//
// Sie listet keine Schritte auf, sondern zeigt einen ZUSTAND: Was ist in diesem Plan noch
// nicht entschieden oder nicht bestätigt? Der Unterschied fällt auf, sobald man einen
// bestehenden Plan öffnet -- der Assistent bot dort Schritte an, die längst erledigt waren.
//
// Zwei Sonderzustände ganz am Anfang: Ohne Elemente und ohne Lebensphasen gibt es
// naturgemäss nichts Offenes, obwohl der Plan leer ist. Eine "0" wäre dort eine Lüge; deshalb
// steht dort eine Aufforderung statt einer Zahl.
import { CheckCircle2, ClipboardList, Sparkles } from "lucide-react";
import type { PlanReview } from "@/lib/review";
export function ReviewTile({
review,
onOpenInventory,
onAddPhase,
onJump,
}: {
review: PlanReview;
onOpenInventory: () => void;
onAddPhase: () => void;
onJump: (group: { id: string; kind: "phase" | "transition" }) => void;
}) {
const done = review.stage === "DONE";
const starting = review.stage === "NO_ELEMENTS" || review.stage === "NO_PHASES";
return (
<div
data-tour="assistant"
// KEIN h-full: In der rechten Leiste ist die Kachel das einzige Element mit Inhalt --
// mit h-full zog sie sich ueber den ganzen Bildschirm, obwohl ein einziger Eintrag
// darin stand.
className={`flex flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
done ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
}`}
>
<div className="mb-2 flex items-center gap-2">
{done ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<ClipboardList className="h-4 w-4 text-attention" />
)}
<span className="text-xs font-semibold uppercase tracking-wide text-fg">Offene Punkte</span>
{!starting && (
<span className={`ml-auto text-[11px] font-semibold ${done ? "text-success" : "text-muted"}`}>
{done ? "keine" : review.total}
</span>
)}
</div>
{review.stage === "NO_ELEMENTS" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="text-[11px] leading-snug text-muted">
Dein Plan ist noch leer. Erfasse zuerst, was du heute hast danach planst du deine erste Lebensphase.
</p>
<button
type="button"
onClick={onOpenInventory}
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
>
<Sparkles className="h-3.5 w-3.5" /> Bestandsaufnahme
</button>
</div>
)}
{review.stage === "NO_PHASES" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="text-[11px] leading-snug text-muted">
Deine Bestandsaufnahme steht. Jetzt fehlt die erste Lebensphase erst dann gibt es etwas zu
entscheiden.
</p>
<button
type="button"
onClick={onAddPhase}
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
>
Erste Lebensphase anlegen
</button>
</div>
)}
{review.stage === "DONE" && (
<div className="flex min-h-0 flex-1 flex-col justify-center gap-1">
<p className="text-sm font-semibold text-success">Alles entschieden</p>
<p className="text-[11px] leading-snug text-muted">
In allen Lebensphasen und Übergängen ist nichts mehr offen. Änderst du etwas, erscheint es hier wieder.
</p>
</div>
)}
{review.stage === "OPEN" && (
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-auto">
{review.groups.map((g) => (
<button
key={`${g.kind}-${g.id}`}
type="button"
onClick={() => onJump(g)}
className="rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface/60"
>
<span className="flex items-baseline gap-1.5">
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{g.title}</span>
<span className="shrink-0 text-[11px] font-semibold text-attention">{g.open}</span>
</span>
<span className="block truncate text-[11px] text-muted">{g.reasons.join(" · ")}</span>
</button>
))}
</div>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { Bookmark, Check } from "lucide-react";
import { useToast } from "@/components/ui";
import { defaultAnalysisName, saveAnalysis, type AnalysisType, type SaveAnalysisPayload } from "@/lib/analyses";
// Einheitlicher Speichern-Knopf für die Analysewerkzeuge. `build` wird erst beim Klick
// aufgerufen -- so wird das (evtl. grosse) Ergebnis nicht bei jedem Render zusammengebaut,
// sondern nur, wenn wirklich gespeichert wird.
export function SaveAnalysisButton({
planId,
type,
scenarioName,
versionLabel,
disabled,
build,
}: {
planId: string;
type: AnalysisType;
scenarioName: string | null;
versionLabel: string | null;
disabled?: boolean;
build: () => Omit<SaveAnalysisPayload, "name" | "type"> | null;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const toast = useToast();
function start() {
setName(defaultAnalysisName(type, scenarioName, versionLabel));
setOpen(true);
}
async function save() {
const payload = build();
if (!payload) {
toast("error", "Es gibt noch kein Ergebnis zum Speichern.");
return;
}
setBusy(true);
try {
await saveAnalysis(planId, { ...payload, name: name.trim() || defaultAnalysisName(type, scenarioName, versionLabel), type });
setOpen(false);
setDone(true);
toast("success", "Analyse gespeichert.");
setTimeout(() => setDone(false), 2000);
} catch (e) {
toast("error", e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setBusy(false);
}
}
if (open) {
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent bg-accent-soft/20 p-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && save()}
className="min-w-0 flex-1 rounded border border-border bg-surface px-2 py-1 text-sm text-fg"
/>
<button
type="button"
disabled={busy}
onClick={save}
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-accent-fg hover:bg-accent-hover disabled:opacity-50"
>
{busy ? "Speichern…" : "Speichern"}
</button>
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-border px-2 py-1 text-xs text-muted hover:bg-surface-2">
Abbrechen
</button>
</div>
);
}
return (
<button
type="button"
disabled={disabled}
onClick={start}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
{done ? <Check className="h-3.5 w-3.5 text-success" /> : <Bookmark className="h-3.5 w-3.5" />}
{done ? "Gespeichert" : "Analyse speichern"}
</button>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useEffect, useState } from "react";
import { Bar, BarChart, CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Eye, X } from "lucide-react";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedResult } from "@/lib/analyses";
interface Full {
id: string;
name: string;
type: AnalysisType;
scenarioName: string | null;
versionLabel: string | null;
metric: string;
source: string;
createdAt: string;
result: SavedResult;
}
// Nur-Lese-Ansicht einer gespeicherten Analyse. Es wird NICHTS neu gerechnet -- die beim
// Speichern festgehaltenen Zahlen werden nur gezeichnet.
export function SavedAnalysisView({ planId, analysisId, onClose }: { planId: string; analysisId: string; onClose: () => void }) {
const [data, setData] = useState<Full | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const d = await api.get<{ analysis: Full }>(`/api/plans/${planId}/analyses/${analysisId}`);
if (!cancelled) setData(d.analysis);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [planId, analysisId]);
const r = data?.result;
return (
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<Eye className="h-5 w-5 text-accent" />
<span className="truncate">{data?.name ?? "Analyse"}</span>
</h2>
{data && (
<p className="mt-0.5 text-xs text-muted">
{ANALYSIS_TYPE_LABEL[data.type]} · gespeichert{" "}
{new Date(data.createdAt).toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" })}
{data.scenarioName ? ` · ${data.scenarioName}${data.versionLabel ? ` ${data.versionLabel}` : ""}` : ""} ·{" "}
{data.metric === "real" ? "real" : "nominal"} · {sourceLabel(data.source)}
<span className="ml-1 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-faint">nur Ansicht</span>
</p>
)}
</div>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
{!data && !error && <p className="text-sm text-muted">Wird geladen</p>}
{r && (
<>
{r.metrics && r.metrics.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{r.metrics.map((m, i) => (
<div key={i} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">{m.label}</div>
<div className={`mt-0.5 text-lg font-semibold ${m.tone === "danger" ? "text-danger" : m.tone === "success" ? "text-success" : "text-fg"}`}>
{m.value}
</div>
</div>
))}
</div>
)}
{r.chart && <SavedChart chart={r.chart} />}
{r.table && (
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
{r.table.columns.map((c, i) => (
<th key={i} className={`px-3 py-2 font-semibold ${i === 0 ? "text-left" : "text-right"}`}>{c}</th>
))}
</tr>
</thead>
<tbody>
{r.table.rows.map((row, ri) => (
<tr key={ri} className="border-t border-border">
{row.map((cell, ci) => (
<td key={ci} className={`px-3 py-2 ${ci === 0 ? "text-left font-medium text-fg" : "text-right tabular-nums text-muted"}`}>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-faint">Parameter</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
{r.params.map((p, i) => (
<span key={i}>
{p.label}: <strong className="text-fg">{p.value}</strong>
</span>
))}
</div>
</div>
</>
)}
</div>
</div>
);
}
function SavedChart({ chart }: { chart: NonNullable<SavedResult["chart"]> }) {
const fmt = (v: number) => (chart.yFormat === "pct" ? `${v} %` : Intl.NumberFormat("de-CH", { notation: "compact" }).format(v));
const tip = (v: unknown) => (typeof v !== "number" ? String(v ?? "") : chart.yFormat === "pct" ? `${v} %` : formatChf(v));
// Punkte aller Serien auf gemeinsame x-Werte bringen.
const xs = Array.from(new Set(chart.series.flatMap((s) => s.points.map((p) => p.x)))).sort((a, b) => a - b);
const data = xs.map((x) => {
const row: Record<string, number | string> = { x };
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 = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6)"];
return (
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
{chart.kind === "bar" ? (
<BarChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="x" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={fmt} />
<Tooltip formatter={tip} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{chart.series.map((s, i) => (
<Bar key={s.label} dataKey={s.label} fill={s.color ?? PALETTE[i % PALETTE.length]} isAnimationActive={false} />
))}
</BarChart>
) : (
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="x" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={fmt} />
<Tooltip formatter={tip} labelFormatter={(v) => (chart.xLabel ? `${chart.xLabel} ${v}` : `${v}`)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{chart.series.map((s, i) => (
<Line
key={s.label}
dataKey={s.label}
stroke={s.color ?? PALETTE[i % PALETTE.length]}
strokeWidth={2}
strokeDasharray={s.dashed ? "5 3" : undefined}
dot={false}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
)}
</ResponsiveContainer>
</div>
);
}
+389
View File
@@ -0,0 +1,389 @@
"use client";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Tornado, X } from "lucide-react";
import { RequiredNumberField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { formatChf } from "@/lib/format";
import {
computeTornado,
DRIVERS,
UNIT_SUFFIX,
type DriverDef,
type DriverId,
type TornadoMetric,
type TornadoResult,
} from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
interface RangeDraft {
checked: boolean;
low: string;
high: string;
}
// Bewusst OHNE Defaults: Die Balkenlänge hängt direkt von der eingegebenen Bandbreite ab.
// Ein stiller Default würde nicht hinterfragt und das Ranking wäre dann eine Aussage über
// unsere Vorgabe statt über den Plan (gleiche Begründung wie bei den Monte-Carlo-Mittelwerten).
const emptyDraft: RangeDraft = { checked: false, low: "", high: "" };
function formatRange(low: number, high: number, unit: DriverDef["unit"]): string {
const suffix = UNIT_SUFFIX[unit];
const sign = (v: number) => (unit === "abs_pct" ? `${v}` : v > 0 ? `+${v}` : `${v}`);
return `${sign(low)} ${suffix}${sign(high)} ${suffix}`;
}
export function SensitivityDialog({
plan: currentPlan,
planId,
actuals = [],
origins = [],
onClose,
}: {
plan: PlanInput;
planId: string;
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
onClose: () => void;
}) {
// Gerechnet wird wahlweise auf dem Arbeitsstand oder auf einer festgehaltenen Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]);
const basis = useAnalysisBasis(plan, actuals, origins, "real");
const metric: TornadoMetric = basis.metric;
const [drafts, setDrafts] = useState<Record<string, RangeDraft>>({});
const [result, setResult] = useState<TornadoResult | null>(null);
function draftFor(id: DriverId): RangeDraft {
return drafts[id] ?? emptyDraft;
}
function setDraft(id: DriverId, patch: Partial<RangeDraft>) {
setDrafts((prev) => ({ ...prev, [id]: { ...draftFor(id), ...patch } }));
setResult(null);
}
const checked = available.filter((d) => draftFor(d.id).checked);
const incomplete = checked.filter((d) => {
const dr = draftFor(d.id);
return dr.low.trim() === "" || dr.high.trim() === "";
});
const degenerate = checked.filter((d) => {
const dr = draftFor(d.id);
return dr.low.trim() !== "" && dr.high.trim() !== "" && Number(dr.low) === Number(dr.high);
});
// Mindestens zwei Treiber: Ein Tornado ist eine RANGLISTE. Mit einem einzigen Balken gibt
// es nichts zu ordnen und die Grafik würde eine Aussage suggerieren, die sie nicht hat.
const canRun =
checked.length >= 2 && incomplete.length === 0 && degenerate.length === 0 && plan.phases.length > 0;
function run() {
setResult(
computeTornado(
plan,
metric,
checked.map((d) => {
const dr = draftFor(d.id);
return { id: d.id, low: Number(dr.low), high: Number(dr.high) };
}),
// Mit Ist-Werten wirken die Treiber nur noch auf die NICHT belegten Jahre -- was
// erfasst ist, steht fest. Die Balken fallen dadurch zu Recht kuerzer aus.
basis.resolvedActuals
)
);
}
const chartData = useMemo(() => {
if (!result) return [];
// Von unten nach oben gezeichnet -> für die Trichterform (grösster Balken zuoberst)
// muss die Reihenfolge umgekehrt in die Grafik.
return [...result.bars].reverse().map((b) => ({
label: b.shortLabel,
range: [b.min, b.max] as [number, number],
swing: b.swing,
}));
}, [result]);
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<Tornado className="h-5 w-5 text-accent" /> Einflussfaktoren (Sensitivitätsanalyse)
</h2>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={(id) => {
setVersionId(id);
setResult(null);
}}
loading={versionLoading}
/>
{/* Erklärung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
<strong className="text-fg">Was ist das?</strong> Nicht «wie viel Geld habe ich am Schluss», sondern
<strong className="text-fg"> «welche meiner Annahmen entscheidet überhaupt über das Ergebnis»</strong>. Bei
manchen Annahmen ist es egal, ob du sie exakt triffst bei anderen kippt eine kleine Abweichung den ganzen Plan.
</p>
<p className="mb-2">
<strong className="text-fg">Wie es funktioniert:</strong> Zuerst wird dein Plan wie erfasst gerechnet
(Basisfall). Dann wird <em>ein einziger</em> Parameter auf seinen tiefen und seinen hohen Wert gesetzt, alle
übrigen bleiben unverändert das ergibt zwei Ergebnisse. Das für jeden Parameter wiederholt und nach
<strong className="text-fg"> Spannweite</strong> sortiert ergibt die Trichterform: längster Balken zuoberst.
</p>
<p className="mb-2">
<strong className="text-fg">Was du davon hast:</strong> Die Reihenfolge ist die Botschaft. Bei den obersten
Balken lohnt sich Genauigkeit (dort exakte Zahlen beschaffen) und sie sind meist auch die, die du selbst
steuern kannst. Bei den untersten darfst du grob schätzen.
</p>
<p>
<strong className="text-fg">Zwei ehrliche Grenzen.</strong> Erstens hängt die Balkenlänge von den
Bandbreiten ab, die du unten eingibst wähle sie so, dass sie <em>ähnlich plausibel</em> sind, nicht ähnlich
gross. Zweitens wird immer nur ein Parameter auf einmal variiert; <em>Kombinationen</em> (schlechte Renditen
<em> und</em> hohe Ausgaben) treffen härter als die Summe der Einzelbalken dafür ist die
Monte-Carlo-Simulation zuständig.
</p>
</div>
{/* Zielgrösse und Datengrundlage */}
<AnalysisBar basis={basis} onChange={() => setResult(null)} />
{/* Parameter */}
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Parameter und Bandbreiten
<InfoBubble text="Hake an, welche Parameter in die Analyse einfliessen sollen, und gib je Parameter eine tiefe und eine hohe Ausprägung an. Es gibt bewusst keine Vorgabewerte: Die Balkenlänge hängt direkt von diesen Bandbreiten ab, deshalb sollst du sie bewusst setzen. Die Hilfe je Parameter nennt plausible Grössenordnungen." />
</div>
<div className="flex flex-col gap-2">
{available.map((d) => {
const dr = draftFor(d.id);
const suffix = UNIT_SUFFIX[d.unit];
const unitHint =
d.unit === "abs_pct"
? "absoluter Wert in %"
: d.unit === "delta_pp"
? "Verschiebung in Prozentpunkten"
: d.unit === "rel_pct"
? "Abweichung vom Planwert in %"
: "Verschiebung in Jahren";
return (
<div
key={d.id}
className={`rounded-xl border p-3 ${dr.checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"}`}
>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={dr.checked} onChange={(e) => setDraft(d.id, { checked: e.target.checked })} />
<span className="font-medium text-fg">{d.label}</span>
<InfoBubble text={d.help} />
<span className="text-[11px] text-faint">({unitHint})</span>
</label>
{dr.checked && (
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
<RequiredNumberField
label="tief"
value={dr.low}
suffix={suffix}
step={d.unit === "delta_years" || d.unit === "delta_months" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { low: v })}
/>
<RequiredNumberField
label="hoch"
value={dr.high}
suffix={suffix}
step={d.unit === "delta_years" || d.unit === "delta_months" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { high: v })}
/>
</div>
)}
</div>
);
})}
</div>
<p className="mt-2 text-[11px] text-faint">
Das <strong>Pensionsalter</strong> ist bewusst nicht enthalten: Es lässt sich nicht sinnvoll variieren, ohne
gleichzeitig die Phasengrenzen mitzuverschieben sonst arbeitet die Person im Modell unverändert weiter
bzw. der Pensions-Übergang entfällt ganz. Der verwandte Treiber «Lebensdauer» ist dagegen sauber abgebildet.
</p>
</div>
{checked.length < 2 && (
<p className="text-xs text-danger">Bitte mindestens zwei Parameter auswählen der Tornado ist eine Rangliste.</p>
)}
{incomplete.length > 0 && (
<p className="text-xs text-danger">Bitte für jeden ausgewählten Parameter eine tiefe und eine hohe Ausprägung angeben.</p>
)}
{degenerate.length > 0 && (
<p className="text-xs text-danger">
Tiefer und hoher Wert sind identisch bei: {degenerate.map((d) => d.label).join(", ")}. Damit gibt es keine Spannweite.
</p>
)}
<div>
<button
type="button"
disabled={!canRun}
onClick={run}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
Einflussfaktoren berechnen
</button>
</div>
{result && (
<>
<div className="flex justify-end">
<SaveAnalysisButton
planId={planId}
type="SENSITIVITY"
scenarioName={currentPlan.name}
versionLabel={versionId === "current" ? null : versionId}
build={() => buildSensitivityResult(result, metric, currentPlan.name, basis)}
/>
</div>
<TornadoResults result={result} metric={metric} chartData={chartData} />
</>
)}
</div>
</div>
);
}
// Generische Momentaufnahme des Tornados (siehe analyses.ts).
function buildSensitivityResult(
result: TornadoResult,
metric: TornadoMetric,
scenarioName: string,
basis: { source: string }
): { metric?: string; source?: string; scenarioName?: string | null; summary?: string | null; inputs: Record<string, unknown>; result: Record<string, unknown> } {
return {
metric,
source: basis.source,
scenarioName,
summary: result.bars[0] ? `Stärkster Hebel: ${result.bars[0].shortLabel}` : null,
inputs: {
params: [
{ label: "Zielgrösse", value: metric === "real" ? "Endvermögen real" : "Endvermögen nominal" },
{ label: "Basiswert", value: formatChf(result.base) },
],
},
result: {
params: [{ label: "Basiswert (unverändert)", value: formatChf(result.base) }],
table: {
columns: ["Treiber", "Bandbreite", "tief", "hoch", "Spannweite"],
rows: result.bars.map((b) => [
b.shortLabel,
`${b.low}${b.high} ${UNIT_SUFFIX[b.unit]}`,
formatChf(b.lowResult),
formatChf(b.highResult),
formatChf(b.swing),
]),
},
chart: {
kind: "bar" as const,
yFormat: "chf" as const,
series: [{ label: "Spannweite", points: result.bars.map((b, i) => ({ x: i, y: b.swing })) }],
},
},
};
}
function TornadoResults({
result,
metric,
chartData,
}: {
result: TornadoResult;
metric: TornadoMetric;
chartData: { label: string; range: [number, number]; swing: number }[];
}) {
const metricLabel = metric === "real" ? "Endvermögen real" : "Endvermögen nominal";
return (
<div className="flex flex-col gap-4 border-t border-border pt-4">
<div className="rounded-xl border border-border bg-surface-2 px-4 py-3">
<div className="text-xs text-muted">Basisfall · {metricLabel}</div>
<div className="mt-0.5 text-xl font-semibold text-accent">{formatChf(result.base)} CHF</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold text-fg">Einflussfaktoren nach Spannweite</div>
<p className="mb-2 text-[11px] text-muted">
Jeder Balken zeigt, zwischen welchen Werten das {metricLabel} schwankt, wenn nur dieser eine Parameter
innerhalb seiner Bandbreite variiert. Die senkrechte Linie ist der Basisfall.
</p>
<div className="w-full" style={{ height: Math.max(200, chartData.length * 46 + 60) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
type="number"
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<YAxis type="category" dataKey="label" tick={{ fontSize: 11 }} width={130} />
<Tooltip
formatter={(v) =>
Array.isArray(v)
? `${formatChf(Number(v[0]))} ${formatChf(Number(v[1]))}`
: typeof v === "number"
? formatChf(v)
: v
}
/>
<ReferenceLine x={result.base} stroke="var(--muted)" strokeDasharray="4 3" />
<Bar dataKey="range" name={metricLabel} fill="var(--accent)" radius={[3, 3, 3, 3]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Parameter</th>
<th className="px-3 py-2 text-left font-semibold">Bandbreite</th>
<th className="px-3 py-2 text-right font-semibold">bei «tief»</th>
<th className="px-3 py-2 text-right font-semibold">bei «hoch»</th>
<th className="px-3 py-2 text-right font-semibold">Spannweite</th>
</tr>
</thead>
<tbody>
{result.bars.map((b) => (
<tr key={b.id} className="border-t border-border">
<td className="px-3 py-2 font-medium text-fg">
{b.label}
{b.note && <div className="mt-0.5 text-[11px] font-normal text-muted">{b.note}</div>}
</td>
<td className="px-3 py-2 text-muted">{formatRange(b.low, b.high, b.unit)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.lowResult)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.highResult)}</td>
<td className="px-3 py-2 text-right font-semibold text-fg">{formatChf(b.swing)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-[11px] text-faint">
Die Reihenfolge nicht der absolute Betrag ist die Aussage. Sie hängt von den eingegebenen Bandbreiten ab.
</p>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
// Mini-Verlaufskurve für die Matrix-Zeilen: der Trend eines Elements über alle Planjahre
// auf einen Blick. Die per-Jahr-Werte existieren seit 0.11 (ElementPhaseComputed.yearly) --
// hier werden sie nur gezeichnet, nicht neu gerechnet. Reines SVG, keine Chart-Bibliothek.
export function Sparkline({
values,
width = 64,
height = 16,
className = "",
}: {
values: number[];
width?: number;
height?: number;
className?: string;
}) {
if (values.length < 2) return null;
const min = Math.min(...values, 0);
const max = Math.max(...values, 0);
if (max === min) return null; // flache Linie trägt keine Information
const pad = 1.5;
const x = (i: number) => pad + (i / (values.length - 1)) * (width - 2 * pad);
const y = (v: number) => pad + (1 - (v - min) / (max - min)) * (height - 2 * pad);
const points = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
aria-hidden="true"
className={`shrink-0 ${className}`}
>
{/* Nulllinie, falls die Werte das Vorzeichen wechseln (z. B. Schulden). */}
{min < 0 && max > 0 && (
<line x1={0} x2={width} y1={y(0)} y2={y(0)} stroke="var(--border-strong)" strokeWidth="1" strokeDasharray="2 2" />
)}
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
+6 -6
View File
@@ -14,12 +14,12 @@ 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 Flaeche // Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Fläche
// dazwischen ist die Spar-/Verzehrquote (gruen = Sparen, rot = Verzehr). Reale Ausgaben als // dazwischen ist die Spar-/Verzehrquote (grün = Sparen, rot = Verzehr). Reale Ausgaben als
// blasse Referenzlinie -> der Keil zu den nominalen Ausgaben ist "das, was die Inflation frisst". // blasse Referenzlinie -> der Keil zu den nominalen Ausgaben ist "das, was die Inflation frisst".
export function SparquoteChart({ computed }: { computed: PlanComputed }) { export function SparquoteChart({ computed }: { computed: PlanComputed }) {
if (computed.yearly.length === 0) return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>; if (computed.yearly.length === 0) return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
@@ -31,7 +31,7 @@ export function SparquoteChart({ computed }: { computed: PlanComputed }) {
income: y.income, income: y.income,
expenseNominal: y.expenseNominal, expenseNominal: y.expenseNominal,
expenseReal: y.expenseReal, expenseReal: y.expenseReal,
// Range-Flaechen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Luecke). // Range-Flächen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Lücke).
sparBand: surplus ? [y.expenseNominal, y.income] : undefined, sparBand: surplus ? [y.expenseNominal, y.income] : undefined,
verzehrBand: surplus ? undefined : [y.income, y.expenseNominal], verzehrBand: surplus ? undefined : [y.income, y.expenseNominal],
}; };
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import { FileText } from "lucide-react";
import { api } from "@/lib/api-client";
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling läuft über
// die Klasse .md-doc in globals.css und folgt damit dem gewählten Farbschema.
export function SpecView({ anchor }: { anchor?: string | null }) {
const [markdown, setMarkdown] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.get<{ markdown: string }>("/api/spec")
.then((data) => setMarkdown(data.markdown))
.catch((e) => setError(e instanceof Error ? e.message : "Laden fehlgeschlagen."));
}, []);
// Sprungmarke aus einem Rechenweg: erst nach dem Rendern des Markdowns existiert die
// Überschrift mit der von rehype-slug erzeugten id.
useEffect(() => {
if (!anchor || markdown === null) return;
const el = document.getElementById(anchor);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, [anchor, markdown]);
if (error) return <p className="text-sm text-danger">{error}</p>;
if (markdown === null) return <p className="text-sm text-muted">Lädt</p>;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-accent" />
<h2 className="text-lg font-semibold text-fg">Spezifikation</h2>
</div>
<article className="md-doc rounded-xl border border-border bg-surface p-6 shadow-sm lg:p-8">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug]}
components={{
// Breite Tabellen scrollen in ihrem eigenen Container statt die Seite zu dehnen.
table: ({ children, ...props }) => (
<div className="md-table-wrap">
<table {...props}>{children}</table>
</div>
),
}}
>
{markdown}
</ReactMarkdown>
</article>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { SlidersHorizontal } from "lucide-react";
import { formatChf } from "@/lib/format";
import { SYSTEM_PARAMETERS, type ParameterGroup, type SystemParameter } from "@/lib/constants";
const GROUPS: { key: ParameterGroup; title: string; intro: string }[] = [
{
key: "AHV",
title: "AHV",
intro:
"Diese Werte legt der Bund fest. Sie ändern sich periodisch der Stand ist je Parameter ausgewiesen.",
},
{
key: "Vorsorge",
title: "Berufliche und gebundene Vorsorge",
intro: "Höchstbeträge und Umwandlung. Der Umwandlungssatz ist ein Vorschlag und pro Ereignis überschreibbar.",
},
{
key: "Steuern",
title: "Steuersätze",
intro:
"Bewusst pauschale Vorschlagswerte, keine Steuerberechnung: Die tatsächlichen Sätze sind kantonal, progressiv und vom Einzelfall abhängig. Sie lassen sich pro Ereignis überschreiben.",
},
];
function formatValue(p: SystemParameter): string {
switch (p.unit) {
case "CHF":
return `${formatChf(p.value)} CHF`;
case "%":
return `${p.value} %`;
case "Jahre":
return `${p.value} Jahre`;
default:
return String(p.value);
}
}
// Zeigt die Systemparameter mit Wert, Bedeutung, Herleitung und Quelle (Roadmap Nr. 41).
// Die Einträge stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch
// gerechnet wird. Ein Abdriften zwischen Anzeige und Rechnung ist damit ausgeschlossen.
export function SystemParametersView() {
return (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5 text-accent" />
<h2 className="text-lg font-semibold text-fg">Systemparameter</h2>
</div>
<p className="max-w-3xl text-sm text-muted">
Alle fest hinterlegten Grössen, mit denen das Tool rechnet inklusive Herleitung und Quelle. Sie stammen aus
derselben Datei, aus der auch die Berechnung liest; was hier steht, ist also garantiert das, was gerechnet wird.
</p>
{GROUPS.map((g) => {
const rows = SYSTEM_PARAMETERS.filter((p) => p.group === g.key);
if (rows.length === 0) return null;
return (
<section key={g.key} className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="text-sm font-semibold text-fg">{g.title}</h3>
<p className="mb-3 mt-0.5 text-xs text-muted">{g.intro}</p>
<div className="flex flex-col gap-3">
{rows.map((p) => (
<div key={p.key} className="rounded-lg border border-border bg-surface-2 p-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="text-sm font-medium text-fg">{p.label}</span>
<span className="text-sm font-semibold text-accent">{formatValue(p)}</span>
</div>
<p className="mt-1 text-xs text-muted">{p.meaning}</p>
{p.derivation && (
<p className="mt-1 font-mono text-[11px] text-faint">Herleitung: {p.derivation}</p>
)}
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-faint">
<span>Quelle: {p.source}</span>
<span>Stand: {p.validFrom}</span>
{p.editablePerEvent && <span className="text-accent">pro Ereignis überschreibbar</span>}
</div>
</div>
))}
</div>
</section>
);
})}
<p className="max-w-3xl text-xs text-faint">
Die drei Steuersätze werden sowohl als Vorschlag im Eingabefeld als auch in der Berechnung als Rückfallwert
verwendet damit ein nicht angetippter Wert nicht fälschlich als 0 % gerechnet wird.
</p>
</div>
);
}
+177 -59
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Flag } from "lucide-react"; import { CalendarCheck, Flag } from "lucide-react";
import type { PhaseComputed } from "@/lib/calculations"; import type { PhaseComputed } from "@/lib/calculations";
interface PersonAxis { interface PersonAxis {
@@ -11,97 +11,215 @@ interface PersonAxis {
color: string; color: string;
} }
// Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem // Die Zeitachse: ein durchgehender Balken, die Lebensphasen als Segmente, Breite nach Dauer.
// Pensionsalter je Person und Trennlinien an den Phasengrenzen. //
// Seit 0.42 ist sie zugleich der NAVIGATOR der Kartenansicht -- ein Klick springt zur Phase.
// Zeit laeuft von links nach rechts; das versteht man ohne Erklaerung.
//
// 0.42.3 gibt ihr die Form, die man von Zeitstrahlen kennt: Die Beschriftungen stehen
// ABWECHSELND ober- und unterhalb des Balkens, mit Dreieck und Verbindungslinie. Das ist nicht
// Zierde, sondern loest ein Platzproblem: Nebeneinander gesetzte Beschriftungen kollidieren,
// sobald eine Phase kurz ist. Abwechselnd hat jede den doppelten Raum -- Nachbarn auf
// derselben Seite liegen zwei Phasen auseinander.
export function Timeline({ export function Timeline({
phases, phases,
persons, persons,
ruinAge, ruinAge,
startYear,
actualYears = [],
activePhaseId,
onSelectPhase,
}: { }: {
phases: PhaseComputed[]; phases: PhaseComputed[];
persons: PersonAxis[]; persons: PersonAxis[];
ruinAge?: number | null; ruinAge?: number | null;
startYear?: number | null;
// Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz
// wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos.
actualYears?: number[];
activePhaseId?: string | null;
onSelectPhase?: (phaseId: string) => void;
}) { }) {
if (phases.length === 0 || persons.length === 0) return null; if (phases.length === 0 || persons.length === 0) return null;
const totalYears = phases.reduce((s, p) => s + p.durationYears, 0); const totalYears = phases.reduce((s, p) => s + p.durationYears, 0);
const minAge = Math.min(...persons.map((p) => p.currentAge)); const minAge = Math.min(...persons.map((p) => p.currentAge));
const maxAge = minAge + totalYears; const maxAge = minAge + totalYears;
const span = Math.max(1, maxAge - minAge); const span = Math.max(1, totalYears);
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`; // Position eines Alters auf dem Balken, in Prozent seiner Länge.
const pctOfAge = (age: number) => (Math.max(0, Math.min(span, age - minAge)) / span) * 100;
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
// Phasengrenzen (kumulierte Jahre). const segments = phases.map((p, i) => {
const boundaries: { year: number; label: string }[] = []; const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
let acc = 0; return { phase: p, from, to: from + p.durationYears, index: i };
for (const p of phases) { });
boundaries.push({ year: acc, label: p.name });
acc += p.durationYears; const clickable = !!onSelectPhase;
const BAR = "var(--fg)";
// Ein Beschriftungsblock. Oben steht er auf dem Kopf (Karte, Linie, Dreieck), unten in
// umgekehrter Reihenfolge -- so zeigt das Dreieck immer zum Balken hin.
const labelFor = (s: (typeof segments)[number], above: boolean) => {
const active = activePhaseId === s.phase.id;
const from = yearOf(s.from);
const to = yearOf(s.to);
const tri = (
<span
aria-hidden="true"
className="h-0 w-0 border-x-[7px] border-x-transparent"
style={
above
? { borderBottomWidth: 9, borderBottomStyle: "solid", borderBottomColor: active ? "var(--accent)" : BAR }
: { borderTopWidth: 9, borderTopStyle: "solid", borderTopColor: active ? "var(--accent)" : BAR }
} }
/>
);
const line = <span className="w-px flex-1" style={{ backgroundColor: active ? "var(--accent)" : "var(--border-strong)" }} />;
const card = (
<span
className={`max-w-[9.5rem] rounded-xl border px-2.5 py-1 text-center transition-colors ${
active
? "border-accent bg-accent-soft text-accent-soft-fg shadow-sm"
: "border-border bg-surface text-muted group-hover/tl:border-accent group-hover/tl:text-accent-soft-fg"
}`}
>
<span className={`block whitespace-nowrap text-[13px] font-semibold ${active ? "" : "text-fg"}`}>
{from ? (s.phase.durationYears === 1 ? `${from}` : `${from}${to! - 1}`) : `Phase ${s.index + 1}`}
</span>
<span className="block truncate text-[11px]">{s.phase.name}</span>
<span className="block text-[10px] text-faint">
{s.phase.durationYears} {s.phase.durationYears === 1 ? "Jahr" : "Jahre"}
</span>
</span>
);
return ( return (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm"> <button
<div className="mb-3 flex items-center justify-between"> key={s.phase.id}
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3> type="button"
<div className="flex gap-3 text-xs text-muted"> disabled={!clickable}
{persons.map((p) => ( onClick={() => onSelectPhase?.(s.phase.id)}
<span key={p.role} className="flex items-center gap-1"> aria-current={active ? "true" : undefined}
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} /> title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
{p.label} (heute {p.currentAge}) className={`group/tl absolute bottom-0 top-0 flex -translate-x-1/2 flex-col items-center ${
clickable ? "cursor-pointer" : "cursor-default"
}`}
style={{ left: `${((s.from + s.phase.durationYears / 2) / span) * 100}%` }}
>
{above ? (
<>
{card}
{line}
{tri}
</>
) : (
<>
{tri}
{line}
{card}
</>
)}
</button>
);
};
return (
<div className="flex items-stretch gap-3">
{/* Das Startjahr steht markant am linken Ende -- es ist der Nullpunkt, auf den sich alle
realen Werte und die ganze Achse beziehen. */}
<div className="flex shrink-0 flex-col justify-center pb-1 text-right">
<span className="text-xl font-semibold leading-none text-fg">{startYear ?? minAge}</span>
<span className="mt-0.5 text-[10px] uppercase tracking-wide text-faint">
{startYear ? "Start" : "Jahre"}
</span> </span>
))}
</div>
</div> </div>
<div className="relative pt-6"> <div className="relative min-w-0 flex-1">
{/* Pensionsmarker je Person */} {/* Beschriftungen oberhalb (Phase 1, 3, 5 …) */}
{persons.map((p, i) => <div className="relative h-[68px]">{segments.filter((s) => s.index % 2 === 0).map((s) => labelFor(s, true))}</div>
{/* Der Balken. Die Segmente teilen sich die Breite streng nach Dauer -- eine Phase von
fünf Jahren ist halb so breit wie eine von zehn. */}
<div className="relative flex h-9 gap-[3px] overflow-hidden rounded-full">
{segments.map((s) => {
const active = activePhaseId === s.phase.id;
return (
<button
key={s.phase.id}
type="button"
disabled={!clickable}
onClick={() => onSelectPhase?.(s.phase.id)}
aria-label={`${s.phase.name}, ${s.phase.durationYears} Jahre`}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`min-w-0 transition-all ${clickable ? "cursor-pointer hover:brightness-110" : ""}`}
style={{
width: `${(s.phase.durationYears / span) * 100}%`,
backgroundColor: active ? "var(--accent)" : BAR,
opacity: active ? 1 : 0.85,
}}
/>
);
})}
{/* Marker auf dem Balken: Pensionierung je Person, Ruinalter, erfasste Ist-Jahre.
Sie liegen AUF dem Balken statt darüber -- oberhalb kollidierten sie mit den
Beschriftungen. */}
{persons.map((p) =>
p.retirementAge > minAge && p.retirementAge < maxAge ? ( p.retirementAge > minAge && p.retirementAge < maxAge ? (
<div <span
key={p.role} key={p.role}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center" className="pointer-events-none absolute inset-y-1 w-0.5 -translate-x-1/2 rounded-full"
style={{ left: pct(p.retirementAge) }} style={{ left: `${pctOfAge(p.retirementAge)}%`, backgroundColor: p.color }}
title={`${p.label}: Pensionierung mit ${p.retirementAge}`} title={`${p.label}: Pensionierung mit ${p.retirementAge}`}
> />
<Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} />
<span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}>
{p.retirementAge}
</span>
<div className="mt-0.5 h-3 w-px" style={{ backgroundColor: p.color, marginTop: i * 2 }} />
</div>
) : null ) : null
)} )}
{/* Ruin-Marker (Person A) */}
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && ( {ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
<div <span
className="absolute top-0 flex -translate-x-1/2 flex-col items-center" className="pointer-events-none absolute inset-y-0 w-1 -translate-x-1/2"
style={{ left: pct(ruinAge) }} style={{ left: `${pctOfAge(ruinAge)}%`, backgroundColor: "var(--danger)" }}
title={`Kapital aufgebraucht mit Alter ${ruinAge}`} title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
>
<span className="text-[10px] font-semibold" style={{ color: "var(--danger)" }}>
Ruin {ruinAge}
</span>
<div className="mt-0.5 h-4 w-px" style={{ backgroundColor: "var(--danger)" }} />
</div>
)}
{/* Achse */}
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-accent-soft to-accent">
{boundaries.slice(1).map((b) => (
<div
key={b.year}
className="absolute top-0 h-2 w-px bg-surface/70"
style={{ left: pct(minAge + b.year) }}
/> />
))} )}
{startYear &&
actualYears.map((y) => {
const age = minAge + (y - startYear);
if (age < minAge || age > maxAge) return null;
const isLatest = y === actualYears[actualYears.length - 1];
return (
<span
key={y}
className="pointer-events-none absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2"
style={{
left: `${pctOfAge(age)}%`,
backgroundColor: isLatest ? "var(--attention)" : "var(--faint)",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
["--tw-ring-color" as any]: "var(--surface)",
}}
title={isLatest ? `Effektive Werte für ${y} (aktuell)` : `Effektive Werte für ${y} (überholt)`}
/>
);
})}
</div> </div>
{/* Alters-Beschriftung */} {/* Beschriftungen unterhalb (Phase 2, 4, 6 …) */}
<div className="mt-1 flex justify-between text-[11px] text-muted"> <div className="relative h-[68px]">{segments.filter((s) => s.index % 2 === 1).map((s) => labelFor(s, false))}</div>
<span>{minAge} J.</span>
<span>{maxAge} J.</span>
</div> </div>
{/* Rechts das Planende und, falls vorhanden, die Marker-Erklärung in Kurzform. */}
<div className="flex shrink-0 flex-col justify-center pb-1">
<span className="text-sm font-semibold leading-none text-muted">
{startYear ? startYear + totalYears - 1 : maxAge}
</span>
<span className="mt-0.5 flex items-center gap-1 text-[10px] uppercase tracking-wide text-faint">
Ende
{persons.some((p) => p.retirementAge > minAge && p.retirementAge < maxAge) && (
<Flag className="h-2.5 w-2.5" aria-label="Pensionierung markiert" />
)}
{actualYears.length > 0 && <CalendarCheck className="h-2.5 w-2.5" aria-label="Effektive Werte markiert" />}
</span>
</div> </div>
</div> </div>
); );
+341
View File
@@ -0,0 +1,341 @@
"use client";
// Die Tour: eine kurze Demo statt eines Spotlights.
//
// Bis 0.35 legte sich die Tour über den ECHTEN Bildschirm und hob Bereiche hervor. Das hatte
// zwei Nachteile, die sich nicht beheben liessen: Auf einem frisch angelegten, leeren Plan gab
// es kaum etwas hervorzuheben -- gerade dann, wenn die Tour am nötigsten ist. Und das
// Ausschneiden der Hervorhebung kämpfte dauernd mit Scroll-Containern und Z-Ebenen.
//
// Neu zeigt die Tour einen NACHGEBAUTEN Bildschirm mit erfundenen Zahlen. Er ist bewusst
// keine echte Ansicht, sondern eine Illustration: gerade so detailliert, dass man den Aufbau
// erkennt, und so einfach, dass sie sich in einem Popup erklären lässt. Der Preis ist, dass
// die Attrappe bei UI-Änderungen nachgeführt werden muss -- dafür funktioniert die Tour ab
// der ersten Sekunde und unabhängig davon, was im Plan schon steht.
import { useState } from "react";
import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui";
export const TOUR_DONE_KEY = "fpt-tour-done";
type Focus = "none" | "settings" | "figures" | "actions" | "assistant" | "timeline" | "matrix" | "sidebar";
interface TourStep {
title: string;
text: string;
focus: Focus;
}
const STEPS: TourStep[] = [
{
title: "Willkommen bei FPT",
text: "In zwei Minuten siehst du, wie das Tool aufgebaut ist. Alles, was hier steht, sind erfundene Beispielzahlen dein eigener Plan ist noch leer.",
focus: "none",
},
{
title: "Oben stellst du ein, unten siehst du das Ergebnis",
text: "Der Bildschirm hat zwei Hälften. Oben die Steuerung: Grundeinstellungen, Kennzahlen, Schnellaktionen und der Assistent. Unten die Matrix mit allen Zahlen.",
focus: "none",
},
{
title: "Die Grundeinstellungen",
text: "Wer plant, ab wann und wie lange. Alter, Startjahr und Inflation gelten für den ganzen Plan; Planungshorizont und Pensionsalter gehören zum Szenario. Je Person führt ein Knopf in die Pensionsplanung.",
focus: "settings",
},
{
title: "Die Kennzahlen",
text: "Was unter dem Strich herauskommt: dein Vermögen heute, das Endvermögen nominal und real und ob das Kapital über die ganze Planung reicht.",
focus: "figures",
},
{
title: "Der FPT-Assistent",
text: "Dein Wegweiser. Sieben Schritte führen von der Bestandsaufnahme bis zum fertigen Plan. Jeder Schritt erklärt zuerst, worum es geht, und bietet danach das passende Werkzeug an. Wann ein Schritt erledigt ist, entscheidest du.",
focus: "assistant",
},
{
title: "Die Zeitachse",
text: "Deine Planung im Überblick: Lebensphasen, die Zeitpunkte der Pensionierung und, falls das Geld nicht reicht, das Jahr, in dem es knapp wird.",
focus: "timeline",
},
{
title: "Die Matrix",
text: "Das Herz des Tools. Jede Zeile ist ein finanzielles Element, jede Spalte eine Lebensphase. Die schmalen Spalten dazwischen sind die Übergänge dort entscheidest du, was beim Wechsel geschieht.",
focus: "matrix",
},
{
title: "Pläne und Szenarien",
text: "Links verwaltest du alles. Ein Szenario ist eine Variante deines Plans etwa mit anderem Pensionsalter. Du kannst beliebig viele anlegen und vergleichen.",
focus: "sidebar",
},
{
title: "Jetzt bist du dran",
text: "Starte mit dem FPT-Assistenten oben rechts. Schritt 1 ist die Bestandsaufnahme: alles, was du heute besitzt. Von dort führt dich der Assistent weiter.",
focus: "assistant",
},
];
export function Tour({ onClose }: { onClose: () => void }) {
const [index, setIndex] = useState(0);
const step = STEPS[index];
const last = index === STEPS.length - 1;
function finish() {
try {
localStorage.setItem(TOUR_DONE_KEY, "1");
} catch {
// Privater Modus o. Ä. -- die Tour lässt sich dann eben erneut öffnen.
}
onClose();
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-3 sm:p-6">
<div className="flex h-full max-h-[900px] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl">
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
<Sparkles className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold text-fg">{step.title}</h2>
<p className="text-[11px] text-muted">
Schritt {index + 1} von {STEPS.length}
</p>
</div>
<button
type="button"
onClick={finish}
aria-label="Tour schliessen"
className="rounded-lg p-1.5 text-faint hover:bg-surface-2 hover:text-fg"
>
<X className="h-4 w-4" />
</button>
</header>
{/* Die Attrappe. Rein illustrativ -- nichts darin ist klickbar. */}
<div className="min-h-0 flex-1 overflow-auto bg-surface-2/40 p-4">
<MockScreen focus={step.focus} />
</div>
<footer className="border-t border-border px-4 py-3">
<p className="mb-3 text-sm leading-relaxed text-fg">{step.text}</p>
<div className="flex items-center gap-2">
<div className="flex flex-1 gap-1">
{STEPS.map((_, i) => (
<div
key={i}
className={`h-1 flex-1 rounded-full transition-colors ${i <= index ? "bg-accent" : "bg-surface-2"}`}
/>
))}
</div>
<Button variant="ghost" size="sm" disabled={index === 0} onClick={() => setIndex((i) => i - 1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
{last ? (
<Button size="sm" onClick={finish}>
<Sparkles className="h-4 w-4" /> Mit dem Assistenten starten
</Button>
) : (
<Button size="sm" onClick={() => setIndex((i) => i + 1)}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
)}
</div>
</footer>
</div>
</div>
);
}
// --- Die Attrappe ---------------------------------------------------------------------------
//
// Feste, erfundene Zahlen. Sie müssen weder stimmen noch zueinander passen -- ihr einziger
// Zweck ist, den Aufbau greifbar zu machen.
function MockScreen({ focus }: { focus: Focus }) {
const dim = (area: Focus) => (focus === "none" || focus === area ? "opacity-100" : "opacity-20");
const ring = (area: Focus) => (focus === area ? "ring-2 ring-accent" : "");
return (
<div className="flex gap-3 text-[10px]">
{/* Seitenleiste */}
<div
className={`hidden w-36 shrink-0 flex-col gap-1 rounded-lg border border-border bg-surface p-2 transition-opacity sm:flex ${dim("sidebar")} ${ring("sidebar")}`}
>
<div className="mb-1 font-semibold uppercase tracking-wide text-faint">Meine Pläne</div>
<div className="rounded bg-accent-soft px-1.5 py-1 font-medium text-accent-soft-fg">Familie Muster</div>
<div className="pl-2 text-muted">Szenarien</div>
<div className="rounded bg-surface-2 px-1.5 py-0.5 pl-3 text-fg">Basis</div>
<div className="px-1.5 py-0.5 pl-3 text-muted">Pension mit 62</div>
<div className="pl-2 text-muted">Effektive Werte</div>
<div className="pl-2 text-muted">Analysen</div>
<div className="pl-2 text-muted">Berichte</div>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3">
{/* Oberer Bereich */}
<div className="rounded-lg border border-border bg-surface-2/60 p-2">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<MockTile title="Grundeinstellungen" cls={`${dim("settings")} ${ring("settings")}`}>
<div>Anna · 42 J.</div>
<div>Beat · 45 J.</div>
<div className="text-faint">Start 2026 · Inflation 1,5 %</div>
<div className="mt-1 border-t border-border pt-1">Horizont 40 Jahre · bis 2065</div>
<div className="flex items-center gap-1">
Pension 65 · Ende 82
<span className="ml-auto rounded border border-border px-1 text-[9px]">Pensionsplanung</span>
</div>
</MockTile>
<MockTile title="Kennzahlen" cls={`${dim("figures")} ${ring("figures")}`}>
<MockFigure label="Vermögen heute" value="412'000" />
<MockFigure label="Endvermögen nominal" value="1'284'000" />
<MockFigure label="Endvermögen real" value="702'000" />
<div className="mt-1 rounded bg-success-soft px-1.5 py-0.5 text-success">
Kapital reicht über die ganze Planung
</div>
</MockTile>
<MockTile title="Schnellaktionen" cls={`${dim("actions")} ${ring("actions")}`}>
{["Neues Szenario", "Tour", "Änderungshistorie", "Rechenwege", "CSV-Export"].map((a) => (
<div key={a} className="rounded border border-border px-1.5 py-0.5 text-muted">
{a}
</div>
))}
</MockTile>
<div
className={`flex flex-col gap-1 rounded-lg border border-attention bg-attention-soft px-2 py-1.5 transition-opacity ${dim("assistant")} ${ring("assistant")}`}
>
<div className="flex items-center gap-1">
<Sparkles className="h-3 w-3 text-attention-fg" />
<span className="font-semibold uppercase tracking-wide text-fg">FPT-Assistent</span>
<span className="ml-auto font-semibold text-muted">2 / 7</span>
</div>
{[
["3. Lebensphasen", "4 Lebensphasen"],
["4. Erwerbsjahre planen", ""],
["5. Pensionierung planen", ""],
].map(([t, sub]) => (
<div key={t} className="flex items-start gap-1">
<span className="mt-0.5 h-2.5 w-2.5 shrink-0 rounded-sm border border-border bg-surface" />
<span className="min-w-0">
<span className="block truncate font-semibold text-fg">{t}</span>
{sub && <span className="block truncate text-muted">{sub}</span>}
</span>
</div>
))}
{["1. Bestandsaufnahme", "2. Eckdaten"].map((t) => (
<div key={t} className="flex items-start gap-1 opacity-50">
<span className="mt-0.5 flex h-2.5 w-2.5 shrink-0 items-center justify-center rounded-sm bg-success text-[7px] text-white">
</span>
<span className="truncate text-muted line-through">{t}</span>
</div>
))}
</div>
</div>
{/* Zeitachse */}
<div
className={`mt-2 rounded-lg border border-border bg-surface p-2 transition-opacity ${dim("timeline")} ${ring("timeline")}`}
>
<div className="mb-1 flex justify-between text-faint">
<span>2026</span>
<span>2065</span>
</div>
<div className="flex h-4 overflow-hidden rounded">
<div className="flex w-[45%] items-center justify-center bg-accent/70 text-[8px] text-white">
Erwerbsjahre
</div>
<div className="flex w-[15%] items-center justify-center bg-attention/70 text-[8px] text-white">
Übergangszeit
</div>
<div className="flex w-[40%] items-center justify-center bg-success/60 text-[8px] text-white">
Ruhestand
</div>
</div>
<div className="mt-1 flex justify-between text-faint">
<span>Anna 42 · Beat 45</span>
<span>Pension 65</span>
<span>Anna 82 · Beat 85</span>
</div>
</div>
</div>
{/* Matrix */}
<div
className={`overflow-hidden rounded-lg border border-border bg-surface transition-opacity ${dim("matrix")} ${ring("matrix")}`}
>
<table className="w-full border-collapse">
<thead>
<tr className="bg-surface-2">
<th className="border-b border-r border-border px-2 py-1.5 text-left align-top">
<div className="font-semibold uppercase tracking-wide text-faint">Matrix</div>
<div className="mt-1 flex gap-1">
<span className="rounded border border-dashed border-border px-1 text-[9px] font-normal text-muted">
+ Element
</span>
<span className="rounded border border-dashed border-border px-1 text-[9px] font-normal text-muted">
+ Phase
</span>
</div>
</th>
{["Erwerbsjahre", "Übergang", "Ruhestand I", "Übergang", "Ruhestand II"].map((h, i) => (
<th
key={i}
className={`border-b border-r border-border px-2 py-1.5 text-center font-medium ${
h === "Übergang" ? "bg-attention text-attention-fg" : "text-fg"
}`}
>
{h}
{h === "Übergang" && <div className="text-[8px] font-normal">2 offen</div>}
</th>
))}
</tr>
</thead>
<tbody>
{[
["Lohn Anna", "96'000", "→", "—", "→", "—"],
["Lebenshaltung", "78'000", "→", "72'000", "→", "68'000"],
["Pensionskasse Anna", "310'000", "40 % Kapital", "Rente 18'400", "→", "18'400"],
["Säule 3a Anna", "84'000", "Bezug mit 64", "—", "→", "—"],
["Wertschriften", "120'000", "→", "486'000", "→", "310'000"],
].map((row, i) => (
<tr key={i} className={i % 2 ? "bg-surface-2/40" : ""}>
<td className="border-b border-r border-border px-2 py-1 text-fg">{row[0]}</td>
{row.slice(1).map((c, j) => (
<td
key={j}
className={`border-b border-r border-border px-2 py-1 text-center tabular-nums ${
j % 2 === 1 ? "bg-attention-soft text-attention-soft-fg" : "text-muted"
}`}
>
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function MockTile({ title, cls, children }: { title: string; cls: string; children: React.ReactNode }) {
return (
<div className={`flex flex-col gap-0.5 rounded-lg border border-border bg-surface px-2 py-1.5 transition-opacity ${cls}`}>
<div className="mb-0.5 font-semibold uppercase tracking-wide text-faint">{title}</div>
{children}
</div>
);
}
function MockFigure({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-1">
<span className="truncate text-muted">{label}</span>
<span className="shrink-0 font-semibold tabular-nums text-fg">{value}</span>
</div>
);
}
+327
View File
@@ -0,0 +1,327 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AlertTriangle, Eye, History, RotateCcw, Tag, X } from "lucide-react";
import { VersionMatrix } from "@/components/VersionMatrix";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
import type { RestoreImpact } from "@/lib/versioning";
export interface VersionRow {
id: string;
major: number;
minor: number;
comment: string | null;
isMajor: boolean;
createdAt: string;
updatedAt: string;
author: string;
}
interface VersionDetail {
version: { id: string; major: number; minor: number; comment: string | null; isMajor: boolean };
plan: PlanInput;
computed: PlanComputed;
impact: RestoreImpact;
}
const dt = (iso: string) =>
new Date(iso).toLocaleString("de-CH", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
export function VersionHistoryDialog({
scenarioId,
scenarioName,
onClose,
onRestored,
}: {
scenarioId: string;
scenarioName: string;
onClose: () => void;
onRestored: () => void;
}) {
const [rows, setRows] = useState<VersionRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<VersionDetail | null>(null);
const [busy, setBusy] = useState(false);
const [majorComment, setMajorComment] = useState("");
const [showMajorForm, setShowMajorForm] = useState(false);
const confirm = useConfirm();
const toast = useToast();
// Nachladen nach einer Aktion (Wiederherstellen, Hauptversion) -- nicht beim Öffnen.
const load = useCallback(async () => {
try {
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
setRows(data.versions);
} catch (e) {
setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
}
}, [scenarioId]);
// Erstes Laden. Das Cancel-Flag verhindert ein setState nach dem Schliessen des Dialogs.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
if (!cancelled) setRows(data.versions);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [scenarioId]);
async function openDetail(id: string) {
setBusy(true);
try {
setDetail(await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${id}`));
} catch (e) {
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
} finally {
setBusy(false);
}
}
async function restore(row: VersionRow) {
// Erst die Auswirkung holen: Kind-Szenarien können ihre Diff-Basis verlieren, und das
// muss VOR dem Klick auf dem Tisch liegen, nicht danach.
setBusy(true);
let impact: RestoreImpact;
try {
const d = await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${row.id}`);
impact = d.impact;
} catch (e) {
setBusy(false);
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
return;
}
setBusy(false);
const warn =
impact.affectedChildren.length > 0
? `\n\nAchtung: ${impact.affectedChildren.length === 1 ? "Das Szenario" : "Die Szenarien"} ` +
`«${impact.affectedChildren.join("», «")}» ${impact.affectedChildren.length === 1 ? "hängt" : "hängen"} ` +
`an diesem Szenario. Dieser Stand kannte ${impact.lostElementIds.length} Element(e) und ` +
`${impact.lostPhaseIds.length} Phase(n) noch nicht, auf die dort verwiesen wird die Abweichungs-Markierung ` +
`zeigt sie danach als «neu» statt als «geändert».`
: "";
const ok = await confirm({
title: `Auf Version ${row.major}.${row.minor} zurücksetzen?`,
message:
`Das Szenario «${scenarioName}» wird vollständig auf diesen Stand zurückgesetzt. ` +
`Es geht nichts verloren: Der wiederhergestellte Stand wird als neue Version festgehalten, ` +
`die bisherige Historie bleibt vollständig erhalten.${warn}`,
confirmLabel: "Wiederherstellen",
danger: impact.affectedChildren.length > 0,
});
if (!ok) return;
setBusy(true);
try {
const res = await api.post<{ version: { major: number; minor: number } }>(
`/api/scenarios/${scenarioId}/versions/${row.id}`,
{}
);
toast("success", `Wiederhergestellt aus ${row.major}.${row.minor} neue Version ${res.version.major}.${res.version.minor}.`);
await load();
onRestored();
} catch (e) {
toast("error", e instanceof Error ? e.message : "Wiederherstellen fehlgeschlagen.");
} finally {
setBusy(false);
}
}
async function createMajor() {
setBusy(true);
try {
const res = await api.post<{ version: { major: number; minor: number } }>(
`/api/scenarios/${scenarioId}/versions`,
{ comment: majorComment }
);
toast("success", `Hauptversion ${res.version.major}.${res.version.minor} festgelegt.`);
setMajorComment("");
setShowMajorForm(false);
await load();
onRestored();
} catch (e) {
toast("error", e instanceof Error ? e.message : "Hauptversion konnte nicht angelegt werden.");
} finally {
setBusy(false);
}
}
// --- Detailansicht (nur lesen) ---
if (detail) {
const last = detail.computed.phases[detail.computed.phases.length - 1];
return (
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={() => setDetail(null)}>
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-5xl flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<Eye className="h-5 w-5 text-accent" />
Version {detail.version.major}.{detail.version.minor}
{detail.version.isMajor && (
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[10px] font-semibold text-accent-soft-fg">
Hauptversion
</span>
)}
</h2>
<p className="mt-0.5 text-xs text-muted">
Nur-Lese-Ansicht dieses Standes. {detail.version.comment && `«${detail.version.comment}»`}
</p>
</div>
<button type="button" onClick={() => setDetail(null)} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{last && (
<div className="flex flex-wrap gap-4 rounded-xl border border-border bg-surface-2 p-3 text-xs">
<span className="text-muted">
Endvermögen nominal: <strong className="text-fg">{formatChf(last.endWealthNominal)}</strong>
</span>
<span className="text-muted">
real: <strong className="text-fg">{formatChf(last.endWealthReal)}</strong>
</span>
<span className="text-muted">
Kapital reicht:{" "}
<strong className={detail.computed.ruinAge === null ? "text-success" : "text-danger"}>
{detail.computed.ruinAge === null ? "bis Planende" : `bis Alter ${detail.computed.ruinAge}`}
</strong>
</span>
</div>
)}
<VersionMatrix computed={detail.computed} />
</div>
</div>
);
}
// --- Liste ---
return (
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<History className="h-5 w-5 text-accent" /> Änderungshistorie
</h2>
<p className="mt-0.5 text-xs text-muted">
Szenario «{scenarioName}». Eine Nebenversion entsteht je Bearbeitungssitzung, nicht je
einzelner Änderung sonst wäre die Liste ein Tastenprotokoll.
</p>
</div>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{/* Hauptversion festlegen */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
{showMajorForm ? (
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-fg">
Wofür steht diese Hauptversion? <span className="text-danger">*</span>
</label>
<input
autoFocus
value={majorComment}
onChange={(e) => setMajorComment(e.target.value)}
placeholder="z. B. «Stand nach Beratungsgespräch, vor dem Hauskauf»"
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
<div className="flex items-center gap-2">
<Button size="sm" disabled={majorComment.trim().length < 3 || busy} onClick={createMajor}>
<Tag className="h-3.5 w-3.5" /> Als Hauptversion festlegen
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(false)}>
Abbrechen
</Button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted">
Einen bewusst gesetzten Meilenstein festhalten mit Begründung.
</span>
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(true)}>
<Tag className="h-3.5 w-3.5" /> Aktuellen Stand als Hauptversion festlegen
</Button>
</div>
)}
</div>
{error && <p className="text-xs text-danger">{error}</p>}
{!rows && !error && <p className="text-xs text-muted">Historie wird geladen</p>}
{rows && rows.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Für dieses Szenario ist noch keine Version festgehalten. Die erste entsteht mit der
nächsten Änderung.
</p>
)}
{rows && rows.length > 0 && (
<div className="flex flex-col gap-2">
{rows.map((r, i) => (
<div
key={r.id}
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
r.isMajor ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
}`}
>
<span className="flex w-16 shrink-0 items-center gap-1.5 font-semibold text-fg">
{r.isMajor && <Tag className="h-3.5 w-3.5 text-accent" />}
{r.major}.{r.minor}
</span>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted">
{r.author} · {dt(r.updatedAt)}
{i === 0 && (
<span className="ml-1.5 rounded bg-surface px-1.5 py-0.5 text-[10px] text-faint">
aktueller Stand
</span>
)}
</div>
{r.comment && <div className="mt-0.5 truncate text-xs text-fg">«{r.comment}»</div>}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button size="sm" variant="secondary" disabled={busy} onClick={() => openDetail(r.id)}>
<Eye className="h-3.5 w-3.5" /> Anzeigen
</Button>
<Button size="sm" variant="secondary" disabled={busy || i === 0} onClick={() => restore(r)}>
<RotateCcw className="h-3.5 w-3.5" /> Wiederherstellen
</Button>
</div>
</div>
))}
</div>
)}
<p className="flex items-start gap-1.5 text-[11px] text-faint">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
Wiederherstellen löscht nichts: Der zurückgesetzte Stand wird selbst als neue Version
festgehalten. Hängen Szenarien an diesem, wird vorher gewarnt.
</p>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { Fragment } from "react";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
import type { ElementCategory } from "@/lib/elements";
// Nur-Lese-Matrix eines festgehaltenen Standes. Bewusst NICHT die Bearbeitungs-Matrix aus
// PlanView: Die trägt Inspector-Anbindung, Übergangs-Ampeln, Diff-Markierung und Dialoge --
// alles ohne Bedeutung für einen alten Stand, den man nur ansehen kann. Diese Ansicht zeigt
// stattdessen genau das, was eine Version ausmacht: Phasen, Elemente, Werte.
export function VersionMatrix({ computed }: { computed: PlanComputed }) {
const phases = computed.phases;
if (phases.length === 0) {
return <p className="text-sm text-muted">Dieser Stand enthielt noch keine Lebensphasen.</p>;
}
// Elemente in der gewohnten Kategorie-Reihenfolge, über alle Phasen gesammelt.
const elements = (() => {
const seen = new Map<string, { name: string; category: ElementCategory; ownerRole: string | null }>();
for (const p of phases) {
for (const e of p.elements) {
if (!seen.has(e.elementId)) {
seen.set(e.elementId, { name: e.name, category: e.category, ownerRole: e.ownerRole });
}
}
}
return [...seen.entries()]
.map(([id, v]) => ({ id, ...v }))
.sort((a, b) => {
const ca = CATEGORY_ORDER.indexOf(a.category);
const cb = CATEGORY_ORDER.indexOf(b.category);
return ca !== cb ? ca - cb : a.name.localeCompare(b.name, "de-CH");
});
})();
// Kategoriewechsel als Zwischenüberschrift -- dieselbe Gliederung wie in der Planansicht.
// Vorab bestimmt statt während des Renderns mitgezählt: Eine Variable, die der Render-Lauf
// fortschreibt, verhält sich bei erneutem Rendern nicht mehr gleich.
const headerAt = new Map<string, ElementCategory>();
elements.forEach((el, i) => {
if (i === 0 || el.category !== elements[i - 1].category) headerAt.set(el.id, el.category);
});
return (
<div className="max-h-[60vh] overflow-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 z-10">
<tr className="bg-surface-2 text-xs text-faint">
<th className="sticky left-0 z-20 bg-surface-2 px-3 py-2 text-left font-semibold">Element</th>
{phases.map((p) => (
<th key={p.id} className="whitespace-nowrap px-3 py-2 text-right font-semibold">
<div className="text-fg">{p.name}</div>
<div className="font-normal text-faint">{p.durationYears} J.</div>
</th>
))}
</tr>
</thead>
<tbody>
{elements.map((el) => {
const header = headerAt.get(el.id) ?? null;
return (
<Fragment key={el.id}>
{header && (
<tr className="border-t border-border bg-surface-2/60">
<td
colSpan={phases.length + 1}
className="sticky left-0 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint"
>
{CATEGORY_LABELS[header]}
</td>
</tr>
)}
<tr className="border-t border-border">
<td className="sticky left-0 z-10 bg-surface px-3 py-2">
<span className="font-medium text-fg">{el.name}</span>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<span className="ml-1.5 text-[10px] text-faint">
{el.ownerRole === "PERSON_A" ? "A" : "B"}
</span>
)}
</td>
{phases.map((p) => {
const c = p.elements.find((x) => x.elementId === el.id);
if (!c) return <td key={p.id} className="px-3 py-2 text-right text-faint"></td>;
return (
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right">
<div className={c.status === "ACTIVE" ? "text-fg" : "text-faint line-through"}>
{formatChf(c.startValue)}
</div>
{c.endValue !== c.startValue && (
<div className="text-[11px] text-muted"> {formatChf(c.endValue)}</div>
)}
</td>
);
})}
</tr>
</Fragment>
);
})}
{/* Vermögen je Phase -- die Kennzahl, für die der ganze Stand steht. */}
<tr className="border-t-2 border-border bg-surface-2">
<td className="sticky left-0 z-10 bg-surface-2 px-3 py-2 text-xs font-semibold text-fg">
Vermögen am Phasenende
</td>
{phases.map((p) => (
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right text-xs font-semibold text-fg">
{formatChf(p.endWealthNominal)}
<div className="font-normal text-faint">({formatChf(p.endWealthReal)} real)</div>
</td>
))}
</tr>
</tbody>
</table>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useState } from "react";
import { History } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { api } from "@/lib/api-client";
import type { PlanInput } from "@/lib/types";
// Kennzeichnet den aktuellen (ungespeicherten) Arbeitsstand -- im Gegensatz zu einer
// festgehaltenen Version.
export const CURRENT = "current";
export interface VersionOption {
id: string;
label: string; // "1.4" bzw. "1.4 (Hauptversion)"
isMajor: boolean;
comment: string | null;
}
// Lädt die Versionsliste eines Szenarios. Bewusst ohne Snapshots -- die kommen erst beim
// tatsächlichen Auswählen dazu.
export function useVersionOptions(scenarioId: string | null): VersionOption[] {
const [options, setOptions] = useState<VersionOption[]>([]);
useEffect(() => {
if (!scenarioId) return;
let cancelled = false;
(async () => {
try {
const data = await api.get<{
versions: { id: string; major: number; minor: number; isMajor: boolean; comment: string | null }[];
}>(`/api/scenarios/${scenarioId}/versions`);
if (cancelled) return;
setOptions(
data.versions.map((v) => ({
id: v.id,
label: `${v.major}.${v.minor}${v.isMajor ? " (Hauptversion)" : ""}`,
isMajor: v.isMajor,
comment: v.comment,
}))
);
} catch {
if (!cancelled) setOptions([]);
}
})();
return () => {
cancelled = true;
};
}, [scenarioId]);
return options;
}
// Holt den Plan-Stand einer Version. `CURRENT` liefert null -- dann gilt der Arbeitsstand.
export async function loadVersionPlan(scenarioId: string, versionId: string): Promise<PlanInput | null> {
if (versionId === CURRENT) return null;
const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${scenarioId}/versions/${versionId}`);
return data.plan;
}
// Ein kompakter Wähler je Szenario. Wird in allen vier Analysewerkzeugen verwendet, damit
// die Bedienung überall dieselbe ist.
export function VersionSelect({
scenarioId,
value,
onChange,
label,
compact,
}: {
scenarioId: string;
value: string;
onChange: (versionId: string) => void;
label?: string;
compact?: boolean;
}) {
const options = useVersionOptions(scenarioId);
return (
<label className={`flex items-center gap-1.5 ${compact ? "text-[11px]" : "text-xs"} text-muted`}>
{label && (
<span className="flex items-center font-medium">
<History className="mr-1 h-3.5 w-3.5 text-faint" />
{label}
</span>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`rounded-lg border border-border bg-surface px-2 py-1 text-fg ${compact ? "text-[11px]" : "text-xs"}`}
>
<option value={CURRENT}>Aktueller Stand</option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
{o.comment ? ` ${o.comment.slice(0, 40)}` : ""}
</option>
))}
</select>
</label>
);
}
// Hält die Auswahl "welcher Stand" für ein einzelnes Szenario. Solange `CURRENT` gewählt
// ist, wird der übergebene Arbeitsstand durchgereicht -- ohne Netzwerkzugriff.
//
// Der Snapshot ist bereits ein PlanInput, deshalb rechnen die Werkzeuge damit unverändert
// weiter; das Gerechnete entsteht bei ihnen lokal (computePlan ist rein und kostet ~0.2 ms).
export function useVersionedPlan(scenarioId: string, currentPlan: PlanInput) {
const [versionId, setVersionId] = useState<string>(CURRENT);
// Nur die geladenen Snapshots liegen im Zustand. Der Arbeitsstand ist bereits da und wird
// abgeleitet -- ihn in den Zustand zu spiegeln, hiesse ihn doppelt zu führen.
const [snapshots, setSnapshots] = useState<Record<string, PlanInput>>({});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (versionId === CURRENT || snapshots[versionId]) return;
let cancelled = false;
(async () => {
try {
const loaded = await loadVersionPlan(scenarioId, versionId);
if (cancelled) return;
if (loaded) setSnapshots((prev) => ({ ...prev, [versionId]: loaded }));
setError(null);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [scenarioId, versionId, snapshots]);
const plan = versionId === CURRENT ? currentPlan : (snapshots[versionId] ?? currentPlan);
// Solange der Snapshot fehlt und kein Fehler vorliegt, laeuft der Abruf noch. Abgeleitet
// statt als eigener Zustand -- ein Ladeflag, das nur den Abruf spiegelt, ist redundant.
const loading = versionId !== CURRENT && !snapshots[versionId] && !error;
return { versionId, setVersionId, plan, loading, error };
}
// Kopfzeile für die Analyse-Dialoge mit einem einzelnen Szenario.
export function VersionBar({
scenarioId,
versionId,
onChange,
loading,
}: {
scenarioId: string;
versionId: string;
onChange: (id: string) => void;
loading?: boolean;
}) {
return (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
<VersionSelect scenarioId={scenarioId} value={versionId} onChange={onChange} label="Berechnungsgrundlage" />
<VersionHint />
{loading && <span className="text-[11px] text-muted">wird geladen</span>}
{versionId !== CURRENT && !loading && (
<span className="rounded bg-attention-soft px-1.5 py-0.5 text-[10px] font-semibold text-attention-soft-fg">
Nicht der aktuelle Stand
</span>
)}
</div>
);
}
// Hinweiszeile für die Analyse-Dialoge.
export function VersionHint() {
return (
<InfoBubble text="Standardmässig rechnen die Werkzeuge mit dem aktuellen Arbeitsstand. Du kannst stattdessen jede festgehaltene Version wählen dann wird genau der damalige Stand gerechnet." />
);
}
+108 -41
View File
@@ -1,43 +1,71 @@
"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;
// Plandaten werden gestrichelt gezeichnet, effektive Daten durchgezogen. Das Stilbudget
// geht bewusst an Plan/Ist statt an nominal/real (siehe SPEZIFIKATION 9.29).
dashed?: boolean;
} }
// Datenpunkte je Serie: Start (Beginn Phase 1) + Endwert je Phase, verortet auf dem // Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
// Alter der Referenzperson (Person A). So laeuft die x-Achse ueber das Alter statt ueber // der Referenzperson (Person A). Der erste Punkt ist das Startvermögen vor Jahr 1.
// die Phasen.
function pointsFor(computed: PlanComputed) { function pointsFor(computed: PlanComputed) {
const phases = computed.phases; const phases = computed.phases;
if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[]; if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[];
const refAge = (ph: PlanComputed["phases"][number]) => ph.persons[0]?.startAge ?? 0; const startAge = phases[0].persons[0]?.startAge ?? 0;
const pts = [ const start = Math.round(phases[0].startWealthNominal);
{ age: refAge(phases[0]), nominal: Math.round(phases[0].startWealthNominal), real: Math.round(phases[0].startWealthNominal) }, const pts = [{ age: startAge, nominal: start, real: start }];
]; for (const y of computed.yearly) {
for (const p of phases) { pts.push({ age: y.age, nominal: y.wealthNominal, real: y.wealthReal });
pts.push({ age: refAge(p) + p.durationYears, nominal: Math.round(p.endWealthNominal), real: Math.round(p.endWealthReal) });
} }
return pts; return pts;
} }
// Liniendiagramm: Gesamtvermoegen (nominal + real) ueber das Alter. Unterstuetzt mehrere // `var(--chart-1)` in einen konkreten Hex-Wert auflösen -- das Farbwahl-Feld (input[type=color])
// ueberlagerte Plaene fuer den Szenario-Vergleich. // braucht einen echten Wert. Läuft nur im Browser und nur, wenn eine Variable übergeben wurde.
export function WealthChart({ series }: { series: TimelineSeries[] }) { function resolveColor(color: string): string {
if (!color.startsWith("var(")) return color;
if (typeof window === "undefined") return "#888888";
const name = color.slice(4, -1).trim();
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || "#888888";
}
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
// überlagerte Pläne für den Szenario-Vergleich.
export function WealthChart({
series,
metric = "nominal",
onColorChange,
}: {
series: TimelineSeries[];
metric?: "nominal" | "real";
// Optional: erlaubt das Umfärben einer Serie über die Legende.
onColorChange?: (id: string, color: string) => void;
}) {
// Aufgelöste Farben für die Farbwahl-Felder; nach dem ersten Rendern (und bei Theme-Wechsel).
const [resolved, setResolved] = useState<Record<string, string>>({});
const colorKey = series.map((s) => `${s.id}:${s.color}`).join("|");
useEffect(() => {
const next: Record<string, string> = {};
for (const s of series) next[s.id] = resolveColor(s.color);
// eslint-disable-next-line react-hooks/set-state-in-effect -- Farbauflösung braucht das DOM
setResolved(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [colorKey]);
if (series.length === 0 || series[0].computed.phases.length === 0) { 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>;
} }
@@ -49,58 +77,97 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
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} (nominal)`] = pt ? pt.nominal : null; row[s.id] = pt ? (metric === "real" ? pt.real : pt.nominal) : null;
row[`${s.label} (real)`] = pt ? pt.real : 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}-nominal`} key={s.id}
type="monotone" type="monotone"
dataKey={`${s.label} (nominal)`} dataKey={s.id}
name={s.label}
stroke={s.color} stroke={s.color}
strokeWidth={2} strokeWidth={2}
strokeDasharray={s.dashed ? "5 3" : undefined}
dot={false} dot={false}
connectNulls connectNulls
/> isAnimationActive={false}
))}
{withPoints.map((s) => (
<Line
key={`${s.label}-real`}
type="monotone"
dataKey={`${s.label} (real)`}
stroke={s.color}
strokeWidth={2}
strokeDasharray="5 3"
dot={false}
connectNulls
/> />
))} ))}
</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>
); );
} }
+384
View File
@@ -0,0 +1,384 @@
"use client";
// UI-Primitiven des Tools. Ein einziger Ort für Buttons, Dialoge, Bestätigungen, Toasts
// und Ladezustände -- damit Verhalten (ESC, Fokus-Falle, Animation) und Optik überall
// identisch sind, statt in jeder Komponente von Hand nachgebaut zu werden.
//
// Ersetzt insbesondere window.confirm()/alert(): Browser-Systemdialoge folgen keinem der
// drei Farbschemata und wirken wie ein Fremdkörper.
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { AlertTriangle, CheckCircle2, X } from "lucide-react";
// --- ESC-Verwaltung ----------------------------------------------------------------------
// Mehrere Schichten können gleichzeitig offen sein (Inspector + Dialog). ESC soll nur die
// OBERSTE schliessen. Jede Schicht registriert sich beim Öffnen; ESC trifft die letzte.
const escStack: (() => void)[] = [];
let escListenerAttached = false;
function ensureEscListener() {
if (escListenerAttached || typeof window === "undefined") return;
escListenerAttached = true;
window.addEventListener("keydown", (e) => {
if (e.key !== "Escape" || escStack.length === 0) return;
e.stopPropagation();
escStack[escStack.length - 1]();
});
}
function useEscClose(onClose: () => void) {
const closeRef = useRef(onClose);
useEffect(() => {
closeRef.current = onClose;
}, [onClose]);
useEffect(() => {
ensureEscListener();
const handler = () => closeRef.current();
escStack.push(handler);
return () => {
const i = escStack.indexOf(handler);
if (i >= 0) escStack.splice(i, 1);
};
}, []);
}
// Einfache Fokus-Falle: beim Öffnen den ersten fokussierbaren Inhalt fokussieren,
// Tab bleibt innerhalb der Schicht, beim Schliessen kehrt der Fokus zurück.
function useFocusTrap(ref: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const el = ref.current;
if (!el) return;
const previous = document.activeElement as HTMLElement | null;
const focusables = () =>
[...el.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)].filter((x) => !x.hasAttribute("disabled"));
(focusables()[0] ?? el).focus({ preventScroll: true });
function onKey(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const f = focusables();
if (f.length === 0) return;
const first = f[0];
const last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
el.addEventListener("keydown", onKey);
return () => {
el.removeEventListener("keydown", onKey);
previous?.focus?.({ preventScroll: true });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
// --- Button ------------------------------------------------------------------------------
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
const BUTTON_CLASSES: Record<ButtonVariant, string> = {
primary:
"bg-accent text-accent-fg shadow-sm hover:bg-accent-hover active:scale-[0.98]",
secondary:
"border border-border text-muted hover:bg-surface-2 hover:text-fg active:scale-[0.98]",
danger:
"border border-border text-muted hover:border-danger hover:bg-danger-soft hover:text-danger active:scale-[0.98]",
ghost: "text-muted hover:bg-surface-2 hover:text-fg",
};
export function Button({
variant = "primary",
size = "md",
className = "",
children,
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: "sm" | "md";
}) {
const sizeClass = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
return (
<button
type="button"
{...rest}
className={`inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 ${BUTTON_CLASSES[variant]} ${sizeClass} ${className}`}
>
{children}
</button>
);
}
// --- Modal -------------------------------------------------------------------------------
export function Modal({
title,
subtitle,
onClose,
children,
wide,
xwide,
}: {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
xwide?: boolean;
}) {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscClose(onClose);
useFocusTrap(panelRef);
const maxWidth = xwide ? "max-w-4xl" : wide ? "max-w-2xl" : "max-w-md";
return (
<div
className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
className={`ui-pop flex w-full ${maxWidth} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-semibold text-fg">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
</div>
<button
type="button"
onClick={onClose}
aria-label="Schliessen"
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
>
<X className="h-4 w-4" />
</button>
</div>
{children}
</div>
</div>
);
}
// --- Inspector ---------------------------------------------------------------------------
// Rechtes Seitenpanel für Einzel-Bearbeitungen (Zellen, Übergänge, Phase, Profil).
// Bewusst OHNE Backdrop: Die Matrix bleibt sichtbar und klickbar -- ein Klick auf eine
// andere Zelle wechselt einfach den Panel-Inhalt. Das hält den Kontext, den Modals nehmen.
// Auf schmalen Screens deckt das Panel die volle Breite ab.
export function InspectorShell({
title,
subtitle,
onClose,
children,
}: {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
}) {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscClose(onClose);
return (
<aside
ref={panelRef}
role="complementary"
aria-label={title}
className="ui-slide-in fixed inset-y-0 right-0 z-30 flex w-full flex-col border-l border-border bg-surface shadow-2xl sm:w-[26rem]"
>
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
</div>
<button
type="button"
onClick={onClose}
aria-label="Panel schliessen"
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
</aside>
);
}
// --- Bestätigungs-Dialog (ersetzt window.confirm) ---------------------------------------
interface ConfirmOptions {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
}
const ConfirmContext = createContext<((opts: ConfirmOptions) => Promise<boolean>) | null>(null);
export function useConfirm() {
const fn = useContext(ConfirmContext);
if (!fn) throw new Error("useConfirm ausserhalb des ConfirmProviders");
return fn;
}
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
const [pending, setPending] = useState<{
opts: ConfirmOptions;
resolve: (ok: boolean) => void;
} | null>(null);
const confirm = useCallback(
(opts: ConfirmOptions) =>
new Promise<boolean>((resolve) => setPending({ opts, resolve })),
[]
);
function settle(ok: boolean) {
pending?.resolve(ok);
setPending(null);
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
{pending && (
<Modal title={pending.opts.title} onClose={() => settle(false)}>
<p className="text-sm text-muted">{pending.opts.message}</p>
<div className="flex gap-2 pt-1">
<Button
variant={pending.opts.danger ? "danger" : "primary"}
autoFocus
onClick={() => settle(true)}
className={pending.opts.danger ? "border-danger bg-danger-soft text-danger" : ""}
>
{pending.opts.confirmLabel ?? "Bestätigen"}
</Button>
<Button variant="secondary" onClick={() => settle(false)}>
Abbrechen
</Button>
</div>
</Modal>
)}
</ConfirmContext.Provider>
);
}
// --- Toasts (ersetzt alert und stumme Erfolge) -------------------------------------------
interface ToastItem {
id: number;
kind: "success" | "error";
text: string;
}
const ToastContext = createContext<((kind: ToastItem["kind"], text: string) => void) | null>(null);
export function useToast() {
const fn = useContext(ToastContext);
if (!fn) throw new Error("useToast ausserhalb des ToastProviders");
return fn;
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const nextId = useRef(1);
const push = useCallback((kind: ToastItem["kind"], text: string) => {
const id = nextId.current++;
setToasts((prev) => [...prev, { id, kind, text }]);
// Fehler bleiben länger stehen als Erfolge.
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), kind === "error" ? 6000 : 3200);
}, []);
return (
<ToastContext.Provider value={push}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
{toasts.map((t) => (
<div
key={t.id}
className={`ui-pop pointer-events-auto flex items-start gap-2 rounded-xl border px-3.5 py-2.5 text-sm shadow-lg ${
t.kind === "error"
? "border-danger bg-danger-soft text-danger"
: "border-border bg-surface text-fg"
}`}
>
{t.kind === "error" ? (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" />
)}
<span className="min-w-0 flex-1">{t.text}</span>
<button
type="button"
aria-label="Meldung schliessen"
onClick={() => setToasts((prev) => prev.filter((x) => x.id !== t.id))}
className="rounded p-0.5 text-faint hover:text-fg"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
// --- Ladezustände und leere Zustände ---------------------------------------------------
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded-lg bg-surface-2 ${className}`} />;
}
// Platzhalter während die Planansicht lädt -- deutet die Struktur an, statt nur
// "Lädt…" zu schreiben (wahrgenommene Geschwindigkeit).
export function PlanSkeleton() {
return (
<div className="flex flex-col gap-5" aria-busy="true" aria-label="Lädt">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-72 w-full" />
</div>
);
}
export function EmptyState({
icon,
title,
text,
children,
}: {
icon: React.ReactNode;
title: string;
text: string;
children?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-surface p-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent-soft text-accent-soft-fg">
{icon}
</div>
<div>
<h3 className="text-sm font-semibold text-fg">{title}</h3>
<p className="mx-auto mt-1 max-w-md text-sm text-muted">{text}</p>
</div>
{children && <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div>}
</div>
);
}
+390
View File
@@ -0,0 +1,390 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import {
actualKindOf,
actualsForYear,
latestPlanYear,
rebaseFlow,
resolveActuals,
toPlanYear,
type ActualsSetInput,
} from "@/lib/actuals";
import type { PlanInput } from "@/lib/types";
// Plan ab 2020: ein Fonds mit 100'000 und 5 % Rendite, keine Zu-/Abflüsse. Das ist bewusst
// das Beispiel aus der Anforderung, nur in ganzen Franken.
function fundPlan(): PlanInput {
return {
id: "s",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2020,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }],
elements: [
{
id: "fonds",
category: "OTHER_ASSET",
name: "Fonds",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { startValue: 100000, expectedReturn: 5 } },
transitionValues: {},
sourceElementId: null,
},
],
} as unknown as PlanInput;
}
const valueAt = (computed: ReturnType<typeof computePlan>, elementId: string, year: number) =>
computed.phases
.flatMap((p) => p.elements.filter((e) => e.elementId === elementId).flatMap((e) => e.yearly))
.find((y) => y.year === year)?.value ?? null;
const setAt = (year: number, values: Record<string, { value?: number; mortgage?: number }>, cash?: number): ActualsSetInput => ({
id: `set-${year}`,
recordedOn: `${year}-08-18`,
year,
cash: cash ?? null,
values,
});
describe("toPlanYear", () => {
it("rechnet das Kalenderjahr auf das Planjahr um", () => {
expect(toPlanYear(2020, 2020)).toBe(1);
expect(toPlanYear(2026, 2020)).toBe(7);
});
it("liefert nichts ohne Planstartjahr", () => {
expect(toPlanYear(2026, null)).toBeNull();
});
});
describe("actualKindOf", () => {
it("trennt Bestände von Flüssen", () => {
expect(actualKindOf("OTHER_ASSET")).toBe("STOCK");
expect(actualKindOf("PENSION_FUND")).toBe("STOCK");
expect(actualKindOf("REAL_ESTATE")).toBe("PROPERTY");
expect(actualKindOf("INCOME")).toBe("FLOW");
expect(actualKindOf("EXPENSE")).toBe("FLOW");
});
});
describe("resolveActuals", () => {
const plan = fundPlan();
it("bildet Wurzel-IDs auf die Elemente des Szenarios ab", () => {
const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
expect(res).toHaveLength(1);
expect(res[0].planYear).toBe(3);
expect(res[0].byElementId.fonds.value).toBe(120000);
});
it("findet das Element auch über eine Kopie-Kette hinweg", () => {
// Kind-Szenario: eigene Element-ID, zeigt über sourceElementId auf das Original.
const child: PlanInput = {
...plan,
id: "child",
elements: [{ ...plan.elements[0], id: "fonds-kopie", sourceElementId: "fonds" }],
} as unknown as PlanInput;
// Der Ist-Satz ist auf die WURZEL-ID erfasst -- er muss trotzdem greifen.
const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], child, [...plan.elements, ...child.elements]);
expect(res[0].byElementId["fonds-kopie"].value).toBe(120000);
});
it("verwirft Sätze ausserhalb des Planzeitraums", () => {
const res = resolveActuals(
[setAt(2019, { fonds: { value: 1 } }), setAt(2099, { fonds: { value: 1 } })],
plan,
plan.elements
);
expect(res).toEqual([]);
});
it("lässt Elemente ohne Ist-Wert weg -- sie laufen auf der Planlinie weiter", () => {
const res = resolveActuals([setAt(2022, {})], plan, plan.elements);
expect(res[0].byElementId).toEqual({});
});
it("sortiert nach Planjahr", () => {
const res = resolveActuals(
[setAt(2024, { fonds: { value: 140000 } }), setAt(2022, { fonds: { value: 120000 } })],
plan,
plan.elements
);
expect(res.map((r) => r.year)).toEqual([2022, 2024]);
});
});
describe("actualsForYear / latestPlanYear", () => {
const plan = fundPlan();
const res = resolveActuals(
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })],
plan,
plan.elements
);
it("findet den Satz des Jahres", () => {
expect(actualsForYear(res, 3)!.year).toBe(2022);
expect(actualsForYear(res, 4)).toBeNull();
});
it("kennt das jüngste erfasste Planjahr", () => {
expect(latestPlanYear(res)).toBe(5);
expect(latestPlanYear([])).toBeNull();
});
});
describe("Berechnung mit Ist-Werten", () => {
const plan = fundPlan();
it("lässt die Plan-Sicht unangetastet", () => {
// Der wichtigste Test überhaupt: Ohne actuals-Option muss auf die Zahl dasselbe
// herauskommen wie vorher.
const a = computePlan(plan);
const b = computePlan(plan, undefined, {});
expect(a.phases[0].endWealthNominal).toBe(b.phases[0].endWealthNominal);
});
it("springt im Ist-Jahr auf den erfassten Wert", () => {
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
const computed = computePlan(plan, undefined, { actuals });
// Planwert 2022 wäre 100'000 x 1.05^3 = 115'762. Erfasst sind 120'000.
expect(valueAt(computePlan(plan), "fonds", 3)).toBe(115763);
expect(valueAt(computed, "fonds", 3)).toBe(120000);
});
it("rechnet ab dem Ist-Wert planmässig weiter", () => {
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
const computed = computePlan(plan, undefined, { actuals });
// Zwei Jahre nach dem Sprung: 120'000 x 1.05^2 = 132'300 -- genau die Erwartung aus der
// Anforderung ("in 2024 wäre man dann bei ca. 132").
expect(valueAt(computed, "fonds", 5)).toBe(132300);
});
it("springt bei mehreren Sätzen an jeder erfassten Stelle", () => {
const actuals = resolveActuals(
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })],
plan,
plan.elements
);
const computed = computePlan(plan, undefined, { actuals });
expect(valueAt(computed, "fonds", 3)).toBe(120000);
expect(valueAt(computed, "fonds", 5)).toBe(140000); // statt 132'300
expect(valueAt(computed, "fonds", 6)).toBe(147000); // 140'000 x 1.05
});
it("führt den Sprung als eigene Position, nicht als Rendite", () => {
// Sonst erschiene eine Planabweichung als Anlageerfolg -- und die Brücke ginge nicht auf.
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
const withActuals = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge;
const planOnly = computePlan(plan).phases[0].wealthBridge;
// Planwert 2022 exakt: 100'000 x 1.05^3 = 115'762.50 -> Korrektur 4'237.50, gerundet 4'238.
expect(withActuals.actualsCorrection).toBe(4238);
// Die Rendite selbst bleibt unberührt: Der Sprung ist KEIN Anlageerfolg. Sie ist nur
// grösser, weil ab 2022 auf einem höheren Kapital verzinst wird.
expect(withActuals.investmentReturn).toBeGreaterThan(planOnly.investmentReturn);
expect(planOnly.actualsCorrection).toBe(0);
});
it("hält die Vermögensbrücke auch bei mehreren Sprüngen geschlossen", () => {
const actuals = resolveActuals(
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 90000 } })],
plan,
plan.elements
);
const bridge = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge;
// Der zweite Sprung geht nach UNTEN -- die Korrektur muss negativ sein können.
expect(bridge.actualsCorrection).toBeLessThan(0);
// Der Restposten bleibt reine Rundung. Die Toleranz entspricht der, ab der die
// Detailansicht eine Fehlermeldung zeigt (ResidualNote: > 2 Franken).
expect(Math.abs(bridge.residual)).toBeLessThanOrEqual(2);
});
it("übernimmt den effektiven Cash-Bestand und hält die Cash-Brücke geschlossen", () => {
const actuals = resolveActuals([setAt(2022, {}, 55000)], plan, plan.elements);
const computed = computePlan(plan, undefined, { actuals });
const cb = computed.phases[0].cashBridge;
expect(cb.actualsCorrection).not.toBe(0);
expect(Math.abs(cb.residual)).toBeLessThanOrEqual(2);
// Der Cash-Bestand am Phasenende trägt den Sprung wirklich mit.
expect(computed.phases[0].cashBridge.cashEnd).toBe(55000);
});
});
describe("Cash-Bestand je Jahr", () => {
// Genau der gemeldete Fall: EINE lange Phase, in der sich das Cash-Konto kontinuierlich
// füllt. Die Vorbelegung im Wizard muss den Stand des GEWÄHLTEN Jahres zeigen -- nicht den
// Stand am Phasenende. Zuvor las der Dialog `cashBridge.cashEnd` und zeigte in der
// Phasenmitte den Endwert an.
function savingPlan(): PlanInput {
return {
id: "s",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 100,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }],
elements: [
{
id: "inc",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 100000 } },
transitionValues: {},
sourceElementId: null,
},
{
id: "exp",
category: "EXPENSE",
name: "Leben",
ownerRole: "HOUSEHOLD",
orderIndex: 2,
phaseValues: { p1: { amount: 90000 } },
transitionValues: {},
sourceElementId: null,
},
],
} as unknown as PlanInput;
}
it("weist den Cash-Stand für JEDES Jahr aus, nicht nur je Phase", () => {
const computed = computePlan(savingPlan());
// 10'000 Überschuss pro Jahr auf 100 Startguthaben.
expect(computed.yearly.find((y) => y.year === 1)!.cash).toBe(10100);
expect(computed.yearly.find((y) => y.year === 5)!.cash).toBe(50100);
expect(computed.yearly.find((y) => y.year === 10)!.cash).toBe(100100);
});
it("liegt in der Phasenmitte deutlich unter dem Phasen-Endwert", () => {
// Die eigentliche Regression: Mitte und Ende dürfen nicht dieselbe Zahl sein.
const computed = computePlan(savingPlan());
const mid = computed.yearly.find((y) => y.year === 5)!.cash;
const phaseEnd = computed.phases[0].cashBridge.cashEnd;
expect(phaseEnd).toBe(computed.yearly.find((y) => y.year === 10)!.cash);
expect(mid).toBeLessThan(phaseEnd);
});
});
describe("rebaseFlow", () => {
it("trifft im Ist-Jahr genau den erfassten Betrag", () => {
// Basis so, dass basis * (1+idx)^(t-1) === Ist-Wert.
const basis = rebaseFlow(120000, 2, 5);
expect(basis * Math.pow(1.02, 4)).toBeCloseTo(120000, 6);
});
it("berücksichtigt bei Ausgaben zusätzlich die Teuerung", () => {
const basis = rebaseFlow(50000, 1, 3, 1.1);
expect(basis * Math.pow(1.01, 2) * 1.1).toBeCloseTo(50000, 6);
});
it("weicht nicht auf NaN aus, wenn kein Wachstum vorliegt", () => {
expect(rebaseFlow(1000, 0, 1)).toBe(1000);
expect(rebaseFlow(1000, -100, 3)).toBe(1000);
});
});
describe("Effektive Flusswerte wirken in die Folgephase (Roadmap Nr. 44)", () => {
// Zwei Phasen a 5 Jahre, Lohn 100'000 ohne Teuerung. Im Planjahr 3 wird der Lohn
// effektiv mit 200'000 erfasst -- das muss auch in Phase 2 ankommen, sonst faellt der
// Wert am Phasenwechsel stillschweigend auf den geplanten zurueck.
function lohnPlan(): PlanInput {
return {
id: "s",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2020,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [
{ id: "p1", sequenceNumber: 1, name: "E1", durationYears: 5, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "E2", durationYears: 5, cashTransition: {} },
],
elements: [
{
id: "lohn",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} },
transitionValues: {},
sourceElementId: null,
},
],
} as unknown as PlanInput;
}
it("traegt den effektiven Wert ueber die Phasengrenze", () => {
const p = lohnPlan();
const actuals = resolveActuals([setAt(2022, { lohn: { value: 200000 } })], p, p.elements);
const computed = computePlan(p, undefined, { actuals });
expect(valueAt(computed, "lohn", 3)).toBe(200000);
// Phase 2 beginnt im Planjahr 6 -- ohne den Fix stuende hier wieder 100'000.
expect(valueAt(computed, "lohn", 6)).toBe(200000);
});
it("bleibt ohne Ist-Werte auf der Planlinie", () => {
const computed = computePlan(lohnPlan());
expect(valueAt(computed, "lohn", 6)).toBe(100000);
});
});
describe("Immobilie: Verkehrswert vs. Eigenkapital (Bugfix 0.31)", () => {
// Immobilie 900'000 mit 600'000 Hypothek -> Eigenkapital 300'000.
// Der Ist-Wizard belegt den Wert mit dem PLANWERT vor. Wuerde er dabei das Eigenkapital
// nehmen (statt des Verkehrswerts), setzte der Rechenkern 300'000 als Verkehrswert ein,
// waehrend die Hypothek bei 600'000 bliebe -- das Eigenkapital kippte auf -300'000.
function immoPlan(): PlanInput {
return {
id: "s", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0, startYear: 2020,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "E", durationYears: 10, cashTransition: {} }],
elements: [
{
id: "immo", category: "REAL_ESTATE", name: "Haus", ownerRole: "HOUSEHOLD", orderIndex: 1,
phaseValues: { p1: { purchasePrice: 900000, mortgage: 600000, amortization: 0, valueGrowth: 0 } },
transitionValues: {}, sourceElementId: null,
},
],
} as unknown as PlanInput;
}
it("fuehrt Verkehrswert und Eigenkapital getrennt in den Jahreswerten", () => {
const c = computePlan(immoPlan());
const y = c.phases[0].elements[0].yearly[0];
expect(y.value).toBe(300000); // Eigenkapital -- das zeigt die Matrix
expect(y.propertyValue).toBe(900000); // Verkehrswert -- den erfasst der Ist-Wizard
expect(y.mortgage).toBe(600000);
});
it("laesst das Eigenkapital unveraendert, wenn der Ist-Wert dem Plan entspricht", () => {
const p = immoPlan();
// So belegt der Wizard vor: Verkehrswert + Restschuld, beide auf Planniveau.
const actuals = resolveActuals([setAt(2023, { immo: { value: 900000, mortgage: 600000 } })], p, p.elements);
const computed = computePlan(p, undefined, { actuals });
expect(valueAt(computed, "immo", 4)).toBe(300000);
// Und der Sprung wird korrekt als "keine Abweichung" ausgewiesen.
expect(computed.phases[0].wealthBridge.actualsCorrection).toBe(0);
});
it("bildet eine echte Wertsteigerung sauber ab", () => {
const p = immoPlan();
const actuals = resolveActuals([setAt(2023, { immo: { value: 950000, mortgage: 580000 } })], p, p.elements);
const computed = computePlan(p, undefined, { actuals });
expect(valueAt(computed, "immo", 4)).toBe(370000); // 950'000 - 580'000
});
});
+151
View File
@@ -0,0 +1,151 @@
// Effektive (Ist-)Werte -- Roadmap Nr. 5, Plan-/Ist-Vergleich.
//
// Grundgedanke: Der Plan bleibt unangetastet. Parallel dazu läuft eine ZWEITE Berechnung mit
// derselben Mechanik, aber korrigierter Ausgangsbasis: In jedem Jahr, für das ein Ist-Satz
// erfasst wurde, schnappen die Werte auf die Realität und laufen von dort planmässig weiter.
//
// Beispiel: Fonds startet 2020 mit 100 bei 5 % Rendite. Ohne Ist-Daten steht 2022 rechnerisch
// 110 da. Wird für 2022 ein Ist-Wert von 120 erfasst, rechnet die Ist-Sicht ab 2022 mit 120
// weiter und steht 2024 bei ~132. Kommt für 2024 ein Ist-Wert von 140 dazu, springt sie dort
// erneut.
//
// Ein Ist-Satz gehört zum PLAN, nicht zum Szenario: Die Realität ist dieselbe, egal gegen
// welches Szenario man sie hält. Die Zuordnung auf die szenario-eigenen Element-IDs läuft
// über dieselbe Herkunfts-Kette (`sourceElementId`), die auch der Diff und die
// Monte-Carlo-Gruppierung benutzen.
import { resolveRootElementId } from "@/lib/montecarlo";
import type { ElementCategory } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
// Was für ein Ist-Wert je Element erfasst werden kann. Bestände und Flüsse verhalten sich
// verschieden: Ein Bestand ersetzt den laufenden Stand, ein Fluss die Basis für alle
// Folgejahre.
export type ActualKind = "STOCK" | "FLOW" | "PROPERTY" | "NONE";
export function actualKindOf(category: ElementCategory): ActualKind {
switch (category) {
case "PENSION_FUND":
case "PILLAR_3A":
case "OTHER_ASSET":
return "STOCK";
case "OTHER_DEBT":
return "STOCK"; // Restschuld
case "REAL_ESTATE":
return "PROPERTY"; // Verkehrswert UND Resthypothek
case "INCOME":
case "EXPENSE":
return "FLOW";
case "AHV":
// Die Rente folgt der amtlichen Formel aus der Beitragskarriere. Erfassbar ist sie nur,
// wenn sie zum Stichtag bereits LÄUFT -- vorher gibt es keinen Stand. Das entscheidet
// sich am Plan, nicht an der Kategorie (siehe `actualFieldsFor`).
return "FLOW";
}
}
// Ein erfasster Wert je Element.
export interface ActualElementValue {
// Bestand bzw. Verkehrswert bei Immobilien; bei Flüssen der NOMINALE Jahresbetrag
// (Einkommen: was aufs Konto kam; Ausgaben: was tatsächlich ausgegeben wurde).
value?: number;
// Nur Immobilie: tatsächliche Restschuld.
mortgage?: number;
}
// Ein Ist-Satz, wie er gespeichert wird. Schlüssel sind WURZEL-Element-IDs.
export interface ActualsSetInput {
id: string;
recordedOn: string; // exaktes Datum (ISO) -- nur für Liste und Zeitachse
year: number; // Kalenderjahr; nur dieses geht in die Rechnung ein
comment?: string | null;
cash?: number | null;
values: Record<string, ActualElementValue>;
}
// Auf ein konkretes Szenario aufgelöster Ist-Satz: Schlüssel sind die Element-IDs DIESES
// Szenarios, und das Kalenderjahr ist in ein Planjahr (1-basiert) umgerechnet.
export interface ResolvedActuals {
planYear: number; // 1 = erstes Planjahr
year: number; // Kalenderjahr (für Anzeige)
cash?: number;
byElementId: Record<string, ActualElementValue>;
}
// Kalenderjahr -> Planjahr. `startYear` ist das Kalenderjahr des ersten Planjahres.
export function toPlanYear(year: number, startYear: number | null | undefined): number | null {
if (!startYear) return null;
return year - startYear + 1;
}
// Herkunft eines Elements: die lose Referenz auf sein Gegenstück im Eltern-Szenario.
export interface ElementOrigin {
id: string;
sourceElementId?: string | null;
}
// Bildet die gespeicherten Ist-Sätze auf ein Szenario ab.
//
// `origins` enthält die Elemente ALLER Szenarien des Plans -- nur so löst sich die
// Herkunfts-Kette auch über ein übersprungenes Zwischen-Szenario hinweg auf.
export function resolveActuals(
sets: ActualsSetInput[],
scenario: PlanInput,
origins: ElementOrigin[]
): ResolvedActuals[] {
const sourceById = new Map<string, string | null>();
for (const o of origins) sourceById.set(o.id, o.sourceElementId ?? null);
// Sicherheitsnetz: Elemente des betrachteten Szenarios sind immer dabei.
for (const e of scenario.elements) if (!sourceById.has(e.id)) sourceById.set(e.id, e.sourceElementId ?? null);
const totalYears = scenario.phases.reduce((s, p) => s + p.durationYears, 0);
return sets
.flatMap<ResolvedActuals>((set) => {
const planYear = toPlanYear(set.year, scenario.startYear);
if (planYear === null) return [];
const byElementId: Record<string, ActualElementValue> = {};
for (const e of scenario.elements) {
const root = resolveRootElementId(e.id, sourceById);
const v = set.values[root];
// Lücken fallen bewusst auf die Plandaten zurück: Ein Element ohne Ist-Wert läuft
// unverändert auf seiner Planlinie weiter.
if (v && (typeof v.value === "number" || typeof v.mortgage === "number")) {
byElementId[e.id] = v;
}
}
const out: ResolvedActuals = { planYear, year: set.year, byElementId };
if (typeof set.cash === "number") out.cash = set.cash;
return [out];
})
// Ausserhalb des Plans liegende Sätze werden ignoriert -- sie hätten keinen Angriffspunkt.
.filter((r) => r.planYear >= 1 && r.planYear <= totalYears)
// Bei zwei Sätzen im selben Planjahr gewinnt der zuletzt erfasste.
.sort((a, b) => a.planYear - b.planYear);
}
// Nachschlagen im Rechenkern: Gibt es für dieses Planjahr einen Ist-Satz?
export function actualsForYear(list: ResolvedActuals[], planYear: number): ResolvedActuals | null {
// Rückwärts, damit bei mehreren Sätzen im selben Jahr der letzte gewinnt.
for (let i = list.length - 1; i >= 0; i--) if (list[i].planYear === planYear) return list[i];
return null;
}
// Das jüngste erfasste Planjahr -- Startpunkt der Monte-Carlo-Simulation im Ist-Modus und
// Grundlage der Hervorhebung auf der Zeitachse.
export function latestPlanYear(list: ResolvedActuals[]): number | null {
return list.length === 0 ? null : Math.max(...list.map((r) => r.planYear));
}
// Rechnet einen Ist-Fluss auf die Basis zurück, mit der der Rechenkern arbeitet.
//
// Einkommen laufen als `basis * (1 + idx/100)^(t-1)`. Ist für Jahr t ein Ist-Wert erfasst,
// muss die Basis so gesetzt werden, dass die Formel in genau diesem Jahr den Ist-Wert trifft
// -- danach wächst sie planmässig weiter. Das ist gleichbedeutend damit, den Bezugspunkt der
// Reihe auf das Ist-Jahr zu legen, kommt aber ohne Eingriff in die Struktur aus.
export function rebaseFlow(actualValue: number, idxPercent: number, tInPhase: number, deflator = 1): number {
const growth = Math.pow(1 + idxPercent / 100, tInPhase - 1);
const divisor = growth * (deflator || 1);
if (!Number.isFinite(divisor) || divisor === 0) return actualValue;
return actualValue / divisor;
}
+98
View File
@@ -0,0 +1,98 @@
// Gespeicherte Analysen -- Typen und Client-Helfer (SPEZIFIKATION 3.10).
//
// Festgehalten werden EINGABEN und ERGEBNIS als Zahlen, read-only. Beim Öffnen wird nichts
// neu gerechnet. Das hält den Datensatz klein und macht ihn druckfähig (PDF-Bericht,
// Roadmap Nr. 11): Aus Zahlen lässt sich vektoriell in jeder Auflösung zeichnen, aus einem
// Screenshot nicht.
import { api } from "@/lib/api-client";
export type AnalysisType = "CHART" | "MONTE_CARLO" | "SENSITIVITY";
export const ANALYSIS_TYPE_LABEL: Record<AnalysisType, string> = {
CHART: "Grafik",
MONTE_CARLO: "Monte-Carlo",
SENSITIVITY: "Einflussfaktoren",
};
// Kopfdaten in der Liste (ohne die grossen JSON-Felder).
export interface SavedAnalysisMeta {
id: string;
name: string;
type: AnalysisType;
scenarioName: string | null;
versionLabel: string | null;
metric: string;
source: string;
summary: string | null;
createdAt: string;
author: string;
}
// Vollständiger Datensatz beim Öffnen.
export interface SavedAnalysisFull extends Omit<SavedAnalysisMeta, "author"> {
inputs: unknown;
result: unknown;
}
export interface SaveAnalysisPayload {
name: string;
type: AnalysisType;
scenarioName?: string | null;
versionLabel?: string | null;
metric?: string;
source?: string;
summary?: string | null;
inputs: Record<string, unknown>;
result: Record<string, unknown>;
}
export async function saveAnalysis(planId: string, payload: SaveAnalysisPayload): Promise<void> {
await api.post(`/api/plans/${planId}/analyses`, payload);
}
// Automatischer Name: Typ · Szenario Version · Datum. Überschreibbar beim Speichern.
export function defaultAnalysisName(
type: AnalysisType,
scenarioName: string | null,
versionLabel: string | null
): string {
const parts = [ANALYSIS_TYPE_LABEL[type]];
if (scenarioName) parts.push(scenarioName + (versionLabel ? ` ${versionLabel}` : ""));
parts.push(new Date().toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" }));
return parts.join(" · ");
}
export function sourceLabel(source: string): string {
return source === "ACTUAL" ? "Effektiv" : "Plan";
}
// --- Generische, druckfähige Momentaufnahme -------------------------------------------
//
// Jedes Werkzeug schreibt sein Ergebnis in DIESE einheitliche Form. Dadurch braucht die
// Nur-Lese-Ansicht nur einen Renderer, und der spätere PDF-Bericht (Roadmap Nr. 11) findet
// überall dieselbe Struktur vor -- Zahlen, keine Pixel.
export interface SavedKeyValue {
label: string;
value: string;
tone?: "success" | "danger" | "muted";
}
export interface SavedChartSeries {
label: string;
color?: string;
dashed?: boolean;
points: { x: number; y: number }[];
}
export interface SavedResult {
// Eingabeparameter, mit denen gerechnet wurde.
params: SavedKeyValue[];
// Kernzahlen des Ergebnisses (gross dargestellt).
metrics?: SavedKeyValue[];
// Optionale Ergebnistabelle.
table?: { columns: string[]; rows: (string | number)[][] };
// Optionale Grafik.
chart?: { kind: "line" | "bar"; xLabel?: string; yFormat?: "chf" | "pct"; series: SavedChartSeries[] };
}
+1 -1
View File
@@ -19,7 +19,7 @@ export async function createSessionToken(userId: string): Promise<string> {
.sign(getSecretKey()); .sign(getSecretKey());
} }
// Liefert die User-ID aus einem gueltigen Session-Token, sonst null. // Liefert die User-ID aus einem gültigen Session-Token, sonst null.
export async function verifySessionToken(token: string): Promise<string | null> { export async function verifySessionToken(token: string): Promise<string | null> {
try { try {
const { payload } = await jwtVerify(token, getSecretKey()); const { payload } = await jwtVerify(token, getSecretKey());
+71
View File
@@ -0,0 +1,71 @@
// «Ein Wert, ein Ort» -- durchgesetzt im Rechenkern, nicht nur in der Oberfläche
// (SPEZIFIKATION 3.14.3).
//
// Der Bestand bei Planbeginn gehört in die Stammdaten. In der ersten Phase stand dafür bis
// 0.39 zusätzlich ein Eingabefeld; eine dort gespeicherte Zahl überschattete die Stammdaten
// still. Seit 0.40 ist das Feld read-only -- womit so ein Altwert unerreichbar WÄRE, wenn er
// weiterhin gewönne. Deshalb entscheidet die Regel der Rechenkern.
import { describe, it, expect } from "vitest";
import { computePlan, firstPhaseValues } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
function plan(elements: PlanInput["elements"]): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "P1", durationYears: 10, cashTransition: { mode: "NONE" } }],
elements,
};
}
function haus(phaseValues: Record<string, Record<string, unknown>>) {
return [
{
id: "e1",
category: "REAL_ESTATE" as never,
name: "Haus",
ownerRole: "HOUSEHOLD" as never,
orderIndex: 1,
baseData: { purchasePrice: 800000, mortgage: 500000 } as Record<string, number>,
phaseValues: phaseValues as never,
transitionValues: {},
},
];
}
describe("Bestandsfelder in der ersten Phase", () => {
it("nimmt den Bestand aus den Stammdaten", () => {
const c = computePlan(plan(haus({})));
expect(c.phases[0].elements[0].startValue).toBe(300000);
});
it("lässt sich von einem Phasenwert NICHT überschatten", () => {
// Genau der Fall aus der Testrunde: In 0.39 schrieb das Speichern der Zelle eine 0 --
// seither stand das Haus mit Startwert 0 da, ohne dass man es noch korrigieren konnte.
const c = computePlan(plan(haus({ p1: { confirmed: true, purchasePrice: 0, mortgage: 0 } })));
expect(c.phases[0].elements[0].startValue).toBe(300000);
});
it("fällt auf den Phasenwert zurück, solange die Stammdaten leer sind", () => {
// Ohne diesen Rückfall wären Pläne aus der Zeit vor den Stammdaten schlagartig auf 0.
const els = haus({ p1: { purchasePrice: 600000, mortgage: 400000 } });
els[0].baseData = {};
expect(computePlan(plan(els)).phases[0].elements[0].startValue).toBe(200000);
});
it("lässt die ANNAHMEN in der ersten Phase überschreibbar", () => {
// Eine Rendite gilt für einen Zeitraum, kein Anfangsbestand -- sie darf je Phase abweichen.
const merged = firstPhaseValues(
{ startValue: 100000, expectedReturn: 3 },
{ expectedReturn: 7, startValue: 0 }
);
expect(merged.expectedReturn).toBe(7);
expect(merged.startValue).toBe(100000);
});
});
+528
View File
@@ -0,0 +1,528 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types";
// Die zentrale Invariante beider Wasserfall-Brücken: `residual` ist die Differenz zwischen
// dem tatsächlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung
// vollständig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, wäre ein
// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb über möglichst
// verschiedene Konstellationen festgenagelt statt an einem einzigen Beispiel.
let idc = 0;
const nid = () => `b${idc++}`;
function el(
category: ElementCategory,
ownerRole: string | null,
phaseValues: Record<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {},
// Pensionierungs-Entscheid: haengt seit 0.34 am Element, nicht an einer Phasengrenze.
retirementDecision: RetirementDecision = {}
) {
return {
id: nid(),
category,
name: category,
ownerRole: ownerRole as never,
orderIndex: idc,
phaseValues,
transitionValues,
retirementDecision,
};
}
function plan(opts: {
age: number;
retirementAge: number;
inflation?: number;
initialCash?: number;
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
elements: ReturnType<typeof el>[];
}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: opts.inflation ?? 1.5,
initialCash: opts.initialCash ?? 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
phases: opts.phases.map((p, i) => ({
id: p.id,
sequenceNumber: i + 1,
name: p.id,
durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {},
})),
elements: opts.elements,
};
}
const konstellationen: { name: string; build: () => PlanInput }[] = [
{
name: "Ansparen mit 3a, Sparbeitrag und Schuldentilgung",
build: () =>
plan({
age: 40,
retirementAge: 70,
initialCash: 50000,
phases: [{ id: "p1", durationYears: 10 }],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 120000, teuerungsausgleich: 1 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 } }),
el("PILLAR_3A", "PERSON_A", { p1: { currentValue: 50000, annualContribution: 7000, expectedReturn: 3 } }),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 5, annualContribution: 10000 } }),
el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 60000, annualRepayment: 8000 } }),
],
}),
},
{
name: "Pensionierung mit PK-Verrentung und 3a-Bezug",
build: () =>
plan({
age: 60,
retirementAge: 65,
initialCash: 20000,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 20 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 130000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 75000 } }),
el("AHV", "PERSON_A", { p1: { gapYears: 0 } }, { p1: { reviewed: true, avgIncomeBefore: 95000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 } },
{},
{ capitalSharePct: 0, conversionRate: 6 }
),
el(
"PILLAR_3A",
"PERSON_A",
{ p1: { currentValue: 120000, annualContribution: 7000, expectedReturn: 3 } },
{},
{ capitalTaxRate: 8 }
),
el("OTHER_ASSET", "HOUSEHOLD", {
p1: { startValue: 300000, expectedReturn: 4 },
p2: { expectedReturn: 4, annualWithdrawal: 30000 },
}),
],
}),
},
{
name: "PK-Kapitalbezug statt Rente (Steuer am Übergang)",
build: () =>
plan({
age: 62,
retirementAge: 65,
phases: [
{ id: "p1", durationYears: 3 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 110000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 70000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 500000, annualContribution: 18000, expectedReturn: 2 } },
{},
{ capitalSharePct: 100, capitalTaxRate: 8 }
),
],
}),
},
{
name: "Immobilie gehalten (Wertsteigerung und Amortisation)",
build: () =>
plan({
age: 45,
retirementAge: 70,
phases: [
{ id: "p1", durationYears: 10 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 140000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
el("REAL_ESTATE", "HOUSEHOLD", {
p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 },
p2: { amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 },
}),
],
}),
},
{
name: "Immobilie verkauft (Verkaufsdifferenz und Grundstückgewinnsteuer)",
build: () =>
plan({
age: 45,
retirementAge: 70,
phases: [
{ id: "p1", durationYears: 10 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 140000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
el(
"REAL_ESTATE",
"HOUSEHOLD",
{ p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 } },
{ p1: { decision: "SELL", salePrice: 1400000, saleTaxRate: 20 } }
),
],
}),
},
{
name: "Einmalige Sonderein-/ausgaben am Übergang",
build: () =>
plan({
age: 50,
retirementAge: 70,
initialCash: 30000,
phases: [
{
id: "p1",
durationYears: 5,
cashTransition: { mode: "BOTH", inflowAmount: 250000, inflowTaxRate: 10, outflowAmount: 40000 },
},
{ id: "p2", durationYears: 5 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 85000 }, p2: { amount: 85000 } }),
el("OTHER_ASSET", "HOUSEHOLD", {
p1: { startValue: 100000, expectedReturn: 4 },
p2: { expectedReturn: 4, additionalInvestment: 50000 },
}),
],
}),
},
{
name: "Sofort-Tilgung und Sonderamortisation am Übergang",
build: () =>
plan({
age: 50,
retirementAge: 70,
initialCash: 200000,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 5 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 120000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 80000 } }),
el(
"OTHER_DEBT",
"HOUSEHOLD",
{ p1: { startValue: 80000, annualRepayment: 5000 }, p2: { annualRepayment: 5000 } },
{ p1: { immediateRepayment: 30000 } }
),
el(
"REAL_ESTATE",
"HOUSEHOLD",
{
p1: { purchasePrice: 800000, mortgage: 500000, amortization: 10000, valueGrowth: 1 },
p2: { amortization: 10000, valueGrowth: 1 },
},
{ p1: { decision: "HOLD", extraAmortization: 50000 } }
),
],
}),
},
{
// Roadmap Nr. 44, Punkt C: Das PK-Kapital wird nicht als Cash liegen gelassen, sondern
// per Quote auf Hypothek und Anlagen verteilt. Beide Brücken müssen weiterhin aufgehen.
name: "Kapitalverwendung am Pensions-Übergang (Quoten)",
build: () =>
plan({
age: 60,
retirementAge: 65,
initialCash: 50000,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 120000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 80000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} },
{},
{ capitalSharePct: 100, capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 }
),
el(
"REAL_ESTATE",
"HOUSEHOLD",
{ p1: { purchasePrice: 900000, mortgage: 600000, amortization: 5000, valueGrowth: 1 }, p2: {} },
{ p1: { decision: "HOLD" } }
),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 4 }, p2: {} }),
],
}),
},
];
describe("Wasserfall-Brücken", () => {
for (const k of konstellationen) {
it(`${k.name}: beide Brücken gehen ohne Restgrösse auf`, () => {
const r = computePlan(k.build());
expect(r.phases.length).toBeGreaterThan(0);
for (const ph of r.phases) {
expect(ph.wealthBridge.residual, `Vermögensbrücke ${ph.name}`).toBe(0);
expect(ph.cashBridge.residual, `Cash-Brücke ${ph.name}`).toBe(0);
// Die Kontrollpunkte müssen den offiziellen Kennzahlen entsprechen.
expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal);
expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal);
expect(ph.cashBridge.cashStart).toBe(ph.cashStart);
expect(ph.cashBridge.cashEnd).toBe(ph.cashEnd);
}
});
}
it("Umbuchungen erscheinen NICHT in der Vermögensbrücke", () => {
// Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermögenswert, ohne
// das Vermögen zu ändern. Sie dürfen deshalb nur in der Cash-Brücke auftauchen; die
// Vermögensänderung erklärt sich allein aus Quote, Erträgen und PK-Beiträgen.
const r = computePlan(konstellationen[0].build());
const ph = r.phases[0];
expect(ph.cashBridge.savingRates).toBeGreaterThan(0);
expect(ph.cashBridge.debtRates).toBeGreaterThan(0);
const w = ph.wealthBridge;
expect(w.endWealth - w.startWealth).toBe(
w.quotaTotal + w.investmentReturn + w.propertyAppreciation + w.pensionFundContribution
);
});
it("PK-Verrentung erscheint als Vermögensabgang am Übergang", () => {
// Das verrentete Kapital verlässt die Bilanz und wird zum Rentenstrom -- in der
// Vermögensbrücke ein echter Abgang, kein Umbuchungsposten.
const r = computePlan(konstellationen[1].build());
const pension = r.phases[1];
expect(pension.wealthBridge.pensionConversion).toBeGreaterThan(0);
expect(pension.wealthBridge.startWealth).toBeLessThan(pension.wealthBridge.openingWealth);
});
it("Verkaufsdifferenz und Steuer erscheinen nur beim Verkauf", () => {
const gehalten = computePlan(konstellationen[3].build()).phases[1].wealthBridge;
const verkauft = computePlan(konstellationen[4].build()).phases[1].wealthBridge;
expect(gehalten.saleGainLoss).toBe(0);
expect(gehalten.transitionTax).toBe(0);
expect(verkauft.transitionTax).toBeGreaterThan(0);
// Verkaufspreis über dem modellierten Verkehrswert -> positive Differenz.
expect(verkauft.saleGainLoss).not.toBe(0);
});
});
describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", () => {
const withQuoten = konstellationen.find((k) => k.name.startsWith("Kapitalverwendung"))!;
it("leitet den Zufluss gemaess Quote in Hypothek und Anlage statt ins Cash", () => {
const mit = computePlan(withQuoten.build());
// Vergleichsplan OHNE Verwendungs-Quoten: Die Quoten haengen seit 0.33 am PK-Element,
// nicht mehr am Cash-Uebergang.
const raw = withQuoten.build();
const ohne = computePlan({
...raw,
elements: raw.elements.map((e) =>
e.category === "PENSION_FUND"
? { ...e, retirementDecision: { capitalSharePct: 100, capitalTaxRate: 5 } }
: e
),
} as PlanInput);
const p2Mit = mit.phases[1];
const p2Ohne = ohne.phases[1];
// 90 % des Kapitals werden verwendet -> deutlich weniger Cash zu Beginn von Phase 2.
expect(p2Mit.cashStart).toBeLessThan(p2Ohne.cashStart);
// Die Hypothek ist tiefer, das Vermoegen dadurch hoeher.
const immoMit = p2Mit.elements.find((e) => e.category === "REAL_ESTATE")!;
const immoOhne = p2Ohne.elements.find((e) => e.category === "REAL_ESTATE")!;
expect(immoMit.mortgageStart).toBeLessThan(immoOhne.mortgageStart);
// Das Sonstige Vermoegen startet hoeher.
const assetMit = p2Mit.elements.find((e) => e.category === "OTHER_ASSET")!;
const assetOhne = p2Ohne.elements.find((e) => e.category === "OTHER_ASSET")!;
expect(assetMit.startValue).toBeGreaterThan(assetOhne.startValue);
});
it("skaliert mit dem Pensionsalter, statt einen Frankenbetrag stehen zu lassen", () => {
// Genau der Grund fuer Quoten statt Betraege: Ein Jahr laenger arbeiten heisst mehr
// Kapital -- und damit automatisch mehr Amortisation und mehr Anlage.
const base = withQuoten.build();
const laenger: PlanInput = {
...base,
persons: base.persons.map((p) => ({ ...p, retirementAge: 66 })),
phases: base.phases.map((p, i) =>
i === 0 ? { ...p, durationYears: 6 } : { ...p, durationYears: 9 }
),
};
const a = computePlan(base).phases[1].elements.find((e) => e.category === "REAL_ESTATE")!;
const b = computePlan(laenger).phases[1].elements.find((e) => e.category === "REAL_ESTATE")!;
expect(b.mortgageStart).toBeLessThan(a.mortgageStart);
});
});
describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => {
// Genau der gemeldete Fehler: 100 % des PK-Kapitals auf den ETF -- danach zeigte das
// Element als Zusatzinvestition eine 0, obwohl der Betrag eingeflossen war.
function p(): PlanInput {
return plan({
age: 62,
retirementAge: 65,
initialCash: 0,
phases: [
{ id: "p1", durationYears: 3 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} },
{},
{ capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 }
),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0 }, p2: {} }),
],
});
}
it("weist den umgeleiteten Betrag am Ziel-Element aus", () => {
const c = computePlan(p());
const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
// 400'000 brutto, 0 % Steuer -> 400'000 netto, davon 100 % in den ETF.
expect(etf.capitalFromTransfer).toBe(400000);
expect(etf.startValue).toBe(450000); // 50'000 fortgeschrieben + 400'000
});
it("zaehlt ihn als Investition in der Cash-Bruecke (nicht als Tilgung)", () => {
const c = computePlan(p());
expect(c.phases[1].cashBridge.investments).toBe(400000);
expect(c.phases[1].cashBridge.residual).toBe(0);
});
it("fliesst NUR einmal -- nicht in jeder Folgephase erneut", () => {
const base = p();
const drei: PlanInput = {
...base,
phases: [...base.phases, { id: "p3", sequenceNumber: 3, name: "p3", durationYears: 5, cashTransition: {} }],
} as PlanInput;
const c = computePlan(drei);
const etfP3 = c.phases[2].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(etfP3.capitalFromTransfer ?? 0).toBe(0);
});
it("verteilt zwei Guthaben getrennt (PK und 3a je eigene Quote)", () => {
const base = p();
const mit3a: PlanInput = {
...base,
elements: [
...base.elements,
el(
"PILLAR_3A",
"PERSON_A",
{ p1: { currentValue: 100000, expectedReturn: 0 }, p2: {} },
{},
{ capitalTaxRate: 0, capitalUseInvestPct: 50 }
),
],
} as PlanInput;
const c = computePlan(mit3a);
const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
// PK: 100 % von 400'000 = 400'000; 3a: 50 % von 100'000 = 50'000.
expect(etf.capitalFromTransfer).toBe(450000);
});
});
describe("Kapitalverwendung: Herkunft und ruhende Guthaben (0.34)", () => {
// Der gemeldete Fall: PK und 3a fliessen beide in denselben ETF. Ohne Aufschluesselung
// sieht man dort nur eine Summe und kann nicht pruefen, ob wirklich beide angekommen sind.
function zweiQuellen(): PlanInput {
return plan({
age: 62,
retirementAge: 65,
initialCash: 0,
phases: [
{ id: "p1", durationYears: 3 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 300000, expectedReturn: 0 }, p2: {} },
{},
{ capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 }
),
el(
"PILLAR_3A",
"PERSON_A",
{ p1: { currentValue: 200000, expectedReturn: 0 }, p2: {} },
{},
{ capitalTaxRate: 0, capitalUseInvestPct: 100 }
),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: {} }),
],
});
}
it("weist beide Quellen einzeln aus", () => {
const c = computePlan(zweiQuellen());
const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(etf.capitalFromTransfer).toBe(500000);
const src = etf.capitalFromTransferSources ?? [];
expect(src).toHaveLength(2);
expect(src.reduce((a, x) => a + x.amount, 0)).toBe(500000);
// Der Name traegt die Person -- sonst sind zwei gleichnamige Guthaben nicht trennbar.
expect(src.every((x) => x.name.includes("Person A"))).toBe(true);
expect(src.map((x) => x.amount).sort((a, b) => a - b)).toEqual([200000, 300000]);
});
it("nennt die Person beim Namen, wenn eine erfasst ist", () => {
const base = zweiQuellen();
const benannt: PlanInput = {
...base,
persons: base.persons.map((x) => ({ ...x, name: "Jeanine" })),
};
const etf = computePlan(benannt).phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
expect((etf.capitalFromTransferSources ?? []).every((x) => x.name.includes("Jeanine"))).toBe(true);
});
it("markiert bezogene PK und 3a als nicht mehr aufnahmefaehig", () => {
// Grundlage der Verteil-Dialoge: Eine bezogene 3a darf dort keine Einzahlungszeile mehr
// anbieten -- die Rechnung ignoriert solche Betraege, der Nutzer haelt sie fuer wirksam.
const c = computePlan(zweiQuellen());
const p2 = c.phases[1];
expect(p2.elements.find((e) => e.category === "PILLAR_3A")!.acceptsCapital).toBe(false);
expect(p2.elements.find((e) => e.category === "PENSION_FUND")!.acceptsCapital).toBe(false);
// Vor der Pensionierung dagegen unbeschraenkt.
const p1 = c.phases[0];
expect(p1.elements.find((e) => e.category === "PILLAR_3A")!.acceptsCapital).toBeUndefined();
});
it("ignoriert eine Einzahlung in die bezogene 3a auch rechnerisch", () => {
// Die Zusicherung hinter dem Dialog-Filter: Selbst wenn der Wert in den Daten steht,
// aendert er nichts -- Filter und Rechnung sagen dasselbe.
const base = zweiQuellen();
const mitRate: PlanInput = {
...base,
elements: base.elements.map((e) =>
e.category === "PILLAR_3A"
? { ...e, phaseValues: { ...e.phaseValues, p2: { annualContribution: 7000, additionalInvestment: 50000 } } }
: e
),
};
expect(computePlan(mitRate).phases[1].endWealthNominal).toBe(computePlan(base).phases[1].endWealthNominal);
});
});
+709 -12
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations"; import { ahvMonthlyFullPension, computePlan } from "@/lib/calculations";
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
// --- kleine Bau-Helfer --- // --- kleine Bau-Helfer ---
@@ -11,9 +12,20 @@ function el(
category: ElementCategory, category: ElementCategory,
ownerRole: string | null, ownerRole: string | null,
phaseValues: Record<string, PhaseData>, phaseValues: Record<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {} transitionValues: Record<string, TransitionData> = {},
// Pensionierungs-Entscheid: hängt seit 0.34 am Element, nicht an einer Phasengrenze.
retirementDecision: RetirementDecision = {}
) { ) {
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues }; return {
id: nid(),
category,
name: category,
ownerRole: ownerRole as never,
orderIndex: idc,
phaseValues,
transitionValues,
retirementDecision,
};
} }
function plan(opts: { function plan(opts: {
@@ -21,7 +33,7 @@ function plan(opts: {
retirementAge: number; retirementAge: number;
inflation?: number; inflation?: number;
initialCash?: number; initialCash?: number;
phases: { id: string; durationYears: number }[]; phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
elements: ReturnType<typeof el>[]; elements: ReturnType<typeof el>[];
household?: "SINGLE" | "COUPLE"; household?: "SINGLE" | "COUPLE";
}): PlanInput { }): PlanInput {
@@ -32,15 +44,360 @@ function plan(opts: {
inflationRateDefault: opts.inflation ?? 2, inflationRateDefault: opts.inflation ?? 2,
initialCash: opts.initialCash ?? 0, initialCash: opts.initialCash ?? 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }], persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears, inflationRate: null })), phases: opts.phases.map((p, i) => ({
id: p.id,
sequenceNumber: i + 1,
name: p.id,
durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {},
})),
elements: opts.elements, elements: opts.elements,
}; };
} }
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct; const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis + // Stützstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44"
// (BSV, gültig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente,
// Formel 1, den Wendepunkt (45'360), Formel 2 und die Maximalrente ab.
const AHV_AMTLICHE_TABELLE: [number, number][] = [
[15120, 1260], // Mindestrente (12 x R0)
[16632, 1293],
[22680, 1424],
[30240, 1588],
[43848, 1882],
[45360, 1915], // Wendepunkt (36 x R0)
[46872, 1935],
[60480, 2117],
[75600, 2318],
[89208, 2500],
[90720, 2520], // Maximalrente (72 x R0)
];
describe("AHV-Rentenformel (Skala 44)", () => {
it("reproduziert die amtliche Tabelle 318.117.1 exakt", () => {
for (const [mdJE, erwartet] of AHV_AMTLICHE_TABELLE) {
expect(Math.round(ahvMonthlyFullPension(mdJE)), `mdJE ${mdJE}`).toBe(erwartet);
}
});
it("kappt ausserhalb der Schwellen", () => {
expect(ahvMonthlyFullPension(0)).toBe(1260); // unter 12 x R0 -> Mindestrente
expect(ahvMonthlyFullPension(10000)).toBe(1260);
expect(ahvMonthlyFullPension(150000)).toBe(2520); // über 72 x R0 -> Maximalrente
});
it("ist am Wendepunkt stetig (kein Sprung zwischen Formel 1 und 2)", () => {
const links = ahvMonthlyFullPension(45360 - 0.01);
const rechts = ahvMonthlyFullPension(45360 + 0.01);
expect(Math.abs(links - rechts)).toBeLessThan(0.01);
});
});
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhöhung; Ausgaben = REALE Basis +
// reale Mehrausgaben, nominal = real x (plan-weite Inflation). // reale Mehrausgaben, nominal = real x (plan-weite Inflation).
// Netto -> Brutto für die AHV (AHV_GROSS_FROM_NET_FACTOR).
const BRUTTO = 1.12;
describe("Teilverkauf Sonstiges Vermögen (Roadmap 42)", () => {
function p42(decision: "HOLD" | "SELL" | "PARTIAL", amount?: number) {
return plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
{ id: "p1", durationYears: 1 },
{ id: "p2", durationYears: 1 },
],
elements: [
el(
"OTHER_ASSET", "HOUSEHOLD",
{ p1: { startValue: 100000, expectedReturn: 0, annualContribution: 0 }, p2: {} },
{ p1: { decision, partialSaleAmount: amount } }
),
],
});
}
const asset = (pl: PlanInput, i: number) =>
computePlan(pl).phases[i].elements.find((e) => e.category === "OTHER_ASSET")!;
it("Teilverkauf: Betrag fliesst ins Cash, Rest bleibt investiert", () => {
const r = computePlan(p42("PARTIAL", 30000));
expect(r.phases[1].capitalInflow).toBe(30000); // "zu investierendes Kapital" im Phasenkopf
expect(r.phases[1].cashStart).toBe(30000);
expect(asset(p42("PARTIAL", 30000), 1).startValue).toBe(70000); // Rest läuft weiter
});
it("Teilverkauf am Endwert gekappt; Vollverkauf und Halten unverändert", () => {
expect(computePlan(p42("PARTIAL", 999999)).phases[1].capitalInflow).toBe(100000);
expect(computePlan(p42("SELL")).phases[1].capitalInflow).toBe(100000);
expect(computePlan(p42("HOLD")).phases[1].capitalInflow).toBe(0);
expect(asset(p42("HOLD"), 1).startValue).toBe(100000);
});
});
describe("Sonderamortisation Hypothek (Roadmap 15)", () => {
function p15(extra?: number) {
return plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 500000,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 5 },
],
elements: [
el(
"REAL_ESTATE", "HOUSEHOLD",
{ p1: { purchasePrice: 1000000, mortgage: 800000, amortization: 0 }, p2: {} },
{ p1: { decision: "HOLD", extraAmortization: extra } }
),
],
});
}
const immo = (pl: PlanInput, i: number) =>
computePlan(pl).phases[i].elements.find((e) => e.category === "REAL_ESTATE")!;
it("Einmaltilgung senkt Restschuld und belastet das Cash", () => {
const r = computePlan(p15(300000));
expect(r.phases[1].capitalInvest).toBe(300000); // erscheint als Kapitalinvestition
expect(r.phases[1].cashStart).toBe(200000); // 500'000 - 300'000
// Netto-Immobilienwert steigt um die Tilgung: 1 Mio - (800k - 300k) = 500'000.
expect(immo(p15(300000), 1).startValue).toBe(500000);
});
it("Einmaltilgung an der Restschuld gekappt", () => {
const r = computePlan(p15(999999));
expect(r.phases[1].capitalInvest).toBe(800000); // nie mehr als die Resthypothek
expect(immo(p15(999999), 1).startValue).toBe(1000000); // schuldenfrei
});
it("ohne Sonderamortisation bleibt alles beim Alten", () => {
expect(computePlan(p15()).phases[1].cashStart).toBe(500000);
expect(immo(p15(), 1).startValue).toBe(200000);
});
});
describe("Immobilie: Zins und Wertsteigerung", () => {
function immoPlan(pd: PhaseData, jahre = 10) {
return plan({
age: 40,
retirementAge: 70,
inflation: 0,
initialCash: 500000,
phases: [{ id: "p1", durationYears: jahre }],
elements: [el("REAL_ESTATE", "HOUSEHOLD", { p1: pd })],
});
}
const immo = (p: PlanInput, i = 0) =>
computePlan(p).phases[i].elements.find((e) => e.category === "REAL_ESTATE")!;
it("Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital (Hebel)", () => {
// Kaufpreis 1 Mio, Hypothek 900k -> EK 100k. 1%/J. auf die Liegenschaft, Amort. 10k/J.
const p = immoPlan({ purchasePrice: 1000000, mortgage: 900000, amortization: 10000, valueGrowth: 1 });
const wertEnde = 1000000 * Math.pow(1.01, 10);
const hypEnde = 900000 - 10000 * 10;
expect(immo(p).startValue).toBe(100000);
expect(immo(p).endValue).toBe(Math.round(wertEnde - hypEnde)); // ~304'622
// Der Hebel: 1% auf 1 Mio sind rund 10% des Eigenkapitals -- nicht 1%.
expect(immo(p).endValue).toBeGreaterThan(Math.round(100000 * Math.pow(1.01, 10) + 100000));
});
it("ohne Wertsteigerung bleibt es beim bisherigen Verhalten", () => {
const p = immoPlan({ purchasePrice: 1000000, mortgage: 900000, amortization: 10000 });
expect(immo(p).endValue).toBe(200000); // 1 Mio - 800k Resthypothek
});
it("Hypothekarzins belastet das Cash nur bei 'ADD'", () => {
const ohne = immoPlan({ purchasePrice: 1000000, mortgage: 900000, amortization: 0, interestRate: 1 });
expect(computePlan(ohne).phases[0].cashEnd).toBe(500000); // Default INCLUDED -> kein Abzug
const mit = immoPlan({
purchasePrice: 1000000, mortgage: 900000, amortization: 0, interestRate: 1, interestHandling: "ADD",
});
// 10 Jahre x 1% von 900'000 = 90'000
expect(computePlan(mit).phases[0].cashEnd).toBe(500000 - 90000);
});
it("Zinsbetrag sinkt mit der Amortisation und zählt in die Quote", () => {
const p = immoPlan({
purchasePrice: 1000000, mortgage: 1000000, amortization: 10000, interestRate: 1, interestHandling: "ADD",
});
const ph = computePlan(p).phases[0];
// Jahr 1: 1% von 1'000'000 = 10'000. Jahr 10: 1% von (1'000'000 - 9 x 10'000) = 9'100.
expect(ph.expenseStart).toBe(10000);
expect(ph.expenseEnd).toBe(9100);
expect(ph.quotaStart).toBe(-10000); // Zins schlägt auf die Quote durch
});
it("Verkauf: Grundstückgewinnsteuer bemisst sich am ursprünglichen Kaufpreis", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
{ id: "p1", durationYears: 10 },
{ id: "p2", durationYears: 1 },
],
elements: [
el(
"REAL_ESTATE", "HOUSEHOLD",
{ p1: { purchasePrice: 1000000, mortgage: 900000, amortization: 10000, valueGrowth: 1 }, p2: {} },
{ p1: { decision: "SELL", salePrice: 1200000, saleTaxRate: 20 } }
),
],
});
const r = computePlan(p);
// Gewinn = 1'200'000 - 1'000'000 (Kaufpreis!) = 200'000 -> Steuer 40'000.
// Erlös = 1'200'000 - 800'000 Resthypothek - 40'000 = 360'000.
expect(r.phases[1].capitalInflow).toBe(360000);
});
});
describe("AHV einkommensabhängig", () => {
// 60-jährig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
function ahvPlan(opts: {
age?: number;
income: number;
avgIncomeBefore?: number;
gapYearsBefore?: number;
gapYearsInPlan?: number;
reviewed?: boolean;
}) {
const age = opts.age ?? 60;
return plan({
age,
retirementAge: 65,
inflation: 0, // real = nominal, damit die Erwartungswerte von Hand prüfbar bleiben
phases: [
{ id: "p1", durationYears: 65 - age },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: opts.income, teuerungsausgleich: 0 }, p2: {} }),
el(
"AHV",
"PERSON_A",
{ p1: { gapYears: opts.gapYearsInPlan ?? 0 }, p2: {} },
{},
{
confirmed: opts.reviewed ?? true,
avgIncomeBefore: opts.avgIncomeBefore ?? 0,
gapYearsBefore: opts.gapYearsBefore ?? 0,
}
),
],
});
}
const renteIn = (p: PlanInput, phaseIdx: number) =>
computePlan(p).phases[phaseIdx].elements.find((e) => e.category === "AHV")!.startValue;
it("volle Karriere auf Maximalniveau -> Maximalrente 32'760", () => {
const p = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
expect(renteIn(p, 1)).toBe(32760); // 2'520 x 13
});
it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => {
// Plan-Einkommen ist NETTO -> für die AHV auf brutto hochgerechnet; das Feld
// "vor Planbeginn" ist bereits brutto. 39 Jahre davor, 5 im Plan.
const p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 });
const mdJE = (60000 * 39 + 60000 * BRUTTO * 5) / 44;
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
expect(renteIn(p, 1)).toBeLessThan(32760);
});
it("Nettolohn wird für die AHV auf den Bruttolohn hochgerechnet", () => {
// Ohne Vorgeschichte (Alter 21 bei Planbeginn) hängt das mdJE nur am Plan-Einkommen.
const p = plan({
age: 21,
retirementAge: 65,
inflation: 0,
phases: [
{ id: "p1", durationYears: 44 },
{ id: "p2", durationYears: 5 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 70000, teuerungsausgleich: 0 }, p2: {} }),
el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true } }),
],
});
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
// mdJE = 70'000 x 1.12 = 78'400 (brutto), NICHT 70'000.
expect(rente).toBe(Math.round(ahvMonthlyFullPension(70000 * BRUTTO) * 13));
expect(rente).toBeGreaterThan(Math.round(ahvMonthlyFullPension(70000) * 13));
});
it("tiefes Einkommen -> Mindestrente 1'260 x 13", () => {
const p = ahvPlan({ income: 10000, avgIncomeBefore: 10000 });
expect(renteIn(p, 1)).toBe(1260 * 13);
});
it("Einkommen vor Planbeginn dominiert bei kurzer Restlaufzeit", () => {
// 39 Jahre vor Planbeginn zu 40'000 brutto, nur 5 Jahre im Plan zu 200'000 netto.
const p = ahvPlan({ income: 200000, avgIncomeBefore: 40000 });
const mdJE = (40000 * 39 + 200000 * BRUTTO * 5) / 44;
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
});
it("Ausfalljahre kürzen die Rente über die Skala 44", () => {
const ohne = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
const mit = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsBefore: 4 });
expect(renteIn(mit, 1)).toBe(Math.round(32760 * (40 / 44))); // 4 Ausfalljahre = 4/44 weniger
expect(renteIn(mit, 1)).toBeLessThan(renteIn(ohne, 1));
});
it("Ausfalljahre im Plan senken die Beitragsjahre, nicht das Durchschnittseinkommen", () => {
// Gleiches Einkommen, aber 2 Ausfalljahre im Plan -> nur die Skala sinkt.
const p = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsInPlan: 2 });
expect(renteIn(p, 1)).toBe(Math.round(32760 * (42 / 44)));
});
it("ohne Prüfung gilt der geplante Durchschnitt auch für die Jahre vor Planbeginn", () => {
// Kein avgIncomeBefore erfasst -> darf NICHT als 0 gerechnet werden (sonst mdJE ~45%).
const p = plan({
age: 60,
retirementAge: 65,
inflation: 0,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 5 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Übergangsdaten
],
});
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
// mdJE = geplantes Brutto (80'000 x 1.12), nicht ~9'091 (das wäre der Fallback auf 0).
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13));
});
it("bereits bei Planbeginn pensioniert: Karriere kommt aus dem Pensionierungs-Entscheid", () => {
const p = plan({
age: 66,
retirementAge: 65,
inflation: 0,
phases: [{ id: "p1", durationYears: 10 }],
elements: [el("AHV", "PERSON_A", { p1: {} }, {}, { avgIncomeBefore: 60000, gapYearsBefore: 0 })],
});
const rente = computePlan(p).phases[0].elements.find((e) => e.category === "AHV")!.startValue;
expect(rente).toBe(Math.round(ahvMonthlyFullPension(60000) * 13));
});
it("Ehepaar-Plafonierung greift weiterhin (150% der Maximalrente)", () => {
const p: PlanInput = {
id: "plan", name: "T", householdType: "COUPLE", inflationRateDefault: 0, initialCash: 0,
persons: [
{ id: "A", role: "PERSON_A", name: null, age: 66, retirementAge: 65 },
{ id: "B", role: "PERSON_B", name: null, age: 66, retirementAge: 65 },
],
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }],
elements: [
el("AHV", "PERSON_A", { p1: {} }, {}, { avgIncomeBefore: 100000 }),
el("AHV", "PERSON_B", { p1: {} }, {}, { avgIncomeBefore: 100000 }),
],
};
const ph = computePlan(p).phases[0];
const summe = ph.elements.filter((e) => e.category === "AHV").reduce((s, e) => s + e.startValue, 0);
expect(summe).toBe(Math.round(32760 * 1.5)); // plafoniert, nicht 2 x 32'760
});
});
describe("V5 Golden Tests", () => { describe("V5 Golden Tests", () => {
it("Test 1 Ansparen (Einkommen +2% nominal, Ausgaben real flach -> nominal +2%)", () => { it("Test 1 Ansparen (Einkommen +2% nominal, Ausgaben real flach -> nominal +2%)", () => {
const p = plan({ const p = plan({
@@ -117,7 +474,7 @@ describe("V5 Golden Tests", () => {
expect(ph.expenseEnd).toBe(Math.round(100000 * Math.pow(1.02, 4))); // 108'243 expect(ph.expenseEnd).toBe(Math.round(100000 * Math.pow(1.02, 4))); // 108'243
}); });
it("Einkommen nominal flach bei Lohnerhoehung 0%", () => { it("Einkommen nominal flach bei Lohnerhöhung 0%", () => {
const p = plan({ const p = plan({
age: 40, age: 40,
retirementAge: 60, retirementAge: 60,
@@ -129,7 +486,7 @@ describe("V5 Golden Tests", () => {
expect(ph.incomeEnd).toBe(80000); expect(ph.incomeEnd).toBe(80000);
}); });
it("Bezugsrate aus Sonstigem Vermoegen fliesst ins Cash (Verzehrrate)", () => { it("Bezugsrate aus Sonstigem Vermögen fliesst ins Cash (Verzehrrate)", () => {
const p = plan({ const p = plan({
age: 65, age: 65,
retirementAge: 65, retirementAge: 65,
@@ -160,11 +517,11 @@ describe("V5 Golden Tests", () => {
}); });
const r = computePlan(p); const r = computePlan(p);
const p2 = r.phases[1]; const p2 = r.phases[1];
expect(p2.capitalInflow).toBe(10000); // Verkaufserloes aus dem Uebergang expect(p2.capitalInflow).toBe(10000); // Verkaufserlös aus dem Übergang
expect(p2.capitalInvest).toBe(10000); // Zusatzinvestition expect(p2.capitalInvest).toBe(10000); // Zusatzinvestition
expect(p2.cashStart).toBe(0); // Startwert bereits nach Abzug der Investition expect(p2.cashStart).toBe(0); // Startwert bereits nach Abzug der Investition
expect(p2.cashEnd).toBe(0); expect(p2.cashEnd).toBe(0);
// Startvermoegen Phase 2: Cash(0) + Vermoegen B(10'000) = 10'000 (kein Doppelzaehlen). // Startvermögen Phase 2: Cash(0) + Vermögen B(10'000) = 10'000 (kein Doppelzählen).
expect(p2.startWealthNominal).toBe(10000); expect(p2.startWealthNominal).toBe(10000);
}); });
@@ -185,7 +542,189 @@ describe("V5 Golden Tests", () => {
expect(ph.cashEnd).toBe(50000); expect(ph.cashEnd).toBe(50000);
}); });
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => { it("Tilgung stoppt, sobald die Schuld getilgt ist (belastet Cash und Sparrate nicht weiter)", () => {
// Schuld 25'000, Tilgung 10'000/Jahr, 5 Jahre: getilgt im Jahr 3 (10'000 + 10'000 + 5'000).
// Gesamtabfluss = 25'000, NICHT 50'000. Einkommen = Ausgaben, damit nur die Tilgung wirkt.
const p = plan({
age: 40,
retirementAge: 70,
inflation: 0,
initialCash: 100000,
phases: [{ id: "p1", durationYears: 5 }],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 50000, teuerungsausgleich: 0 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000, teuerungsausgleich: 0 } }),
el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 25000, annualRepayment: 10000 } }),
],
});
const ph = computePlan(p).phases[0];
expect(ph.cashEnd).toBe(75000); // 100'000 - 25'000
expect(ph.plannedSaveRate).toBe(10000); // erstes Jahr volle Rate
const debt = ph.elements.find((e) => e.category === "OTHER_DEBT")!;
expect(debt.endValue).toBe(0);
});
it("Amortisation stoppt, sobald die Hypothek abbezahlt ist", () => {
// Hypothek 15'000, Amortisation 10'000/Jahr, 4 Jahre: abbezahlt im Jahr 2 (10'000 + 5'000).
const p = plan({
age: 40,
retirementAge: 70,
inflation: 0,
initialCash: 100000,
phases: [{ id: "p1", durationYears: 4 }],
elements: [
el("REAL_ESTATE", "HOUSEHOLD", { p1: { purchasePrice: 500000, mortgage: 15000, amortization: 10000 } }),
],
});
const ph = computePlan(p).phases[0];
expect(ph.cashEnd).toBe(85000); // 100'000 - 15'000 (nicht - 40'000)
const re = ph.elements.find((e) => e.category === "REAL_ESTATE")!;
expect(re.endValue).toBe(500000); // schuldenfrei
});
it("Vorbezug PK vor Pensionierung: Kapitalbezugssteuer wird abgezogen", () => {
// Bezug 100'000 brutto bei 8% Steuer -> 92'000 netto ins Cash der Folgephase.
const p = plan({
age: 40,
retirementAge: 70,
inflation: 0,
phases: [
{ id: "p1", durationYears: 1 },
{ id: "p2", durationYears: 1 },
],
elements: [
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 300000, expectedReturn: 0, annualContribution: 0 }, p2: {} },
{ p1: { withdrawalMode: "AMOUNT", withdrawal: 100000, capitalTaxRate: 8 } }
),
],
});
const r = computePlan(p);
expect(r.phases[1].capitalInflow).toBe(92000); // netto nach 8% Steuer
expect(r.phases[1].cashStart).toBe(92000);
const pk = r.phases[1].elements.find((e) => e.category === "PENSION_FUND")!;
expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
});
it("Einmaliger Zufluss am Übergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => {
const p = plan({
age: 40,
retirementAge: 70,
inflation: 0,
initialCash: 1000,
phases: [
{ id: "p1", durationYears: 2, cashTransition: { mode: "INFLOW", inflowLabel: "Erbschaft", inflowAmount: 100000, inflowTaxRate: 10 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const r = computePlan(p);
expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzüglich 10% Steuer
expect(r.phases[1].oneOffInflowLabel).toBe("Erbschaft");
expect(r.phases[1].cashStart).toBe(91000); // 1'000 + 90'000
expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Übergang
});
it("Einmalige Kosten am Übergang: real erfasst, mit Inflation aufgewertet", () => {
// Kosten 20'000 real, 2% Inflation, Grenze nach 10 Jahren -> 20'000 x 1.02^10 = 24'380.
const p = plan({
age: 40,
retirementAge: 70,
inflation: 2,
initialCash: 100000,
phases: [
{ id: "p1", durationYears: 10, cashTransition: { mode: "OUTFLOW", outflowLabel: "Poolbau", outflowAmount: 20000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const r = computePlan(p);
const erwartet = Math.round(20000 * Math.pow(1.02, 10));
expect(r.phases[1].oneOffOutflow).toBe(erwartet);
expect(r.phases[1].oneOffOutflowLabel).toBe("Poolbau");
expect(r.phases[1].cashStart).toBe(100000 - erwartet);
});
it("Zufluss und Kosten zusammen (BOTH); Modus NONE bleibt wirkungslos", () => {
const beide = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
{ id: "p1", durationYears: 1, cashTransition: { mode: "BOTH", inflowAmount: 50000, outflowAmount: 20000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const r1 = computePlan(beide);
expect(r1.phases[1].cashStart).toBe(30000); // +50'000 -20'000
// Beträge sind erfasst, aber der Entscheid lautet "1:1 übernehmen" -> keine Wirkung.
const keine = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
{ id: "p1", durationYears: 1, cashTransition: { mode: "NONE", inflowAmount: 50000, outflowAmount: 20000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
expect(computePlan(keine).phases[1].cashStart).toBe(0);
});
it("Einmalige Kosten können eine Liquiditätslücke auslösen", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 10000,
phases: [
{ id: "p1", durationYears: 1, cashTransition: { mode: "OUTFLOW", outflowAmount: 25000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const p2 = computePlan(p).phases[1];
expect(p2.cashStart).toBe(-15000);
expect(p2.cashNegative).toBe(true);
expect(p2.incomplete).toBe(true);
});
it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Übergang mehr)", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 5000,
phases: [{ id: "p1", durationYears: 1, cashTransition: { mode: "INFLOW", inflowAmount: 999999 } }],
elements: [],
});
const r = computePlan(p);
expect(r.phases[0].cashEnd).toBe(5000);
expect(r.nachlass).toBe(5000);
});
it("Vermögen wird pro JAHR mitgeführt (Grundlage des Verlaufs über alle Jahre)", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [{ id: "p1", durationYears: 3 }],
elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 10, annualContribution: 0 } })],
});
const r = computePlan(p);
expect(r.yearly).toHaveLength(3); // ein Punkt je Jahr, nicht nur je Phase
expect(r.yearly[0].wealthNominal).toBe(110000);
expect(r.yearly[1].wealthNominal).toBe(121000);
expect(r.yearly[2].wealthNominal).toBe(133100);
// Ohne Inflation sind real und nominal identisch; Endjahr = Phasen-Endvermögen.
expect(r.yearly[2].wealthReal).toBe(133100);
expect(r.yearly[2].wealthNominal).toBe(r.phases[0].endWealthNominal);
});
it("Vermögen real ist bei Inflation kaufkraftbereinigt", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 10, initialCash: 100000,
phases: [{ id: "p1", durationYears: 1 }],
elements: [],
});
const y = computePlan(p).yearly[0];
expect(y.wealthNominal).toBe(100000);
expect(y.wealthReal).toBe(Math.round(100000 / 1.1)); // 90'909
});
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash läuft fort", () => {
const p = plan({ const p = plan({
age: 40, age: 40,
retirementAge: 70, retirementAge: 70,
@@ -205,3 +744,161 @@ describe("V5 Golden Tests", () => {
expect(r.phases[1].cashStart).toBe(r.phases[0].cashEnd); expect(r.phases[1].cashStart).toBe(r.phases[0].cashEnd);
}); });
}); });
// --- Roadmap Nr. 44: Referenzalter, Beitragspflicht, Einkommen bei Pensionierung ---
describe("AHV-Referenzalter (Kap. 4.4.6)", () => {
// Person wird mit 60 pensioniert, die AHV-Rente beginnt trotzdem erst mit 65.
function fruehPlan(beitrag: number) {
return plan({
age: 55,
retirementAge: 60,
inflation: 0,
initialCash: 500000,
phases: [
{ id: "p1", durationYears: 5 }, // Alter 55-60, erwerbstaetig
{ id: "p2", durationYears: 15 }, // Alter 60-75, pensioniert
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 60000, teuerungsausgleich: 0 }, p2: {} }),
el(
"AHV",
"PERSON_A",
{ p1: {}, p2: { ahvContribution: beitrag } },
{ p1: { reviewed: true, avgIncomeBefore: 100000, gapYearsBefore: 0 } }
),
],
});
}
const ahvYearly = (p: PlanInput, phaseIdx: number) =>
computePlan(p).phases[phaseIdx].elements.find((e) => e.category === "AHV")!.yearly.map((y) => y.value);
it("zahlt vor 65 den Beitrag und ab 65 die Rente", () => {
const werte = ahvYearly(fruehPlan(2000), 1);
// Phase 2 laeuft von Alter 60 bis 75: 5 Jahre Beitrag (60-64), dann 10 Jahre Rente.
expect(werte.slice(0, 5)).toEqual([-2000, -2000, -2000, -2000, -2000]);
expect(new Set(werte.slice(5))).toEqual(new Set([32760]));
});
it("belastet den Beitrag als Kosten -- ohne Beitrag bleibt mehr Vermoegen", () => {
const mit = computePlan(fruehPlan(20000));
const ohne = computePlan(fruehPlan(0));
const endeMit = mit.phases[1].endWealthNominal;
const endeOhne = ohne.phases[1].endWealthNominal;
expect(endeOhne).toBeGreaterThan(endeMit);
// 5 Beitragsjahre x 20'000 = 100'000 Unterschied (zzgl. entgangener Rendite darauf).
expect(endeOhne - endeMit).toBeGreaterThanOrEqual(100000);
});
it("laesst die Rente auch waehrend der Erwerbstaetigkeit ab 65 fliessen", () => {
// Pension erst mit 70 -- die Rente muss trotzdem ab 65 kommen.
const p = plan({
age: 60,
retirementAge: 70,
inflation: 0,
phases: [
{ id: "p1", durationYears: 10 }, // Alter 60-70, erwerbstaetig (deckt 65 ab)
{ id: "p2", durationYears: 5 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true, avgIncomeBefore: 100000 } }),
],
});
const werte = ahvYearly(p, 0);
// Jahre 1-5 (Alter 60-64): nichts, danach die Rente.
expect(werte.slice(0, 5)).toEqual([0, 0, 0, 0, 0]);
expect(werte[5]).toBeGreaterThan(0);
});
it("Pensionierung mit genau 65 bleibt unveraendert", () => {
const p = plan({
age: 60,
retirementAge: 65,
inflation: 0,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true, avgIncomeBefore: 100000 } }),
],
});
const werte = ahvYearly(p, 1);
expect(werte).toHaveLength(10);
expect(new Set(werte)).toEqual(new Set([32760]));
});
});
describe("Einkommen bei Pensionierung (Roadmap Nr. 44, Punkt B)", () => {
function lohnPlan(p2: PhaseData, owner: "PERSON_A" | "HOUSEHOLD") {
return plan({
age: 60,
retirementAge: 65,
inflation: 0,
household: "COUPLE", // sonst zaehlt "Gemeinsam" zur Person A
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 5 },
],
elements: [el("INCOME", owner, { p1: { amount: 100000, teuerungsausgleich: 0 }, p2 })],
});
}
const lohn = (p: PlanInput) => computePlan(p).phases[1].elements[0].startValue;
it("faellt ein personenbezogener Lohn ohne eigenen Wert auf 0", () => {
expect(lohn(lohnPlan({}, "PERSON_A"))).toBe(0);
});
it("bleibt bestehen, wenn ausdruecklich ein Betrag erfasst ist (Teilzeit)", () => {
expect(lohn(lohnPlan({ amount: 30000, teuerungsausgleich: 0 }, "PERSON_A"))).toBe(30000);
});
it("laesst gemeinsame Einkommen unberuehrt", () => {
expect(lohn(lohnPlan({}, "HOUSEHOLD"))).toBe(100000);
});
});
describe("Wiederkehr-Parameter aus der Vorphase (Roadmap Nr. 44, Punkt A)", () => {
function assetPlan(p2: PhaseData) {
return plan({
age: 40,
retirementAge: 65,
inflation: 0,
phases: [
{ id: "p1", durationYears: 10 },
{ id: "p2", durationYears: 10 },
],
elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 5 }, p2 })],
});
}
const endwert = (p: PlanInput) => computePlan(p).phases[1].elements[0].endValue;
it("uebernimmt eine nicht erfasste Rendite aus der Vorphase", () => {
// Ohne Vererbung waere Phase 2 zinslos -- der Endwert bliebe auf dem Stand von Phase 1.
// Rundung je Phase -> auf den Franken genau vergleichen waere zu streng.
expect(endwert(assetPlan({}))).toBeCloseTo(100000 * Math.pow(1.05, 20), -1);
});
it("laesst einen ausdruecklich erfassten Wert gewinnen", () => {
expect(endwert(assetPlan({ expectedReturn: 0 }))).toBe(Math.round(100000 * Math.pow(1.05, 10)));
});
it("traegt die Vererbung ueber mehrere Phasen", () => {
const p = plan({
age: 40,
retirementAge: 75,
inflation: 0,
phases: [
{ id: "p1", durationYears: 10 },
{ id: "p2", durationYears: 10 },
{ id: "p3", durationYears: 10 },
],
elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 5 }, p2: {}, p3: {} })],
});
expect(computePlan(p).phases[2].elements[0].endValue).toBeCloseTo(100000 * Math.pow(1.05, 30), -1);
});
});

Some files were not shown because too many files have changed in this diff Show More