Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
Deploy App / deploy (push) Successful in 1m43s

Groesste Umstrukturierung bisher. Der PLAN ist neu ein schlanker Behaelter ohne
Finanzdaten; die berechenbare Einheit ist das SZENARIO.

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

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

Charts vergleichen neu die Geschwister-Szenarien statt fremder Plaene.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 09:51:44 +02:00
parent 71137f7cea
commit a5d4868c58
25 changed files with 1336 additions and 486 deletions
+163 -61
View File
@@ -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` (v1v5) 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 810, `src/lib/types.ts` Zeilen 384
# 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)
└── 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 110, 114151.
**(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 | 180 |
| `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` 1120; `inflationRateDefault` 20…50; `persons` 12 Einträge;
`age` 0120; `retirementAge` 30100; `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/<planId>`
Liefert **Eingabe und Berechnung** in einem Zug:
`GET /api/plans` liefert die Pläne inkl. Szenario-Kopfdaten:
```json
{ "plan": <PlanInput>, "computed": <PlanComputed> }
{ "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/<planId>`
`{ name }` der Plan trägt nur noch den Namen. → 200 `{ plan: { id, name } }`
### `DELETE /api/plans/<planId>`
→ 200 `{ ok: true }`, Cascade über alle Szenarien.
## 6.3 Szenarien
### `GET /api/scenarios/<scenarioId>`
Liefert Eingabe, Berechnung **und die Vergleichsbasis** in einem Zug:
```json
{ "plan": <PlanInput>, "computed": <PlanComputed>,
"base": <PlanInput|null>,
"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/<scenarioId>`
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/<planId>`
→ 200 `{ ok: true }`, Cascade-Löschung.
### `DELETE /api/scenarios/<scenarioId>`
→ 200 `{ ok: true }` · 400 wenn es das **Basisszenario** ist.
### `POST /api/plans/<planId>/scenario`
```json
{ "name": "Frühpensionierung", "branchFromPhaseId": "<phaseId>" }
```
→ 201 `{ planId: "<neue Id>" }`
### `POST /api/scenarios/<scenarioId>/copy`
`{ name }` vollständige Kopie; setzt `parentScenarioId` sowie die Herkunfts-Verweise.
→ 201 `{ scenarioId: "<neue Id>" }`
### `GET /api/plans/<planId>/export`
### `GET /api/scenarios/<scenarioId>/export`
`text/csv; charset=utf-8`, `Content-Disposition: attachment`.
## 6.3 Phasen
## 6.4 Phasen
### `POST /api/plans/<planId>/phases`
### `POST /api/scenarios/<scenarioId>/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/<planId>/elements`
### `POST /api/scenarios/<scenarioId>/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/<id>` 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/<id>` 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
+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -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;
+54 -23
View File
@@ -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
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
@@ -96,12 +121,15 @@ model Plan {
// 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)
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[]
}
@@ -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();
@@ -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();
+5 -5
View File
@@ -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 });
+9 -66
View File
@@ -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 },
},
});
});
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 } });
}
const updated = await prisma.plan.update({
where: { id: plan.id },
data: {
name: "name" in data ? data.name : undefined,
initialCash: "initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
},
});
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
}
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 });
}
@@ -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<string, string>();
let lastNewPhaseId = "";
for (const phase of copiedPhases) {
const created = await tx.phase.create({
data: {
planId: newPlan.id,
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
// 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 });
}
+25 -21
View File
@@ -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<typeof planProfileSchema>): string | null {
export function validatePersonsForType(data: z.infer<typeof scenarioProfileSchema>): 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<typeof planProfileSchema>):
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,
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 }
);
}
@@ -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<string, string>();
for (const phase of source.phases) {
const p = await tx.phase.create({
data: {
scenarioId: created.id,
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
cashTransition: phase.cashTransition ?? undefined,
sourcePhaseId: phase.id,
},
});
phaseIdMap.set(phase.id, p.id);
}
// Elemente inkl. Phasen- und 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 });
}
@@ -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,
@@ -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"`,
},
});
}
@@ -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,
+110
View File
@@ -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<typeof toPlanInput> | 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 });
}
+30
View File
@@ -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);
+228 -109
View File
@@ -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<PlanListItem[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null);
const [selectedScenarioId, setSelectedScenarioId] = useState<string | null>(null);
const [detail, setDetail] = useState<ScenarioDetail | null>(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<ScenarioMeta | null>(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<ScenarioDetail>(`/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 = (
<div className="flex h-full flex-col">
@@ -98,14 +117,12 @@ export function AppShell({ username }: { username: string }) {
<button
type="button"
onClick={() => {
setSelectedPlanId(null);
setSelectedScenarioId(null);
setShowSpec(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
selectedPlanId === null && !showSpec
? "bg-accent-soft text-accent-soft-fg"
: "text-muted hover:bg-surface-2"
selectedScenarioId === null && !showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<LayoutDashboard className="h-4 w-4" />
@@ -124,25 +141,31 @@ export function AppShell({ username }: { username: string }) {
</button>
</div>
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>}
{plans.map((p) => (
<div key={p.id} className="mb-1">
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-faint" />
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
<button
key={p.id}
type="button"
onClick={() => {
setSelectedPlanId(p.id);
setShowSpec(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
selectedPlanId === p.id && !showSpec
? "bg-accent-soft font-medium text-accent-soft-fg"
: "text-muted hover:bg-surface-2"
}`}
aria-label="Plan loeschen"
onClick={() => handleDeletePlan(p.id)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<FolderKanban className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{p.name}</span>
<span className="text-[11px] text-faint">{p.phases.length}</span>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<ScenarioTree
scenarios={p.scenarios}
parentId={null}
depth={0}
selectedId={selectedScenarioId}
onSelect={openScenario}
onCopy={setCopyFrom}
onDelete={handleDeleteScenario}
/>
</div>
))}
<div className="mt-4 border-t border-border pt-3">
@@ -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 }) {
</div>
);
const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null;
return (
<div className="flex min-h-screen w-full">
{/* Sidebar Desktop */}
<aside className="hidden w-60 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
<aside className="hidden w-64 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
{/* Sidebar Mobile (Overlay) */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
<aside className="absolute left-0 top-0 h-full w-64 border-r border-border bg-surface shadow-xl">
<aside className="absolute left-0 top-0 h-full w-72 border-r border-border bg-surface shadow-xl">
<button
type="button"
onClick={() => setSidebarOpen(false)}
@@ -188,7 +211,6 @@ export function AppShell({ username }: { username: string }) {
</div>
)}
{/* Hauptbereich */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
<button
@@ -200,7 +222,11 @@ export function AppShell({ username }: { username: string }) {
<Menu className="h-4 w-4" />
</button>
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
{showSpec ? "Spezifikation" : activePlan ? activePlan.name : "Uebersicht"}
{showSpec
? "Spezifikation"
: detail
? `${detail.meta.planName} · ${detail.meta.name}`
: "Uebersicht"}
</h1>
<ProfileMenu username={username} />
</header>
@@ -210,80 +236,169 @@ export function AppShell({ username }: { username: string }) {
{!showSpec && loading && <p className="text-sm text-muted">Laedt</p>}
{!showSpec && !loading && selectedPlanId === null && (
{!showSpec && !loading && selectedScenarioId === null && (
<DashboardHome
username={username}
plans={plans}
onSelect={(id) => {
setSelectedPlanId(id);
setShowSpec(false);
}}
onSelect={openScenario}
onCreate={() => setShowNewPlan(true)}
onDelete={handleDeletePlan}
/>
)}
{!showSpec && !loading && detail && selectedPlanId && (
{!showSpec && !loading && detail && selectedScenarioId && (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-2">
{detail.plan.phases.length > 0 && (
<button
type="button"
onClick={() => setShowScenario(true)}
onClick={() => setCopyFrom(detail.meta)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Plus className="h-4 w-4" />
Szenario
<Copy className="h-4 w-4" />
Neues Szenario aus diesem
</button>
)}
{!detail.meta.isBase && (
<button
type="button"
onClick={() => handleDeletePlan(selectedPlanId)}
onClick={() => handleDeleteScenario(detail.meta)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-4 w-4" />
Plan loeschen
Szenario loeschen
</button>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
{diff.total === 0
? "Unveraendert gegenueber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenueber der Vorlage`}
</span>
)}
</div>
<PlanView plan={detail.plan} computed={detail.computed} onChanged={refreshCurrent} />
<PlanView plan={detail.plan} computed={detail.computed} diff={diff} onChanged={refreshCurrent} />
{detail.plan.phases.length > 0 && <Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />}
{detail.plan.phases.length > 0 && (
<Dashboard
plan={detail.plan}
computed={detail.computed}
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
/>
)}
</div>
)}
</main>
</div>
{/* Dialoge */}
{showNewPlan && (
<PlanDialog
onCreate={async (name, profile) => {
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 && (
<ScenarioDialog
phases={detail.plan.phases}
onCreate={async (name, branchFromPhaseId) => {
const { planId } = await api.post<{ planId: string }>(`/api/plans/${selectedPlanId}/scenario`, {
name,
branchFromPhaseId,
});
setShowScenario(false);
await loadPlans(planId);
{copyFrom && (
<CopyScenarioDialog
source={copyFrom}
onClose={() => 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)}
/>
)}
</div>
);
}
// 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) => (
<div key={s.id}>
<div
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm ${
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
style={{ paddingLeft: `${12 + depth * 14}px` }}
>
<button type="button" onClick={() => onSelect(s.id)} className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
{s.isBase ? (
<PiggyBank className="h-3.5 w-3.5 shrink-0" />
) : (
<GitBranch className="h-3.5 w-3.5 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate" title={s.name}>{s.name}</span>
</button>
<button
type="button"
aria-label="Kopie erstellen"
title="Neues Szenario aus diesem"
onClick={() => onCopy(s)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover:opacity-100"
>
<Copy className="h-3.5 w-3.5" />
</button>
{!s.isBase && (
<button
type="button"
aria-label="Szenario loeschen"
onClick={() => onDelete(s)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<ScenarioTree
scenarios={scenarios}
parentId={s.id}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
onCopy={onCopy}
onDelete={onDelete}
/>
</div>
))}
</>
);
}
// 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,16 +417,20 @@ function DashboardHome({
<div>
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
<p className="mt-1 text-sm text-muted">
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.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{plans.map((p) => (
{plans.map((p) => {
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
const others = p.scenarios.length - 1;
return (
<div
key={p.id}
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
onClick={() => onSelect(p.id)}
onClick={() => base && onSelect(base.id)}
>
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
@@ -320,8 +439,7 @@ function DashboardHome({
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-fg">{p.name}</div>
<div className="text-xs text-muted">
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
{p.parentPlanId ? " · Szenario" : ""}
Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""}
</div>
</div>
<button
@@ -337,7 +455,8 @@ function DashboardHome({
</button>
</div>
</div>
))}
);
})}
<button
type="button"
@@ -359,7 +478,7 @@ function PlanDialog({
onCreate: (name: string, profile: ProfileDraft) => void;
onClose: () => void;
}) {
const [name, setName] = useState("Basisplan");
const [name, setName] = useState("Meine Planung");
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft);
const [saving, setSaving] = useState(false);
@@ -370,6 +489,10 @@ function PlanDialog({
className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-fg">Neuen Plan erstellen</h2>
<p className="text-xs text-muted">
Es wird automatisch ein <strong className="text-fg">Basisszenario</strong> angelegt. Weitere
Szenarien entstehen spaeter als Kopien davon.
</p>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
<input
@@ -406,24 +529,28 @@ function PlanDialog({
);
}
function ScenarioDialog({
phases,
function CopyScenarioDialog({
source,
onCreate,
onClose,
}: {
phases: { id: string; name: string }[];
onCreate: (name: string, branchFromPhaseId: string) => void;
source: ScenarioMeta;
onCreate: (name: string) => void;
onClose: () => void;
}) {
const [name, setName] = useState("Neues Szenario");
const [branchFromPhaseId, setBranchFromPhaseId] = useState(phases[phases.length - 1]?.id ?? "");
const [name, setName] = useState(`${source.name} Variante`);
const [saving, setSaving] = useState(false);
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-fg">Szenario erstellen</h2>
<h2 className="text-base font-semibold text-fg">Neues Szenario</h2>
<p className="text-xs text-muted">
Vollstaendige Kopie von <strong className="text-fg">{source.name}</strong>. Aenderungen darin
werden anschliessend farblich hervorgehoben.
</p>
<input
autoFocus
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
@@ -431,25 +558,17 @@ function ScenarioDialog({
onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios"
/>
<label className="text-xs text-muted">Verzweigen ab Phase</label>
<select
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
value={branchFromPhaseId}
onChange={(e) => setBranchFromPhaseId(e.target.value)}
>
{phases.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate(name, branchFromPhaseId)}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover"
disabled={saving}
onClick={() => {
setSaving(true);
onCreate(name.trim() || "Szenario");
}}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
Erstellen
{saving ? "..." : "Erstellen"}
</button>
<button
type="button"
+9 -8
View File
@@ -20,11 +20,12 @@ interface PlanListItem {
export function Dashboard({
plan,
computed,
allPlans,
siblings,
}: {
plan: PlanInput;
computed: PlanComputed;
allPlans: PlanListItem[];
// Die uebrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
}) {
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
@@ -36,7 +37,7 @@ export function Dashboard({
}
setCompareIds((prev) => [...prev, id]);
if (!compareData[id]) {
const data = await api.get<{ computed: PlanComputed }>(`/api/plans/${id}`);
const data = await api.get<{ computed: PlanComputed }>(`/api/scenarios/${id}`);
setCompareData((prev) => ({ ...prev, [id]: data.computed }));
}
}
@@ -45,11 +46,11 @@ export function Dashboard({
const result: TimelineSeries[] = [{ label: plan.name, color: PALETTE[0], computed }];
compareIds.forEach((id, i) => {
const c = compareData[id];
const name = allPlans.find((p) => p.id === id)?.name ?? id;
const name = siblings.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: PALETTE[(i + 1) % PALETTE.length], computed: c });
});
return result;
}, [plan.name, computed, compareIds, compareData, allPlans]);
}, [plan.name, computed, compareIds, compareData, siblings]);
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
@@ -87,7 +88,7 @@ export function Dashboard({
[computed]
);
const otherPlans = allPlans.filter((p) => p.id !== plan.id);
const otherPlans = siblings;
const lastPhase = computed.phases[computed.phases.length - 1];
return (
@@ -117,7 +118,7 @@ export function Dashboard({
Vermoegensverlauf nach Alter
</h3>
<a
href={`/api/plans/${plan.id}/export`}
href={`/api/scenarios/${plan.id}/export`}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted hover:bg-surface-2"
>
<Download className="h-3.5 w-3.5" />
@@ -126,7 +127,7 @@ export function Dashboard({
</div>
{otherPlans.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
<span className="text-xs text-muted">Vergleichen mit:</span>
<span className="text-xs text-muted">Szenarien vergleichen:</span>
{otherPlans.map((p) => (
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
<input
+85 -14
View File
@@ -49,6 +49,7 @@ import {
type TransitionData,
} from "@/lib/elements";
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
import type { ScenarioDiff } from "@/lib/diff";
import type { ElementInput, PlanInput } from "@/lib/types";
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
@@ -91,12 +92,28 @@ type Selection =
export function PlanView({
plan,
computed,
diff,
onChanged,
}: {
plan: PlanInput;
computed: PlanComputed;
// Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
diff: ScenarioDiff | null;
onChanged: () => void;
}) {
// Markierungs-Klassen: geaendert = gelb, neu = gruen, entfernt = grau.
const cellDiff = (elementId: string, phaseId: string) =>
diff?.phaseCell.has(`${elementId}:${phaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : "";
const transDiff = (elementId: string, fromPhaseId: string) =>
diff?.transitionCell.has(`${elementId}:${fromPhaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : "";
const rowDiff = (elementId: string) => {
const k = diff?.elementRow.get(elementId);
return k === "added"
? "bg-diff-added-soft"
: k === "changed"
? "bg-diff-soft"
: "";
};
const [selected, setSelected] = useState<Selection | null>(null);
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
const [showAdd, setShowAdd] = useState(false);
@@ -275,7 +292,7 @@ export function PlanView({
}
async function handleAddPhase(payload: { name?: string; durationYears?: number }) {
await api.post(`/api/plans/${plan.id}/phases`, payload);
await api.post(`/api/scenarios/${plan.id}/phases`, payload);
setShowAddPhase(false);
onChanged();
}
@@ -307,8 +324,14 @@ export function PlanView({
<Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} />
{/* Plan-Profil */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Grundprofil (Plan)</span>
<div
className={`flex flex-wrap items-center gap-3 rounded-xl border px-4 py-3 text-sm shadow-sm ${
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
}`}
>
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
Grundprofil (Szenario){diff?.profileChanged ? " · abweichend" : ""}
</span>
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
@@ -385,6 +408,7 @@ export function PlanView({
phase={col.phase}
personLabel={personLabel}
mode={valueMode}
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
/>
@@ -416,7 +440,9 @@ export function PlanView({
title={isFirst ? "Cash-Anfangswert bearbeiten" : undefined}
className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${
isFirst ? "cursor-pointer hover:bg-accent-soft" : ""
} ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"}`}
} ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"} ${
isFirst && diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
}`}
>
<span className="whitespace-nowrap">
{valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "}
@@ -435,7 +461,11 @@ export function PlanView({
onClick={() => setEditCashTransition(col.fromPhase.id)}
title="Einmalige Sonderein-/ausgaben"
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
open ? "bg-accent font-semibold text-accent-fg" : "bg-accent-soft/40 text-accent"
open
? "bg-accent font-semibold text-accent-fg"
: diff?.cashTransitionCell.has(col.fromPhase.id)
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
: "bg-accent-soft/40 text-accent"
}`}
>
{cashTransitionSummary(ct)}
@@ -474,8 +504,13 @@ export function PlanView({
{!collapsed &&
els.map((el) => (
<tr key={el.id} className="hover:bg-surface-2">
<td className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
<div className="truncate text-xs font-medium text-fg">{el.name}</div>
<td className={`sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
<div className="flex items-center gap-1 truncate text-xs font-medium text-fg">
{el.name}
{diff?.elementRow.get(el.id) === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
</div>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
)}
@@ -487,9 +522,10 @@ export function PlanView({
<td
key={col.phase.id}
onClick={() => setEditPhaseCell({ elementId: el.id, phaseId: col.phase.id })}
title={cellDiff(el.id, col.phase.id) ? "Weicht von der Vorlage ab" : undefined}
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
ce?.locked ? "text-faint" : "text-fg"
}`}
} ${cellDiff(el.id, col.phase.id)}`}
>
{phaseCellContent(ce, col.phase, valueMode)}
</td>
@@ -505,9 +541,19 @@ export function PlanView({
canTransition &&
setEditTransition({ elementId: el.id, fromPhaseId: col.fromPhase.id })
}
title={transDiff(el.id, col.fromPhase.id) ? "Weicht von der Vorlage ab" : undefined}
className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
canTransition ? "cursor-pointer" : "text-faint"
} ${open ? "bg-accent font-semibold text-accent-fg" : canTransition ? "bg-accent-soft/40 text-accent" : ""}`}
} ${
// Offene Entscheide bleiben in Akzentfarbe; sonst gewinnt die Abweichungs-Markierung.
open
? "bg-accent font-semibold text-accent-fg"
: transDiff(el.id, col.fromPhase.id)
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
: canTransition
? "bg-accent-soft/40 text-accent"
: ""
}`}
>
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : locked ? "" : "→"}
</td>
@@ -518,7 +564,21 @@ export function PlanView({
</FragmentRows>
);
})}
{plan.elements.length === 0 && (
{/* Geisterzeilen: in der Vorlage vorhanden, in diesem Szenario geloescht. */}
{(diff?.removedElements ?? []).map((r) => (
<tr key={`removed-${r.id}`} className="bg-diff-removed-soft">
<td className="sticky left-0 z-10 border-b border-r border-border bg-diff-removed-soft px-3 py-1.5">
<div className="flex items-center gap-1 truncate text-xs font-medium text-diff-removed line-through">
{r.name}
</div>
<div className="text-[10px] text-diff-removed">{CATEGORY_LABELS[r.category]} · entfernt</div>
</td>
<td colSpan={columns.length} className="border-b border-border px-3 py-1.5 text-center text-[11px] text-diff-removed">
In diesem Szenario entfernt
</td>
</tr>
))}
{plan.elements.length === 0 && (diff?.removedElements.length ?? 0) === 0 && (
<tr>
<td className="sticky left-0 bg-surface px-3 py-4 text-xs text-faint" colSpan={columns.length + 1}>
Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
@@ -793,12 +853,14 @@ function PhaseHeader({
phase,
personLabel,
mode,
diffKind,
onClick,
active,
}: {
phase: PhaseComputed;
personLabel: (role: string) => string;
mode: ValueMode;
diffKind: "changed" | "added" | "removed" | null;
onClick: () => void;
active: boolean;
}) {
@@ -810,11 +872,20 @@ function PhaseHeader({
<th
onClick={onClick}
className={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active ? "bg-accent-soft" : "bg-surface"
active
? "bg-accent-soft"
: diffKind === "added"
? "bg-diff-added-soft"
: diffKind === "changed"
? "bg-diff-soft"
: "bg-surface"
}`}
>
<div className="flex items-center gap-1">
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
{diffKind === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
{phase.cashNegative ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
) : (
@@ -973,7 +1044,7 @@ function AddElementDialog({
setSaving(true);
setError(null);
try {
const { element } = await api.post<{ element: { id: string } }>(`/api/plans/${plan.id}/elements`, {
const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${plan.id}/elements`, {
category,
name: name.trim() || CATEGORY_LABELS[category],
ownerRole,
@@ -1128,7 +1199,7 @@ function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClo
setSaving(true);
setError(null);
try {
await api.patch(`/api/plans/${plan.id}`, draft);
await api.patch(`/api/scenarios/${plan.id}`, draft);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
@@ -1324,7 +1395,7 @@ function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClos
setSaving(true);
setError(null);
try {
await api.patch(`/api/plans/${plan.id}`, { initialCash: value });
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: value });
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { computeScenarioDiff } from "@/lib/diff";
import type { PlanInput } from "@/lib/types";
// Basis: 1 Phase, 2 Elemente. Das Kind ist eine Kopie mit Herkunfts-Verweisen.
function base(): PlanInput {
return {
id: "S1", name: "Basisszenario", householdType: "SINGLE", inflationRateDefault: 1.5, initialCash: 1000,
persons: [{ id: "pA", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "b-ph1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {}, sourcePhaseId: null }],
elements: [
{ id: "b-e1", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1, phaseValues: { "b-ph1": { amount: 90000 } }, transitionValues: {}, sourceElementId: null },
{ id: "b-e2", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 2, phaseValues: { "b-ph1": { startValue: 100000, expectedReturn: 5 } }, transitionValues: { "b-ph1": { decision: "HOLD" } }, sourceElementId: null },
],
};
}
// Unveraenderte Kopie.
function copy(): PlanInput {
return {
id: "S2", name: "Variante", householdType: "SINGLE", inflationRateDefault: 1.5, initialCash: 1000,
persons: [{ id: "pA2", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "c-ph1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {}, sourcePhaseId: "b-ph1" }],
elements: [
{ id: "c-e1", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1, phaseValues: { "c-ph1": { amount: 90000 } }, transitionValues: {}, sourceElementId: "b-e1" },
{ id: "c-e2", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 2, phaseValues: { "c-ph1": { startValue: 100000, expectedReturn: 5 } }, transitionValues: { "c-ph1": { decision: "HOLD" } }, sourceElementId: "b-e2" },
],
};
}
describe("Szenario-Diff", () => {
it("frische Kopie zeigt keine Abweichung", () => {
const d = computeScenarioDiff(copy(), base());
expect(d.total).toBe(0);
expect(d.phaseCell.size).toBe(0);
expect(d.profileChanged).toBe(false);
});
it("Basisszenario (ohne Elternteil) hat nie Abweichungen", () => {
expect(computeScenarioDiff(base(), null).total).toBe(0);
});
it("geaenderter Zellwert wird genau einer Zelle zugeordnet", () => {
const c = copy();
c.elements[0].phaseValues["c-ph1"] = { amount: 120000 };
const d = computeScenarioDiff(c, base());
expect(d.phaseCell.has("c-e1:c-ph1")).toBe(true);
expect(d.phaseCell.size).toBe(1);
expect(d.elementRow.size).toBe(0); // Zeile selbst unveraendert
});
it("geaenderter Uebergangs-Entscheid markiert die Uebergangszelle", () => {
const c = copy();
c.elements[1].transitionValues["c-ph1"] = { decision: "SELL" };
const d = computeScenarioDiff(c, base());
expect(d.transitionCell.has("c-e2:c-ph1")).toBe(true);
expect(d.phaseCell.size).toBe(0);
});
it("neues Element ist 'added', geloeschtes wird als entfernt gemeldet", () => {
const c = copy();
c.elements.push({ id: "c-e3", category: "OTHER_DEBT", name: "Kredit", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: {}, transitionValues: {}, sourceElementId: null });
c.elements = c.elements.filter((e) => e.id !== "c-e1"); // Lohn geloescht
const d = computeScenarioDiff(c, base());
expect(d.elementRow.get("c-e3")).toBe("added");
expect(d.removedElements.map((r) => r.name)).toEqual(["Lohn"]);
});
it("Phasenkopf: Dauer/Name geaendert bzw. Phase neu", () => {
const c = copy();
c.phases[0].durationYears = 15;
c.phases.push({ id: "c-ph2", sequenceNumber: 2, name: "Pension", durationYears: 25, cashTransition: {}, sourcePhaseId: null });
const d = computeScenarioDiff(c, base());
expect(d.phaseHeader.get("c-ph1")).toBe("changed");
expect(d.phaseHeader.get("c-ph2")).toBe("added");
});
it("Profil (Pensionsalter) und Cash-Anfangswert werden erkannt", () => {
const c = copy();
c.persons[0].retirementAge = 62;
c.initialCash = 5000;
const d = computeScenarioDiff(c, base());
expect(d.profileChanged).toBe(true);
expect(d.cashInitialChanged).toBe(true);
});
it("Cash-Uebergang wird erkannt", () => {
const c = copy();
c.phases[0].cashTransition = { mode: "INFLOW", inflowAmount: 100000 };
const d = computeScenarioDiff(c, base());
expect(d.cashTransitionCell.has("c-ph1")).toBe(true);
});
it("fehlendes Feld und 0 gelten als gleich (keine Falsch-Markierung)", () => {
const c = copy();
c.elements[0].phaseValues["c-ph1"] = { amount: 90000, teuerungsausgleich: 0 };
expect(computeScenarioDiff(c, base()).phaseCell.size).toBe(0);
});
});
+153
View File
@@ -0,0 +1,153 @@
// Abweichungs-Erkennung zwischen einem Szenario und seinem ELTERN-Szenario.
//
// Die Zuordnung laeuft ueber die Herkunfts-Verweise, die beim Kopieren gesetzt werden
// (Phase.sourcePhaseId, FinancialElement.sourceElementId). Ueber den Namen zu matchen waere
// fragil: Umbenennen wuerde die Verknuepfung brechen und gleichnamige Elemente kollidieren.
//
// Der Vergleich ist LIVE gegen den aktuellen Stand des Elternteils -- aendert man dort einen
// Wert, verschiebt sich die Markierung im Kind entsprechend.
import { num } from "@/lib/elements";
import type { PhaseData, TransitionData, CashTransitionData } from "@/lib/elements";
import type { ElementInput, PlanInput } from "@/lib/types";
export type DiffKind = "changed" | "added" | "removed";
export interface ScenarioDiff {
// elementId -> Status der ganzen Zeile ("added" = im Elternteil nicht vorhanden).
elementRow: Map<string, DiffKind>;
// `${elementId}:${phaseId}` -> Zelle weicht ab.
phaseCell: Set<string>;
// `${elementId}:${fromPhaseId}` -> Uebergangs-Zelle weicht ab.
transitionCell: Set<string>;
// phaseId -> Phasenkopf weicht ab (Name/Dauer) bzw. Phase ist neu.
phaseHeader: Map<string, DiffKind>;
// phaseId -> Cash-Uebergang nach dieser Phase weicht ab.
cashTransitionCell: Set<string>;
cashInitialChanged: boolean;
profileChanged: boolean;
// Im Elternteil vorhandene, hier geloeschte Elemente (fuer die Geisterzeilen).
removedElements: { id: string; name: string; category: ElementInput["category"] }[];
removedPhaseCount: number;
total: number;
}
export function emptyDiff(): ScenarioDiff {
return {
elementRow: new Map(),
phaseCell: new Set(),
transitionCell: new Set(),
phaseHeader: new Map(),
cashTransitionCell: new Set(),
cashInitialChanged: false,
profileChanged: false,
removedElements: [],
removedPhaseCount: 0,
total: 0,
};
}
// Vergleicht zwei JSON-Payloads feldweise; fehlend und 0 gelten als gleich, damit ein
// nicht gesetztes Feld nicht faelschlich als Aenderung erscheint.
function sameData(a: object = {}, b: object = {}): boolean {
const ra = a as Record<string, unknown>;
const rb = b as Record<string, unknown>;
const keys = new Set([...Object.keys(ra), ...Object.keys(rb)]);
for (const k of keys) {
const va = ra[k];
const vb = rb[k];
if (typeof va === "number" || typeof vb === "number") {
if (Math.round(num(va as number)) !== Math.round(num(vb as number))) return false;
} else if ((va ?? "") !== (vb ?? "")) {
return false;
}
}
return true;
}
export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null): ScenarioDiff {
const d = emptyDiff();
if (!base) return d;
// --- Phasen ---
const basePhaseById = new Map(base.phases.map((p) => [p.id, p]));
const usedBasePhases = new Set<string>();
// Zuordnung eigene Phase -> Eltern-Phase (fuer die Zellen-Vergleiche).
const phaseToBase = new Map<string, string>();
for (const ph of scenario.phases) {
const src = ph.sourcePhaseId ? basePhaseById.get(ph.sourcePhaseId) : undefined;
if (!src) {
d.phaseHeader.set(ph.id, "added");
continue;
}
usedBasePhases.add(src.id);
phaseToBase.set(ph.id, src.id);
if (ph.name !== src.name || ph.durationYears !== src.durationYears) {
d.phaseHeader.set(ph.id, "changed");
}
if (!sameData(ph.cashTransition as CashTransitionData, src.cashTransition as CashTransitionData)) {
d.cashTransitionCell.add(ph.id);
}
}
d.removedPhaseCount = base.phases.filter((p) => !usedBasePhases.has(p.id)).length;
// --- Elemente ---
const baseElById = new Map(base.elements.map((e) => [e.id, e]));
const usedBaseEls = new Set<string>();
for (const el of scenario.elements) {
const src = el.sourceElementId ? baseElById.get(el.sourceElementId) : undefined;
if (!src) {
d.elementRow.set(el.id, "added");
continue;
}
usedBaseEls.add(src.id);
if (el.name !== src.name || el.ownerRole !== src.ownerRole) {
d.elementRow.set(el.id, "changed");
}
// Zellen je Phase / Uebergang ueber die Phasen-Zuordnung vergleichen.
for (const ph of scenario.phases) {
const basePhaseId = phaseToBase.get(ph.id);
const mine = el.phaseValues[ph.id];
const theirs = basePhaseId ? src.phaseValues[basePhaseId] : undefined;
if (!basePhaseId) continue; // neue Phase -> Kopf ist bereits als "added" markiert
if (!sameData(mine as PhaseData, theirs as PhaseData)) {
d.phaseCell.add(`${el.id}:${ph.id}`);
}
const myT = el.transitionValues[ph.id];
const theirT = src.transitionValues[basePhaseId];
if (!sameData(myT as TransitionData, theirT as TransitionData)) {
d.transitionCell.add(`${el.id}:${ph.id}`);
}
}
}
d.removedElements = base.elements
.filter((e) => !usedBaseEls.has(e.id))
.map((e) => ({ id: e.id, name: e.name, category: e.category }));
// --- Profil und Cash-Anfangswert ---
d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash);
const personKey = (p: PlanInput["persons"][number]) =>
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}`;
d.profileChanged =
scenario.householdType !== base.householdType ||
scenario.inflationRateDefault !== base.inflationRateDefault ||
scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";");
d.total =
d.elementRow.size +
d.phaseCell.size +
d.transitionCell.size +
d.phaseHeader.size +
d.cashTransitionCell.size +
d.removedElements.length +
d.removedPhaseCount +
(d.cashInitialChanged ? 1 : 0) +
(d.profileChanged ? 1 : 0);
return d;
}
+63
View File
@@ -0,0 +1,63 @@
// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prueft das
// Ergebnis. Faengt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy
// gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfuegung.
import { describe, it, expect } from "vitest";
import { PGlite } from "@electric-sql/pglite";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
const MIG = path.join(process.cwd(), "prisma", "migrations");
describe("Datenbank-Migrationen", () => {
it("laufen vollstaendig durch und ergeben das erwartete Schema", async () => {
const db = await PGlite.create();
const dirs = readdirSync(MIG).filter((d) => !d.endsWith(".toml")).sort();
expect(dirs.length).toBeGreaterThan(0);
for (const d of dirs) {
await db.exec(readFileSync(path.join(MIG, d, "migration.sql"), "utf8"));
}
const tables = (
await db.query<{ table_name: string }>(
`SELECT table_name FROM information_schema.tables WHERE table_schema='public'`
)
).rows.map((r) => r.table_name);
// V6-Struktur: Behaelter Plan + berechenbares Scenario.
for (const t of ["User", "Plan", "Scenario", "Person", "Phase", "FinancialElement"]) {
expect(tables, `Tabelle ${t} fehlt`).toContain(t);
}
const cols = async (table: string) =>
(
await db.query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns WHERE table_name=$1`,
[table]
)
).rows.map((r) => r.column_name);
// Kind-Tabellen haengen am Szenario, nicht mehr am Plan.
for (const t of ["Person", "Phase", "FinancialElement"]) {
const c = await cols(t);
expect(c, `${t}.scenarioId fehlt`).toContain("scenarioId");
expect(c, `${t}.planId haette entfernt werden muessen`).not.toContain("planId");
}
// Herkunfts-Verweise fuer den Diff.
expect(await cols("Phase")).toContain("sourcePhaseId");
expect(await cols("FinancialElement")).toContain("sourceElementId");
// Der Plan traegt keine Finanzdaten mehr.
const planCols = await cols("Plan");
expect(planCols).not.toContain("householdType");
expect(planCols).toContain("userId");
// Das Szenario traegt sie.
const scenCols = await cols("Scenario");
for (const c of ["planId", "isBase", "parentScenarioId", "householdType", "initialCash"]) {
expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
}
expect(scenCols).not.toContain("userId"); // Eigentuemer haengt am Plan
}, 60000);
});
+28 -6
View File
@@ -11,9 +11,9 @@ export const planInclude = {
orderBy: { orderIndex: "asc" },
include: { phaseValues: true, transitionValues: true },
},
} satisfies Prisma.PlanInclude;
} satisfies Prisma.ScenarioInclude;
export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof planInclude }>;
export type PlanWithRelations = Prisma.ScenarioGetPayload<{ include: typeof planInclude }>;
function parsePhaseData(raw: unknown): PhaseData {
const parsed = phaseDataSchema.safeParse(raw);
@@ -30,6 +30,7 @@ function parseCashTransition(raw: unknown): CashTransitionData {
return parsed.success ? parsed.data : {};
}
// Wandelt ein Szenario (DB) in die berechenbare Einheit (PlanInput) um.
export function toPlanInput(plan: PlanWithRelations): PlanInput {
return {
id: plan.id,
@@ -50,6 +51,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: phase.name,
durationYears: phase.durationYears,
cashTransition: parseCashTransition(phase.cashTransition),
sourcePhaseId: phase.sourcePhaseId,
})),
elements: plan.elements.map((e) => {
const phaseValues: Record<string, PhaseData> = {};
@@ -64,29 +66,49 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
orderIndex: e.orderIndex,
phaseValues,
transitionValues,
sourceElementId: e.sourceElementId,
};
}),
};
}
// Laedt einen Plan inkl. Profil + Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
// --- Ownership-Abfragen. Der Eigentuemer haengt neu am PLAN; Szenario/Phase/Element
// erben ihn ueber die Kette Scenario -> Plan -> User. ---
// Laedt ein Szenario inkl. Profil + Phasen + Elemente, nur wenn es dem Benutzer gehoert.
export async function getOwnedScenario(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({
where: { id: scenarioId, plan: { userId } },
include: planInclude,
});
}
// Wie oben, zusaetzlich mit den Kopfdaten (Plan, Basis-Flag, Elternteil).
export async function getOwnedScenarioWithMeta(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({
where: { id: scenarioId, plan: { userId } },
include: { ...planInclude, plan: true },
});
}
// Laedt einen Plan (Behaelter) inkl. Szenario-Kopfdaten.
export async function getOwnedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({
where: { id: planId, userId },
include: planInclude,
include: { scenarios: { orderBy: { createdAt: "asc" } } },
});
}
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
export async function getOwnedPhase(phaseId: string, userId: string) {
return prisma.phase.findFirst({
where: { id: phaseId, plan: { userId } },
where: { id: phaseId, scenario: { plan: { userId } } },
});
}
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
export async function getOwnedElement(elementId: string, userId: string) {
return prisma.financialElement.findFirst({
where: { id: elementId, plan: { userId } },
where: { id: elementId, scenario: { plan: { userId } } },
});
}
+25 -2
View File
@@ -23,6 +23,8 @@ export interface PhaseInput {
durationYears: number;
// Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
cashTransition: CashTransitionData;
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourcePhaseId?: string | null;
}
export interface ElementInput {
@@ -34,10 +36,31 @@ export interface ElementInput {
// Werte je Phase (Key = phaseId) bzw. je Uebergang (Key = fromPhaseId).
phaseValues: Record<string, PhaseData>;
transitionValues: Record<string, TransitionData>;
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourceElementId?: string | null;
}
// Ein Plan ist selbsttragend: er traegt sein eigenes Grundprofil (Haushaltsform, Personen,
// Inflationsannahme) plus die Phasenkette und die finanziellen Elemente.
// Kopf-Daten eines Szenarios (fuer Baum und Auswahl in der Seitenleiste).
export interface ScenarioMeta {
id: string;
planId: string;
name: string;
isBase: boolean;
parentScenarioId: string | null;
}
// Ein Plan ist der Behaelter; er traegt nur den Namen und seine Szenarien.
export interface PlanListItem {
id: string;
name: string;
createdAt?: string;
scenarios: ScenarioMeta[];
}
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und traegt ihr
// eigenes Grundprofil (Haushaltsform, Personen, Inflation, Cash) plus Phasen und Elemente.
// Der Name `PlanInput` ist historisch und bleibt, weil die ganze Berechnungsschicht darauf
// aufsetzt (computePlan, Monte Carlo, Tests).
export interface PlanInput {
id: string;
name: string;