Navigation auf Plan-Ebene und gespeicherte Analysen (Roadmap-Redesign)
Deploy App / deploy (push) Successful in 1m45s

Sidebar zweistufig: pro Plan die Unterpunkte Szenarien / Effektive Werte /
Analysen; Klick auf Plan-Name oeffnet ein Plan-Dashboard.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 22:21:02 +02:00
parent ce5f83823f
commit fb70781e5b
19 changed files with 1720 additions and 113 deletions
+116 -3
View File
@@ -4,7 +4,7 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.23 |
| **Version** | 0.24 |
| **Datum** | 2026-07-18 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
@@ -17,6 +17,7 @@
| 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.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). |
@@ -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`,
`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
@@ -2682,7 +2747,7 @@ Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`,
FPT/
├── prisma/
│ ├── schema.prisma Datenmodell
│ └── migrations/ 15 Migrationen (chronologisch, siehe 5.4.6)
│ └── migrations/ 16 Migrationen (chronologisch, siehe 5.4.6)
├── src/
│ ├── app/
│ │ ├── 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. |
| `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. |
| `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. |
| `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. |
@@ -2941,7 +3007,8 @@ sondern zu leeren Werten.
| `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` |
| `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
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 |
| `VersionHistoryDialog` | ~290 | Änderungshistorie: Liste, Hauptversion festlegen, Anzeigen, Wiederherstellen mit Warnung ([3.8](#38-versionierung-und-änderungshistorie)) |
| `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 |
| `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 |
@@ -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
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
### `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
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
@@ -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;
+31
View File
@@ -35,6 +35,7 @@ model User {
plans Plan[]
versions ScenarioVersion[]
actuals ActualsSet[]
analyses SavedAnalysis[]
}
enum HouseholdType {
@@ -111,6 +112,36 @@ model Plan {
scenarios Scenario[]
actuals ActualsSet[]
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).
@@ -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,
});
}
+5
View File
@@ -44,10 +44,15 @@ export async function GET() {
id: true,
name: true,
createdAt: true,
householdType: true,
startYear: true,
persons: { select: { role: true, name: true, age: true }, orderBy: { role: "asc" } },
scenarios: {
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
select: { id: true, planId: true, name: true, isBase: true, parentScenarioId: true },
},
// Zähler für die Übersicht und das Plan-Dashboard -- ohne die Datensätze selbst zu laden.
_count: { select: { actuals: true, analyses: true } },
},
});
return NextResponse.json({ plans });
+171 -39
View File
@@ -11,6 +11,7 @@ import {
CalendarClock,
GitBranch,
History,
Layers,
LayoutDashboard,
Menu,
PiggyBank,
@@ -28,6 +29,8 @@ import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { LiveSimDialog } from "@/components/LiveSimDialog";
import { ActualsDialog } from "@/components/ActualsDialog";
import { PlanDashboardView, ScenarioListView, AnalysesView } from "@/components/PlanViews";
import { SavedAnalysisView } from "@/components/SavedAnalysisView";
import { buildViews } from "@/lib/dataview";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
@@ -99,6 +102,9 @@ function AppShellInner({ username }: { username: string }) {
const [showLiveSim, setShowLiveSim] = useState(false);
const [showHistory, setShowHistory] = 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 [showPlanTraces, setShowPlanTraces] = useState(false);
const [showPalette, setShowPalette] = useState(false);
@@ -110,6 +116,7 @@ function AppShellInner({ username }: { username: string }) {
setShowSpec(true);
setShowSystemParams(false);
setSelectedScenarioId(null);
setPlanNav(null);
}
const loadPlans = useCallback(async () => {
@@ -123,7 +130,9 @@ function AppShellInner({ username }: { username: string }) {
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
if (!silent) setLoading(true);
try {
setDetail(await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`));
const d = await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`);
setDetail(d);
return d;
} finally {
if (!silent) setLoading(false);
}
@@ -138,9 +147,9 @@ function AppShellInner({ username }: { username: string }) {
if (selectedScenarioId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf
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]);
// Cmd/Ctrl+K öffnet die Befehls-Palette.
@@ -161,11 +170,45 @@ function AppShellInner({ username }: { username: string }) {
function openScenario(id: string) {
setSelectedScenarioId(id);
setPlanNav(null);
setShowSpec(false);
setShowSystemParams(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) {
const plan = plans.find((p) => p.id === id);
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
// die Oberflaeche verhaelt sich exakt wie bisher.
@@ -231,9 +276,9 @@ function AppShellInner({ username }: { username: string }) {
const paletteActions = useMemo<PaletteAction[]>(() => {
const base: PaletteAction[] = [
{ 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-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); } },
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); } },
{ 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); setPlanNav(null); } },
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); setPlanNav(null); } },
];
if (detail && selectedScenarioId) {
base.unshift(
@@ -270,12 +315,13 @@ function AppShellInner({ username }: { username: string }) {
type="button"
onClick={() => {
setSelectedScenarioId(null);
setPlanNav(null);
setShowSpec(false);
setShowSystemParams(false);
setSidebarOpen(false);
}}
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" />
@@ -295,31 +341,67 @@ function AppShellInner({ username }: { username: string }) {
</div>
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Pläne.</p>}
{plans.map((p) => (
<div key={p.id} className="mb-1">
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-faint" />
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
{plans.map((p) => {
const navHere = planNav?.planId === p.id;
const subItem = (tab: "dashboard" | "scenarios" | "analyses", label: string, Icon: typeof FolderKanban) => (
<button
type="button"
onClick={() => openPlanTab(p.id, tab)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-3 text-left text-xs font-medium transition-colors ${
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
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"
onClick={() => openActualsForPlan(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"
>
<Trash2 className="h-3.5 w-3.5" />
<CalendarClock className="h-3.5 w-3.5 shrink-0" />
Effektive Werte
</button>
{subItem("analyses", "Analysen", BarChart3)}
</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">
<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);
setShowSystemParams(false);
setSelectedScenarioId(null);
setPlanNav(null);
setSidebarOpen(false);
}}
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);
setShowSpec(false);
setSelectedScenarioId(null);
setPlanNav(null);
setSidebarOpen(false);
}}
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"
: showSpec
? "So rechnet FPT"
: detail
? `${detail.meta.planName} · ${detail.meta.name}`
: "Übersicht"}
: planNav
? `${activePlan?.name ?? "Plan"} · ${planNav.tab === "dashboard" ? "Dashboard" : planNav.tab === "scenarios" ? "Szenarien" : "Analysen"}`
: selectedScenarioId && detail
? `${detail.meta.planName} · ${detail.meta.name}`
: "Übersicht"}
</h1>
<ProfileMenu username={username} />
</header>
@@ -409,14 +495,39 @@ function AppShellInner({ username }: { username: string }) {
{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
username={username}
plans={plans}
creatingDemo={creatingDemo}
onSelect={openScenario}
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
onCreateGuided={() => setShowWizard(true)}
onCreateChooser={() => setShowNewPlanChooser(true)}
onCreateDemo={handleCreateDemo}
@@ -546,6 +657,7 @@ function AppShellInner({ username }: { username: string }) {
<ChartsDialog onClose={() => setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}>
<Dashboard
plan={detail.plan}
planId={detail.meta.planId}
computed={detail.computed}
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
actuals={detail.actuals ?? []}
@@ -569,6 +681,7 @@ function AppShellInner({ username }: { username: string }) {
{showSensitivity && detail && (
<SensitivityDialog
plan={detail.plan}
planId={detail.meta.planId}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
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 && (
<ActualsDialog
planId={detail.meta.planId}
@@ -817,7 +938,7 @@ function DashboardHome({
username,
plans,
creatingDemo,
onSelect,
onOpenPlan,
onCreateGuided,
onCreateChooser,
onCreateDemo,
@@ -826,7 +947,7 @@ function DashboardHome({
username: string;
plans: PlanListItem[];
creatingDemo: boolean;
onSelect: (scenarioId: string) => void;
onOpenPlan: (planId: string) => void;
onCreateGuided: () => void;
onCreateChooser: () => 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">
{plans.map((p) => {
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
const others = p.scenarios.length - 1;
const persons = (p.persons ?? [])
.map((pe) => pe.name || (pe.role === "PERSON_A" ? "Person A" : "Person B"))
.join(" · ");
return (
<div
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"
onClick={() => base && onSelect(base.id)}
onClick={() => onOpenPlan(p.id)}
>
<div className="flex items-center gap-2">
<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="truncate font-medium text-fg">{p.name}</div>
<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>
<button
@@ -888,6 +1012,14 @@ function DashboardHome({
<Trash2 className="h-4 w-4" />
</button>
</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>
);
})}
+152 -60
View File
@@ -1,10 +1,11 @@
"use client";
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 { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { SparquoteChart } from "@/components/SparquoteChart";
@@ -18,20 +19,31 @@ interface PlanListItem {
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({
plan: currentPlan,
planId,
computed: currentComputed,
siblings,
actuals = [],
origins = [],
}: {
plan: PlanInput;
planId: string;
computed: PlanComputed;
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
}) {
// Eine Grafik zur Zeit -- man wählt zuerst, welche (Punkt 5 der Analysen-Ansicht).
const [chartId, setChartId] = useState<ChartId>("wealth");
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
@@ -92,77 +104,157 @@ export function Dashboard({
<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">
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
</div>
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
<LineChartIcon className="h-4 w-4 text-accent" />
Einkommen vs. Ausgaben pro Jahr
</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 &ndash; 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">
{chartId === "cashflow" && (
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
<LineChartIcon className="h-4 w-4 text-accent" />
Vermögensverlauf nach Alter
Einkommen vs. Ausgaben pro Jahr
</h3>
{/* Der Export liest immer das Szenario aus der Datenbank -- also den aktuellen
Stand, nicht die betrachtete Version. Das wird beschriftet statt verschwiegen. */}
<a
href={`/api/scenarios/${currentPlan.id}/export`}
title={
versionId === "current"
? 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>
<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 &ndash; 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">
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
<BarChart3 className="h-4 w-4 text-accent" />
Vermögensaufteilung pro Phase (Beginn &amp; Ende)
</h3>
<p className="mb-3 text-xs text-muted">
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
Gesamtvolumen dem Beginn der nächsten &ndash; die Aufteilung kann durch Umschichtung abweichen.
</p>
<AllocationChart computed={computed} />
</section>
{chartId === "wealth" && (
<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" />
Vermögensverlauf nach Alter
</h3>
</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>
)}
{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 &amp; 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 &ndash; die Aufteilung kann durch Umschichtung abweichen.
</p>
<AllocationChart computed={computed} />
</section>
)}
</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 }) {
return (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
+82 -9
View File
@@ -7,6 +7,7 @@ import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/com
import { InfoBubble } from "@/components/InfoBubble";
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { computePlan } from "@/lib/calculations";
import { api } from "@/lib/api-client";
@@ -596,21 +597,93 @@ export function MonteCarloDialog({
</div>
{outcomes && fanRes && (
<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 className="flex justify-end">
<SaveAnalysisButton
planId={meta.planId}
type="MONTE_CARLO"
scenarioName={outcomes.length === 1 ? outcomes[0].name : `${outcomes.length} Szenarien`}
versionLabel={null}
build={() => buildMcResult(outcomes, fanRes, manualTarget, basis)}
/>
</div>
<MonteCarloResults
outcomes={outcomes}
fanRes={fanRes}
detPoints={
fanRes.length === 1 && loaded[fanRes[0].scenarioId]
? detPointsOf(loaded[fanRes[0].scenarioId].plan, loaded[fanRes[0].scenarioId].computed)
: []
}
/>
</>
)}
</div>
</div>
);
}
// Baut die generische, druckfähige Momentaufnahme des MC-Ergebnisses (siehe analyses.ts).
// Bewusst OHNE finalWealthSorted -- das sind zehntausende Zahlen je Lauf.
function buildMcResult(
outcomes: Outcome[],
fanRes: ScenarioMcResult[],
target: number,
basis: { metric: string; source: string }
): { metric?: string; source?: string; scenarioName?: string | null; summary?: string | null; inputs: Record<string, unknown>; result: Record<string, unknown> } {
const single = outcomes.length === 1;
const table = {
columns: ["Szenario", "Planung hist. (Plan)", "Ziel hist. (Plan)", "Ruin", "Median"],
rows: outcomes.map((o) => [
o.name,
`${Math.round(o.f1 * 100)} % (${Math.round(o.f2 * 100)} %)`,
`${Math.round(o.f3 * 100)} % (${Math.round(o.f4 * 100)} %)`,
`${Math.round(o.ruin * 100)} %`,
formatChf(o.medianHist),
]),
};
// Fächer nur beim Einzelszenario; bei mehreren die Median-Linien.
const chart = {
kind: "line" as const,
xLabel: "Alter",
yFormat: "chf" as const,
series: fanRes.map((r, i) => ({
label: r.name,
color: PALETTE[i % PALETTE.length],
points: r.bands.map((b) => ({ x: b.age, y: b.p50 })),
})),
};
return {
metric: basis.metric,
source: basis.source,
scenarioName: single ? outcomes[0].name : `${outcomes.length} Szenarien`,
summary: single ? `Realismus ${Math.round(outcomes[0].f1 * 100)} %` : null,
inputs: {
params: [
{ label: "Zielbetrag", value: formatChf(target) },
{ label: "Läufe/Szenario", value: outcomes[0]?.runs.toLocaleString("de-CH") ?? "" },
{ label: "Grösse", value: basis.metric === "real" ? "real" : "nominal" },
{ label: "Grundlage", value: basis.source === "ACTUAL" ? "effektiv" : "Plan" },
],
},
result: {
params: [
{ label: "Zielbetrag", value: formatChf(target) },
{ label: "Läufe/Szenario", value: outcomes[0]?.runs.toLocaleString("de-CH") ?? "" },
],
metrics: single
? [
{ label: "Realismus der Planung", value: `${Math.round(outcomes[0].f1 * 100)} %` },
{ label: "Ziel erreicht (historisch)", value: `${Math.round(outcomes[0].f3 * 100)} %` },
{ label: "Median (historisch)", value: formatChf(outcomes[0].medianHist) },
]
: [],
table,
chart,
},
};
}
function ReadOnlySigma({ value }: { value: number }) {
return (
<div>
+9 -1
View File
@@ -9,6 +9,7 @@ import {
ChevronDown,
ChevronRight,
CreditCard,
Download,
HelpCircle,
Home,
Landmark,
@@ -490,11 +491,18 @@ export function PlanView({
>
<Plus className="h-4 w-4" /> Lebensphase
</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
type="button"
onClick={() => setShowTour(true)}
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
</button>
+418
View File
@@ -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>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { Bookmark, Check } from "lucide-react";
import { useToast } from "@/components/ui";
import { defaultAnalysisName, saveAnalysis, type AnalysisType, type SaveAnalysisPayload } from "@/lib/analyses";
// Einheitlicher Speichern-Knopf für die Analysewerkzeuge. `build` wird erst beim Klick
// aufgerufen -- so wird das (evtl. grosse) Ergebnis nicht bei jedem Render zusammengebaut,
// sondern nur, wenn wirklich gespeichert wird.
export function SaveAnalysisButton({
planId,
type,
scenarioName,
versionLabel,
disabled,
build,
}: {
planId: string;
type: AnalysisType;
scenarioName: string | null;
versionLabel: string | null;
disabled?: boolean;
build: () => Omit<SaveAnalysisPayload, "name" | "type"> | null;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const toast = useToast();
function start() {
setName(defaultAnalysisName(type, scenarioName, versionLabel));
setOpen(true);
}
async function save() {
const payload = build();
if (!payload) {
toast("error", "Es gibt noch kein Ergebnis zum Speichern.");
return;
}
setBusy(true);
try {
await saveAnalysis(planId, { ...payload, name: name.trim() || defaultAnalysisName(type, scenarioName, versionLabel), type });
setOpen(false);
setDone(true);
toast("success", "Analyse gespeichert.");
setTimeout(() => setDone(false), 2000);
} catch (e) {
toast("error", e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setBusy(false);
}
}
if (open) {
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent bg-accent-soft/20 p-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && save()}
className="min-w-0 flex-1 rounded border border-border bg-surface px-2 py-1 text-sm text-fg"
/>
<button
type="button"
disabled={busy}
onClick={save}
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-accent-fg hover:bg-accent-hover disabled:opacity-50"
>
{busy ? "Speichern…" : "Speichern"}
</button>
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-border px-2 py-1 text-xs text-muted hover:bg-surface-2">
Abbrechen
</button>
</div>
);
}
return (
<button
type="button"
disabled={disabled}
onClick={start}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
{done ? <Check className="h-3.5 w-3.5 text-success" /> : <Bookmark className="h-3.5 w-3.5" />}
{done ? "Gespeichert" : "Analyse speichern"}
</button>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useEffect, useState } from "react";
import { Bar, BarChart, CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Eye, X } from "lucide-react";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedResult } from "@/lib/analyses";
interface Full {
id: string;
name: string;
type: AnalysisType;
scenarioName: string | null;
versionLabel: string | null;
metric: string;
source: string;
createdAt: string;
result: SavedResult;
}
// Nur-Lese-Ansicht einer gespeicherten Analyse. Es wird NICHTS neu gerechnet -- die beim
// Speichern festgehaltenen Zahlen werden nur gezeichnet.
export function SavedAnalysisView({ planId, analysisId, onClose }: { planId: string; analysisId: string; onClose: () => void }) {
const [data, setData] = useState<Full | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const d = await api.get<{ analysis: Full }>(`/api/plans/${planId}/analyses/${analysisId}`);
if (!cancelled) setData(d.analysis);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [planId, analysisId]);
const r = data?.result;
return (
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<Eye className="h-5 w-5 text-accent" />
<span className="truncate">{data?.name ?? "Analyse"}</span>
</h2>
{data && (
<p className="mt-0.5 text-xs text-muted">
{ANALYSIS_TYPE_LABEL[data.type]} · gespeichert{" "}
{new Date(data.createdAt).toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" })}
{data.scenarioName ? ` · ${data.scenarioName}${data.versionLabel ? ` ${data.versionLabel}` : ""}` : ""} ·{" "}
{data.metric === "real" ? "real" : "nominal"} · {sourceLabel(data.source)}
<span className="ml-1 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-faint">nur Ansicht</span>
</p>
)}
</div>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
{!data && !error && <p className="text-sm text-muted">Wird geladen</p>}
{r && (
<>
{r.metrics && r.metrics.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{r.metrics.map((m, i) => (
<div key={i} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">{m.label}</div>
<div className={`mt-0.5 text-lg font-semibold ${m.tone === "danger" ? "text-danger" : m.tone === "success" ? "text-success" : "text-fg"}`}>
{m.value}
</div>
</div>
))}
</div>
)}
{r.chart && <SavedChart chart={r.chart} />}
{r.table && (
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
{r.table.columns.map((c, i) => (
<th key={i} className={`px-3 py-2 font-semibold ${i === 0 ? "text-left" : "text-right"}`}>{c}</th>
))}
</tr>
</thead>
<tbody>
{r.table.rows.map((row, ri) => (
<tr key={ri} className="border-t border-border">
{row.map((cell, ci) => (
<td key={ci} className={`px-3 py-2 ${ci === 0 ? "text-left font-medium text-fg" : "text-right tabular-nums text-muted"}`}>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-faint">Parameter</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
{r.params.map((p, i) => (
<span key={i}>
{p.label}: <strong className="text-fg">{p.value}</strong>
</span>
))}
</div>
</div>
</>
)}
</div>
</div>
);
}
function SavedChart({ chart }: { chart: NonNullable<SavedResult["chart"]> }) {
const fmt = (v: number) => (chart.yFormat === "pct" ? `${v} %` : Intl.NumberFormat("de-CH", { notation: "compact" }).format(v));
const tip = (v: unknown) => (typeof v !== "number" ? String(v ?? "") : chart.yFormat === "pct" ? `${v} %` : formatChf(v));
// Punkte aller Serien auf gemeinsame x-Werte bringen.
const xs = Array.from(new Set(chart.series.flatMap((s) => s.points.map((p) => p.x)))).sort((a, b) => a - b);
const data = xs.map((x) => {
const row: Record<string, number | string> = { x };
for (const s of chart.series) row[s.label] = s.points.find((p) => p.x === x)?.y ?? (null as unknown as number);
return row;
});
const PALETTE = ["#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>
);
}
+56 -1
View File
@@ -7,6 +7,7 @@ import { RequiredNumberField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { formatChf } from "@/lib/format";
import {
@@ -39,11 +40,13 @@ function formatRange(low: number, high: number, unit: DriverDef["unit"]): string
export function SensitivityDialog({
plan: currentPlan,
planId,
actuals = [],
origins = [],
onClose,
}: {
plan: PlanInput;
planId: string;
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
onClose: () => void;
@@ -244,12 +247,64 @@ export function SensitivityDialog({
</button>
</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>
);
}
// Generische Momentaufnahme des Tornados (siehe analyses.ts).
function buildSensitivityResult(
result: TornadoResult,
metric: TornadoMetric,
scenarioName: string,
basis: { source: string }
): { metric?: string; source?: string; scenarioName?: string | null; summary?: string | null; inputs: Record<string, unknown>; result: Record<string, unknown> } {
return {
metric,
source: basis.source,
scenarioName,
summary: result.bars[0] ? `Stärkster Hebel: ${result.bars[0].shortLabel}` : null,
inputs: {
params: [
{ label: "Zielgrösse", value: metric === "real" ? "Endvermögen real" : "Endvermögen nominal" },
{ label: "Basiswert", value: formatChf(result.base) },
],
},
result: {
params: [{ label: "Basiswert (unverändert)", value: formatChf(result.base) }],
table: {
columns: ["Treiber", "Bandbreite", "tief", "hoch", "Spannweite"],
rows: result.bars.map((b) => [
b.shortLabel,
`${b.low}${b.high} ${UNIT_SUFFIX[b.unit]}`,
formatChf(b.lowResult),
formatChf(b.highResult),
formatChf(b.swing),
]),
},
chart: {
kind: "bar" as const,
yFormat: "chf" as const,
series: [{ label: "Spannweite", points: result.bars.map((b, i) => ({ x: i, y: b.swing })) }],
},
},
};
}
function TornadoResults({
result,
metric,
+98
View File
@@ -0,0 +1,98 @@
// Gespeicherte Analysen -- Typen und Client-Helfer (SPEZIFIKATION 3.10).
//
// Festgehalten werden EINGABEN und ERGEBNIS als Zahlen, read-only. Beim Öffnen wird nichts
// neu gerechnet. Das hält den Datensatz klein und macht ihn druckfähig (PDF-Bericht,
// Roadmap Nr. 11): Aus Zahlen lässt sich vektoriell in jeder Auflösung zeichnen, aus einem
// Screenshot nicht.
import { api } from "@/lib/api-client";
export type AnalysisType = "CHART" | "MONTE_CARLO" | "SENSITIVITY";
export const ANALYSIS_TYPE_LABEL: Record<AnalysisType, string> = {
CHART: "Grafik",
MONTE_CARLO: "Monte-Carlo",
SENSITIVITY: "Einflussfaktoren",
};
// Kopfdaten in der Liste (ohne die grossen JSON-Felder).
export interface SavedAnalysisMeta {
id: string;
name: string;
type: AnalysisType;
scenarioName: string | null;
versionLabel: string | null;
metric: string;
source: string;
summary: string | null;
createdAt: string;
author: string;
}
// Vollständiger Datensatz beim Öffnen.
export interface SavedAnalysisFull extends Omit<SavedAnalysisMeta, "author"> {
inputs: unknown;
result: unknown;
}
export interface SaveAnalysisPayload {
name: string;
type: AnalysisType;
scenarioName?: string | null;
versionLabel?: string | null;
metric?: string;
source?: string;
summary?: string | null;
inputs: Record<string, unknown>;
result: Record<string, unknown>;
}
export async function saveAnalysis(planId: string, payload: SaveAnalysisPayload): Promise<void> {
await api.post(`/api/plans/${planId}/analyses`, payload);
}
// Automatischer Name: Typ · Szenario Version · Datum. Überschreibbar beim Speichern.
export function defaultAnalysisName(
type: AnalysisType,
scenarioName: string | null,
versionLabel: string | null
): string {
const parts = [ANALYSIS_TYPE_LABEL[type]];
if (scenarioName) parts.push(scenarioName + (versionLabel ? ` ${versionLabel}` : ""));
parts.push(new Date().toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" }));
return parts.join(" · ");
}
export function sourceLabel(source: string): string {
return source === "ACTUAL" ? "Effektiv" : "Plan";
}
// --- Generische, druckfähige Momentaufnahme -------------------------------------------
//
// Jedes Werkzeug schreibt sein Ergebnis in DIESE einheitliche Form. Dadurch braucht die
// Nur-Lese-Ansicht nur einen Renderer, und der spätere PDF-Bericht (Roadmap Nr. 11) findet
// überall dieselbe Struktur vor -- Zahlen, keine Pixel.
export interface SavedKeyValue {
label: string;
value: string;
tone?: "success" | "danger" | "muted";
}
export interface SavedChartSeries {
label: string;
color?: string;
dashed?: boolean;
points: { x: number; y: number }[];
}
export interface SavedResult {
// Eingabeparameter, mit denen gerechnet wurde.
params: SavedKeyValue[];
// Kernzahlen des Ergebnisses (gross dargestellt).
metrics?: SavedKeyValue[];
// Optionale Ergebnistabelle.
table?: { columns: string[]; rows: (string | number)[][] };
// Optionale Grafik.
chart?: { kind: "line" | "bar"; xLabel?: string; yFormat?: "chf" | "pct"; series: SavedChartSeries[] };
}
+6
View File
@@ -126,6 +126,12 @@ describe("Datenbank-Migrationen", () => {
WHERE table_name='ActualsSet' AND column_name='cash'`
);
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);
});
+5
View File
@@ -55,6 +55,11 @@ export interface PlanListItem {
name: string;
createdAt?: string;
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
+2
View File
@@ -32,6 +32,8 @@ const EXEMPT: Record<string, string> = {
"plans/[planId]/actuals":
"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]/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", () => {