From a5d4868c58eb22265299d0390c31ec047431ca1d Mon Sep 17 00:00:00 2001 From: kelle Date: Sat, 18 Jul 2026 09:51:44 +0200 Subject: [PATCH] Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6) Groesste Umstrukturierung bisher. Der PLAN ist neu ein schlanker Behaelter ohne Finanzdaten; die berechenbare Einheit ist das SZENARIO. Modell: - Jeder Plan bekommt beim Anlegen automatisch ein Basisszenario (isBase). - Das Grundprofil liegt am Szenario, nicht am Plan -- nur so sind Szenarien mit abweichendem PENSIONSALTER moeglich (Fruehpensionierung), das in Person steckt. - Neue Szenarien sind vollstaendige Kopien eines BELIEBIGEN Szenarios und haengen als Baum darunter (parentScenarioId); die Seitenleiste rueckt sie ein. - Kopierte Phasen/Elemente tragen Herkunfts-Verweise (sourcePhaseId, sourceElementId). Ueber den Namen zu matchen waere fragil gewesen. Abweichungs-Markierung (Diff gegen das Eltern-Szenario, live): - geaendert = gelb, neu = gruen + Badge, entfernt = graue Geisterzeile. - Markiert: Phasen-/Uebergangszellen, Element-Zeilen, Phasenkoepfe, Cash-Anfangswert, Cash-Uebergaenge, Grundprofil. Zaehler ueber der Matrix. - Eigene Theme-Tokens fuer Hell/Dunkel/Warm -- ein fester Gelbwert waere im Dunkelschema unbrauchbar. Charts vergleichen neu die Geschwister-Szenarien statt fremder Plaene. Datenmodell/Migration: - Neue Tabelle Plan; bisheriger Plan -> Scenario (IDs erhalten, damit alle Kind-Fremdschluessel gueltig bleiben); planId -> scenarioId in Person/Phase/ FinancialElement. Bestehende Szenarien werden per rekursivem CTE demselben Behaelter zugeordnet, auch mehrfach verschachtelte. - Migration VOR dem Deploy gegen echtes PostgreSQL verifiziert (PGlite, in-process), inkl. verschachtelter Szenarien und Cascade. Der Test ist als migrations.test.ts committet und sichert kuenftige Migrationen ab. API neu unter /api/scenarios/*. 10 neue Tests (48 -> 58). Spezifikation auf v0.8. Co-Authored-By: Claude Opus 4.8 --- SPEZIFIKATION.md | 228 +++++++--- package-lock.json | 1 + package.json | 1 + .../migration.sql | 86 ++++ prisma/schema.prisma | 87 ++-- .../[elementId]/phase/[phaseId]/route.ts | 2 +- .../transition/[fromPhaseId]/route.ts | 2 +- src/app/api/phases/[phaseId]/route.ts | 10 +- src/app/api/plans/[planId]/route.ts | 75 +--- src/app/api/plans/[planId]/scenario/route.ts | 102 ----- src/app/api/plans/route.ts | 52 +-- .../api/scenarios/[scenarioId]/copy/route.ts | 93 ++++ .../[scenarioId]}/elements/route.ts | 16 +- .../[scenarioId]}/export/route.ts | 16 +- .../[scenarioId]}/phases/route.ts | 14 +- src/app/api/scenarios/[scenarioId]/route.ts | 110 +++++ src/app/globals.css | 30 ++ src/components/AppShell.tsx | 405 +++++++++++------- src/components/Dashboard.tsx | 17 +- src/components/PlanView.tsx | 99 ++++- src/lib/diff.test.ts | 99 +++++ src/lib/diff.ts | 153 +++++++ src/lib/migrations.test.ts | 63 +++ src/lib/queries.ts | 34 +- src/lib/types.ts | 27 +- 25 files changed, 1336 insertions(+), 486 deletions(-) create mode 100644 prisma/migrations/20260718090000_plan_scenario_hierarchy/migration.sql delete mode 100644 src/app/api/plans/[planId]/scenario/route.ts create mode 100644 src/app/api/scenarios/[scenarioId]/copy/route.ts rename src/app/api/{plans/[planId] => scenarios/[scenarioId]}/elements/route.ts (78%) rename src/app/api/{plans/[planId] => scenarios/[scenarioId]}/export/route.ts (54%) rename src/app/api/{plans/[planId] => scenarios/[scenarioId]}/phases/route.ts (91%) create mode 100644 src/app/api/scenarios/[scenarioId]/route.ts create mode 100644 src/lib/diff.test.ts create mode 100644 src/lib/diff.ts create mode 100644 src/lib/migrations.test.ts diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index ed1d35b..af66705 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.7 | -| **Datum** | 2026-07-17 | +| **Version** | 0.8 | +| **Datum** | 2026-07-18 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `5bf35bd` inkl. Monte-Carlo-Simulation (Stufe A) (Branch `main`) | +| **Codestand** | Arbeitsstand nach `71137f7` inkl. Szenario-Hierarchie (V6) (Branch `main`) | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.8 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Hierarchie (V6)** – grösste Umstrukturierung bisher. Der **Plan** ist neu ein schlanker Behälter ohne Finanzdaten; die berechenbare Einheit ist das **Szenario**, das Grundprofil (inkl. **Pensionsalter** → Frühpensionierungs-Szenarien), Phasen und Elemente trägt. Jeder Plan erhält beim Anlegen automatisch ein **Basisszenario**; weitere Szenarien entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als **Baum** darunter (Sidebar zeigt die Verschachtelung). Kopierte Phasen/Elemente tragen Herkunfts-Verweise (`sourcePhaseId`, `sourceElementId`) – darauf beruht die **Abweichungs-Markierung**: geändert = gelb, neu = grün, entfernt = graue Geisterzeile (eigene Theme-Tokens für alle drei Farbschemata). Charts vergleichen neu die Geschwister-Szenarien. Datenmodell: neue Tabelle `Plan`, bisheriger `Plan` → `Scenario` (IDs erhalten), `planId` → `scenarioId` in Person/Phase/FinancialElement. API neu unter `/api/scenarios/*`. Neue Kapitel 2.1, 3.2, 4.13; 9 Diff-Tests + Migrations-Test (48 → 58). **Migration mit echtem Postgres (PGlite) verifiziert**, inkl. verschachtelter Szenarien und Cascade. | | 0.7 | 2026-07-17 | Claude (Opus 4.8) | **Monte-Carlo-Simulation** (Roadmap Nr. 19, Stufe A). Button in der Planansicht → Dialog mit Erklärung, Eingaben und Ergebnis. Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet; ausgewiesen werden **Ruinwahrscheinlichkeit**, **Erfolgswahrscheinlichkeit** (P(Endvermögen ≥ Zielbetrag)) und ein **Fächer** (10 %/Median/90 %) plus die deterministische Linie. Läuft komplett im Browser (`computePlan` ist rein). Modell: fettschwänzige Verteilung (Student-t, ν=5), gemeinsamer Marktschock (ρ=0.7), Böden 0 % für PK/3a und −100 % sonst. Pro renditetragendem Element und für die Inflation je: historischer Ø (Pflicht), Streuungsstufe, σ. Neue Datei `montecarlo.ts` + optionaler `sample`-Parameter in `computePlan` (Inflation neu als kumulatives Array; deterministisch identisch). Neues Kapitel 4.12; 7 MC-Tests + Refactor-Absicherung (41 → 48). Keine Verhaltensänderung, keine DB-Änderung. | | 0.6 | 2026-07-17 | Claude (Opus 4.8) | **Teilverkauf** von Sonstigem Vermögen (Roadmap Nr. 42) und **Sonderamortisation** der Hypothek (Roadmap Nr. 15). `OTHER_ASSET` am Übergang neu: Halten / Verkaufen / **Teilverkauf** – ein Betrag fliesst ins Cash (erscheint als „Kapitalzufluss" im Phasenkopf), der Rest bleibt investiert. `REAL_ESTATE` im Halten-Fall neu mit **Einmaltilgung** aus dem Cash (analog zur Sofort-Tilgung bei Schulden; erscheint als „Kapitalinvestition"). Damit lässt sich die indirekte Amortisation via 3a mechanisch nachbilden. Die eigentliche Roadmap Nr. 15 (Steuerwirkung der indirekten Amortisation) bleibt mit dem Steuer-Bündel 13/14/23 zurückgestellt – siehe 9.14. Kapitel 4.9.3/4.9.4 ergänzt. Fünf Regressionstests (36 → 41). Keine Verhaltensänderung für bestehende Pläne. | | 0.5 | 2026-07-17 | Claude (Opus 4.8) | **Netto/Brutto geklärt** (Roadmap Nr. 9, reduziert) und **Immobilien-Modul erweitert** (Roadmap Nr. 8). Einkommen ist neu explizit als **Nettolohn** definiert (Label und Hilfetext); für die AHV rechnet das Tool intern mit `AHV_GROSS_FROM_NET_FACTOR = 1.12` auf den Bruttolohn hoch – die AHV bemisst sich am Brutto, die bisherige Netto-Basis unterschätzte die Rente um bis zu ~1'900/Jahr. Immobilie neu mit **Hypothekarzins** (% der Restschuld, sinkt mit der Amortisation, mit Doppelzählungs-Schalter) und **Wertsteigerung** (auf die **Liegenschaft**, nicht auf das Eigenkapital – Hebeleffekt). Grundstückgewinnsteuer bemisst sich neu explizit am ursprünglichen Kaufpreis. Keine Aufschlüsselung bei Einkommen oder Ausgaben, keine Steuerschätzung (Begründung: 9.14). Neues Kapitel 4.4.5; 4.6.5 und 4.9.4 überarbeitet; Abschnitt 9 um zwei Punkte ergänzt. Sechs Regressionstests (30 → 36). **Verhaltensänderung:** siehe 9.13. | @@ -86,26 +87,39 @@ Referenz: `prisma/schema.prisma` Zeilen 8–10, `src/lib/types.ts` Zeilen 38–4 # 2. Fachliche Grundkonzepte -## 2.1 Die vier Ebenen +## 2.1 Die Ebenen ``` User - └── Plan (Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert) - ├── Person[] (1 bei SINGLE, 2 bei COUPLE) - ├── Phase[] (geordnete Kette: sequenceNumber 1..n, je mit Dauer in Jahren) - └── FinancialElement[] (plan-weit, kategorisiert, optional personenzugeordnet) - ├── ElementPhaseValue[] (Werte je Phase, JSON) - └── ElementTransitionValue[] (Entscheide je Übergang, JSON) + └── Plan (Behälter: nur Name — KEINE Finanzdaten) + └── Scenario[] (die berechenbare Einheit) + │ Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert + │ isBase = genau eines je Plan · parentScenarioId = Baum + Vergleichsbasis + ├── Person[] (1 bei SINGLE, 2 bei COUPLE — je Szenario eigen!) + ├── Phase[] (Kette 1..n; sourcePhaseId = Gegenstück in der Vorlage) + └── FinancialElement[] (szenario-weit; sourceElementId = Gegenstück in der Vorlage) + ├── ElementPhaseValue[] (Werte je Phase, JSON) + └── ElementTransitionValue[] (Entscheide je Übergang, JSON) ``` -Das zentrale Designprinzip (Element-Rework 07/2026, Migration `20260713100000_element_model_rework`): -**Ein finanzielles Element ist über alle Lebensphasen hinweg dieselbe Entität.** Eine -Pensionskasse "PK Arbeitgeber" existiert einmal pro Plan; sie hat pro Phase einen Werte-Satz und -pro Phasenübergang einen Entscheid-Satz. Dadurch bleibt die Identität eines Vermögensgegenstands -über die Zeit erhalten – Voraussetzung für die Fortschreibung (Carry) und für die -Vermögensaufteilungs-Grafik. +**Zwei Designprinzipien tragen dieses Modell:** -Referenz: `prisma/schema.prisma` Zeilen 1–10, 114–151. +**(1) Ein finanzielles Element ist über alle Lebensphasen hinweg dieselbe Entität.** Eine +Pensionskasse „PK Arbeitgeber" existiert einmal pro Szenario; sie hat pro Phase einen Werte-Satz +und pro Phasenübergang einen Entscheid-Satz. Dadurch bleibt die Identität eines +Vermögensgegenstands über die Zeit erhalten – Voraussetzung für die Fortschreibung (Carry) und +die Vermögensaufteilungs-Grafik. + +**(2) Das Grundprofil liegt am Szenario, nicht am Plan** (V6). Nur so lassen sich die +wertvollsten Szenario-Fragen abbilden – allen voran ein abweichendes **Pensionsalter** +(„Was, wenn ich mit 62 statt 65 aufhöre?"), das in `Person` steckt. Wäre das Profil geteilt, +wären Frühpensionierungs-Szenarien unmöglich. + +Der **Plan** ist damit ein reiner Behälter: Er bündelt Szenarien und trägt den Eigentümer +(`userId`). Ownership von Szenario/Phase/Element läuft über die Kette +`Scenario → Plan → User`. + +Referenz: `prisma/schema.prisma`. ## 2.2 Die Matrix als Leitmetapher @@ -263,21 +277,53 @@ Referenz: `prisma/schema.prisma` Zeile 82. ### 3.2.5 Szenarien -Ein Szenario ist eine **Deep-Copy eines Plans bis zu einer gewählten Verzweigungsphase (inklusive)**. +Beim Anlegen eines Plans entsteht **automatisch das Basisszenario** (`isBase = true`, +Name „Basisszenario"). Der beim Anlegen erfasste Profilteil (Haushaltsform, Personen, +Inflation) landet dort, der Name am Plan. -Kopiert werden: -- Grundprofil (Haushaltsform, Inflation, `initialCash`) und alle Personen -- alle Phasen mit `sequenceNumber <= branchPhase.sequenceNumber` -- **alle** Elemente (unabhängig von der Verzweigungsphase) -- Phasenwerte nur für kopierte Phasen; Übergangswerte nur für Übergänge aus kopierten Phasen +Ein weiteres Szenario ist eine **vollständige Kopie eines beliebigen bestehenden Szenarios** +(nicht nur des Basisszenarios). Kopiert werden Grundprofil, alle Personen, alle Phasen, alle +Elemente sowie sämtliche Phasen- und Übergangswerte. -Gesetzt werden `parentPlanId` (Ursprungsplan) und `branchFromPhaseId` (die **neue** ID der -letzten kopierten Phase). Der Benutzer landet direkt im neuen Szenario, das anschliessend -unabhängig weiterentwickelt wird. +Gesetzt werden dabei: +- `parentScenarioId` = das kopierte Szenario → ergibt den **Baum** in der Seitenleiste **und** + die **Vergleichsbasis** für die Abweichungs-Markierung +- `sourcePhaseId` / `sourceElementId` je kopierter Phase bzw. Element → die **Identität**, über + die der Diff „dieselbe Zelle" wiederfindet -In der Planliste erscheint ein Szenario mit dem Zusatz „· Szenario". +Da jede Kopie wieder kopierbar ist, entstehen **Sub-Szenarien** beliebiger Tiefe; die +Seitenleiste rückt sie entsprechend ein. -Referenz: `src/app/api/plans/[planId]/scenario/route.ts`. +**Löschen:** Ein Szenario lässt sich löschen, das **Basisszenario nicht** (dafür den ganzen Plan +löschen). Das Löschen eines Plans entfernt per Cascade alle seine Szenarien. + +Referenz: `src/app/api/scenarios/[scenarioId]/copy/route.ts`. + +### 3.2.6 Abweichungs-Markierung (Diff) + +Jedes Szenario ausser dem Basisszenario wird **live gegen sein Eltern-Szenario** verglichen. +Abweichende Stellen sind in der Matrix farblich markiert: + +| Zustand | Darstellung | +|---|---| +| **geändert** | gelb hinterlegt (`--diff` / `--diff-soft`) | +| **neu** (in der Vorlage nicht vorhanden) | grün hinterlegt + Badge „neu" (`--diff-added`) | +| **entfernt** (in der Vorlage vorhanden, hier gelöscht) | graue **Geisterzeile**, durchgestrichen (`--diff-removed`) | + +Markiert werden: Phasenzellen, Übergangszellen, Element-Zeilen (Name/Zuordnung), Phasenköpfe +(Name/Dauer), der Cash-Anfangswert, Cash-Übergänge und das Grundprofil-Banner. Über der Matrix +steht die Gesamtzahl der Abweichungen. + +Die Farben sind **semantische Tokens** und für Hell, Dunkel und Warm getrennt abgestimmt – ein +fester Gelbwert würde im Dunkelschema unbrauchbar aussehen. + +**Zwei bewusste Eigenschaften:** +- Der Vergleich läuft gegen das **direkte Eltern-Szenario**, nicht immer gegen die Basis. Bei + einem Sub-Szenario ist das genau „was habe ich gegenüber der Vorlage geändert". +- Der Diff ist **live**: Ändert man die Vorlage, verschiebt sich die Markierung im Kind + rückwirkend (siehe [9.16](#916-diff-ist-live-gegen-die-vorlage)). + +Referenz: `src/lib/diff.ts`. ## 3.3 Lebensphasen @@ -1618,25 +1664,34 @@ PlanComputed ← an den Client geliefert | Feld | Typ | Constraints | |---|---|---| | `id` | String | PK, `cuid()` | -| `planId` | String | FK → Plan, **Cascade** | +| `scenarioId` | String | FK → Scenario, **Cascade** | | `role` | `PersonRole` | `PERSON_A` \| `PERSON_B` | | `name` | String? | optional | | `age` | Int | aktuelles Alter | -| `retirementAge` | Int | plan-eigenes Pensionsalter | -| | | `@@unique([planId, role])` | +| `retirementAge` | Int | **szenario-eigenes** Pensionsalter | +| | | `@@unique([scenarioId, role])` | -**Plan** +**Plan** (Behälter – trägt keine Finanzdaten) | Feld | Typ | Constraints | |---|---|---| | `id` | String | PK, `cuid()` | | `userId` | String | FK → User, **Cascade** | | `name` | String | | +| `createdAt` / `updatedAt` | DateTime | | + +**Scenario** (die berechenbare Einheit) + +| Feld | Typ | Constraints | +|---|---|---| +| `id` | String | PK, `cuid()` | +| `planId` | String | FK → Plan, **Cascade** | +| `name` | String | | +| `isBase` | Boolean | Default false; genau eines je Plan ist `true` | +| `parentScenarioId` | String? | FK → Scenario (Self-Relation „ScenarioTree"), **SetNull** – Baum **und** Vergleichsbasis | | `householdType` | `HouseholdType` | `SINGLE` \| `COUPLE` | -| `inflationRateDefault` | Float | plan-weite Inflation in % | +| `inflationRateDefault` | Float | szenario-weite Inflation in % | | `initialCash` | Float | Default 0 | -| `parentPlanId` | String? | FK → Plan (Self-Relation „PlanScenarios"), **SetNull** | -| `branchFromPhaseId` | String? | ID der letzten kopierten Phase (**keine** FK-Constraint) | | `createdAt` / `updatedAt` | DateTime | | **Phase** @@ -1644,13 +1699,14 @@ PlanComputed ← an den Client geliefert | Feld | Typ | Constraints | |---|---|---| | `id` | String | PK, `cuid()` | -| `planId` | String | FK → Plan, **Cascade** | +| `scenarioId` | String | FK → Scenario, **Cascade** | | `sequenceNumber` | Int | 1-basiert, lückenlos | | `name` | String | | | `durationYears` | Int | 1–80 | | `cashTransition` | Json? | Cash-Entscheid beim Übergang **nach** dieser Phase (siehe 5.4.5) | +| `sourcePhaseId` | String? | Gegenstück in der Vorlage (**lose** Referenz, kein FK) – Diff-Grundlage | | `createdAt` / `updatedAt` | DateTime | | -| | | `@@unique([planId, sequenceNumber])` | +| | | `@@unique([scenarioId, sequenceNumber])` | `cashTransition` liegt an der Phase und nicht in `ElementTransitionValue`, weil Cash kein `FinancialElement` ist und damit keine `elementId` besitzt. Die Verschlüsselung folgt derselben @@ -1661,13 +1717,18 @@ Logik wie dort: Der Übergang gehört der **Von**-Phase. | Feld | Typ | Constraints | |---|---|---| | `id` | String | PK, `cuid()` | -| `planId` | String | FK → Plan, **Cascade** | +| `scenarioId` | String | FK → Scenario, **Cascade** | | `category` | `ElementCategory` | 8 Werte | | `name` | String | | | `ownerRole` | `OwnerRole?` | `PERSON_A` \| `PERSON_B` \| `HOUSEHOLD` | | `orderIndex` | Int | Default 0 | +| `sourceElementId` | String? | Gegenstück in der Vorlage (**lose** Referenz, kein FK) – Diff-Grundlage | | `createdAt` | DateTime | | +Die Herkunfts-Verweise sind bewusst **lose** (kein Fremdschlüssel): Wird das Gegenstück in der +Vorlage gelöscht, soll die Kopie bestehen bleiben und im Diff einfach als „neu" gelten – ein +Cascade wäre hier falsch. + **ElementPhaseValue** | Feld | Typ | Constraints | @@ -1779,6 +1840,18 @@ sondern zu leeren Werten. | `20260715120000_plan_initial_cash` | `Plan.initialCash` | | `20260716210000_drop_phase_inflation_rate` | `Phase.inflationRate` entfernt (Inflation ist plan-weit) | | `20260716230000_phase_cash_transition` | `Phase.cashTransition` (JSONB) für einmalige Sonderein-/ausgaben | +| `20260718090000_plan_scenario_hierarchy` | **V6**: `Plan` → `Scenario` (IDs erhalten), neuer Behälter `Plan`, `planId` → `scenarioId`, Herkunfts-Verweise | + +**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 +Wurzel-Element entsteht ein neuer Behälter (`'plan_' || id`, deterministisch ableitbar, daher +ohne Hilfstabelle); der bisherige Plan-Name wandert dorthin, das Szenario heisst „Basisszenario". +Bestehende Szenarien werden per rekursivem CTE demselben Behälter zugeordnet – auch mehrfach +verschachtelte. Ein Sicherheitsnetz fängt verwaiste Szenarien ab und macht sie eigenständig. + +Die Migration wurde **vor dem Deploy gegen echtes PostgreSQL verifiziert** (PGlite, in-process): +alle Vorgänger-Migrationen einspielen, realistische Daten inkl. verschachtelter Szenarien +anlegen, migrieren, Ergebnis und Cascade prüfen. ## 5.5 Frontend-Architektur @@ -1893,39 +1966,54 @@ Liste der eigenen Pläne, sortiert nach `createdAt` aufsteigend. ``` Validierung: `name` 1–120; `inflationRateDefault` −20…50; `persons` 1–2 Einträge; `age` 0–120; `retirementAge` 30–100; `name` je Person ≤ 60. Zusätzlich Konsistenzregel -SINGLE=1 / COUPLE=2 Personen. → 201 `{ plan: { id } }` +SINGLE=1 / COUPLE=2 Personen. Legt Plan **und Basisszenario** an. +→ 201 `{ plan: { id }, scenario: { id } }` -### `GET /api/plans/` -Liefert **Eingabe und Berechnung** in einem Zug: +`GET /api/plans` liefert die Pläne inkl. Szenario-Kopfdaten: ```json -{ "plan": , "computed": } +{ "plans": [ { "id", "name", "createdAt", + "scenarios": [ { "id", "planId", "name", "isBase", "parentScenarioId" } ] } ] } ``` -Dies ist der einzige Endpunkt, der die Berechnung ausführt (neben `export`). → 404 wenn fremd. ### `PATCH /api/plans/` +`{ name }` – der Plan trägt nur noch den Namen. → 200 `{ plan: { id, name } }` + +### `DELETE /api/plans/` +→ 200 `{ ok: true }`, Cascade über alle Szenarien. + +## 6.3 Szenarien + +### `GET /api/scenarios/` +Liefert Eingabe, Berechnung **und die Vergleichsbasis** in einem Zug: +```json +{ "plan": , "computed": , + "base": , + "meta": { "id", "planId", "planName", "name", "isBase", "parentScenarioId" } } +``` +`base` ist das Eltern-Szenario (null beim Basisszenario) – daraus rechnet der Client den Diff. +Dies ist der einzige Endpunkt, der die Berechnung ausführt (neben `export`). → 404 wenn fremd. + +### `PATCH /api/scenarios/` Akzeptiert eine **Union** von zwei Formen: 1. Vollständiges Profil: `{ householdType, inflationRateDefault, persons[], name? }` – ersetzt die Personen in einer Transaktion. 2. Teilaktualisierung: `{ name?, initialCash? }` – `initialCash` 0…1'000'000'000, gerundet. -Die Unterscheidung erfolgt über das Vorhandensein von `householdType`. -→ 200 `{ plan: { id, name } }` +→ 200 `{ scenario: { id, name } }` -### `DELETE /api/plans/` -→ 200 `{ ok: true }`, Cascade-Löschung. +### `DELETE /api/scenarios/` +→ 200 `{ ok: true }` · 400 wenn es das **Basisszenario** ist. -### `POST /api/plans//scenario` -```json -{ "name": "Frühpensionierung", "branchFromPhaseId": "" } -``` -→ 201 `{ planId: "" }` +### `POST /api/scenarios//copy` +`{ name }` – vollständige Kopie; setzt `parentScenarioId` sowie die Herkunfts-Verweise. +→ 201 `{ scenarioId: "" }` -### `GET /api/plans//export` +### `GET /api/scenarios//export` → `text/csv; charset=utf-8`, `Content-Disposition: attachment`. -## 6.3 Phasen +## 6.4 Phasen -### `POST /api/plans//phases` +### `POST /api/scenarios//phases` Body optional: `{ name?, durationYears? }`. Hängt eine Phase am Ende an, kappt die Dauer, vergibt Default-Name, legt vorbelegte `ElementPhaseValue` für alle aktiven Elemente an (alles in einer Transaktion). @@ -1943,9 +2031,9 @@ Body = `CashTransitionData` (siehe 5.4.5). Speichert den Cash-Entscheid für den dieser Phase (einmalige Sonderein-/ausgaben). Schreibt die Spalte `Phase.cashTransition`. → 200 `{ ok }` · 404 wenn die Phase nicht dem Benutzer gehört. -## 6.4 Elemente +## 6.5 Elemente -### `POST /api/plans//elements` +### `POST /api/scenarios//elements` `{ category, name, ownerRole? }` → 201 `{ element: { id } }` - `PERSON_ONLY_CATEGORIES` ohne Person → 400 - fehlendes `ownerRole` sonst → `HOUSEHOLD` @@ -2024,7 +2112,7 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests") und -`src/lib/montecarlo.test.ts` (7 Tests) ergeben zusammen **48 Tests**, ausgeführt mit Vitest in +`src/lib/montecarlo.test.ts` (7 Tests), `src/lib/diff.test.ts` (9 Tests) und `src/lib/migrations.test.ts` (1 Test, spielt alle Migrationen gegen echtes PostgreSQL ein) ergeben zusammen **58 Tests**, ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests. @@ -2098,10 +2186,10 @@ Negatives Cash geht mit negativem Vorzeichen ins Gesamtvermögen ein. ## 9.2 Verwaiste `PERSON_B`-Elemente -Die Haushaltsform ist eine **Plan-Eigenschaft** und lässt sich im Dialog „Plan-Einstellungen" -auch bei einem bestehenden Plan nachträglich ändern (nicht pro Phase – innerhalb eines Plans -gilt sie durchgehend). Wechselt ein Plan dabei von `COUPLE` auf `SINGLE`, schneidet -`PlanProfileFields` die Personen auf eine zusammen und `PATCH /api/plans/` löscht Person B +Die Haushaltsform ist eine **Szenario-Eigenschaft** und lässt sich im Dialog „Plan-Einstellungen" +auch bei einem bestehenden Szenario nachträglich ändern (nicht pro Phase – innerhalb eines Szenarios +gilt sie durchgehend). Wechselt ein Szenario dabei von `COUPLE` auf `SINGLE`, schneidet +`PlanProfileFields` die Personen auf eine zusammen und `PATCH /api/scenarios/` löscht Person B aus der Datenbank. Elemente mit `ownerRole = "PERSON_B"` bleiben bestehen. In der Berechnung liefert `personByRole` dann `null`: @@ -2247,7 +2335,21 @@ angeboten (fette Ränder fest eingebaut); die Simulationsparameter werden **nich (ephemer im Dialog). Ein historischer Backtest (Stufe B) und korrelierte/vollständigere Modelle (Stufe C) sind offen. -## 9.16 Kleinere Beobachtungen +## 9.16 Diff ist live gegen die Vorlage + +Die Abweichungs-Markierung vergleicht **immer den aktuellen Stand** des Eltern-Szenarios. Ändert +man die Vorlage nachträglich, verschiebt sich die Markierung in allen Kindern rückwirkend: Setzt +man in der Vorlage einen Wert auf das, was ein Szenario ohnehin hatte, verschwindet dort die +gelbe Markierung, ohne dass das Szenario angefasst wurde. + +Das ist logisch korrekt („weicht ab von der Vorlage"), kann aber überraschen. Die Alternative – +ein eingefrorener Snapshot beim Kopieren – wäre schnell veraltet und würde Abweichungen anzeigen, +die keine mehr sind. Bewusster Entscheid zugunsten des Live-Vergleichs. + +Verwandt: Wird ein Element in der Vorlage gelöscht, gilt das Gegenstück im Kind ab dann als +**neu** (der Herkunfts-Verweis zeigt ins Leere). Auch das folgt aus dem Live-Vergleich. + +## 9.17 Kleinere Beobachtungen - `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht. - Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) – ein Rest der diff --git a/package-lock.json b/package-lock.json index 9830196..a1270ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@electric-sql/pglite": "^0.4.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/pg": "^8.20.0", diff --git a/package.json b/package.json index af21fb4..250142c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@electric-sql/pglite": "^0.4.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/pg": "^8.20.0", diff --git a/prisma/migrations/20260718090000_plan_scenario_hierarchy/migration.sql b/prisma/migrations/20260718090000_plan_scenario_hierarchy/migration.sql new file mode 100644 index 0000000..b200a2c --- /dev/null +++ b/prisma/migrations/20260718090000_plan_scenario_hierarchy/migration.sql @@ -0,0 +1,86 @@ +-- V6: Szenario-Hierarchie. Der bisherige "Plan" wird zum SZENARIO (IDs bleiben erhalten, +-- damit alle Kind-Datensaetze gueltig bleiben); darueber entsteht ein neuer, schlanker PLAN +-- als Behaelter. Bestehende Szenarien (bisher: Plan mit parentPlanId) werden als Baum unter +-- denselben Plan gehaengt wie ihr Ursprung. + +-- 1) Bisherige Plan-Tabelle wird zum Szenario. Kind-FKs folgen dem Rename automatisch. +ALTER TABLE "Plan" RENAME TO "Scenario"; +ALTER TABLE "Scenario" RENAME COLUMN "parentPlanId" TO "parentScenarioId"; +ALTER TABLE "Scenario" RENAME CONSTRAINT "Plan_pkey" TO "Scenario_pkey"; +ALTER TABLE "Scenario" RENAME CONSTRAINT "Plan_parentPlanId_fkey" TO "Scenario_parentScenarioId_fkey"; + +ALTER TABLE "Scenario" ADD COLUMN "isBase" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Scenario" ADD COLUMN "planId" TEXT; + +-- 2) Neuer Behaelter "Plan". +CREATE TABLE "Plan" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Plan_pkey" PRIMARY KEY ("id") +); + +-- 3) Je Wurzel-Szenario (bisher: eigenstaendiger Plan) einen Behaelter anlegen. Die Plan-ID +-- wird deterministisch aus der Szenario-ID abgeleitet, damit die Zuordnung ohne +-- Hilfstabelle moeglich ist. +INSERT INTO "Plan" ("id", "userId", "name", "createdAt", "updatedAt") +SELECT 'plan_' || s."id", s."userId", s."name", s."createdAt", CURRENT_TIMESTAMP +FROM "Scenario" s +WHERE s."parentScenarioId" IS NULL; + +-- 4) Wurzel-Szenarien werden zum Basisszenario ihres Behaelters. Der bisherige Plan-Name +-- wandert auf den Behaelter (Schritt 3); das Szenario heisst neu "Basisszenario". +UPDATE "Scenario" s +SET "planId" = 'plan_' || s."id", "isBase" = true, "name" = 'Basisszenario' +WHERE s."parentScenarioId" IS NULL; + +-- 5) Kind-Szenarien erben den Behaelter ihrer Wurzel (beliebig tief verschachtelt). +WITH RECURSIVE tree AS ( + SELECT "id", "planId" FROM "Scenario" WHERE "parentScenarioId" IS NULL + UNION ALL + SELECT c."id", t."planId" FROM "Scenario" c JOIN tree t ON c."parentScenarioId" = t."id" +) +UPDATE "Scenario" s +SET "planId" = t."planId" +FROM tree t +WHERE s."id" = t."id" AND s."planId" IS NULL; + +-- 6) Sicherheitsnetz: Szenarien, deren Elternteil fehlt (verwaist), werden eigenstaendig. +INSERT INTO "Plan" ("id", "userId", "name", "createdAt", "updatedAt") +SELECT 'plan_' || s."id", s."userId", s."name", s."createdAt", CURRENT_TIMESTAMP +FROM "Scenario" s +WHERE s."planId" IS NULL; + +UPDATE "Scenario" s +SET "planId" = 'plan_' || s."id", "isBase" = true, "parentScenarioId" = NULL +WHERE s."planId" IS NULL; + +-- 7) Beziehungen des Behaelters festziehen. +ALTER TABLE "Scenario" ALTER COLUMN "planId" SET NOT NULL; +ALTER TABLE "Plan" ADD CONSTRAINT "Plan_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Scenario" ADD CONSTRAINT "Scenario_planId_fkey" + FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- 8) Eigentuemer haengt neu am Behaelter; die alten Felder am Szenario entfallen. +ALTER TABLE "Scenario" DROP CONSTRAINT "Plan_userId_fkey"; +ALTER TABLE "Scenario" DROP COLUMN "userId"; +ALTER TABLE "Scenario" DROP COLUMN "branchFromPhaseId"; + +-- 9) Kind-Tabellen: planId -> scenarioId (inkl. Constraint- und Index-Namen). +ALTER TABLE "Person" RENAME COLUMN "planId" TO "scenarioId"; +ALTER TABLE "Person" RENAME CONSTRAINT "Person_planId_fkey" TO "Person_scenarioId_fkey"; +ALTER INDEX "Person_planId_role_key" RENAME TO "Person_scenarioId_role_key"; + +ALTER TABLE "Phase" RENAME COLUMN "planId" TO "scenarioId"; +ALTER TABLE "Phase" RENAME CONSTRAINT "Phase_planId_fkey" TO "Phase_scenarioId_fkey"; +ALTER INDEX "Phase_planId_sequenceNumber_key" RENAME TO "Phase_scenarioId_sequenceNumber_key"; + +ALTER TABLE "FinancialElement" RENAME COLUMN "planId" TO "scenarioId"; +ALTER TABLE "FinancialElement" RENAME CONSTRAINT "FinancialElement_planId_fkey" TO "FinancialElement_scenarioId_fkey"; + +-- 10) Herkunfts-Verweise fuer die Abweichungs-Markierung (Diff gegen das Eltern-Szenario). +ALTER TABLE "Phase" ADD COLUMN "sourcePhaseId" TEXT; +ALTER TABLE "FinancialElement" ADD COLUMN "sourceElementId" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d4ffeb..ffa4666 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,13 +1,21 @@ // FPT (Financial Planning Tool) — Datenmodell. -// Kernkonzept (Rework 07/2026): finanzielle Elemente leben auf PLAN-Ebene und sind -// ueber alle Lebensphasen hinweg dieselbe Entitaet. Pro Element existiert je Lebensphase -// ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz +// +// Kernkonzept: finanzielle Elemente leben auf SZENARIO-Ebene und sind ueber alle +// Lebensphasen hinweg dieselbe Entitaet. Pro Element existiert je Lebensphase ein +// Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz // (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON, // validiert und typisiert in der Applikationsschicht (lib/elements.ts). // -// Rework 07/2026 (V3): Das Grundprofil (Haushaltsform, Personen, Inflation) wurde vom -// frueheren Household auf die PLAN-Ebene verschoben. Jeder Plan ist selbsttragend und -// definiert seine eigenen Personen (Alter, Pensionsalter) und Inflationsannahme. +// Rework 07/2026 (V6, Szenario-Hierarchie): Der PLAN ist neu ein schlanker Behaelter und +// traegt selbst keine Finanzdaten. Die berechenbare Einheit ist das SZENARIO -- es traegt +// das Grundprofil (Haushaltsform, Personen, Inflation, Cash-Anfangswert), die Phasenkette +// und die Elemente. Jeder Plan hat genau ein Basisszenario (isBase) und beliebig viele +// weitere Szenarien, die als Baum (parentScenarioId) darunter haengen. Kopierte Phasen und +// Elemente tragen einen Herkunfts-Verweis (sourcePhaseId / sourceElementId) auf ihr +// Gegenstueck im Eltern-Szenario -- darauf beruht die Abweichungs-Markierung im UI. +// +// Namens-Hinweis: In der Berechnungsschicht heisst die berechenbare Einheit weiterhin +// `PlanInput` / `computePlan` (sie beschreibt eine Finanzplanung -- fachlich ein Szenario). generator client { provider = "prisma-client" @@ -54,35 +62,52 @@ enum ElementCategory { OTHER_DEBT } -// Einzelperson eines Plans. retirementAge ist das (plan-eigene) Pensionsalter. +// Einzelperson eines Szenarios. retirementAge ist das (szenario-eigene) Pensionsalter -- +// dadurch sind Frueh-/Spaetpensionierungs-Szenarien moeglich. model Person { id String @id @default(cuid()) - planId String - plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + scenarioId String + scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) role PersonRole name String? age Int retirementAge Int - @@unique([planId, role]) + @@unique([scenarioId, role]) } -// Eine vollstaendige Phasenkette; selbsttragend inkl. Grundprofil (Haushaltsform, -// Personen, Inflationsannahme). Kann Szenario eines anderen Plans sein (Deep-Copy). +// Behaelter einer Planung. Traegt selbst KEINE Finanzdaten, nur den Namen und die Szenarien. model Plan { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - name String + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + name String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + scenarios Scenario[] +} + +// Die berechenbare Einheit: Grundprofil + Phasenkette + Elemente. Genau ein Szenario je +// Plan ist das Basisszenario (isBase); weitere haengen als Baum darunter (parentScenarioId). +model Scenario { + id String @id @default(cuid()) + planId String + plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + name String + isBase Boolean @default(false) + + // Szenario, aus dem dieses kopiert wurde. Zugleich die Vergleichsbasis fuer den Diff + // (Basisszenario: null). Beim Loeschen des Elternteils bleibt der Baum flach erhalten. + parentScenarioId String? + parentScenario Scenario? @relation("ScenarioTree", fields: [parentScenarioId], references: [id], onDelete: SetNull) + children Scenario[] @relation("ScenarioTree") + householdType HouseholdType inflationRateDefault Float initialCash Float @default(0) - parentPlanId String? - parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull) - scenarios Plan[] @relation("PlanScenarios") - branchFromPhaseId String? - createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -95,13 +120,16 @@ model Plan { // wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations). // Die Inflation liegt seit V5 plan-weit am Plan (inflationRateDefault), nicht mehr hier. model Phase { - id String @id @default(cuid()) - planId String - plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + scenarioId String + scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) sequenceNumber Int name String durationYears Int + // Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs. + sourcePhaseId String? + // Entscheid fuer das Cash-Konto beim UEBERGANG NACH dieser Phase (JSON, validiert in // lib/elements.ts): 1:1 uebernehmen oder einmaliger Zufluss/einmalige Kosten. Liegt hier // und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist. @@ -113,20 +141,23 @@ model Phase { phaseValues ElementPhaseValue[] transitionValues ElementTransitionValue[] @relation("TransitionFromPhase") - @@unique([planId, sequenceNumber]) + @@unique([scenarioId, sequenceNumber]) } -// Ein finanzielles Element (plan-weit): Kategorie + optionale Personenzuordnung. +// Ein finanzielles Element (szenario-weit): Kategorie + optionale Personenzuordnung. model FinancialElement { id String @id @default(cuid()) - planId String - plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + scenarioId String + scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) category ElementCategory name String ownerRole OwnerRole? orderIndex Int @default(0) createdAt DateTime @default(now()) + // Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs. + sourceElementId String? + phaseValues ElementPhaseValue[] transitionValues ElementTransitionValue[] } diff --git a/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts b/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts index 7c4eaad..4bd79de 100644 --- a/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts +++ b/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts @@ -16,7 +16,7 @@ export async function PUT( const element = await getOwnedElement(elementId, userId); if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); - const phase = await prisma.phase.findFirst({ where: { id: phaseId, planId: element.planId } }); + const phase = await prisma.phase.findFirst({ where: { id: phaseId, scenarioId: element.scenarioId } }); if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); const body = await request.json(); diff --git a/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts b/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts index 9a451d4..ff15d05 100644 --- a/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts +++ b/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts @@ -16,7 +16,7 @@ export async function PUT( const element = await getOwnedElement(elementId, userId); if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); - const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, planId: element.planId } }); + const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, scenarioId: element.scenarioId } }); if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); const body = await request.json(); diff --git a/src/app/api/phases/[phaseId]/route.ts b/src/app/api/phases/[phaseId]/route.ts index 83772c4..37712f4 100644 --- a/src/app/api/phases/[phaseId]/route.ts +++ b/src/app/api/phases/[phaseId]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries"; +import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; import { maxPhaseDuration } from "@/lib/calculations"; @@ -28,9 +28,9 @@ export async function PUT( let duration = parsed.data.durationYears; if (duration != null) { // Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase). - const plan = await getOwnedPlan(existing.planId, userId); - if (plan) { - const planInput = toPlanInput(plan); + const scenario = await getOwnedScenario(existing.scenarioId, userId); + if (scenario) { + const planInput = toPlanInput(scenario); const yearsBefore = planInput.phases .filter((p) => p.sequenceNumber < existing.sequenceNumber) .reduce((s, p) => s + p.durationYears, 0); @@ -63,7 +63,7 @@ export async function DELETE( if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); const later = await prisma.phase.findFirst({ - where: { planId: phase.planId, sequenceNumber: { gt: phase.sequenceNumber } }, + where: { scenarioId: phase.scenarioId, sequenceNumber: { gt: phase.sequenceNumber } }, }); if (later) { return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 }); diff --git a/src/app/api/plans/[planId]/route.ts b/src/app/api/plans/[planId]/route.ts index 9e53438..3425f5f 100644 --- a/src/app/api/plans/[planId]/route.ts +++ b/src/app/api/plans/[planId]/route.ts @@ -1,41 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { toPlanInput, getOwnedPlan } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; -import { computePlan } from "@/lib/calculations"; -import { planProfileSchema, validatePersonsForType } from "@/app/api/plans/route"; -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; +// Der Plan ist nur noch der Behaelter: er traegt den Namen; die Finanzdaten liegen im Szenario. +const patchSchema = z.object({ name: z.string().min(1).max(120) }); - const plan = await getOwnedPlan(planId, userId); - if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); - - const planInput = toPlanInput(plan); - const computed = computePlan(planInput); - - return NextResponse.json({ plan: planInput, computed }); -} - -// Name/Cash-Anfangswert aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation). -const patchSchema = z.union([ - planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }), - z.object({ - name: z.string().min(1).max(120).optional(), - initialCash: z.number().min(0).max(1_000_000_000).optional(), - }), -]); - -export async function PATCH( - request: NextRequest, - { params }: { params: Promise<{ planId: string }> } -) { +export async function PATCH(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; @@ -43,50 +14,22 @@ export async function PATCH( const plan = await prisma.plan.findFirst({ where: { id: planId, userId } }); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); - const body = await request.json(); - const parsed = patchSchema.safeParse(body); + const parsed = patchSchema.safeParse(await request.json()); if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); - const data = parsed.data; - const hasProfile = "householdType" in data; - - if (hasProfile) { - const error = validatePersonsForType(data); - if (error) return NextResponse.json({ error }, { status: 400 }); - const updated = await prisma.$transaction(async (tx) => { - await tx.person.deleteMany({ where: { planId: plan.id } }); - return tx.plan.update({ - where: { id: plan.id }, - data: { - name: data.name ?? undefined, - householdType: data.householdType, - inflationRateDefault: data.inflationRateDefault, - persons: { create: data.persons }, - }, - }); - }); - return NextResponse.json({ plan: { id: updated.id, name: updated.name } }); - } - - const updated = await prisma.plan.update({ - where: { id: plan.id }, - data: { - name: "name" in data ? data.name : undefined, - initialCash: "initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined, - }, - }); + const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } }); return NextResponse.json({ plan: { id: updated.id, name: updated.name } }); } -export async function DELETE( - _request: NextRequest, - { params }: { params: Promise<{ planId: string }> } -) { +// Loescht den Plan inkl. aller Szenarien (Cascade). +export async function DELETE(_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 } }); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); + await prisma.plan.delete({ where: { id: plan.id } }); return NextResponse.json({ ok: true }); } diff --git a/src/app/api/plans/[planId]/scenario/route.ts b/src/app/api/plans/[planId]/scenario/route.ts deleted file mode 100644 index 40b56da..0000000 --- a/src/app/api/plans/[planId]/scenario/route.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { prisma } from "@/lib/db"; -import { getOwnedPlan } from "@/lib/queries"; -import { getCurrentUserId } from "@/lib/session"; - -const scenarioSchema = z.object({ - name: z.string().min(1).max(120), - branchFromPhaseId: z.string().min(1), -}); - -// Erstellt ein Szenario als Deep-Copy eines Plans bis zur Verzweigungsphase (inkl.). -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ planId: string }> } -) { - const userId = await getCurrentUserId(); - if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - const { planId } = await params; - - const body = await request.json(); - const parsed = scenarioSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); - - const source = await getOwnedPlan(planId, userId); - if (!source) return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 }); - - const branchPhase = source.phases.find((p) => p.id === parsed.data.branchFromPhaseId); - if (!branchPhase) return NextResponse.json({ error: "Verzweigungsphase nicht gefunden." }, { status: 404 }); - - const copiedPhases = source.phases - .filter((p) => p.sequenceNumber <= branchPhase.sequenceNumber) - .sort((a, b) => a.sequenceNumber - b.sequenceNumber); - const copiedPhaseIds = new Set(copiedPhases.map((p) => p.id)); - - const newPlanId = await prisma.$transaction(async (tx) => { - const newPlan = await tx.plan.create({ - data: { - userId, - name: parsed.data.name, - householdType: source.householdType, - inflationRateDefault: source.inflationRateDefault, - initialCash: source.initialCash, - parentPlanId: source.id, - persons: { - create: source.persons.map((p) => ({ role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge })), - }, - }, - }); - - // Phasen kopieren (alte -> neue Id). - const phaseIdMap = new Map(); - let lastNewPhaseId = ""; - for (const phase of copiedPhases) { - const created = await tx.phase.create({ - data: { - planId: newPlan.id, - sequenceNumber: phase.sequenceNumber, - name: phase.name, - durationYears: phase.durationYears, - // Cash-Entscheid (einmalige Sonderein-/ausgaben) mitkopieren. - cashTransition: phase.cashTransition ?? undefined, - }, - }); - phaseIdMap.set(phase.id, created.id); - lastNewPhaseId = created.id; - } - - // Elemente + deren Phasen-/Uebergangswerte kopieren. - for (const el of source.elements) { - const newEl = await tx.financialElement.create({ - data: { - planId: newPlan.id, - category: el.category, - name: el.name, - ownerRole: el.ownerRole, - orderIndex: el.orderIndex, - }, - }); - for (const pv of el.phaseValues) { - const newPhaseId = phaseIdMap.get(pv.phaseId); - if (!newPhaseId) continue; - await tx.elementPhaseValue.create({ - data: { elementId: newEl.id, phaseId: newPhaseId, data: pv.data as object }, - }); - } - for (const tv of el.transitionValues) { - if (!copiedPhaseIds.has(tv.fromPhaseId)) continue; - const newFromId = phaseIdMap.get(tv.fromPhaseId); - if (!newFromId) continue; - await tx.elementTransitionValue.create({ - data: { elementId: newEl.id, fromPhaseId: newFromId, data: tv.data as object }, - }); - } - } - - await tx.plan.update({ where: { id: newPlan.id }, data: { branchFromPhaseId: lastNewPhaseId } }); - return newPlan.id; - }); - - return NextResponse.json({ planId: newPlanId }, { status: 201 }); -} diff --git a/src/app/api/plans/route.ts b/src/app/api/plans/route.ts index 3cc9c8e..02305f9 100644 --- a/src/app/api/plans/route.ts +++ b/src/app/api/plans/route.ts @@ -10,18 +10,17 @@ const personSchema = z.object({ retirementAge: z.number().int().min(30).max(100), }); -// Ein Plan traegt sein eigenes Grundprofil (Haushaltsform, Personen, Inflation). -export const planProfileSchema = z.object({ +// Das Grundprofil liegt am SZENARIO (nicht am Plan) -- dadurch kann ein Szenario z. B. ein +// anderes Pensionsalter tragen als das Basisszenario (Frühpensionierungs-Szenario). +export const scenarioProfileSchema = z.object({ householdType: z.enum(["SINGLE", "COUPLE"]), inflationRateDefault: z.number().min(-20).max(50), persons: z.array(personSchema).min(1).max(2), }); -const createPlanSchema = z - .object({ name: z.string().min(1).max(120) }) - .and(planProfileSchema); +const createPlanSchema = z.object({ name: z.string().min(1).max(120) }).and(scenarioProfileSchema); -export function validatePersonsForType(data: z.infer): string | null { +export function validatePersonsForType(data: z.infer): string | null { if (data.householdType === "SINGLE" && data.persons.length !== 1) { return "Einzelperson-Plan benoetigt genau eine Person."; } @@ -31,40 +30,35 @@ export function validatePersonsForType(data: z.infer): return null; } +// Liste der Plaene mit ihrem Szenario-Baum (nur Kopfdaten). export async function GET() { const userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const plans = await prisma.plan.findMany({ where: { userId }, orderBy: { createdAt: "asc" }, select: { id: true, name: true, - parentPlanId: true, - branchFromPhaseId: true, createdAt: true, - phases: { - select: { id: true, name: true, sequenceNumber: true }, - orderBy: { sequenceNumber: "asc" }, + scenarios: { + orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], + select: { id: true, planId: true, name: true, isBase: true, parentScenarioId: true }, }, }, }); return NextResponse.json({ plans }); } +// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil traegt. export async function POST(request: NextRequest) { const userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); const body = await request.json(); const parsed = createPlanSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - } + if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); const error = validatePersonsForType(parsed.data); if (error) return NextResponse.json({ error }, { status: 400 }); @@ -72,11 +66,21 @@ export async function POST(request: NextRequest) { data: { userId, name: parsed.data.name, - householdType: parsed.data.householdType, - inflationRateDefault: parsed.data.inflationRateDefault, - persons: { create: parsed.data.persons }, + scenarios: { + create: { + name: "Basisszenario", + isBase: true, + householdType: parsed.data.householdType, + inflationRateDefault: parsed.data.inflationRateDefault, + persons: { create: parsed.data.persons }, + }, + }, }, + include: { scenarios: true }, }); - return NextResponse.json({ plan: { id: plan.id } }, { status: 201 }); + return NextResponse.json( + { plan: { id: plan.id }, scenario: { id: plan.scenarios[0].id } }, + { status: 201 } + ); } diff --git a/src/app/api/scenarios/[scenarioId]/copy/route.ts b/src/app/api/scenarios/[scenarioId]/copy/route.ts new file mode 100644 index 0000000..85b4eea --- /dev/null +++ b/src/app/api/scenarios/[scenarioId]/copy/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/db"; +import { getOwnedScenario } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; + +const copySchema = z.object({ name: z.string().min(1).max(120) }); + +// Erstellt ein neues Szenario als vollstaendige Kopie eines bestehenden. Das Original wird +// zum Elternteil -- damit haengt der Baum in der Seitenleiste und der Diff hat seine Basis. +// Jede kopierte Phase/jedes kopierte Element traegt einen Herkunfts-Verweis auf sein +// Gegenstueck im Original; darauf beruht die Abweichungs-Markierung im UI. +export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { scenarioId } = await params; + + const parsed = copySchema.safeParse(await request.json()); + if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + + const source = await getOwnedScenario(scenarioId, userId); + if (!source) return NextResponse.json({ error: "Ursprungs-Szenario nicht gefunden." }, { status: 404 }); + + const newId = await prisma.$transaction(async (tx) => { + const created = await tx.scenario.create({ + data: { + planId: source.planId, + name: parsed.data.name, + isBase: false, + parentScenarioId: source.id, + householdType: source.householdType, + inflationRateDefault: source.inflationRateDefault, + initialCash: source.initialCash, + persons: { + create: source.persons.map((p) => ({ + role: p.role, + name: p.name, + age: p.age, + retirementAge: p.retirementAge, + })), + }, + }, + }); + + // Phasen kopieren (alte -> neue Id merken, fuer die Werte-Zuordnung). + const phaseIdMap = new Map(); + for (const phase of source.phases) { + const p = await tx.phase.create({ + data: { + scenarioId: created.id, + sequenceNumber: phase.sequenceNumber, + name: phase.name, + durationYears: phase.durationYears, + cashTransition: phase.cashTransition ?? undefined, + sourcePhaseId: phase.id, + }, + }); + phaseIdMap.set(phase.id, p.id); + } + + // Elemente inkl. Phasen- und Uebergangswerten kopieren. + for (const el of source.elements) { + const newEl = await tx.financialElement.create({ + data: { + scenarioId: created.id, + category: el.category, + name: el.name, + ownerRole: el.ownerRole, + orderIndex: el.orderIndex, + sourceElementId: el.id, + }, + }); + for (const pv of el.phaseValues) { + const phaseId = phaseIdMap.get(pv.phaseId); + if (!phaseId) continue; + await tx.elementPhaseValue.create({ + data: { elementId: newEl.id, phaseId, data: pv.data as object }, + }); + } + for (const tv of el.transitionValues) { + const fromPhaseId = phaseIdMap.get(tv.fromPhaseId); + if (!fromPhaseId) continue; + await tx.elementTransitionValue.create({ + data: { elementId: newEl.id, fromPhaseId, data: tv.data as object }, + }); + } + } + + return created.id; + }); + + return NextResponse.json({ scenarioId: newId }, { status: 201 }); +} diff --git a/src/app/api/plans/[planId]/elements/route.ts b/src/app/api/scenarios/[scenarioId]/elements/route.ts similarity index 78% rename from src/app/api/plans/[planId]/elements/route.ts rename to src/app/api/scenarios/[scenarioId]/elements/route.ts index 9fb9a71..c7a2f9f 100644 --- a/src/app/api/plans/[planId]/elements/route.ts +++ b/src/app/api/scenarios/[scenarioId]/elements/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { getOwnedPlan } from "@/lib/queries"; +import { getOwnedScenario } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; import { PERSON_ONLY_CATEGORIES } from "@/lib/elements"; @@ -20,17 +20,17 @@ const createSchema = z.object({ ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).nullable().optional(), }); -// Legt ein neues finanzielles Element (plan-weit) an. Personen-Pflicht je Kategorie. +// Legt ein neues finanzielles Element (szenario-weit) an. Personen-Pflicht je Kategorie. export async function POST( request: NextRequest, - { params }: { params: Promise<{ planId: string }> } + { params }: { params: Promise<{ scenarioId: string }> } ) { const userId = await getCurrentUserId(); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - const { planId } = await params; + const { scenarioId } = await params; - const plan = await getOwnedPlan(planId, userId); - if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); + const scenario = await getOwnedScenario(scenarioId, userId); + if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 }); const body = await request.json(); const parsed = createSchema.safeParse(body); @@ -51,13 +51,13 @@ export async function POST( } const maxOrder = await prisma.financialElement.aggregate({ - where: { planId: plan.id }, + where: { scenarioId: scenario.id }, _max: { orderIndex: true }, }); const element = await prisma.financialElement.create({ data: { - planId: plan.id, + scenarioId: scenario.id, category, name, ownerRole, diff --git a/src/app/api/plans/[planId]/export/route.ts b/src/app/api/scenarios/[scenarioId]/export/route.ts similarity index 54% rename from src/app/api/plans/[planId]/export/route.ts rename to src/app/api/scenarios/[scenarioId]/export/route.ts index 43412d6..9ea8861 100644 --- a/src/app/api/plans/[planId]/export/route.ts +++ b/src/app/api/scenarios/[scenarioId]/export/route.ts @@ -1,31 +1,31 @@ import { NextRequest, NextResponse } from "next/server"; -import { toPlanInput, getOwnedPlan } from "@/lib/queries"; +import { toPlanInput, getOwnedScenario } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; import { computePlan, planToCsv } from "@/lib/calculations"; export async function GET( _request: NextRequest, - { params }: { params: Promise<{ planId: string }> } + { params }: { params: Promise<{ scenarioId: string }> } ) { const userId = await getCurrentUserId(); if (!userId) { return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); } - const { planId } = await params; + const { scenarioId } = await params; - const plan = await getOwnedPlan(planId, userId); - if (!plan) { - return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); + const scenario = await getOwnedScenario(scenarioId, userId); + if (!scenario) { + return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 }); } - const planInput = toPlanInput(plan); + const planInput = toPlanInput(scenario); const computed = computePlan(planInput); const csv = planToCsv(planInput, computed); return new NextResponse(csv, { headers: { "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": `attachment; filename="${plan.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`, + "Content-Disposition": `attachment; filename="${scenario.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`, }, }); } diff --git a/src/app/api/plans/[planId]/phases/route.ts b/src/app/api/scenarios/[scenarioId]/phases/route.ts similarity index 91% rename from src/app/api/plans/[planId]/phases/route.ts rename to src/app/api/scenarios/[scenarioId]/phases/route.ts index c858cc8..7dc85fd 100644 --- a/src/app/api/plans/[planId]/phases/route.ts +++ b/src/app/api/scenarios/[scenarioId]/phases/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { getOwnedPlan, toPlanInput } from "@/lib/queries"; +import { getOwnedScenario, toPlanInput } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; import { Prisma } from "@/generated/prisma/client"; import { computePlan, maxPhaseDuration } from "@/lib/calculations"; @@ -17,20 +17,20 @@ const createPhaseSchema = z.object({ // vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben. export async function POST( request: NextRequest, - { params }: { params: Promise<{ planId: string }> } + { params }: { params: Promise<{ scenarioId: string }> } ) { const userId = await getCurrentUserId(); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - const { planId } = await params; + const { scenarioId } = await params; - const plan = await getOwnedPlan(planId, userId); - if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); + const scenario = await getOwnedScenario(scenarioId, userId); + if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 }); const body = await request.json().catch(() => ({})); const parsed = createPhaseSchema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); - const planInput = toPlanInput(plan); + const planInput = toPlanInput(scenario); const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0); const cap = maxPhaseDuration(planInput.persons, yearsBefore); @@ -54,7 +54,7 @@ export async function POST( const phase = await prisma.$transaction(async (tx) => { const created = await tx.phase.create({ data: { - planId: plan.id, + scenarioId: scenario.id, sequenceNumber: nextSequence, name: defaultName, durationYears: duration, diff --git a/src/app/api/scenarios/[scenarioId]/route.ts b/src/app/api/scenarios/[scenarioId]/route.ts new file mode 100644 index 0000000..28dc290 --- /dev/null +++ b/src/app/api/scenarios/[scenarioId]/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/db"; +import { toPlanInput, getOwnedScenario, getOwnedScenarioWithMeta } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; +import { computePlan } from "@/lib/calculations"; +import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route"; + +// Liefert das Szenario samt Berechnung -- und zusaetzlich das ELTERN-Szenario als +// Vergleichsbasis, damit das UI die Abweichungen markieren kann (Basisszenario: null). +export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { scenarioId } = await params; + + const scenario = await getOwnedScenarioWithMeta(scenarioId, userId); + if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 }); + + const planInput = toPlanInput(scenario); + const computed = computePlan(planInput); + + let base: ReturnType | null = null; + if (scenario.parentScenarioId) { + const parent = await getOwnedScenario(scenario.parentScenarioId, userId); + if (parent) base = toPlanInput(parent); + } + + return NextResponse.json({ + plan: planInput, + computed, + base, + meta: { + id: scenario.id, + planId: scenario.planId, + planName: scenario.plan.name, + name: scenario.name, + isBase: scenario.isBase, + parentScenarioId: scenario.parentScenarioId, + }, + }); +} + +// Name/Cash-Anfangswert ODER das ganze Grundprofil (Haushaltsform/Personen/Inflation). +const patchSchema = z.union([ + scenarioProfileSchema.extend({ name: z.string().min(1).max(120).optional() }), + z.object({ + name: z.string().min(1).max(120).optional(), + initialCash: z.number().min(0).max(1_000_000_000).optional(), + }), +]); + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { scenarioId } = await params; + + const scenario = await prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } }); + if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 }); + + const parsed = patchSchema.safeParse(await request.json()); + if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + + const data = parsed.data; + if ("householdType" in data) { + const error = validatePersonsForType(data); + if (error) return NextResponse.json({ error }, { status: 400 }); + const updated = await prisma.$transaction(async (tx) => { + await tx.person.deleteMany({ where: { scenarioId: scenario.id } }); + return tx.scenario.update({ + where: { id: scenario.id }, + data: { + name: data.name ?? undefined, + householdType: data.householdType, + inflationRateDefault: data.inflationRateDefault, + persons: { create: data.persons }, + }, + }); + }); + return NextResponse.json({ scenario: { id: updated.id, name: updated.name } }); + } + + const updated = await prisma.scenario.update({ + where: { id: scenario.id }, + data: { + name: "name" in data ? data.name : undefined, + initialCash: + "initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined, + }, + }); + return NextResponse.json({ scenario: { id: updated.id, name: updated.name } }); +} + +// Loescht ein Szenario. Das Basisszenario kann nicht geloescht werden (dafuer den Plan loeschen). +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { scenarioId } = await params; + + const scenario = await prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } }); + if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 }); + if (scenario.isBase) { + return NextResponse.json( + { error: "Das Basisszenario kann nicht geloescht werden. Loeschen Sie stattdessen den Plan." }, + { status: 400 } + ); + } + + await prisma.scenario.delete({ where: { id: scenario.id } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 660881c..a7530c3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -26,6 +26,12 @@ --danger-soft: #fef2f2; --success: #059669; --person-a: #4f46e5; + --diff: #b45309; + --diff-soft: #fef3c7; + --diff-added: #15803d; + --diff-added-soft: #dcfce7; + --diff-removed: #71717a; + --diff-removed-soft: #f4f4f5; --person-b: #0ea5e9; } @@ -48,6 +54,12 @@ --danger-soft: rgba(220, 38, 38, 0.16); --success: #34d399; --person-a: #818cf8; + --diff: #fbbf24; + --diff-soft: rgba(251, 191, 36, 0.16); + --diff-added: #4ade80; + --diff-added-soft: rgba(74, 222, 128, 0.14); + --diff-removed: #a1a1aa; + --diff-removed-soft: rgba(161, 161, 170, 0.12); --person-b: #38bdf8; } @@ -71,6 +83,12 @@ --danger-soft: #fbeae7; --success: #2e9e7b; --person-a: #e8663c; + --diff: #b45309; + --diff-soft: #fbeccb; + --diff-added: #2e7d52; + --diff-added-soft: #dcf0e2; + --diff-removed: #8a7f72; + --diff-removed-soft: #f0e8dd; --person-b: #f2a93b; } @@ -95,6 +113,12 @@ --danger-soft: rgba(220, 38, 38, 0.16); --success: #34d399; --person-a: #818cf8; + --diff: #fbbf24; + --diff-soft: rgba(251, 191, 36, 0.16); + --diff-added: #4ade80; + --diff-added-soft: rgba(74, 222, 128, 0.14); + --diff-removed: #a1a1aa; + --diff-removed-soft: rgba(161, 161, 170, 0.12); --person-b: #38bdf8; } } @@ -117,6 +141,12 @@ --color-danger: var(--danger); --color-danger-soft: var(--danger-soft); --color-success: var(--success); + --color-diff: var(--diff); + --color-diff-soft: var(--diff-soft); + --color-diff-added: var(--diff-added); + --color-diff-added-soft: var(--diff-added-soft); + --color-diff-removed: var(--diff-removed); + --color-diff-removed-soft: var(--diff-removed-soft); --color-person-a: var(--person-a); --color-person-b: var(--person-b); --font-sans: var(--font-geist-sans); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 57c8c5b..b99be0d 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -2,8 +2,10 @@ import { useCallback, useEffect, useState } from "react"; import { + Copy, FileText, FolderKanban, + GitBranch, LayoutDashboard, Menu, PiggyBank, @@ -17,42 +19,39 @@ import { SpecView } from "@/components/SpecView"; import { ProfileMenu } from "@/components/ProfileMenu"; import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; import { api } from "@/lib/api-client"; -import type { PlanInput } from "@/lib/types"; +import { computeScenarioDiff } from "@/lib/diff"; +import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types"; import type { PlanComputed } from "@/lib/calculations"; -interface PlanListItem { - id: string; - name: string; - parentPlanId: string | null; - branchFromPhaseId: string | null; - phases: { id: string; name: string; sequenceNumber: number }[]; +interface ScenarioDetail { + plan: PlanInput; // das Szenario selbst (berechenbare Einheit) + computed: PlanComputed; + base: PlanInput | null; // Eltern-Szenario als Vergleichsbasis + meta: ScenarioMeta & { planName: string }; } export function AppShell({ username }: { username: string }) { const [plans, setPlans] = useState([]); - const [selectedPlanId, setSelectedPlanId] = useState(null); - const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null); + const [selectedScenarioId, setSelectedScenarioId] = useState(null); + const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(false); const [showNewPlan, setShowNewPlan] = useState(false); - const [showScenario, setShowScenario] = useState(false); - // Die Spezifikation ist eine eigene Ansicht neben Uebersicht und Plan (schliessen sich aus). + const [copyFrom, setCopyFrom] = useState(null); const [showSpec, setShowSpec] = useState(false); - const loadPlans = useCallback(async (preferId?: string) => { + const loadPlans = useCallback(async () => { const data = await api.get<{ plans: PlanListItem[] }>("/api/plans"); setPlans(data.plans); - if (preferId) setSelectedPlanId(preferId); return data.plans; }, []); // silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert, // damit die Scrollposition (z. B. nach dem Schliessen eines Popups) erhalten bleibt. - const loadDetail = useCallback(async (planId: string, silent = false) => { + const loadDetail = useCallback(async (scenarioId: string, silent = false) => { if (!silent) setLoading(true); try { - const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/plans/${planId}`); - setDetail(data); + setDetail(await api.get(`/api/scenarios/${scenarioId}`)); } finally { if (!silent) setLoading(false); } @@ -64,26 +63,46 @@ export function AppShell({ username }: { username: string }) { }, [loadPlans]); useEffect(() => { - if (selectedPlanId) { + if (selectedScenarioId) { // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf - loadDetail(selectedPlanId); + loadDetail(selectedScenarioId); } else { setDetail(null); } - }, [selectedPlanId, loadDetail]); + }, [selectedScenarioId, loadDetail]); function refreshCurrent() { - if (selectedPlanId) loadDetail(selectedPlanId, true); + if (selectedScenarioId) loadDetail(selectedScenarioId, true); + } + + function openScenario(id: string) { + setSelectedScenarioId(id); + setShowSpec(false); + setSidebarOpen(false); } async function handleDeletePlan(id: string) { - if (!confirm("Diesen Plan wirklich loeschen?")) return; + if (!confirm("Diesen Plan mit ALLEN Szenarien wirklich loeschen?")) return; await api.delete(`/api/plans/${id}`); - await loadPlans(); - if (selectedPlanId === id) setSelectedPlanId(null); + const rest = await loadPlans(); + if (!rest.some((p) => p.scenarios.some((s) => s.id === selectedScenarioId))) { + setSelectedScenarioId(null); + } } - const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null; + async function handleDeleteScenario(s: ScenarioMeta) { + if (!confirm(`Szenario "${s.name}" wirklich loeschen?`)) return; + try { + await api.delete(`/api/scenarios/${s.id}`); + } catch (e) { + alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen."); + return; + } + await loadPlans(); + if (selectedScenarioId === s.id) setSelectedScenarioId(null); + } + + const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null; const sidebar = (
@@ -98,14 +117,12 @@ export function AppShell({ username }: { username: string }) {
{plans.length === 0 &&

Noch keine Plaene.

} + {plans.map((p) => ( - +
+
+ + {p.name} + +
+ +
))}
@@ -150,7 +173,7 @@ export function AppShell({ username }: { username: string }) { type="button" onClick={() => { setShowSpec(true); - setSelectedPlanId(null); + setSelectedScenarioId(null); setSidebarOpen(false); }} className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${ @@ -165,16 +188,16 @@ export function AppShell({ username }: { username: string }) {
); + const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null; + return (
- {/* Sidebar Desktop */} - + - {/* Sidebar Mobile (Overlay) */} {sidebarOpen && (
setSidebarOpen(false)} /> -
)} - {/* Hauptbereich */}

- {showSpec ? "Spezifikation" : activePlan ? activePlan.name : "Uebersicht"} + {showSpec + ? "Spezifikation" + : detail + ? `${detail.meta.planName} · ${detail.meta.name}` + : "Uebersicht"}

@@ -210,80 +236,169 @@ export function AppShell({ username }: { username: string }) { {!showSpec && loading &&

Laedt…

} - {!showSpec && !loading && selectedPlanId === null && ( + {!showSpec && !loading && selectedScenarioId === null && ( { - setSelectedPlanId(id); - setShowSpec(false); - }} + onSelect={openScenario} onCreate={() => setShowNewPlan(true)} onDelete={handleDeletePlan} /> )} - {!showSpec && !loading && detail && selectedPlanId && ( + {!showSpec && !loading && detail && selectedScenarioId && (
- {detail.plan.phases.length > 0 && ( - - )} + {!detail.meta.isBase && ( + + )} + {diff && detail.base && ( + + + {diff.total === 0 + ? "Unveraendert gegenueber der Vorlage" + : `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenueber der Vorlage`} + + )}
- + - {detail.plan.phases.length > 0 && } + {detail.plan.phases.length > 0 && ( + s.id !== detail.meta.id)} + /> + )}
)}
- {/* Dialoge */} {showNewPlan && ( { - const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name, ...profile }); + const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>( + "/api/plans", + { name, ...profile } + ); setShowNewPlan(false); - await loadPlans(plan.id); + await loadPlans(); + openScenario(scenario.id); }} onClose={() => setShowNewPlan(false)} /> )} - {showScenario && detail && selectedPlanId && ( - { - const { planId } = await api.post<{ planId: string }>(`/api/plans/${selectedPlanId}/scenario`, { - name, - branchFromPhaseId, - }); - setShowScenario(false); - await loadPlans(planId); + + {copyFrom && ( + setCopyFrom(null)} + onCreate={async (name) => { + const { scenarioId } = await api.post<{ scenarioId: string }>( + `/api/scenarios/${copyFrom.id}/copy`, + { name } + ); + setCopyFrom(null); + await loadPlans(); + openScenario(scenarioId); }} - onClose={() => setShowScenario(false)} /> )}
); } -// Startansicht: Begruessung + Plan-Kacheln. +// Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind. +function ScenarioTree({ + scenarios, + parentId, + depth, + selectedId, + onSelect, + onCopy, + onDelete, +}: { + scenarios: ScenarioMeta[]; + parentId: string | null; + depth: number; + selectedId: string | null; + onSelect: (id: string) => void; + onCopy: (s: ScenarioMeta) => void; + onDelete: (s: ScenarioMeta) => void; +}) { + const level = scenarios.filter((s) => (s.parentScenarioId ?? null) === parentId); + if (level.length === 0) return null; + return ( + <> + {level.map((s) => ( +
+
+ + + {!s.isBase && ( + + )} +
+ +
+ ))} + + ); +} + +// Startansicht: Begruessung + Plan-Kacheln (Klick oeffnet das Basisszenario). function DashboardHome({ username, plans, @@ -293,7 +408,7 @@ function DashboardHome({ }: { username: string; plans: PlanListItem[]; - onSelect: (id: string) => void; + onSelect: (scenarioId: string) => void; onCreate: () => void; onDelete: (id: string) => void; }) { @@ -302,42 +417,46 @@ function DashboardHome({

Willkommen, {username}

- Waehlen Sie einen Plan oder erstellen Sie einen neuen, um Ihre finanzielle Zukunft zu planen. + Waehlen Sie einen Plan oder erstellen Sie einen neuen. Jeder Plan enthaelt ein Basisszenario + und beliebig viele Varianten davon.

- {plans.map((p) => ( -
onSelect(p.id)} - > -
-
- -
-
-
{p.name}
-
- {p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"} - {p.parentPlanId ? " · Szenario" : ""} + {plans.map((p) => { + const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0]; + const others = p.scenarios.length - 1; + return ( +
base && onSelect(base.id)} + > +
+
+
+
+
{p.name}
+
+ Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""} +
+
+
-
-
- ))} + ); + })}
{otherPlans.length > 0 && (
- Vergleichen mit: + Szenarien vergleichen: {otherPlans.map((p) => (