Navigation auf Plan-Ebene und gespeicherte Analysen (Roadmap-Redesign)
Deploy App / deploy (push) Successful in 1m45s
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>
This commit is contained in:
+116
-3
@@ -4,7 +4,7 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.23 |
|
| **Version** | 0.24 |
|
||||||
| **Datum** | 2026-07-18 |
|
| **Datum** | 2026-07-18 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| Version | Datum | Autor | Änderung |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 0.24 | 2026-07-20 | Claude (Opus 4.8) | **Navigation auf Plan-Ebene und gespeicherte Analysen** (neues Kapitel 3.10). Die Seitenleiste ist neu zweistufig: Unter jedem Plan liegen die Unterpunkte **Szenarien**, **Effektive Werte** und **Analysen**; ein Klick auf den Plan-Namen öffnet ein **Plan-Dashboard** (Kennzahlen – Haushaltsdaten direkt, gerechnete Werte ausdrücklich «laut Basisszenario», dazu die Ist-Abweichung, falls erfasst). Die **Szenario-Liste** zeigt je Szenario Version, Elementzahl, Endvermögen und Ruinalter mit den Aktionen Historie und Matrix; das Basisszenario ist hervorgehoben, der Szenario-Baum in der Seitenleiste bleibt daneben erhalten. Die **Analysen-Ansicht** bietet vier umklappende Kacheln (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren – Umklappen auch per Antippen für Touch). **Grafiken** öffnen neu nicht mehr alle drei Diagramme, sondern lassen zuerst **eines** wählen; der Szenario-Vergleich zieht mit zu den Grafiken, der CSV-Export auf die Matrix. **Gespeicherte Analysen:** Jede Grafik, MC-Simulation und Einflussfaktoren-Berechnung kann festgehalten werden – als **Zahlen, nicht als Bild** (read-only, es wird nichts neu gerechnet). Das hält den Datensatz klein und macht ihn druckfähig: Der spätere PDF-Bericht (Roadmap Nr. 11) zeichnet daraus vektoriell in Druckauflösung, was ein Screenshot nicht könnte. Bewusst **nicht** gespeichert wird `finalWealthSorted` (megabyteweise). Jedes Werkzeug schreibt sein Ergebnis in dieselbe generische Form, sodass eine Nur-Lese-Ansicht genügt. Neue Tabelle `SavedAnalysis`, neue Endpunkte unter `/api/plans/<id>/analyses` und `/dashboard`; neue Komponenten `PlanViews`, `SavedAnalysisView`, `SaveAnalysisButton`, neues Modul `analyses.ts`. Kein Eingriff in den Rechenkern; Testbestand 212 (Migration um `SavedAnalysis` erweitert). |
|
||||||
| 0.23 | 2026-07-20 | Claude (Opus 4.8) | **V7: Der Haushalt liegt am Plan, die Annahmen am Szenario.** **Haushaltsform**, **Personen** (Name, Alter) und **Planstartjahr** wandern vom Szenario auf den **Plan**; das **Pensionsalter** bleibt szenario-eigen (es ist der Kern jedes Früh-/Spätpensionierungs-Szenarios), ebenso Inflation und Cash-Anfangswert. Begründung: Diese Angaben beschreiben den Haushalt, nicht eine Planungsvariante – unterscheiden sie sich, ist es ein anderer **Plan**, kein anderes Szenario. Neue Tabelle `PlanPerson` (Rolle, Name, Alter je Plan); `Person` behält nur noch Rolle und Pensionsalter; `Plan` bekommt `householdType` und `startYear`. **Der Rechenkern bleibt unberührt:** `toPlanInput()` fügt Plan- und Szenario-Ebene wieder zu einem unveränderten `PlanInput` zusammen, die 43 Golden Tests laufen durch. **Nebeneffekt, der ein reales Problem löst:** Weil das Startjahr nun plan-weit ist, landet ein erfasster Ist-Satz für 2031 in **allen** Szenarien zwingend auf demselben Planjahr – vorher war das nicht garantiert. Das **Wiederherstellen einer Szenario-Version** setzt folgerichtig nur noch das Szenario-Eigene zurück; plan-weite Angaben über eine Version *eines* Szenarios zu überschreiben, hätte die übrigen stillschweigend mitverändert. Der Profil-Dialog kennzeichnet neu je Feld, ob es **plan-weit** oder **nur dieses Szenario** gilt. Die Migration übernimmt die Werte aus dem **Basisszenario**; zwei neue Tests spielen dafür echte V6-Daten ein und prüfen die Übernahme inkl. abweichender Nebenszenarien (210 → 212). |
|
| 0.23 | 2026-07-20 | Claude (Opus 4.8) | **V7: Der Haushalt liegt am Plan, die Annahmen am Szenario.** **Haushaltsform**, **Personen** (Name, Alter) und **Planstartjahr** wandern vom Szenario auf den **Plan**; das **Pensionsalter** bleibt szenario-eigen (es ist der Kern jedes Früh-/Spätpensionierungs-Szenarios), ebenso Inflation und Cash-Anfangswert. Begründung: Diese Angaben beschreiben den Haushalt, nicht eine Planungsvariante – unterscheiden sie sich, ist es ein anderer **Plan**, kein anderes Szenario. Neue Tabelle `PlanPerson` (Rolle, Name, Alter je Plan); `Person` behält nur noch Rolle und Pensionsalter; `Plan` bekommt `householdType` und `startYear`. **Der Rechenkern bleibt unberührt:** `toPlanInput()` fügt Plan- und Szenario-Ebene wieder zu einem unveränderten `PlanInput` zusammen, die 43 Golden Tests laufen durch. **Nebeneffekt, der ein reales Problem löst:** Weil das Startjahr nun plan-weit ist, landet ein erfasster Ist-Satz für 2031 in **allen** Szenarien zwingend auf demselben Planjahr – vorher war das nicht garantiert. Das **Wiederherstellen einer Szenario-Version** setzt folgerichtig nur noch das Szenario-Eigene zurück; plan-weite Angaben über eine Version *eines* Szenarios zu überschreiben, hätte die übrigen stillschweigend mitverändert. Der Profil-Dialog kennzeichnet neu je Feld, ob es **plan-weit** oder **nur dieses Szenario** gilt. Die Migration übernimmt die Werte aus dem **Basisszenario**; zwei neue Tests spielen dafür echte V6-Daten ein und prüfen die Übernahme inkl. abweichender Nebenszenarien (210 → 212). |
|
||||||
| 0.22 | 2026-07-20 | Claude (Opus 4.8) | **Fehlerbehebung: Cash-Vorbelegung im Ist-Wizard.** Der Wizard für die effektiven Werte zeigte als geplanten Cash-Bestand den Stand am **Phasenende** statt am gewählten Stichtag -- in einer Phase von 2026 bis 2036 also für 2031 den Wert von 2036. Ursache: Der Dialog las `cashBridge.cashEnd`, weil `computePlan` den Cash-Bestand bisher nur **je Phase** auswies. `YearPoint` trägt 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 decken den gemeldeten Fall ab (208 -> 210). Rein additiv, die 43 Golden Tests laufen unverändert. |
|
| 0.22 | 2026-07-20 | Claude (Opus 4.8) | **Fehlerbehebung: Cash-Vorbelegung im Ist-Wizard.** Der Wizard für die effektiven Werte zeigte als geplanten Cash-Bestand den Stand am **Phasenende** statt am gewählten Stichtag -- in einer Phase von 2026 bis 2036 also für 2031 den Wert von 2036. Ursache: Der Dialog las `cashBridge.cashEnd`, weil `computePlan` den Cash-Bestand bisher nur **je Phase** auswies. `YearPoint` trägt 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 decken den gemeldeten Fall ab (208 -> 210). Rein additiv, die 43 Golden Tests laufen unverändert. |
|
||||||
| 0.21 | 2026-07-20 | Claude (Opus 4.8) | **Effektive Werte / Plan-Ist-Vergleich** (Roadmap Nr. 5, neue Kapitel 3.9 und 9.29). Macht aus dem Planer ein Monitoring-Werkzeug. Neuer Knopf **«Effektive Werte»** auf Plan-Ebene: Liste der Erfassungen plus Wizard in zwei Schritten (Stichtag, dann alle Elemente **aller** Szenarien inkl. **Cash**, Einkommen und Ausgaben, vorbelegt mit dem Planwert für dieses Jahr). Ein Ist-Satz hängt am **Plan**, nicht am Szenario – die Wirklichkeit ist dieselbe, egal wogegen man sie hält; die Zuordnung läuft über die Herkunfts-Kette `sourceElementId`. Das exakte Datum steht in Liste und Zeitachse, für die Rechnung zählt nur die **Jahreszahl**. **Zweiter Rechenlauf:** `computePlan` nimmt neu `{ actuals }`; die Werte schnappen in **jedem** erfassten Jahr auf die Realität und laufen von dort planmässig weiter (Lücken fallen auf die Plandaten zurück). Ohne die Option verhält sich die Funktion exakt wie bisher – die 43 Golden Tests laufen unverändert. Der Sprung wird als eigene Brückenposition `actualsCorrection` geführt (Vermögens- **und** Cash-Brücke): Eine Planabweichung ist keine Rendite, und ohne diese Zeile ginge die Zerlegung im Ist-Jahr nicht mehr auf. **Matrix:** zweiter Umschalter «Plan» / «Effektiv»; im Ist-Modus steht neben dem Wert die **Abweichung** zum Plan, farbig – bewusst kein «beide», das wären mit nominal/real acht Zahlen je Zelle (Begründung 9.29). **Zeitachse:** Marker je erfasstem Jahr, der jüngste farbig, ältere blass. **Alle vier Analysewerkzeuge** erhalten eine einheitliche Leiste (nominal/real als **Einfach**auswahl, Plan/Effektiv); im Vermögensverlauf kommt die Planlinie **gestrichelt** als Referenz dazu, max. vier Serien. **Monte-Carlo:** Der Zielbetrag dreht mit real/nominal mit und wird entsprechend beschriftet; eine Zeile weist aus, ab welchem Jahr simuliert wird – die Jahre davor sind durch Ist-Werte belegt und werden nicht gewürfelt. Das Startjahr ist **abgeleitet, nicht eingebbar**: Ein frei gesetztes Jahr würde Jahre als sicher behandeln, die nie erfasst wurden. Ein Ist-Satz erzeugt **keine** Szenario-Version – er ist eine Beobachtung, keine Planänderung. Neue Tabelle `ActualsSet` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/plans/<id>/actuals`, neue Module `actuals.ts` und `dataview.ts`; 27 Tests ergänzt (181 → 208). |
|
| 0.21 | 2026-07-20 | Claude (Opus 4.8) | **Effektive Werte / Plan-Ist-Vergleich** (Roadmap Nr. 5, neue Kapitel 3.9 und 9.29). Macht aus dem Planer ein Monitoring-Werkzeug. Neuer Knopf **«Effektive Werte»** auf Plan-Ebene: Liste der Erfassungen plus Wizard in zwei Schritten (Stichtag, dann alle Elemente **aller** Szenarien inkl. **Cash**, Einkommen und Ausgaben, vorbelegt mit dem Planwert für dieses Jahr). Ein Ist-Satz hängt am **Plan**, nicht am Szenario – die Wirklichkeit ist dieselbe, egal wogegen man sie hält; die Zuordnung läuft über die Herkunfts-Kette `sourceElementId`. Das exakte Datum steht in Liste und Zeitachse, für die Rechnung zählt nur die **Jahreszahl**. **Zweiter Rechenlauf:** `computePlan` nimmt neu `{ actuals }`; die Werte schnappen in **jedem** erfassten Jahr auf die Realität und laufen von dort planmässig weiter (Lücken fallen auf die Plandaten zurück). Ohne die Option verhält sich die Funktion exakt wie bisher – die 43 Golden Tests laufen unverändert. Der Sprung wird als eigene Brückenposition `actualsCorrection` geführt (Vermögens- **und** Cash-Brücke): Eine Planabweichung ist keine Rendite, und ohne diese Zeile ginge die Zerlegung im Ist-Jahr nicht mehr auf. **Matrix:** zweiter Umschalter «Plan» / «Effektiv»; im Ist-Modus steht neben dem Wert die **Abweichung** zum Plan, farbig – bewusst kein «beide», das wären mit nominal/real acht Zahlen je Zelle (Begründung 9.29). **Zeitachse:** Marker je erfasstem Jahr, der jüngste farbig, ältere blass. **Alle vier Analysewerkzeuge** erhalten eine einheitliche Leiste (nominal/real als **Einfach**auswahl, Plan/Effektiv); im Vermögensverlauf kommt die Planlinie **gestrichelt** als Referenz dazu, max. vier Serien. **Monte-Carlo:** Der Zielbetrag dreht mit real/nominal mit und wird entsprechend beschriftet; eine Zeile weist aus, ab welchem Jahr simuliert wird – die Jahre davor sind durch Ist-Werte belegt und werden nicht gewürfelt. Das Startjahr ist **abgeleitet, nicht eingebbar**: Ein frei gesetztes Jahr würde Jahre als sicher behandeln, die nie erfasst wurden. Ein Ist-Satz erzeugt **keine** Szenario-Version – er ist eine Beobachtung, keine Planänderung. Neue Tabelle `ActualsSet` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/plans/<id>/actuals`, neue Module `actuals.ts` und `dataview.ts`; 27 Tests ergänzt (181 → 208). |
|
||||||
@@ -1480,6 +1481,70 @@ entfernt ihn ersatzlos; der Plan bleibt unberührt. Die beiden Historien bleiben
|
|||||||
Referenz: `src/lib/actuals.ts`, `src/lib/dataview.ts`, `src/components/ActualsDialog.tsx`,
|
Referenz: `src/lib/actuals.ts`, `src/lib/dataview.ts`, `src/components/ActualsDialog.tsx`,
|
||||||
`src/components/AnalysisControls.tsx`.
|
`src/components/AnalysisControls.tsx`.
|
||||||
|
|
||||||
|
## 3.10 Navigation auf Plan-Ebene und gespeicherte Analysen
|
||||||
|
|
||||||
|
Die Seitenleiste ist zweistufig: Unter jedem **Plan** liegen drei leicht eingezogene
|
||||||
|
Unterpunkte. Ein Klick auf den **Plan-Namen** öffnet dessen Dashboard.
|
||||||
|
|
||||||
|
| Ort | Führt zu |
|
||||||
|
|---|---|
|
||||||
|
| Plan-Name | **Plan-Dashboard** (Kennzahlen) |
|
||||||
|
| Szenarien | **Szenario-Liste** – darunter bleibt der Szenario-Baum, dessen Einträge direkt in die Matrix führen |
|
||||||
|
| Effektive Werte | Liste + Wizard der Ist-Werte ([3.9](#39-effektive-werte-plan-ist-vergleich)) |
|
||||||
|
| Analysen | Vier Werkzeug-Kacheln + Liste gespeicherter Analysen |
|
||||||
|
|
||||||
|
### 3.10.1 Plan- vs. Szenario-Ebene der Kennzahlen
|
||||||
|
|
||||||
|
Seit V7 ([9.30](#930-warum-der-haushalt-am-plan-hängt)) liegen Haushaltsform, Personen und
|
||||||
|
Startjahr am Plan; Endvermögen, Ruinalter, Phasen und Elemente sind dagegen **szenario-eigen**.
|
||||||
|
Das Plan-Dashboard zeigt deshalb die Haushaltsdaten direkt, alle gerechneten Kennzahlen aber
|
||||||
|
ausdrücklich als **«laut Basisszenario»** – das Basisszenario ist der kanonische Vertreter.
|
||||||
|
Falls Ist-Werte erfasst sind, weist es zusätzlich die **Abweichung** des Endvermögens gegenüber
|
||||||
|
dem Plan aus.
|
||||||
|
|
||||||
|
Die **Szenario-Liste** zeigt je Szenario Name, aktuelle Hauptversion, Anzahl Elemente, das
|
||||||
|
Endvermögen und ob das Kapital reicht; das Basisszenario ist farblich hervorgehoben, und die
|
||||||
|
Herkunft (aus welchem Szenario kopiert) steht darunter. Zwei Aktionen je Zeile: **Historie**
|
||||||
|
und **Matrix**.
|
||||||
|
|
||||||
|
### 3.10.2 Analysen: vier Kacheln
|
||||||
|
|
||||||
|
Die Werkzeuge **Grafiken**, **Live-Simulation**, **Monte-Carlo** und **Einflussfaktoren** liegen
|
||||||
|
als Kacheln vor, die beim Darüberfahren – oder Antippen (Touch hat kein Hover) – umklappen und
|
||||||
|
einen Erklärtext zeigen. Ein Klick startet das jeweilige Werkzeug auf dem Basisszenario; Szenario
|
||||||
|
und Version lassen sich darin weiterhin umstellen.
|
||||||
|
|
||||||
|
**Grafiken** öffnen neu **nicht mehr alle drei Diagramme**, sondern lassen zuerst **eine** wählen
|
||||||
|
(Vermögensverlauf / Einkommen vs. Ausgaben / Vermögensaufteilung), dazu wie bisher
|
||||||
|
nominal/real und Plan/effektiv. Der **Szenario-Vergleich** wandert mit zu den Grafiken; der
|
||||||
|
**CSV-Export** zieht dorthin, wo er hingehört – auf die **Matrix**.
|
||||||
|
|
||||||
|
### 3.10.3 Gespeicherte Analysen
|
||||||
|
|
||||||
|
Jede Grafik, Monte-Carlo-Simulation und Einflussfaktoren-Berechnung kann **gespeichert** werden
|
||||||
|
(die Live-Simulation vorerst nicht – sie ist bewusst flüchtig). Festgehalten werden **Eingaben
|
||||||
|
und Ergebnis als Zahlen**, read-only: Beim Öffnen wird **nichts neu gerechnet**, die
|
||||||
|
gespeicherten Werte werden nur gezeichnet.
|
||||||
|
|
||||||
|
**Zahlen statt Bild** – und zwar bewusst: Ein Bildschirm-Abbild wäre im Druck unscharf (300 dpi
|
||||||
|
gegen 96), im Seitenformat fix und im Dunkelmodus falsch eingefärbt. Aus Zahlen zeichnet der
|
||||||
|
spätere **PDF-Bericht** (Roadmap Nr. 11) die Grafik **vektoriell und in Druckauflösung** neu –
|
||||||
|
und braucht die Zahlen für Tabellen und Fliesstext ohnehin. Bewusst **nicht** gespeichert wird
|
||||||
|
`finalWealthSorted` aus der Monte-Carlo-Simulation (ein Eintrag je Lauf, megabyteweise); für die
|
||||||
|
Anzeige genügen die abgelesenen Wahrscheinlichkeiten und die Bänder.
|
||||||
|
|
||||||
|
Jedes Werkzeug schreibt sein Ergebnis in **dieselbe generische Form** (Parameter, Kernzahlen,
|
||||||
|
Tabelle, Grafik). Dadurch braucht die Nur-Lese-Ansicht nur einen Renderer, und der PDF-Bericht
|
||||||
|
findet überall dieselbe Struktur vor. Der Name wird automatisch vorgeschlagen
|
||||||
|
(«Typ · Szenario Version · Datum»), ist aber überschreibbar.
|
||||||
|
|
||||||
|
Die Liste je Plan zeigt Typ, Zeitpunkt, Szenario/Version, nominal/real und Plan/effektiv. Ein
|
||||||
|
gespeicherter Datensatz ist eine **Momentaufnahme**, keine Planänderung: Er erzeugt keine
|
||||||
|
Version, und Löschen entfernt ihn ersatzlos.
|
||||||
|
|
||||||
|
Referenz: `src/components/PlanViews.tsx`, `src/components/SavedAnalysisView.tsx`,
|
||||||
|
`src/components/SaveAnalysisButton.tsx`, `src/lib/analyses.ts`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 4. Berechnungsmodell
|
# 4. Berechnungsmodell
|
||||||
@@ -2682,7 +2747,7 @@ Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`,
|
|||||||
FPT/
|
FPT/
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ ├── schema.prisma Datenmodell
|
│ ├── schema.prisma Datenmodell
|
||||||
│ └── migrations/ 15 Migrationen (chronologisch, siehe 5.4.6)
|
│ └── migrations/ 16 Migrationen (chronologisch, siehe 5.4.6)
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
|
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
|
||||||
@@ -2724,6 +2789,7 @@ PlanComputed ← an den Client geliefert
|
|||||||
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
|
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
|
||||||
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`; zusätzlich `applyElementDriver` / `tunableElements` für die einzeln regelbaren Element-Renditen. Rein, läuft im Browser. |
|
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`; zusätzlich `applyElementDriver` / `tunableElements` für die einzeln regelbaren Element-Renditen. Rein, läuft im Browser. |
|
||||||
| `actuals.ts` | Effektive Werte: Zuordnung auf die Szenario-Elemente über die Herkunfts-Kette, Einspielen in den Rechenkern, Bestand/Fluss. Rein. |
|
| `actuals.ts` | Effektive Werte: Zuordnung auf die Szenario-Elemente über die Herkunfts-Kette, Einspielen in den Rechenkern, Bestand/Fluss. Rein. |
|
||||||
|
| `analyses.ts` | Gespeicherte Analysen: Typen, generische Ergebnis-Form, Speicher-Helfer (Kap. 3.10). |
|
||||||
| `dataview.ts` | Bündelt Plan-Sicht und Ist-Sicht für Matrix, Grafiken und Analysewerkzeuge. Rein. |
|
| `dataview.ts` | Bündelt Plan-Sicht und Ist-Sicht für Matrix, Grafiken und Analysewerkzeuge. Rein. |
|
||||||
| `ratefields.ts` | Ratenfelder je Kategorie, Reichweite der Übernahme über Lebensphasen, Aufbau der Schreibvorgänge (Kap. 3.6.11). Rein. |
|
| `ratefields.ts` | Ratenfelder je Kategorie, Reichweite der Übernahme über Lebensphasen, Aufbau der Schreibvorgänge (Kap. 3.6.11). Rein. |
|
||||||
| `versioning.ts` | Versionierung: Nummerierung A.B, Zusammenfassung je Sitzung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien. Rein, ohne I/O. |
|
| `versioning.ts` | Versionierung: Nummerierung A.B, Zusammenfassung je Sitzung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien. Rein, ohne I/O. |
|
||||||
@@ -2941,7 +3007,8 @@ sondern zu leeren Werten.
|
|||||||
| `20260718140000_scenario_start_year` | `Scenario.startYear` (Kalenderjahr des Planbeginns), bestehende auf das laufende Jahr gesetzt |
|
| `20260718140000_scenario_start_year` | `Scenario.startYear` (Kalenderjahr des Planbeginns), bestehende auf das laufende Jahr gesetzt |
|
||||||
| `20260719210000_scenario_versioning` | Tabelle `ScenarioVersion` (Snapshot als JSONB, A.B eindeutig je Szenario) und `Scenario.currentMajor` |
|
| `20260719210000_scenario_versioning` | Tabelle `ScenarioVersion` (Snapshot als JSONB, A.B eindeutig je Szenario) und `Scenario.currentMajor` |
|
||||||
| `20260720090000_actuals` | Tabelle `ActualsSet` (effektive Werte je Plan, Werte als JSONB, Cash separat) |
|
| `20260720090000_actuals` | Tabelle `ActualsSet` (effektive Werte je Plan, Werte als JSONB, Cash separat) |
|
||||||
| `20260720140000_plan_level_profile` | **V7**: Haushaltsform, Personen (Name/Alter) und Startjahr vom Szenario auf den Plan; neue Tabelle `PlanPerson`; `Person` behaelt nur das Pensionsalter. Datenübernahme aus dem Basisszenario. |
|
| `20260720140000_plan_level_profile` | **V7**: Haushaltsform, Personen (Name/Alter) und Startjahr vom Szenario auf den Plan; neue Tabelle `PlanPerson`; `Person` behält nur das Pensionsalter. Datenübernahme aus dem Basisszenario. |
|
||||||
|
| `20260720160000_saved_analyses` | Tabelle `SavedAnalysis` (Eingaben + Ergebnis als JSONB, denormalisierte Kerndaten) |
|
||||||
|
|
||||||
**Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um – dadurch
|
**Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um – dadurch
|
||||||
bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige
|
bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige
|
||||||
@@ -2991,6 +3058,9 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
|
|||||||
| `MonteCarloDialog` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer |
|
| `MonteCarloDialog` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer |
|
||||||
| `VersionHistoryDialog` | ~290 | Änderungshistorie: Liste, Hauptversion festlegen, Anzeigen, Wiederherstellen mit Warnung ([3.8](#38-versionierung-und-änderungshistorie)) |
|
| `VersionHistoryDialog` | ~290 | Änderungshistorie: Liste, Hauptversion festlegen, Anzeigen, Wiederherstellen mit Warnung ([3.8](#38-versionierung-und-änderungshistorie)) |
|
||||||
| `VersionMatrix` | ~110 | Nur-Lese-Matrix eines festgehaltenen Standes |
|
| `VersionMatrix` | ~110 | Nur-Lese-Matrix eines festgehaltenen Standes |
|
||||||
|
| `PlanViews` | ~350 | Plan-Dashboard, Szenario-Liste, Analysen-Kacheln + gespeicherte Liste (Kap. 3.10) |
|
||||||
|
| `SavedAnalysisView` | ~180 | Nur-Lese-Ansicht einer gespeicherten Analyse |
|
||||||
|
| `SaveAnalysisButton` | ~90 | Speichern-Knopf mit Namensvorschlag |
|
||||||
| `VersionPicker` | ~140 | Wahl der Berechnungsgrundlage in den vier Analysewerkzeugen |
|
| `VersionPicker` | ~140 | Wahl der Berechnungsgrundlage in den vier Analysewerkzeugen |
|
||||||
| `LiveSimDialog` | ~330 | Live-Simulation: Regler links, Grafikwahl rechts, Kennzahlenleiste mit Differenz zum Plan ([4.15](#415-live-simulation-was-wäre-wenn-regler)) |
|
| `LiveSimDialog` | ~330 | Live-Simulation: Regler links, Grafikwahl rechts, Kennzahlenleiste mit Differenz zum Plan ([4.15](#415-live-simulation-was-wäre-wenn-regler)) |
|
||||||
| `AllocationChart` | ~80 | Gestapelte Vermögensaufteilung je Phase; aus dem Dashboard herausgelöst, damit die Live-Simulation sie mitbenutzen kann |
|
| `AllocationChart` | ~80 | Gestapelte Vermögensaufteilung je Phase; aus dem Dashboard herausgelöst, damit die Live-Simulation sie mitbenutzen kann |
|
||||||
@@ -3113,6 +3183,19 @@ Alle erfassten Ist-Sätze, neueste zuerst ([3.9](#39-effektive-werte-plan-ist-ve
|
|||||||
→ 200 `{ ok: true }`. Ein Ist-Satz ist eine Beobachtung – es gibt weder Versionierung noch
|
→ 200 `{ ok: true }`. Ein Ist-Satz ist eine Beobachtung – es gibt weder Versionierung noch
|
||||||
Wiederherstellung.
|
Wiederherstellung.
|
||||||
|
|
||||||
|
### `GET /api/plans/<planId>/dashboard`
|
||||||
|
Gebündelte Kennzahlen fürs Plan-Dashboard und die Szenario-Liste ([3.10](#310-navigation-auf-plan-ebene-und-gespeicherte-analysen)):
|
||||||
|
Haushaltsdaten, Zähler (Szenarien/Ist-Sätze/Analysen), Basisszenario-Kennzahlen inkl.
|
||||||
|
Ist-Abweichung, und je Szenario Version/Elementzahl/Endvermögen/Ruinalter.
|
||||||
|
|
||||||
|
### `GET/POST /api/plans/<planId>/analyses`
|
||||||
|
Gespeicherte Analysen – GET listet die Kopfdaten (ohne die grossen JSON-Felder), POST legt eine
|
||||||
|
Momentaufnahme an (`{ name, type, metric, source, scenarioName?, versionLabel?, inputs, result }`).
|
||||||
|
|
||||||
|
### `GET/DELETE /api/plans/<planId>/analyses/<analysisId>`
|
||||||
|
GET liefert die vollständige Analyse inkl. der eingefrorenen Zahlen (Eingaben + Ergebnis), DELETE
|
||||||
|
entfernt sie. Read-only – es wird nichts neu gerechnet.
|
||||||
|
|
||||||
## 6.3 Szenarien
|
## 6.3 Szenarien
|
||||||
|
|
||||||
### `GET /api/scenarios/<scenarioId>`
|
### `GET /api/scenarios/<scenarioId>`
|
||||||
@@ -3817,6 +3900,36 @@ Aussage unkenntlich. Beide zeigen deshalb nur die gewählte Grundlage.
|
|||||||
**Serienobergrenze vier.** Szenario mal Version mal Datenquelle wächst schnell; darüber hinaus
|
**Serienobergrenze vier.** Szenario mal Version mal Datenquelle wächst schnell; darüber hinaus
|
||||||
hilft keine Farbpalette mehr.
|
hilft keine Farbpalette mehr.
|
||||||
|
|
||||||
|
## 9.30 Warum der Haushalt am Plan hängt
|
||||||
|
|
||||||
|
Bis V6 trug jedes **Szenario** sein eigenes Grundprofil: Haushaltsform, Personen (Name, Alter,
|
||||||
|
Pensionsalter) und Startjahr. Das war zu grosszügig. Zwei Szenarien desselben Plans konnten so
|
||||||
|
verschiedene Startjahre oder Haushaltsformen tragen – und niemand hätte es bemerkt, bis eine
|
||||||
|
Auswertung Unsinn ergab: Derselbe erfasste Ist-Satz für 2031 wäre je Szenario auf einem anderen
|
||||||
|
Planjahr gelandet.
|
||||||
|
|
||||||
|
**V7 zieht die Trennlinie neu.** Was den **Haushalt** beschreibt, gehört an den Plan; was eine
|
||||||
|
**Planungsvariante** ausmacht, ans Szenario:
|
||||||
|
|
||||||
|
| Am Plan (für alle Szenarien) | Am Szenario (variantenspezifisch) |
|
||||||
|
|---|---|
|
||||||
|
| Haushaltsform | **Pensionsalter** je Person |
|
||||||
|
| Personen: Name, Alter | Inflationsannahme |
|
||||||
|
| Planstartjahr | Cash-Anfangswert |
|
||||||
|
| | Phasen, Elemente, Werte |
|
||||||
|
|
||||||
|
Das **Pensionsalter** bleibt bewusst unten – es ist der Kern jedes Früh- oder
|
||||||
|
Spätpensionierungs-Szenarios. Wollte man Name oder Alter einer Person unterschiedlich planen,
|
||||||
|
wäre das kein Szenario mehr, sondern ein **anderer Plan**.
|
||||||
|
|
||||||
|
**Der Rechenkern merkt nichts davon.** `toPlanInput()` fügt die beiden Ebenen wieder zu einem
|
||||||
|
unveränderten `PlanInput` zusammen; `computePlan` und die 43 Golden Tests bleiben unberührt. Die
|
||||||
|
Verlagerung ist eine Frage der Datenhaltung, nicht der Berechnung.
|
||||||
|
|
||||||
|
**Preis der Klarheit:** Der Profil-Dialog muss jetzt je Feld anzeigen, ob es plan-weit gilt oder
|
||||||
|
nur das Szenario betrifft – sonst änderte man beim Bearbeiten eines Nebenszenarios unbemerkt den
|
||||||
|
ganzen Plan. Diese Beschriftung ist der sichtbare Teil der Entscheidung.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 10. Glossar
|
# 10. Glossar
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -35,6 +35,7 @@ model User {
|
|||||||
plans Plan[]
|
plans Plan[]
|
||||||
versions ScenarioVersion[]
|
versions ScenarioVersion[]
|
||||||
actuals ActualsSet[]
|
actuals ActualsSet[]
|
||||||
|
analyses SavedAnalysis[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HouseholdType {
|
enum HouseholdType {
|
||||||
@@ -111,6 +112,36 @@ model Plan {
|
|||||||
scenarios Scenario[]
|
scenarios Scenario[]
|
||||||
actuals ActualsSet[]
|
actuals ActualsSet[]
|
||||||
persons PlanPerson[]
|
persons PlanPerson[]
|
||||||
|
analyses SavedAnalysis[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).
|
// Ein erfasster Stand der WIRKLICHKEIT zu einem Stichtag (Roadmap Nr. 5).
|
||||||
|
|||||||
@@ -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,105 @@
|
|||||||
|
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 } } },
|
||||||
|
},
|
||||||
|
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];
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
isBase: s.isBase,
|
||||||
|
parentScenarioId: s.parentScenarioId,
|
||||||
|
currentMajor: s.currentMajor,
|
||||||
|
elementCount: s._count.elements,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,10 +44,15 @@ export async function GET() {
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
householdType: true,
|
||||||
|
startYear: true,
|
||||||
|
persons: { select: { role: true, name: true, age: true }, orderBy: { role: "asc" } },
|
||||||
scenarios: {
|
scenarios: {
|
||||||
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
|
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
|
||||||
select: { id: true, planId: true, name: true, isBase: true, parentScenarioId: true },
|
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 });
|
||||||
|
|||||||
+171
-39
@@ -11,6 +11,7 @@ import {
|
|||||||
CalendarClock,
|
CalendarClock,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
History,
|
History,
|
||||||
|
Layers,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Menu,
|
Menu,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
@@ -28,6 +29,8 @@ import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
|||||||
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
||||||
import { LiveSimDialog } from "@/components/LiveSimDialog";
|
import { LiveSimDialog } from "@/components/LiveSimDialog";
|
||||||
import { ActualsDialog } from "@/components/ActualsDialog";
|
import { ActualsDialog } from "@/components/ActualsDialog";
|
||||||
|
import { PlanDashboardView, ScenarioListView, AnalysesView } from "@/components/PlanViews";
|
||||||
|
import { SavedAnalysisView } from "@/components/SavedAnalysisView";
|
||||||
import { buildViews } from "@/lib/dataview";
|
import { buildViews } from "@/lib/dataview";
|
||||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||||
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
|
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
|
||||||
@@ -99,6 +102,9 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
const [showLiveSim, setShowLiveSim] = useState(false);
|
const [showLiveSim, setShowLiveSim] = useState(false);
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
const [showActuals, setShowActuals] = useState(false);
|
const [showActuals, setShowActuals] = useState(false);
|
||||||
|
// Plan-Ebene: Dashboard / Szenarien-Liste / Analysen. Null = kein Plan-View aktiv.
|
||||||
|
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "analyses" } | null>(null);
|
||||||
|
const [savedAnalysisId, setSavedAnalysisId] = useState<string | null>(null);
|
||||||
const [showSystemParams, setShowSystemParams] = useState(false);
|
const [showSystemParams, setShowSystemParams] = useState(false);
|
||||||
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
||||||
const [showPalette, setShowPalette] = useState(false);
|
const [showPalette, setShowPalette] = useState(false);
|
||||||
@@ -110,6 +116,7 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
setShowSpec(true);
|
setShowSpec(true);
|
||||||
setShowSystemParams(false);
|
setShowSystemParams(false);
|
||||||
setSelectedScenarioId(null);
|
setSelectedScenarioId(null);
|
||||||
|
setPlanNav(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPlans = useCallback(async () => {
|
const loadPlans = useCallback(async () => {
|
||||||
@@ -123,7 +130,9 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
|
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
|
||||||
if (!silent) setLoading(true);
|
if (!silent) setLoading(true);
|
||||||
try {
|
try {
|
||||||
setDetail(await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`));
|
const d = await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`);
|
||||||
|
setDetail(d);
|
||||||
|
return d;
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setLoading(false);
|
if (!silent) setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -138,9 +147,9 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
if (selectedScenarioId) {
|
if (selectedScenarioId) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf
|
||||||
loadDetail(selectedScenarioId);
|
loadDetail(selectedScenarioId);
|
||||||
} else {
|
|
||||||
setDetail(null);
|
|
||||||
}
|
}
|
||||||
|
// Kein `else setDetail(null)`: In der Plan-Ebene (planNav) wird `detail` fürs Basisszenario
|
||||||
|
// geladen, damit die Werkzeuge starten können -- das darf hier nicht weggeräumt werden.
|
||||||
}, [selectedScenarioId, loadDetail]);
|
}, [selectedScenarioId, loadDetail]);
|
||||||
|
|
||||||
// Cmd/Ctrl+K öffnet die Befehls-Palette.
|
// Cmd/Ctrl+K öffnet die Befehls-Palette.
|
||||||
@@ -161,11 +170,45 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
|
|
||||||
function openScenario(id: string) {
|
function openScenario(id: string) {
|
||||||
setSelectedScenarioId(id);
|
setSelectedScenarioId(id);
|
||||||
|
setPlanNav(null);
|
||||||
setShowSpec(false);
|
setShowSpec(false);
|
||||||
setShowSystemParams(false);
|
setShowSystemParams(false);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plan-Ebene öffnen (Dashboard / Szenarien / Analysen). Räumt die Szenario- und Wissens-
|
||||||
|
// Ansichten weg -- es kann immer nur eine Hauptansicht aktiv sein.
|
||||||
|
function openPlanTab(planId: string, tab: "dashboard" | "scenarios" | "analyses") {
|
||||||
|
setPlanNav({ planId, tab });
|
||||||
|
setSelectedScenarioId(null);
|
||||||
|
setShowSpec(false);
|
||||||
|
setShowSystemParams(false);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Analysewerkzeug aus der Analysen-Ansicht heraus starten. Die Werkzeuge arbeiten auf
|
||||||
|
// einem Szenario -- ohne ausgewähltes nehmen wir das Basisszenario; im Werkzeug selbst
|
||||||
|
// lässt sich Szenario/Version weiterhin umstellen.
|
||||||
|
async function launchTool(planId: string, kind: "CHART" | "MONTE_CARLO" | "SENSITIVITY" | "LIVESIM") {
|
||||||
|
const plan = plans.find((p) => p.id === planId);
|
||||||
|
const baseId = plan?.scenarios.find((s) => s.isBase)?.id ?? plan?.scenarios[0]?.id;
|
||||||
|
if (!baseId) return;
|
||||||
|
if (detail?.meta.id !== baseId) await loadDetail(baseId, true);
|
||||||
|
if (kind === "CHART") setShowCharts(true);
|
||||||
|
else if (kind === "MONTE_CARLO") setShowMonteCarlo(true);
|
||||||
|
else if (kind === "SENSITIVITY") setShowSensitivity(true);
|
||||||
|
else setShowLiveSim(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effektive Werte von der Plan-Ebene aus öffnen (braucht das Basisszenario geladen).
|
||||||
|
async function openActualsForPlan(planId: string) {
|
||||||
|
const plan = plans.find((p) => p.id === planId);
|
||||||
|
const baseId = plan?.scenarios.find((s) => s.isBase)?.id ?? plan?.scenarios[0]?.id;
|
||||||
|
if (!baseId) return;
|
||||||
|
if (detail?.meta.id !== baseId) await loadDetail(baseId, true);
|
||||||
|
setShowActuals(true);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeletePlan(id: string) {
|
async function handleDeletePlan(id: string) {
|
||||||
const plan = plans.find((p) => p.id === id);
|
const plan = plans.find((p) => p.id === id);
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
@@ -217,7 +260,9 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
|
const activePlan =
|
||||||
|
plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ??
|
||||||
|
(planNav ? plans.find((p) => p.id === planNav.planId) ?? null : null);
|
||||||
|
|
||||||
// Plan-Sicht und Ist-Sicht in einem Zug. Ohne erfasste Ist-Werte bleibt `actual` null und
|
// Plan-Sicht und Ist-Sicht in einem Zug. Ohne erfasste Ist-Werte bleibt `actual` null und
|
||||||
// die Oberflaeche verhaelt sich exakt wie bisher.
|
// die Oberflaeche verhaelt sich exakt wie bisher.
|
||||||
@@ -231,9 +276,9 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
const paletteActions = useMemo<PaletteAction[]>(() => {
|
const paletteActions = useMemo<PaletteAction[]>(() => {
|
||||||
const base: PaletteAction[] = [
|
const base: PaletteAction[] = [
|
||||||
{ id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowNewPlanChooser(true) },
|
{ id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowNewPlanChooser(true) },
|
||||||
{ id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setShowSpec(false); setShowSystemParams(false); } },
|
{ id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setPlanNav(null); setShowSpec(false); setShowSystemParams(false); } },
|
||||||
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); } },
|
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); setPlanNav(null); } },
|
||||||
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); } },
|
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); setPlanNav(null); } },
|
||||||
];
|
];
|
||||||
if (detail && selectedScenarioId) {
|
if (detail && selectedScenarioId) {
|
||||||
base.unshift(
|
base.unshift(
|
||||||
@@ -270,12 +315,13 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedScenarioId(null);
|
setSelectedScenarioId(null);
|
||||||
|
setPlanNav(null);
|
||||||
setShowSpec(false);
|
setShowSpec(false);
|
||||||
setShowSystemParams(false);
|
setShowSystemParams(false);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
selectedScenarioId === null && !planNav && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
@@ -295,31 +341,67 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
</div>
|
</div>
|
||||||
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Pläne.</p>}
|
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Pläne.</p>}
|
||||||
|
|
||||||
{plans.map((p) => (
|
{plans.map((p) => {
|
||||||
<div key={p.id} className="mb-1">
|
const navHere = planNav?.planId === p.id;
|
||||||
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
|
const subItem = (tab: "dashboard" | "scenarios" | "analyses", label: string, Icon: typeof FolderKanban) => (
|
||||||
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-faint" />
|
<button
|
||||||
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
|
type="button"
|
||||||
|
onClick={() => openPlanTab(p.id, tab)}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-3 text-left text-xs font-medium transition-colors ${
|
||||||
|
navHere && planNav?.tab === tab ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="mb-1">
|
||||||
|
{/* Plan-Name: Klick öffnet das Plan-Dashboard. */}
|
||||||
|
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openPlanTab(p.id, "dashboard")}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||||
|
title={p.name}
|
||||||
|
>
|
||||||
|
<FolderKanban className={`h-3.5 w-3.5 shrink-0 ${navHere && planNav?.tab === "dashboard" ? "text-accent" : "text-faint"}`} />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Plan löschen"
|
||||||
|
onClick={() => handleDeletePlan(p.id)}
|
||||||
|
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unter-Menüpunkte, leicht eingezogen. */}
|
||||||
|
{subItem("scenarios", "Szenarien", Layers)}
|
||||||
|
{/* Der Szenario-Baum bleibt darunter -- ein Klick führt direkt in die Matrix. */}
|
||||||
|
<ScenarioTree
|
||||||
|
scenarios={p.scenarios}
|
||||||
|
parentId={null}
|
||||||
|
depth={1}
|
||||||
|
selectedId={selectedScenarioId}
|
||||||
|
onSelect={openScenario}
|
||||||
|
onCopy={setCopyFrom}
|
||||||
|
onDelete={handleDeleteScenario}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Plan löschen"
|
onClick={() => openActualsForPlan(p.id)}
|
||||||
onClick={() => handleDeletePlan(p.id)}
|
className="flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-3 text-left text-xs font-medium text-muted transition-colors hover:bg-surface-2"
|
||||||
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<CalendarClock className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
Effektive Werte
|
||||||
</button>
|
</button>
|
||||||
|
{subItem("analyses", "Analysen", BarChart3)}
|
||||||
</div>
|
</div>
|
||||||
<ScenarioTree
|
);
|
||||||
scenarios={p.scenarios}
|
})}
|
||||||
parentId={null}
|
|
||||||
depth={0}
|
|
||||||
selectedId={selectedScenarioId}
|
|
||||||
onSelect={openScenario}
|
|
||||||
onCopy={setCopyFrom}
|
|
||||||
onDelete={handleDeleteScenario}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
|
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
|
||||||
<span className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">Wissen</span>
|
<span className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">Wissen</span>
|
||||||
@@ -330,6 +412,7 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
setSpecAnchor(null);
|
setSpecAnchor(null);
|
||||||
setShowSystemParams(false);
|
setShowSystemParams(false);
|
||||||
setSelectedScenarioId(null);
|
setSelectedScenarioId(null);
|
||||||
|
setPlanNav(null);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
||||||
@@ -345,6 +428,7 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
setShowSystemParams(true);
|
setShowSystemParams(true);
|
||||||
setShowSpec(false);
|
setShowSpec(false);
|
||||||
setSelectedScenarioId(null);
|
setSelectedScenarioId(null);
|
||||||
|
setPlanNav(null);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
||||||
@@ -397,9 +481,11 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
? "Systemparameter"
|
? "Systemparameter"
|
||||||
: showSpec
|
: showSpec
|
||||||
? "So rechnet FPT"
|
? "So rechnet FPT"
|
||||||
: detail
|
: planNav
|
||||||
? `${detail.meta.planName} · ${detail.meta.name}`
|
? `${activePlan?.name ?? "Plan"} · ${planNav.tab === "dashboard" ? "Dashboard" : planNav.tab === "scenarios" ? "Szenarien" : "Analysen"}`
|
||||||
: "Übersicht"}
|
: selectedScenarioId && detail
|
||||||
|
? `${detail.meta.planName} · ${detail.meta.name}`
|
||||||
|
: "Übersicht"}
|
||||||
</h1>
|
</h1>
|
||||||
<ProfileMenu username={username} />
|
<ProfileMenu username={username} />
|
||||||
</header>
|
</header>
|
||||||
@@ -409,14 +495,39 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
|
|
||||||
{showSpec && <SpecView anchor={specAnchor} />}
|
{showSpec && <SpecView anchor={specAnchor} />}
|
||||||
|
|
||||||
{!showSpec && !showSystemParams && loading && <PlanSkeleton />}
|
{!showSpec && !showSystemParams && loading && !planNav && <PlanSkeleton />}
|
||||||
|
|
||||||
{!showSpec && !showSystemParams && !loading && selectedScenarioId === null && (
|
{/* Plan-Ebene: Dashboard / Szenarien / Analysen */}
|
||||||
|
{!showSpec && !showSystemParams && planNav?.tab === "dashboard" && (
|
||||||
|
<PlanDashboardView planId={planNav.planId} />
|
||||||
|
)}
|
||||||
|
{!showSpec && !showSystemParams && planNav?.tab === "scenarios" && (
|
||||||
|
<ScenarioListView
|
||||||
|
planId={planNav.planId}
|
||||||
|
onOpenMatrix={openScenario}
|
||||||
|
onOpenHistory={(sid) => {
|
||||||
|
void loadDetail(sid, true).then(() => setShowHistory(true));
|
||||||
|
}}
|
||||||
|
onNew={() => {
|
||||||
|
const base = activePlan?.scenarios.find((s) => s.isBase) ?? activePlan?.scenarios[0];
|
||||||
|
if (base) setCopyFrom({ id: base.id, planId: base.planId, planName: activePlan?.name ?? "", name: base.name, isBase: base.isBase, parentScenarioId: base.parentScenarioId } as ScenarioMeta);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!showSpec && !showSystemParams && planNav?.tab === "analyses" && (
|
||||||
|
<AnalysesView
|
||||||
|
planId={planNav.planId}
|
||||||
|
onLaunch={(kind) => void launchTool(planNav.planId, kind)}
|
||||||
|
onOpenSaved={(id) => setSavedAnalysisId(id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showSpec && !showSystemParams && !loading && !planNav && selectedScenarioId === null && (
|
||||||
<DashboardHome
|
<DashboardHome
|
||||||
username={username}
|
username={username}
|
||||||
plans={plans}
|
plans={plans}
|
||||||
creatingDemo={creatingDemo}
|
creatingDemo={creatingDemo}
|
||||||
onSelect={openScenario}
|
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
|
||||||
onCreateGuided={() => setShowWizard(true)}
|
onCreateGuided={() => setShowWizard(true)}
|
||||||
onCreateChooser={() => setShowNewPlanChooser(true)}
|
onCreateChooser={() => setShowNewPlanChooser(true)}
|
||||||
onCreateDemo={handleCreateDemo}
|
onCreateDemo={handleCreateDemo}
|
||||||
@@ -546,6 +657,7 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
<ChartsDialog onClose={() => setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}>
|
<ChartsDialog onClose={() => setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}>
|
||||||
<Dashboard
|
<Dashboard
|
||||||
plan={detail.plan}
|
plan={detail.plan}
|
||||||
|
planId={detail.meta.planId}
|
||||||
computed={detail.computed}
|
computed={detail.computed}
|
||||||
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
|
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
|
||||||
actuals={detail.actuals ?? []}
|
actuals={detail.actuals ?? []}
|
||||||
@@ -569,6 +681,7 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
{showSensitivity && detail && (
|
{showSensitivity && detail && (
|
||||||
<SensitivityDialog
|
<SensitivityDialog
|
||||||
plan={detail.plan}
|
plan={detail.plan}
|
||||||
|
planId={detail.meta.planId}
|
||||||
actuals={detail.actuals ?? []}
|
actuals={detail.actuals ?? []}
|
||||||
origins={detail.elementOrigins ?? []}
|
origins={detail.elementOrigins ?? []}
|
||||||
onClose={() => setShowSensitivity(false)}
|
onClose={() => setShowSensitivity(false)}
|
||||||
@@ -584,6 +697,14 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{savedAnalysisId && planNav && (
|
||||||
|
<SavedAnalysisView
|
||||||
|
planId={planNav.planId}
|
||||||
|
analysisId={savedAnalysisId}
|
||||||
|
onClose={() => setSavedAnalysisId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showActuals && detail && activePlan && (
|
{showActuals && detail && activePlan && (
|
||||||
<ActualsDialog
|
<ActualsDialog
|
||||||
planId={detail.meta.planId}
|
planId={detail.meta.planId}
|
||||||
@@ -817,7 +938,7 @@ function DashboardHome({
|
|||||||
username,
|
username,
|
||||||
plans,
|
plans,
|
||||||
creatingDemo,
|
creatingDemo,
|
||||||
onSelect,
|
onOpenPlan,
|
||||||
onCreateGuided,
|
onCreateGuided,
|
||||||
onCreateChooser,
|
onCreateChooser,
|
||||||
onCreateDemo,
|
onCreateDemo,
|
||||||
@@ -826,7 +947,7 @@ function DashboardHome({
|
|||||||
username: string;
|
username: string;
|
||||||
plans: PlanListItem[];
|
plans: PlanListItem[];
|
||||||
creatingDemo: boolean;
|
creatingDemo: boolean;
|
||||||
onSelect: (scenarioId: string) => void;
|
onOpenPlan: (planId: string) => void;
|
||||||
onCreateGuided: () => void;
|
onCreateGuided: () => void;
|
||||||
onCreateChooser: () => void;
|
onCreateChooser: () => void;
|
||||||
onCreateDemo: () => void;
|
onCreateDemo: () => void;
|
||||||
@@ -858,13 +979,15 @@ function DashboardHome({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{plans.map((p) => {
|
{plans.map((p) => {
|
||||||
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
|
|
||||||
const others = p.scenarios.length - 1;
|
const others = p.scenarios.length - 1;
|
||||||
|
const persons = (p.persons ?? [])
|
||||||
|
.map((pe) => pe.name || (pe.role === "PERSON_A" ? "Person A" : "Person B"))
|
||||||
|
.join(" · ");
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
|
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||||
onClick={() => base && onSelect(base.id)}
|
onClick={() => onOpenPlan(p.id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
|
||||||
@@ -873,7 +996,8 @@ function DashboardHome({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate font-medium text-fg">{p.name}</div>
|
<div className="truncate font-medium text-fg">{p.name}</div>
|
||||||
<div className="text-xs text-muted">
|
<div className="text-xs text-muted">
|
||||||
Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""}
|
{persons || "Basisszenario"}
|
||||||
|
{others > 0 ? ` · +${others} Variante${others === 1 ? "" : "n"}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -888,6 +1012,14 @@ function DashboardHome({
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ein paar Kennzahlen pro Plan direkt in der Übersicht. */}
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||||
|
<span>{p.scenarios.length} Szenario{p.scenarios.length === 1 ? "" : "s"}</span>
|
||||||
|
{p.startYear && <span>ab {p.startYear}</span>}
|
||||||
|
{p._count && p._count.actuals > 0 && <span>{p._count.actuals} Ist-Datensatz{p._count.actuals === 1 ? "" : "e"}</span>}
|
||||||
|
{p._count && p._count.analyses > 0 && <span>{p._count.analyses} Analyse{p._count.analyses === 1 ? "" : "n"}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+152
-60
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
|
import { BarChart3, LineChart as LineChartIcon } from "lucide-react";
|
||||||
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
|
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
|
||||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||||
|
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
|
||||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
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";
|
||||||
@@ -18,20 +19,31 @@ interface PlanListItem {
|
|||||||
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: currentPlan,
|
plan: currentPlan,
|
||||||
|
planId,
|
||||||
computed: currentComputed,
|
computed: currentComputed,
|
||||||
siblings,
|
siblings,
|
||||||
actuals = [],
|
actuals = [],
|
||||||
origins = [],
|
origins = [],
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
|
planId: string;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
||||||
siblings: PlanListItem[];
|
siblings: PlanListItem[];
|
||||||
actuals?: ActualsSetInput[];
|
actuals?: ActualsSetInput[];
|
||||||
origins?: ElementOrigin[];
|
origins?: ElementOrigin[];
|
||||||
}) {
|
}) {
|
||||||
|
// Eine Grafik zur Zeit -- man wählt zuerst, welche (Punkt 5 der Analysen-Ansicht).
|
||||||
|
const [chartId, setChartId] = useState<ChartId>("wealth");
|
||||||
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
|
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
|
||||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||||
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
|
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
|
||||||
@@ -92,77 +104,157 @@ export function Dashboard({
|
|||||||
|
|
||||||
<AnalysisBar basis={basis} />
|
<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={currentPlan.name}
|
||||||
|
versionLabel={versionId === "current" ? null : versionId}
|
||||||
|
build={() => buildChartResult(chartId, computed, series, basis)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<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 (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
||||||
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
{chartId === "cashflow" && (
|
||||||
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
<LineChartIcon className="h-4 w-4 text-accent" />
|
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||||
Einkommen vs. Ausgaben pro Jahr
|
|
||||||
</h3>
|
|
||||||
<p className="mb-3 text-xs text-muted">
|
|
||||||
Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- (grün) bzw. Verzehrquote (rot).
|
|
||||||
Die blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
|
||||||
</p>
|
|
||||||
<SparquoteChart computed={computed} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<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" />
|
||||||
Vermögensverlauf nach Alter
|
Einkommen vs. Ausgaben pro Jahr
|
||||||
</h3>
|
</h3>
|
||||||
{/* Der Export liest immer das Szenario aus der Datenbank -- also den aktuellen
|
<p className="mb-3 text-xs text-muted">
|
||||||
Stand, nicht die betrachtete Version. Das wird beschriftet statt verschwiegen. */}
|
Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- (grün) bzw. Verzehrquote (rot).
|
||||||
<a
|
Die blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
||||||
href={`/api/scenarios/${currentPlan.id}/export`}
|
</p>
|
||||||
title={
|
<SparquoteChart computed={computed} />
|
||||||
versionId === "current"
|
</section>
|
||||||
? undefined
|
)}
|
||||||
: "Der CSV-Export liefert immer den aktuellen Stand, nicht die betrachtete Version."
|
|
||||||
}
|
|
||||||
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{versionId === "current" ? "" : " (aktueller Stand)"}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{otherPlans.length > 0 && (
|
|
||||||
<div className="mb-3 flex flex-wrap gap-2">
|
|
||||||
<span className="text-xs text-muted">Szenarien vergleichen:</span>
|
|
||||||
{otherPlans.map((p) => (
|
|
||||||
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={compareIds.includes(p.id)}
|
|
||||||
onChange={() => toggleCompare(p.id)}
|
|
||||||
/>
|
|
||||||
{p.name}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<WealthChart series={series} metric={basis.metric} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
{chartId === "wealth" && (
|
||||||
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
<BarChart3 className="h-4 w-4 text-accent" />
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
Vermögensaufteilung pro Phase (Beginn & Ende)
|
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||||
</h3>
|
<LineChartIcon className="h-4 w-4 text-accent" />
|
||||||
<p className="mb-3 text-xs text-muted">
|
Vermögensverlauf nach Alter
|
||||||
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
|
</h3>
|
||||||
Gesamtvolumen dem Beginn der nächsten – die Aufteilung kann durch Umschichtung abweichen.
|
</div>
|
||||||
</p>
|
{otherPlans.length > 0 && (
|
||||||
<AllocationChart computed={computed} />
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
</section>
|
<span className="text-xs text-muted">Szenarien vergleichen:</span>
|
||||||
|
{otherPlans.map((p) => (
|
||||||
|
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
|
||||||
|
<input type="checkbox" checked={compareIds.includes(p.id)} onChange={() => toggleCompare(p.id)} />
|
||||||
|
{p.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<WealthChart series={series} metric={basis.metric} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chartId === "allocation" && (
|
||||||
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
|
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||||
|
<BarChart3 className="h-4 w-4 text-accent" />
|
||||||
|
Vermögensaufteilung pro Phase (Beginn & Ende)
|
||||||
|
</h3>
|
||||||
|
<p className="mb-3 text-xs text-muted">
|
||||||
|
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
|
||||||
|
Gesamtvolumen dem Beginn der nächsten – die Aufteilung kann durch Umschichtung abweichen.
|
||||||
|
</p>
|
||||||
|
<AllocationChart computed={computed} />
|
||||||
|
</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: "#16a34a", points: computed.yearly.map((y) => ({ x: y.year, y: y.income })) },
|
||||||
|
{ label: "Ausgaben", color: "#dc2626", points: computed.yearly.map((y) => ({ x: y.year, y: y.expenseNominal })) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 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">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/com
|
|||||||
import { InfoBubble } from "@/components/InfoBubble";
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
|
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
|
||||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||||
|
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
|
||||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { computePlan } from "@/lib/calculations";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
@@ -596,21 +597,93 @@ export function MonteCarloDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{outcomes && fanRes && (
|
{outcomes && fanRes && (
|
||||||
<MonteCarloResults
|
<>
|
||||||
outcomes={outcomes}
|
<div className="flex justify-end">
|
||||||
fanRes={fanRes}
|
<SaveAnalysisButton
|
||||||
detPoints={
|
planId={meta.planId}
|
||||||
fanRes.length === 1 && loaded[fanRes[0].scenarioId]
|
type="MONTE_CARLO"
|
||||||
? detPointsOf(loaded[fanRes[0].scenarioId].plan, loaded[fanRes[0].scenarioId].computed)
|
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>
|
||||||
</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 }) {
|
function ReadOnlySigma({ value }: { value: number }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
Download,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Home,
|
Home,
|
||||||
Landmark,
|
Landmark,
|
||||||
@@ -490,11 +491,18 @@ export function PlanView({
|
|||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" /> Lebensphase
|
<Plus className="h-4 w-4" /> Lebensphase
|
||||||
</button>
|
</button>
|
||||||
|
{/* CSV-Export der Matrix. Liest immer den aktuellen Stand aus der Datenbank. */}
|
||||||
|
<a
|
||||||
|
href={`/api/scenarios/${plan.id}/export`}
|
||||||
|
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" /> CSV-Export
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowTour(true)}
|
onClick={() => setShowTour(true)}
|
||||||
title="Kurze Tour durch die Ansicht"
|
title="Kurze Tour durch die Ansicht"
|
||||||
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
className="flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
||||||
>
|
>
|
||||||
<HelpCircle className="h-3.5 w-3.5" /> Tour
|
<HelpCircle className="h-3.5 w-3.5" /> Tour
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Dices,
|
||||||
|
Eye,
|
||||||
|
GitBranch,
|
||||||
|
History,
|
||||||
|
LineChart as LineChartIcon,
|
||||||
|
Plus,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Table2,
|
||||||
|
Tornado,
|
||||||
|
Trash2,
|
||||||
|
} 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";
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
currentMajor: number;
|
||||||
|
elementCount: 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, hint, tone }: { label: string; value: string; hint?: string; tone?: "danger" }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
|
<div className="text-xs text-muted">{label}</div>
|
||||||
|
<div className={`mt-1 text-xl font-semibold ${tone === "danger" ? "text-danger" : "text-fg"}`}>{value}</div>
|
||||||
|
{hint && <div className="mt-0.5 text-[11px] text-faint">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================================
|
||||||
|
// Plan-Dashboard
|
||||||
|
// =========================================================================================
|
||||||
|
export function PlanDashboardView({ planId }: { planId: string }) {
|
||||||
|
const { data, error } = useDashboard(planId, 0);
|
||||||
|
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(" · ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-fg">{plan.name}</h2>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons}
|
||||||
|
{plan.startYear ? ` · Planstart ${plan.startYear}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Stat label="Szenarien" value={String(counts.scenarios)} />
|
||||||
|
<Stat label="Effektive Werte" value={String(counts.actuals)} hint="erfasste Ist-Datensätze" />
|
||||||
|
<Stat label="Gespeicherte Analysen" value={String(counts.analyses)} />
|
||||||
|
<Stat label="Lebensphasen" value={base ? String(base.phaseCount) : "–"} hint="laut Basisszenario" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{base && (
|
||||||
|
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-fg">
|
||||||
|
Basisszenario «{base.name}»
|
||||||
|
<span className="ml-2 text-[11px] font-normal text-faint">alle Kennzahlen beziehen sich hierauf</span>
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
|
<Stat label="Endvermögen (nominal)" value={formatChf(base.endNominal)} />
|
||||||
|
<Stat label="Endvermögen (real)" value={formatChf(base.endReal)} />
|
||||||
|
{base.ruinAge !== null ? (
|
||||||
|
<Stat label="Kapital reicht" value={`bis Alter ${base.ruinAge}`} tone="danger" hint="danach aufgebraucht" />
|
||||||
|
) : (
|
||||||
|
<Stat label="Kapital reicht" value="bis Planende" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{base.actualEndNominal !== null && (
|
||||||
|
<p className="mt-3 rounded-lg border border-accent bg-accent-soft/20 px-3 py-2 text-xs text-accent-soft-fg">
|
||||||
|
Mit den effektiven Werten liegt das Endvermögen (nominal) bei{" "}
|
||||||
|
<strong>{formatChf(base.actualEndNominal)}</strong> – eine Abweichung von{" "}
|
||||||
|
<strong>{formatChf(base.actualEndNominal - base.endNominal)}</strong> gegenüber dem Plan.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================================
|
||||||
|
// Szenario-Liste
|
||||||
|
// =========================================================================================
|
||||||
|
export function ScenarioListView({
|
||||||
|
planId,
|
||||||
|
onOpenMatrix,
|
||||||
|
onOpenHistory,
|
||||||
|
onNew,
|
||||||
|
}: {
|
||||||
|
planId: string;
|
||||||
|
onOpenMatrix: (scenarioId: string) => void;
|
||||||
|
onOpenHistory: (scenarioId: string) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
}) {
|
||||||
|
const { data, error } = useDashboard(planId, 0);
|
||||||
|
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]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-fg">Szenarien</h2>
|
||||||
|
<Button onClick={onNew}>
|
||||||
|
<Plus className="h-4 w-4" /> Neues Szenario
|
||||||
|
</Button>
|
||||||
|
</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">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) => (
|
||||||
|
<tr key={s.id} className={`border-t border-border ${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 tabular-nums text-muted">{s.currentMajor}.x</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>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
onClick={() => onOpenMatrix(s.id)}
|
||||||
|
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<Table2 className="h-3.5 w-3.5" /> Matrix
|
||||||
|
</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-colors hover:border-accent"
|
||||||
|
>
|
||||||
|
{!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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { RequiredNumberField } from "@/components/FormField";
|
|||||||
import { InfoBubble } from "@/components/InfoBubble";
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||||
|
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
|
||||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
@@ -39,11 +40,13 @@ function formatRange(low: number, high: number, unit: DriverDef["unit"]): string
|
|||||||
|
|
||||||
export function SensitivityDialog({
|
export function SensitivityDialog({
|
||||||
plan: currentPlan,
|
plan: currentPlan,
|
||||||
|
planId,
|
||||||
actuals = [],
|
actuals = [],
|
||||||
origins = [],
|
origins = [],
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
|
planId: string;
|
||||||
actuals?: ActualsSetInput[];
|
actuals?: ActualsSetInput[];
|
||||||
origins?: ElementOrigin[];
|
origins?: ElementOrigin[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -244,12 +247,64 @@ export function SensitivityDialog({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result && <TornadoResults result={result} metric={metric} chartData={chartData} />}
|
{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>
|
||||||
</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({
|
function TornadoResults({
|
||||||
result,
|
result,
|
||||||
metric,
|
metric,
|
||||||
|
|||||||
@@ -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[] };
|
||||||
|
}
|
||||||
@@ -126,6 +126,12 @@ describe("Datenbank-Migrationen", () => {
|
|||||||
WHERE table_name='ActualsSet' AND column_name='cash'`
|
WHERE table_name='ActualsSet' AND column_name='cash'`
|
||||||
);
|
);
|
||||||
expect(cashCol.rows[0].is_nullable).toBe("YES");
|
expect(cashCol.rows[0].is_nullable).toBe("YES");
|
||||||
|
|
||||||
|
// --- Gespeicherte Analysen ---
|
||||||
|
expect(tables, "Tabelle SavedAnalysis fehlt").toContain("SavedAnalysis");
|
||||||
|
for (const c of ["planId", "name", "type", "metric", "source", "inputs", "result", "createdById"]) {
|
||||||
|
expect(await cols("SavedAnalysis"), `SavedAnalysis.${c} fehlt`).toContain(c);
|
||||||
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ export interface PlanListItem {
|
|||||||
name: string;
|
name: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
scenarios: ScenarioMeta[];
|
scenarios: ScenarioMeta[];
|
||||||
|
// Seit V7 am Plan (für die Übersichtskarten). Optional, weil ältere Aufrufer sie nicht laden.
|
||||||
|
householdType?: "SINGLE" | "COUPLE";
|
||||||
|
startYear?: number | null;
|
||||||
|
persons?: { role: PersonRole; name: string | null; age: number }[];
|
||||||
|
_count?: { actuals: number; analyses: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und trägt ihr
|
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und trägt ihr
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const EXEMPT: Record<string, string> = {
|
|||||||
"plans/[planId]/actuals":
|
"plans/[planId]/actuals":
|
||||||
"Ist-Werte sind eine Beobachtung, keine Planänderung – sie verändern kein Szenario",
|
"Ist-Werte sind eine Beobachtung, keine Planänderung – sie verändern kein Szenario",
|
||||||
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
|
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
|
||||||
|
"plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen – kein Szenario betroffen",
|
||||||
|
"plans/[planId]/analyses/[analysisId]": "dito (Öffnen/Löschen einer Analyse)",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Versionierung: Abdeckung der Schreibpfade", () => {
|
describe("Versionierung: Abdeckung der Schreibpfade", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user