diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index dca1561..19ada00 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -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//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//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//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//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//analyses/` +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/` @@ -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 diff --git a/prisma/migrations/20260720160000_saved_analyses/migration.sql b/prisma/migrations/20260720160000_saved_analyses/migration.sql new file mode 100644 index 0000000..d3bd574 --- /dev/null +++ b/prisma/migrations/20260720160000_saved_analyses/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3a9dee1..d14157f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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). diff --git a/src/app/api/plans/[planId]/analyses/[analysisId]/route.ts b/src/app/api/plans/[planId]/analyses/[analysisId]/route.ts new file mode 100644 index 0000000..bd24a81 --- /dev/null +++ b/src/app/api/plans/[planId]/analyses/[analysisId]/route.ts @@ -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 }); +} diff --git a/src/app/api/plans/[planId]/analyses/route.ts b/src/app/api/plans/[planId]/analyses/route.ts new file mode 100644 index 0000000..5505f7e --- /dev/null +++ b/src/app/api/plans/[planId]/analyses/route.ts @@ -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 }); +} diff --git a/src/app/api/plans/[planId]/dashboard/route.ts b/src/app/api/plans/[planId]/dashboard/route.ts new file mode 100644 index 0000000..9a9fe6f --- /dev/null +++ b/src/app/api/plans/[planId]/dashboard/route.ts @@ -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, + })), + 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, + }); +} diff --git a/src/app/api/plans/route.ts b/src/app/api/plans/route.ts index 63ff2aa..f1ab866 100644 --- a/src/app/api/plans/route.ts +++ b/src/app/api/plans/route.ts @@ -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 }); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 5f9178f..1b666c3 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -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(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(`/api/scenarios/${scenarioId}`)); + const d = await api.get(`/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(() => { 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" }`} > @@ -295,31 +341,67 @@ function AppShellInner({ username }: { username: string }) { {plans.length === 0 &&

Noch keine Pläne.

} - {plans.map((p) => ( -
-
- - {p.name} + {plans.map((p) => { + const navHere = planNav?.planId === p.id; + const subItem = (tab: "dashboard" | "scenarios" | "analyses", label: string, Icon: typeof FolderKanban) => ( + + ); + return ( +
+ {/* Plan-Name: Klick öffnet das Plan-Dashboard. */} +
+ + +
+ + {/* Unter-Menüpunkte, leicht eingezogen. */} + {subItem("scenarios", "Szenarien", Layers)} + {/* Der Szenario-Baum bleibt darunter -- ein Klick führt direkt in die Matrix. */} + + {subItem("analyses", "Analysen", BarChart3)}
- -
- ))} + ); + })}
Wissen @@ -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"} @@ -409,14 +495,39 @@ function AppShellInner({ username }: { username: string }) { {showSpec && } - {!showSpec && !showSystemParams && loading && } + {!showSpec && !showSystemParams && loading && !planNav && } - {!showSpec && !showSystemParams && !loading && selectedScenarioId === null && ( + {/* Plan-Ebene: Dashboard / Szenarien / Analysen */} + {!showSpec && !showSystemParams && planNav?.tab === "dashboard" && ( + + )} + {!showSpec && !showSystemParams && planNav?.tab === "scenarios" && ( + { + 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" && ( + void launchTool(planNav.planId, kind)} + onOpenSaved={(id) => setSavedAnalysisId(id)} + /> + )} + + {!showSpec && !showSystemParams && !loading && !planNav && selectedScenarioId === null && ( openPlanTab(id, "dashboard")} onCreateGuided={() => setShowWizard(true)} onCreateChooser={() => setShowNewPlanChooser(true)} onCreateDemo={handleCreateDemo} @@ -546,6 +657,7 @@ function AppShellInner({ username }: { username: string }) { setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}> s.id !== detail.meta.id)} actuals={detail.actuals ?? []} @@ -569,6 +681,7 @@ function AppShellInner({ username }: { username: string }) { {showSensitivity && detail && ( setShowSensitivity(false)} @@ -584,6 +697,14 @@ function AppShellInner({ username }: { username: string }) { /> )} + {savedAnalysisId && planNav && ( + setSavedAnalysisId(null)} + /> + )} + {showActuals && detail && activePlan && ( void; + onOpenPlan: (planId: string) => void; onCreateGuided: () => void; onCreateChooser: () => void; onCreateDemo: () => void; @@ -858,13 +979,15 @@ function DashboardHome({ ) : (
{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 (
base && onSelect(base.id)} + onClick={() => onOpenPlan(p.id)} >
@@ -873,7 +996,8 @@ function DashboardHome({
{p.name}
- Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""} + {persons || "Basisszenario"} + {others > 0 ? ` · +${others} Variante${others === 1 ? "" : "n"}` : ""}
+ + {/* Ein paar Kennzahlen pro Plan direkt in der Übersicht. */} +
+ {p.scenarios.length} Szenario{p.scenarios.length === 1 ? "" : "s"} + {p.startYear && ab {p.startYear}} + {p._count && p._count.actuals > 0 && {p._count.actuals} Ist-Datensatz{p._count.actuals === 1 ? "" : "e"}} + {p._count && p._count.analyses > 0 && {p._count.analyses} Analyse{p._count.analyses === 1 ? "" : "n"}} +
); })} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index a3014fe..9181b6d 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -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("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({ + {/* Grafikauswahl */} +
+
+ {CHART_OPTIONS.map((o) => ( + + ))} +
+ buildChartResult(chartId, computed, series, basis)} + /> +
+
-
-

- - Einkommen vs. Ausgaben pro Jahr -

-

- 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. -

- -
- -
-
-

+ {chartId === "cashflow" && ( +
+

- Vermögensverlauf nach Alter + Einkommen vs. Ausgaben pro Jahr

- {/* Der Export liest immer das Szenario aus der Datenbank -- also den aktuellen - Stand, nicht die betrachtete Version. Das wird beschriftet statt verschwiegen. */} - - - CSV-Export{versionId === "current" ? "" : " (aktueller Stand)"} - -

- {otherPlans.length > 0 && ( -
- Szenarien vergleichen: - {otherPlans.map((p) => ( - - ))} -
- )} - -
+

+ 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. +

+ + + )} -
-

- - Vermögensaufteilung pro Phase (Beginn & Ende) -

-

- 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. -

- -
+ {chartId === "wealth" && ( +
+
+

+ + Vermögensverlauf nach Alter +

+
+ {otherPlans.length > 0 && ( +
+ Szenarien vergleichen: + {otherPlans.map((p) => ( + + ))} +
+ )} + +
+ )} + + {chartId === "allocation" && ( +
+

+ + Vermögensaufteilung pro Phase (Beginn & Ende) +

+

+ 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. +

+ +
+ )}
); } +// 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; result: Record } { + 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 (
diff --git a/src/components/MonteCarloDialog.tsx b/src/components/MonteCarloDialog.tsx index 8d643ef..5e5c7ae 100644 --- a/src/components/MonteCarloDialog.tsx +++ b/src/components/MonteCarloDialog.tsx @@ -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({
{outcomes && fanRes && ( - + <> +
+ buildMcResult(outcomes, fanRes, manualTarget, basis)} + /> +
+ + )}
); } +// 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; result: Record } { + 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 (
diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index 977d23c..fcdd35f 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -9,6 +9,7 @@ import { ChevronDown, ChevronRight, CreditCard, + Download, HelpCircle, Home, Landmark, @@ -490,11 +491,18 @@ export function PlanView({ > Lebensphase + {/* CSV-Export der Matrix. Liest immer den aktuellen Stand aus der Datenbank. */} + + CSV-Export + diff --git a/src/components/PlanViews.tsx b/src/components/PlanViews.tsx new file mode 100644 index 0000000..93a4d96 --- /dev/null +++ b/src/components/PlanViews.tsx @@ -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(null); + const [error, setError] = useState(null); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const d = await api.get(`/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 ( +
+
{label}
+
{value}
+ {hint &&
{hint}
} +
+ ); +} + +// ========================================================================================= +// Plan-Dashboard +// ========================================================================================= +export function PlanDashboardView({ planId }: { planId: string }) { + const { data, error } = useDashboard(planId, 0); + if (error) return

{error}

; + if (!data) return

Wird geladen…

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

{plan.name}

+

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

+
+ +
+ + + + +
+ + {base && ( +
+

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

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

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

+ )} +
+ )} +
+ ); +} + +// ========================================================================================= +// 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

{error}

; + if (!data) return

Wird geladen…

; + + const nameById = new Map(data.scenarios.map((s) => [s.id, s.name])); + + return ( +
+
+

Szenarien

+ +
+ +
+ + + + + + + + + + + + + {data.scenarios.map((s) => ( + + + + + + + + + ))} + +
SzenarioVersionElementeEndvermögen (nom.)Kapital reichtAktionen
+
+ {s.name} + {s.isBase && Basis} +
+ {s.parentScenarioId && ( +
+ aus {nameById.get(s.parentScenarioId) ?? "…"} +
+ )} +
{s.currentMajor}.x{s.elementCount}{formatChf(s.endNominal)} + {s.ruinAge !== null ? ( + Alter {s.ruinAge} + ) : ( + Planende + )} + +
+ + +
+
+
+
+ ); +} + +// ========================================================================================= +// 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(null); + const [error, setError] = useState(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 ( +
+

Analysen

+ + {/* Vier Kacheln, die beim Darüberfahren (oder Antippen) umklappen. */} +
+ {TILES.map((t) => ( + onLaunch(t.kind)} /> + ))} +
+ +
+

Gespeicherte Analysen

+ {error &&

{error}

} + {!saved && !error &&

Wird geladen…

} + {saved && saved.length === 0 && ( +

+ Noch nichts gespeichert. In jedem Werkzeug oben kannst du ein Ergebnis festhalten – es erscheint dann hier. +

+ )} + {saved && saved.length > 0 && ( +
+ {saved.map((a) => { + const Icon = typeIcon(a.type); + return ( +
+ +
+
{a.name}
+
+ {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}` : ""} +
+
+ + +
+ ); + })} +
+ )} +
+
+ ); +} + +function FlipTile({ tile, onClick }: { tile: (typeof TILES)[number]; onClick: () => void }) { + const [flipped, setFlipped] = useState(false); + const Icon = tile.icon; + return ( + + ); +} diff --git a/src/components/SaveAnalysisButton.tsx b/src/components/SaveAnalysisButton.tsx new file mode 100644 index 0000000..f336fb6 --- /dev/null +++ b/src/components/SaveAnalysisButton.tsx @@ -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 | 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 ( +
+ 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" + /> + + +
+ ); + } + + return ( + + ); +} diff --git a/src/components/SavedAnalysisView.tsx b/src/components/SavedAnalysisView.tsx new file mode 100644 index 0000000..3b8b62e --- /dev/null +++ b/src/components/SavedAnalysisView.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+
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"> +
+
+

+ + {data?.name ?? "Analyse"} +

+ {data && ( +

+ {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)} + nur Ansicht +

+ )} +
+ +
+ + {error &&

{error}

} + {!data && !error &&

Wird geladen…

} + + {r && ( + <> + {r.metrics && r.metrics.length > 0 && ( +
+ {r.metrics.map((m, i) => ( +
+
{m.label}
+
+ {m.value} +
+
+ ))} +
+ )} + + {r.chart && } + + {r.table && ( +
+ + + + {r.table.columns.map((c, i) => ( + + ))} + + + + {r.table.rows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
{c}
+ {cell} +
+
+ )} + +
+
Parameter
+
+ {r.params.map((p, i) => ( + + {p.label}: {p.value} + + ))} +
+
+ + )} +
+
+ ); +} + +function SavedChart({ chart }: { chart: NonNullable }) { + 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 = { 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 ( +
+ + {chart.kind === "bar" ? ( + + + + + + + {chart.series.map((s, i) => ( + + ))} + + ) : ( + + + `${v}`} /> + + (chart.xLabel ? `${chart.xLabel} ${v}` : `${v}`)} /> + + {chart.series.map((s, i) => ( + + ))} + + )} + +
+ ); +} diff --git a/src/components/SensitivityDialog.tsx b/src/components/SensitivityDialog.tsx index a2873c9..705d3a3 100644 --- a/src/components/SensitivityDialog.tsx +++ b/src/components/SensitivityDialog.tsx @@ -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({
- {result && } + {result && ( + <> +
+ buildSensitivityResult(result, metric, currentPlan.name, basis)} + /> +
+ + + )}
); } +// 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; result: Record } { + 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, diff --git a/src/lib/analyses.ts b/src/lib/analyses.ts new file mode 100644 index 0000000..62f80c4 --- /dev/null +++ b/src/lib/analyses.ts @@ -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 = { + 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 { + 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; + result: Record; +} + +export async function saveAnalysis(planId: string, payload: SaveAnalysisPayload): Promise { + 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[] }; +} diff --git a/src/lib/migrations.test.ts b/src/lib/migrations.test.ts index b81e3de..134561c 100644 --- a/src/lib/migrations.test.ts +++ b/src/lib/migrations.test.ts @@ -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); }); diff --git a/src/lib/types.ts b/src/lib/types.ts index 5a1ef0f..dffbbb4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 diff --git a/src/lib/versioning-coverage.test.ts b/src/lib/versioning-coverage.test.ts index a4dba39..dad81c5 100644 --- a/src/lib/versioning-coverage.test.ts +++ b/src/lib/versioning-coverage.test.ts @@ -32,6 +32,8 @@ const EXEMPT: Record = { "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", () => {