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
+165 -63
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.7 | | **Version** | 0.8 |
| **Datum** | 2026-07-17 | | **Datum** | 2026-07-18 |
| **Status** | Lebendes Dokument | | **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 | | **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` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | 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.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.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. | | 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. Fachliche Grundkonzepte
## 2.1 Die vier Ebenen ## 2.1 Die Ebenen
``` ```
User User
└── Plan (Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert) └── Plan (Behälter: nur Name — KEINE Finanzdaten)
── Person[] (1 bei SINGLE, 2 bei COUPLE) ── Scenario[] (die berechenbare Einheit)
├── Phase[] (geordnete Kette: sequenceNumber 1..n, je mit Dauer in Jahren) │ Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert
└── FinancialElement[] (plan-weit, kategorisiert, optional personenzugeordnet) │ isBase = genau eines je Plan · parentScenarioId = Baum + Vergleichsbasis
├── ElementPhaseValue[] (Werte je Phase, JSON) ├── Person[] (1 bei SINGLE, 2 bei COUPLE — je Szenario eigen!)
── ElementTransitionValue[] (Entscheide je Übergang, JSON) ── 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`): **Zwei Designprinzipien tragen dieses Modell:**
**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.
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 ## 2.2 Die Matrix als Leitmetapher
@@ -263,21 +277,53 @@ Referenz: `prisma/schema.prisma` Zeile 82.
### 3.2.5 Szenarien ### 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: Ein weiteres Szenario ist eine **vollständige Kopie eines beliebigen bestehenden Szenarios**
- Grundprofil (Haushaltsform, Inflation, `initialCash`) und alle Personen (nicht nur des Basisszenarios). Kopiert werden Grundprofil, alle Personen, alle Phasen, alle
- alle Phasen mit `sequenceNumber <= branchPhase.sequenceNumber` Elemente sowie sämtliche Phasen- und Übergangswerte.
- **alle** Elemente (unabhängig von der Verzweigungsphase)
- Phasenwerte nur für kopierte Phasen; Übergangswerte nur für Übergänge aus kopierten Phasen
Gesetzt werden `parentPlanId` (Ursprungsplan) und `branchFromPhaseId` (die **neue** ID der Gesetzt werden dabei:
letzten kopierten Phase). Der Benutzer landet direkt im neuen Szenario, das anschliessend - `parentScenarioId` = das kopierte Szenario → ergibt den **Baum** in der Seitenleiste **und**
unabhängig weiterentwickelt wird. 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 ## 3.3 Lebensphasen
@@ -1618,25 +1664,34 @@ PlanComputed ← an den Client geliefert
| Feld | Typ | Constraints | | Feld | Typ | Constraints |
|---|---|---| |---|---|---|
| `id` | String | PK, `cuid()` | | `id` | String | PK, `cuid()` |
| `planId` | String | FK → Plan, **Cascade** | | `scenarioId` | String | FK → Scenario, **Cascade** |
| `role` | `PersonRole` | `PERSON_A` \| `PERSON_B` | | `role` | `PersonRole` | `PERSON_A` \| `PERSON_B` |
| `name` | String? | optional | | `name` | String? | optional |
| `age` | Int | aktuelles Alter | | `age` | Int | aktuelles Alter |
| `retirementAge` | Int | plan-eigenes Pensionsalter | | `retirementAge` | Int | **szenario-eigenes** Pensionsalter |
| | | `@@unique([planId, role])` | | | | `@@unique([scenarioId, role])` |
**Plan** **Plan** (Behälter trägt keine Finanzdaten)
| Feld | Typ | Constraints | | Feld | Typ | Constraints |
|---|---|---| |---|---|---|
| `id` | String | PK, `cuid()` | | `id` | String | PK, `cuid()` |
| `userId` | String | FK → User, **Cascade** | | `userId` | String | FK → User, **Cascade** |
| `name` | String | | | `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` | | `householdType` | `HouseholdType` | `SINGLE` \| `COUPLE` |
| `inflationRateDefault` | Float | plan-weite Inflation in % | | `inflationRateDefault` | Float | szenario-weite Inflation in % |
| `initialCash` | Float | Default 0 | | `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 | | | `createdAt` / `updatedAt` | DateTime | |
**Phase** **Phase**
@@ -1644,13 +1699,14 @@ PlanComputed ← an den Client geliefert
| Feld | Typ | Constraints | | Feld | Typ | Constraints |
|---|---|---| |---|---|---|
| `id` | String | PK, `cuid()` | | `id` | String | PK, `cuid()` |
| `planId` | String | FK → Plan, **Cascade** | | `scenarioId` | String | FK → Scenario, **Cascade** |
| `sequenceNumber` | Int | 1-basiert, lückenlos | | `sequenceNumber` | Int | 1-basiert, lückenlos |
| `name` | String | | | `name` | String | |
| `durationYears` | Int | 180 | | `durationYears` | Int | 180 |
| `cashTransition` | Json? | Cash-Entscheid beim Übergang **nach** dieser Phase (siehe 5.4.5) | | `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 | | | `createdAt` / `updatedAt` | DateTime | |
| | | `@@unique([planId, sequenceNumber])` | | | | `@@unique([scenarioId, sequenceNumber])` |
`cashTransition` liegt an der Phase und nicht in `ElementTransitionValue`, weil Cash kein `cashTransition` liegt an der Phase und nicht in `ElementTransitionValue`, weil Cash kein
`FinancialElement` ist und damit keine `elementId` besitzt. Die Verschlüsselung folgt derselben `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 | | Feld | Typ | Constraints |
|---|---|---| |---|---|---|
| `id` | String | PK, `cuid()` | | `id` | String | PK, `cuid()` |
| `planId` | String | FK → Plan, **Cascade** | | `scenarioId` | String | FK → Scenario, **Cascade** |
| `category` | `ElementCategory` | 8 Werte | | `category` | `ElementCategory` | 8 Werte |
| `name` | String | | | `name` | String | |
| `ownerRole` | `OwnerRole?` | `PERSON_A` \| `PERSON_B` \| `HOUSEHOLD` | | `ownerRole` | `OwnerRole?` | `PERSON_A` \| `PERSON_B` \| `HOUSEHOLD` |
| `orderIndex` | Int | Default 0 | | `orderIndex` | Int | Default 0 |
| `sourceElementId` | String? | Gegenstück in der Vorlage (**lose** Referenz, kein FK) Diff-Grundlage |
| `createdAt` | DateTime | | | `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** **ElementPhaseValue**
| Feld | Typ | Constraints | | Feld | Typ | Constraints |
@@ -1779,6 +1840,18 @@ sondern zu leeren Werten.
| `20260715120000_plan_initial_cash` | `Plan.initialCash` | | `20260715120000_plan_initial_cash` | `Plan.initialCash` |
| `20260716210000_drop_phase_inflation_rate` | `Phase.inflationRate` entfernt (Inflation ist plan-weit) | | `20260716210000_drop_phase_inflation_rate` | `Phase.inflationRate` entfernt (Inflation ist plan-weit) |
| `20260716230000_phase_cash_transition` | `Phase.cashTransition` (JSONB) für einmalige Sonderein-/ausgaben | | `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 ## 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; Validierung: `name` 1120; `inflationRateDefault` 20…50; `persons` 12 Einträge;
`age` 0120; `retirementAge` 30100; `name` je Person ≤ 60. Zusätzlich Konsistenzregel `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>` `GET /api/plans` liefert die Pläne inkl. Szenario-Kopfdaten:
Liefert **Eingabe und Berechnung** in einem Zug:
```json ```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>` ### `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: Akzeptiert eine **Union** von zwei Formen:
1. Vollständiges Profil: `{ householdType, inflationRateDefault, persons[], name? }` ersetzt 1. Vollständiges Profil: `{ householdType, inflationRateDefault, persons[], name? }` ersetzt
die Personen in einer Transaktion. die Personen in einer Transaktion.
2. Teilaktualisierung: `{ name?, initialCash? }` `initialCash` 0…1'000'000'000, gerundet. 2. Teilaktualisierung: `{ name?, initialCash? }` `initialCash` 0…1'000'000'000, gerundet.
Die Unterscheidung erfolgt über das Vorhandensein von `householdType`. → 200 `{ scenario: { id, name } }`
→ 200 `{ plan: { id, name } }`
### `DELETE /api/plans/<planId>` ### `DELETE /api/scenarios/<scenarioId>`
→ 200 `{ ok: true }`, Cascade-Löschung. → 200 `{ ok: true }` · 400 wenn es das **Basisszenario** ist.
### `POST /api/plans/<planId>/scenario` ### `POST /api/scenarios/<scenarioId>/copy`
```json `{ name }` vollständige Kopie; setzt `parentScenarioId` sowie die Herkunfts-Verweise.
{ "name": "Frühpensionierung", "branchFromPhaseId": "<phaseId>" } → 201 `{ scenarioId: "<neue Id>" }`
```
→ 201 `{ planId: "<neue Id>" }`
### `GET /api/plans/<planId>/export` ### `GET /api/scenarios/<scenarioId>/export`
`text/csv; charset=utf-8`, `Content-Disposition: attachment`. `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? }`. Body optional: `{ name?, durationYears? }`.
Hängt eine Phase am Ende an, kappt die Dauer, vergibt Default-Name, legt vorbelegte 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). `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`. dieser Phase (einmalige Sonderein-/ausgaben). Schreibt die Spalte `Phase.cashTransition`.
→ 200 `{ ok }` · 404 wenn die Phase nicht dem Benutzer gehört. → 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 } }` `{ category, name, ownerRole? }` → 201 `{ element: { id } }`
- `PERSON_ONLY_CATEGORIES` ohne Person → 400 - `PERSON_ONLY_CATEGORIES` ohne Person → 400
- fehlendes `ownerRole` sonst → `HOUSEHOLD` - 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 Getestet wird ausschliesslich der Berechnungskern bewusst, da dort die Fachlogik und das
Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie, Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie,
Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests") und 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-, der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-,
API- oder E2E-Tests. API- oder E2E-Tests.
@@ -2098,10 +2186,10 @@ Negatives Cash geht mit negativem Vorzeichen ins Gesamtvermögen ein.
## 9.2 Verwaiste `PERSON_B`-Elemente ## 9.2 Verwaiste `PERSON_B`-Elemente
Die Haushaltsform ist eine **Plan-Eigenschaft** und lässt sich im Dialog „Plan-Einstellungen" Die Haushaltsform ist eine **Szenario-Eigenschaft** und lässt sich im Dialog „Plan-Einstellungen"
auch bei einem bestehenden Plan nachträglich ändern (nicht pro Phase innerhalb eines Plans auch bei einem bestehenden Szenario nachträglich ändern (nicht pro Phase innerhalb eines Szenarios
gilt sie durchgehend). Wechselt ein Plan dabei von `COUPLE` auf `SINGLE`, schneidet gilt sie durchgehend). Wechselt ein Szenario dabei von `COUPLE` auf `SINGLE`, schneidet
`PlanProfileFields` die Personen auf eine zusammen und `PATCH /api/plans/<id>` löscht Person B `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 aus der Datenbank. Elemente mit `ownerRole = "PERSON_B"` bleiben bestehen. In der Berechnung
liefert `personByRole` dann `null`: 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 (ephemer im Dialog). Ein historischer Backtest (Stufe B) und korrelierte/vollständigere Modelle
(Stufe C) sind offen. (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. - `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 - 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" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@electric-sql/pglite": "^0.4.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
+1
View File
@@ -28,6 +28,7 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@electric-sql/pglite": "^0.4.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.20.0", "@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;
+59 -28
View File
@@ -1,13 +1,21 @@
// FPT (Financial Planning Tool) — Datenmodell. // 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 // Kernkonzept: finanzielle Elemente leben auf SZENARIO-Ebene und sind ueber alle
// ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz // 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, // (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON,
// validiert und typisiert in der Applikationsschicht (lib/elements.ts). // validiert und typisiert in der Applikationsschicht (lib/elements.ts).
// //
// Rework 07/2026 (V3): Das Grundprofil (Haushaltsform, Personen, Inflation) wurde vom // Rework 07/2026 (V6, Szenario-Hierarchie): Der PLAN ist neu ein schlanker Behaelter und
// frueheren Household auf die PLAN-Ebene verschoben. Jeder Plan ist selbsttragend und // traegt selbst keine Finanzdaten. Die berechenbare Einheit ist das SZENARIO -- es traegt
// definiert seine eigenen Personen (Alter, Pensionsalter) und Inflationsannahme. // 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 { generator client {
provider = "prisma-client" provider = "prisma-client"
@@ -54,35 +62,52 @@ enum ElementCategory {
OTHER_DEBT 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 { model Person {
id String @id @default(cuid()) id String @id @default(cuid())
planId String scenarioId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
role PersonRole role PersonRole
name String? name String?
age Int age Int
retirementAge Int retirementAge Int
@@unique([planId, role]) @@unique([scenarioId, role])
} }
// Eine vollstaendige Phasenkette; selbsttragend inkl. Grundprofil (Haushaltsform, // Behaelter einer Planung. Traegt selbst KEINE Finanzdaten, nur den Namen und die Szenarien.
// Personen, Inflationsannahme). Kann Szenario eines anderen Plans sein (Deep-Copy).
model Plan { model Plan {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String 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 householdType HouseholdType
inflationRateDefault Float inflationRateDefault Float
initialCash Float @default(0) 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -95,13 +120,16 @@ model Plan {
// wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations). // wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations).
// Die Inflation liegt seit V5 plan-weit am Plan (inflationRateDefault), nicht mehr hier. // Die Inflation liegt seit V5 plan-weit am Plan (inflationRateDefault), nicht mehr hier.
model Phase { model Phase {
id String @id @default(cuid()) id String @id @default(cuid())
planId String scenarioId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
sequenceNumber Int sequenceNumber Int
name String name String
durationYears Int 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 // 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 // lib/elements.ts): 1:1 uebernehmen oder einmaliger Zufluss/einmalige Kosten. Liegt hier
// und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist. // und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist.
@@ -113,20 +141,23 @@ model Phase {
phaseValues ElementPhaseValue[] phaseValues ElementPhaseValue[]
transitionValues ElementTransitionValue[] @relation("TransitionFromPhase") 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 { model FinancialElement {
id String @id @default(cuid()) id String @id @default(cuid())
planId String scenarioId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
category ElementCategory category ElementCategory
name String name String
ownerRole OwnerRole? ownerRole OwnerRole?
orderIndex Int @default(0) orderIndex Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs.
sourceElementId String?
phaseValues ElementPhaseValue[] phaseValues ElementPhaseValue[]
transitionValues ElementTransitionValue[] transitionValues ElementTransitionValue[]
} }
@@ -16,7 +16,7 @@ export async function PUT(
const element = await getOwnedElement(elementId, userId); const element = await getOwnedElement(elementId, userId);
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); 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 }); if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
const body = await request.json(); const body = await request.json();
@@ -16,7 +16,7 @@ export async function PUT(
const element = await getOwnedElement(elementId, userId); const element = await getOwnedElement(elementId, userId);
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); 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 }); if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
const body = await request.json(); const body = await request.json();
+5 -5
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; 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 { getCurrentUserId } from "@/lib/session";
import { maxPhaseDuration } from "@/lib/calculations"; import { maxPhaseDuration } from "@/lib/calculations";
@@ -28,9 +28,9 @@ export async function PUT(
let duration = parsed.data.durationYears; let duration = parsed.data.durationYears;
if (duration != null) { if (duration != null) {
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase). // Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
const plan = await getOwnedPlan(existing.planId, userId); const scenario = await getOwnedScenario(existing.scenarioId, userId);
if (plan) { if (scenario) {
const planInput = toPlanInput(plan); const planInput = toPlanInput(scenario);
const yearsBefore = planInput.phases const yearsBefore = planInput.phases
.filter((p) => p.sequenceNumber < existing.sequenceNumber) .filter((p) => p.sequenceNumber < existing.sequenceNumber)
.reduce((s, p) => s + p.durationYears, 0); .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 }); if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
const later = await prisma.phase.findFirst({ const later = await prisma.phase.findFirst({
where: { planId: phase.planId, sequenceNumber: { gt: phase.sequenceNumber } }, where: { scenarioId: phase.scenarioId, sequenceNumber: { gt: phase.sequenceNumber } },
}); });
if (later) { if (later) {
return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 }); 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 { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { planProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
export async function GET( // Der Plan ist nur noch der Behaelter: er traegt den Namen; die Finanzdaten liegen im Szenario.
_request: NextRequest, const patchSchema = z.object({ name: z.string().min(1).max(120) });
{ 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 getOwnedPlan(planId, userId); export async function PATCH(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
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 }> }
) {
const userId = await getCurrentUserId(); 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 { planId } = await params; const { planId } = await params;
@@ -43,50 +14,22 @@ export async function PATCH(
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } }); const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const body = await request.json(); const parsed = patchSchema.safeParse(await request.json());
const parsed = patchSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const data = parsed.data; const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } });
const hasProfile = "householdType" in data;
if (hasProfile) {
const error = validatePersonsForType(data);
if (error) return NextResponse.json({ error }, { status: 400 });
const updated = await prisma.$transaction(async (tx) => {
await tx.person.deleteMany({ where: { planId: plan.id } });
return tx.plan.update({
where: { id: plan.id },
data: {
name: data.name ?? undefined,
householdType: data.householdType,
inflationRateDefault: data.inflationRateDefault,
persons: { create: data.persons },
},
});
});
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
}
const updated = await prisma.plan.update({
where: { id: plan.id },
data: {
name: "name" in data ? data.name : undefined,
initialCash: "initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
},
});
return NextResponse.json({ plan: { id: updated.id, name: updated.name } }); return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
} }
export async function DELETE( // Loescht den Plan inkl. aller Szenarien (Cascade).
_request: NextRequest, export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId(); 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 { planId } = await params; const { planId } = await params;
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } }); const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
await prisma.plan.delete({ where: { id: plan.id } }); await prisma.plan.delete({ where: { id: plan.id } });
return NextResponse.json({ ok: true }); 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 });
}
+28 -24
View File
@@ -10,18 +10,17 @@ const personSchema = z.object({
retirementAge: z.number().int().min(30).max(100), retirementAge: z.number().int().min(30).max(100),
}); });
// Ein Plan traegt sein eigenes Grundprofil (Haushaltsform, Personen, Inflation). // Das Grundprofil liegt am SZENARIO (nicht am Plan) -- dadurch kann ein Szenario z. B. ein
export const planProfileSchema = z.object({ // anderes Pensionsalter tragen als das Basisszenario (Frühpensionierungs-Szenario).
export const scenarioProfileSchema = z.object({
householdType: z.enum(["SINGLE", "COUPLE"]), householdType: z.enum(["SINGLE", "COUPLE"]),
inflationRateDefault: z.number().min(-20).max(50), inflationRateDefault: z.number().min(-20).max(50),
persons: z.array(personSchema).min(1).max(2), persons: z.array(personSchema).min(1).max(2),
}); });
const createPlanSchema = z const createPlanSchema = z.object({ name: z.string().min(1).max(120) }).and(scenarioProfileSchema);
.object({ name: z.string().min(1).max(120) })
.and(planProfileSchema);
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) { if (data.householdType === "SINGLE" && data.persons.length !== 1) {
return "Einzelperson-Plan benoetigt genau eine Person."; return "Einzelperson-Plan benoetigt genau eine Person.";
} }
@@ -31,40 +30,35 @@ export function validatePersonsForType(data: z.infer<typeof planProfileSchema>):
return null; return null;
} }
// Liste der Plaene mit ihrem Szenario-Baum (nur Kopfdaten).
export async function GET() { export async function GET() {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) { if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const plans = await prisma.plan.findMany({ const plans = await prisma.plan.findMany({
where: { userId }, where: { userId },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
select: { select: {
id: true, id: true,
name: true, name: true,
parentPlanId: true,
branchFromPhaseId: true,
createdAt: true, createdAt: true,
phases: { scenarios: {
select: { id: true, name: true, sequenceNumber: true }, orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
orderBy: { sequenceNumber: "asc" }, select: { id: true, planId: true, name: true, isBase: true, parentScenarioId: true },
}, },
}, },
}); });
return NextResponse.json({ plans }); return NextResponse.json({ plans });
} }
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil traegt.
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) { if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const body = await request.json(); const body = await request.json();
const parsed = createPlanSchema.safeParse(body); const parsed = createPlanSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const error = validatePersonsForType(parsed.data); const error = validatePersonsForType(parsed.data);
if (error) return NextResponse.json({ error }, { status: 400 }); if (error) return NextResponse.json({ error }, { status: 400 });
@@ -72,11 +66,21 @@ export async function POST(request: NextRequest) {
data: { data: {
userId, userId,
name: parsed.data.name, name: parsed.data.name,
householdType: parsed.data.householdType, scenarios: {
inflationRateDefault: parsed.data.inflationRateDefault, create: {
persons: { create: parsed.data.persons }, 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 { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOwnedPlan } from "@/lib/queries"; import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements"; 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(), 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( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ planId: string }> } { params }: { params: Promise<{ scenarioId: string }> }
) { ) {
const userId = await getCurrentUserId(); 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 { planId } = await params; const { scenarioId } = await params;
const plan = await getOwnedPlan(planId, userId); const scenario = await getOwnedScenario(scenarioId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const body = await request.json(); const body = await request.json();
const parsed = createSchema.safeParse(body); const parsed = createSchema.safeParse(body);
@@ -51,13 +51,13 @@ export async function POST(
} }
const maxOrder = await prisma.financialElement.aggregate({ const maxOrder = await prisma.financialElement.aggregate({
where: { planId: plan.id }, where: { scenarioId: scenario.id },
_max: { orderIndex: true }, _max: { orderIndex: true },
}); });
const element = await prisma.financialElement.create({ const element = await prisma.financialElement.create({
data: { data: {
planId: plan.id, scenarioId: scenario.id,
category, category,
name, name,
ownerRole, ownerRole,
@@ -1,31 +1,31 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { toPlanInput, getOwnedPlan } from "@/lib/queries"; import { toPlanInput, getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { computePlan, planToCsv } from "@/lib/calculations"; import { computePlan, planToCsv } from "@/lib/calculations";
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ planId: string }> } { params }: { params: Promise<{ scenarioId: string }> }
) { ) {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
} }
const { planId } = await params; const { scenarioId } = await params;
const plan = await getOwnedPlan(planId, userId); const scenario = await getOwnedScenario(scenarioId, userId);
if (!plan) { if (!scenario) {
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
} }
const planInput = toPlanInput(plan); const planInput = toPlanInput(scenario);
const computed = computePlan(planInput); const computed = computePlan(planInput);
const csv = planToCsv(planInput, computed); const csv = planToCsv(planInput, computed);
return new NextResponse(csv, { return new NextResponse(csv, {
headers: { headers: {
"Content-Type": "text/csv; charset=utf-8", "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 { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOwnedPlan, toPlanInput } from "@/lib/queries"; import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { Prisma } from "@/generated/prisma/client"; import { Prisma } from "@/generated/prisma/client";
import { computePlan, maxPhaseDuration } from "@/lib/calculations"; 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. // vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ planId: string }> } { params }: { params: Promise<{ scenarioId: string }> }
) { ) {
const userId = await getCurrentUserId(); 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 { planId } = await params; const { scenarioId } = await params;
const plan = await getOwnedPlan(planId, userId); const scenario = await getOwnedScenario(scenarioId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const parsed = createPhaseSchema.safeParse(body); const parsed = createPhaseSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); 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 yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
const cap = maxPhaseDuration(planInput.persons, yearsBefore); const cap = maxPhaseDuration(planInput.persons, yearsBefore);
@@ -54,7 +54,7 @@ export async function POST(
const phase = await prisma.$transaction(async (tx) => { const phase = await prisma.$transaction(async (tx) => {
const created = await tx.phase.create({ const created = await tx.phase.create({
data: { data: {
planId: plan.id, scenarioId: scenario.id,
sequenceNumber: nextSequence, sequenceNumber: nextSequence,
name: defaultName, name: defaultName,
durationYears: duration, 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; --danger-soft: #fef2f2;
--success: #059669; --success: #059669;
--person-a: #4f46e5; --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; --person-b: #0ea5e9;
} }
@@ -48,6 +54,12 @@
--danger-soft: rgba(220, 38, 38, 0.16); --danger-soft: rgba(220, 38, 38, 0.16);
--success: #34d399; --success: #34d399;
--person-a: #818cf8; --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; --person-b: #38bdf8;
} }
@@ -71,6 +83,12 @@
--danger-soft: #fbeae7; --danger-soft: #fbeae7;
--success: #2e9e7b; --success: #2e9e7b;
--person-a: #e8663c; --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; --person-b: #f2a93b;
} }
@@ -95,6 +113,12 @@
--danger-soft: rgba(220, 38, 38, 0.16); --danger-soft: rgba(220, 38, 38, 0.16);
--success: #34d399; --success: #34d399;
--person-a: #818cf8; --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; --person-b: #38bdf8;
} }
} }
@@ -117,6 +141,12 @@
--color-danger: var(--danger); --color-danger: var(--danger);
--color-danger-soft: var(--danger-soft); --color-danger-soft: var(--danger-soft);
--color-success: var(--success); --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-a: var(--person-a);
--color-person-b: var(--person-b); --color-person-b: var(--person-b);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
+262 -143
View File
@@ -2,8 +2,10 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Copy,
FileText, FileText,
FolderKanban, FolderKanban,
GitBranch,
LayoutDashboard, LayoutDashboard,
Menu, Menu,
PiggyBank, PiggyBank,
@@ -17,42 +19,39 @@ import { SpecView } from "@/components/SpecView";
import { ProfileMenu } from "@/components/ProfileMenu"; import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client"; 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"; import type { PlanComputed } from "@/lib/calculations";
interface PlanListItem { interface ScenarioDetail {
id: string; plan: PlanInput; // das Szenario selbst (berechenbare Einheit)
name: string; computed: PlanComputed;
parentPlanId: string | null; base: PlanInput | null; // Eltern-Szenario als Vergleichsbasis
branchFromPhaseId: string | null; meta: ScenarioMeta & { planName: string };
phases: { id: string; name: string; sequenceNumber: number }[];
} }
export function AppShell({ username }: { username: string }) { export function AppShell({ username }: { username: string }) {
const [plans, setPlans] = useState<PlanListItem[]>([]); const [plans, setPlans] = useState<PlanListItem[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null); const [selectedScenarioId, setSelectedScenarioId] = useState<string | null>(null);
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null); const [detail, setDetail] = useState<ScenarioDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [showNewPlan, setShowNewPlan] = useState(false); const [showNewPlan, setShowNewPlan] = useState(false);
const [showScenario, setShowScenario] = useState(false); const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
// Die Spezifikation ist eine eigene Ansicht neben Uebersicht und Plan (schliessen sich aus).
const [showSpec, setShowSpec] = useState(false); const [showSpec, setShowSpec] = useState(false);
const loadPlans = useCallback(async (preferId?: string) => { const loadPlans = useCallback(async () => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans"); const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
setPlans(data.plans); setPlans(data.plans);
if (preferId) setSelectedPlanId(preferId);
return data.plans; return data.plans;
}, []); }, []);
// silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert, // silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert,
// damit die Scrollposition (z. B. nach dem Schliessen eines Popups) erhalten bleibt. // 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); if (!silent) setLoading(true);
try { try {
const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/plans/${planId}`); setDetail(await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`));
setDetail(data);
} finally { } finally {
if (!silent) setLoading(false); if (!silent) setLoading(false);
} }
@@ -64,26 +63,46 @@ export function AppShell({ username }: { username: string }) {
}, [loadPlans]); }, [loadPlans]);
useEffect(() => { useEffect(() => {
if (selectedPlanId) { if (selectedScenarioId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf
loadDetail(selectedPlanId); loadDetail(selectedScenarioId);
} else { } else {
setDetail(null); setDetail(null);
} }
}, [selectedPlanId, loadDetail]); }, [selectedScenarioId, loadDetail]);
function refreshCurrent() { 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) { 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 api.delete(`/api/plans/${id}`);
await loadPlans(); const rest = await loadPlans();
if (selectedPlanId === id) setSelectedPlanId(null); 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 = ( const sidebar = (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -98,14 +117,12 @@ export function AppShell({ username }: { username: string }) {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedPlanId(null); setSelectedScenarioId(null);
setShowSpec(false); setShowSpec(false);
setSidebarOpen(false); setSidebarOpen(false);
}} }}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${ className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
selectedPlanId === null && !showSpec selectedScenarioId === null && !showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
? "bg-accent-soft text-accent-soft-fg"
: "text-muted hover:bg-surface-2"
}`} }`}
> >
<LayoutDashboard className="h-4 w-4" /> <LayoutDashboard className="h-4 w-4" />
@@ -124,25 +141,31 @@ export function AppShell({ username }: { username: string }) {
</button> </button>
</div> </div>
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>} {plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>}
{plans.map((p) => ( {plans.map((p) => (
<button <div key={p.id} className="mb-1">
key={p.id} <div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
type="button" <FolderKanban className="h-3.5 w-3.5 shrink-0 text-faint" />
onClick={() => { <span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
setSelectedPlanId(p.id); <button
setShowSpec(false); type="button"
setSidebarOpen(false); aria-label="Plan loeschen"
}} onClick={() => handleDeletePlan(p.id)}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${ className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
selectedPlanId === p.id && !showSpec >
? "bg-accent-soft font-medium text-accent-soft-fg" <Trash2 className="h-3.5 w-3.5" />
: "text-muted hover:bg-surface-2" </button>
}`} </div>
> <ScenarioTree
<FolderKanban className="h-4 w-4 shrink-0" /> scenarios={p.scenarios}
<span className="min-w-0 flex-1 truncate">{p.name}</span> parentId={null}
<span className="text-[11px] text-faint">{p.phases.length}</span> depth={0}
</button> selectedId={selectedScenarioId}
onSelect={openScenario}
onCopy={setCopyFrom}
onDelete={handleDeleteScenario}
/>
</div>
))} ))}
<div className="mt-4 border-t border-border pt-3"> <div className="mt-4 border-t border-border pt-3">
@@ -150,7 +173,7 @@ export function AppShell({ username }: { username: string }) {
type="button" type="button"
onClick={() => { onClick={() => {
setShowSpec(true); setShowSpec(true);
setSelectedPlanId(null); setSelectedScenarioId(null);
setSidebarOpen(false); setSidebarOpen(false);
}} }}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${ 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> </div>
); );
const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null;
return ( return (
<div className="flex min-h-screen w-full"> <div className="flex min-h-screen w-full">
{/* Sidebar Desktop */} <aside className="hidden w-64 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
<aside className="hidden w-60 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
{/* Sidebar Mobile (Overlay) */}
{sidebarOpen && ( {sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden"> <div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} /> <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 <button
type="button" type="button"
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
@@ -188,7 +211,6 @@ export function AppShell({ username }: { username: string }) {
</div> </div>
)} )}
{/* Hauptbereich */}
<div className="flex min-w-0 flex-1 flex-col"> <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"> <header className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
<button <button
@@ -200,7 +222,11 @@ export function AppShell({ username }: { username: string }) {
<Menu className="h-4 w-4" /> <Menu className="h-4 w-4" />
</button> </button>
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg"> <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> </h1>
<ProfileMenu username={username} /> <ProfileMenu username={username} />
</header> </header>
@@ -210,80 +236,169 @@ export function AppShell({ username }: { username: string }) {
{!showSpec && loading && <p className="text-sm text-muted">Laedt</p>} {!showSpec && loading && <p className="text-sm text-muted">Laedt</p>}
{!showSpec && !loading && selectedPlanId === null && ( {!showSpec && !loading && selectedScenarioId === null && (
<DashboardHome <DashboardHome
username={username} username={username}
plans={plans} plans={plans}
onSelect={(id) => { onSelect={openScenario}
setSelectedPlanId(id);
setShowSpec(false);
}}
onCreate={() => setShowNewPlan(true)} onCreate={() => setShowNewPlan(true)}
onDelete={handleDeletePlan} onDelete={handleDeletePlan}
/> />
)} )}
{!showSpec && !loading && detail && selectedPlanId && ( {!showSpec && !loading && detail && selectedScenarioId && (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{detail.plan.phases.length > 0 && (
<button
type="button"
onClick={() => setShowScenario(true)}
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
</button>
)}
<button <button
type="button" type="button"
onClick={() => handleDeletePlan(selectedPlanId)} 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:border-danger hover:bg-danger-soft hover:text-danger" 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"
> >
<Trash2 className="h-4 w-4" /> <Copy className="h-4 w-4" />
Plan loeschen Neues Szenario aus diesem
</button> </button>
{!detail.meta.isBase && (
<button
type="button"
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" />
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> </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> </div>
)} )}
</main> </main>
</div> </div>
{/* Dialoge */}
{showNewPlan && ( {showNewPlan && (
<PlanDialog <PlanDialog
onCreate={async (name, profile) => { 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); setShowNewPlan(false);
await loadPlans(plan.id); await loadPlans();
openScenario(scenario.id);
}} }}
onClose={() => setShowNewPlan(false)} onClose={() => setShowNewPlan(false)}
/> />
)} )}
{showScenario && detail && selectedPlanId && (
<ScenarioDialog {copyFrom && (
phases={detail.plan.phases} <CopyScenarioDialog
onCreate={async (name, branchFromPhaseId) => { source={copyFrom}
const { planId } = await api.post<{ planId: string }>(`/api/plans/${selectedPlanId}/scenario`, { onClose={() => setCopyFrom(null)}
name, onCreate={async (name) => {
branchFromPhaseId, const { scenarioId } = await api.post<{ scenarioId: string }>(
}); `/api/scenarios/${copyFrom.id}/copy`,
setShowScenario(false); { name }
await loadPlans(planId); );
setCopyFrom(null);
await loadPlans();
openScenario(scenarioId);
}} }}
onClose={() => setShowScenario(false)}
/> />
)} )}
</div> </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({ function DashboardHome({
username, username,
plans, plans,
@@ -293,7 +408,7 @@ function DashboardHome({
}: { }: {
username: string; username: string;
plans: PlanListItem[]; plans: PlanListItem[];
onSelect: (id: string) => void; onSelect: (scenarioId: string) => void;
onCreate: () => void; onCreate: () => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
}) { }) {
@@ -302,42 +417,46 @@ function DashboardHome({
<div> <div>
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2> <h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
<p className="mt-1 text-sm text-muted"> <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> </p>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{plans.map((p) => ( {plans.map((p) => {
<div const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
key={p.id} const others = p.scenarios.length - 1;
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" return (
onClick={() => onSelect(p.id)} <div
> key={p.id}
<div className="flex items-center gap-2"> 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"
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft"> onClick={() => base && onSelect(base.id)}
<FolderKanban className="h-5 w-5 text-accent-soft-fg" /> >
</div> <div className="flex items-center gap-2">
<div className="min-w-0 flex-1"> <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
<div className="truncate font-medium text-fg">{p.name}</div> <FolderKanban className="h-5 w-5 text-accent-soft-fg" />
<div className="text-xs text-muted">
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
{p.parentPlanId ? " · Szenario" : ""}
</div> </div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-fg">{p.name}</div>
<div className="text-xs text-muted">
Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""}
</div>
</div>
<button
type="button"
aria-label="Plan loeschen"
onClick={(e) => {
e.stopPropagation();
onDelete(p.id);
}}
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-4 w-4" />
</button>
</div> </div>
<button
type="button"
aria-label="Plan loeschen"
onClick={(e) => {
e.stopPropagation();
onDelete(p.id);
}}
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-4 w-4" />
</button>
</div> </div>
</div> );
))} })}
<button <button
type="button" type="button"
@@ -359,7 +478,7 @@ function PlanDialog({
onCreate: (name: string, profile: ProfileDraft) => void; onCreate: (name: string, profile: ProfileDraft) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const [name, setName] = useState("Basisplan"); const [name, setName] = useState("Meine Planung");
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft); const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft);
const [saving, setSaving] = useState(false); 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" 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> <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> <div>
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label> <label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
<input <input
@@ -406,24 +529,28 @@ function PlanDialog({
); );
} }
function ScenarioDialog({ function CopyScenarioDialog({
phases, source,
onCreate, onCreate,
onClose, onClose,
}: { }: {
phases: { id: string; name: string }[]; source: ScenarioMeta;
onCreate: (name: string, branchFromPhaseId: string) => void; onCreate: (name: string) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const [name, setName] = useState("Neues Szenario"); const [name, setName] = useState(`${source.name} Variante`);
const [branchFromPhaseId, setBranchFromPhaseId] = useState(phases[phases.length - 1]?.id ?? ""); const [saving, setSaving] = useState(false);
return ( return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}> <div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<div <div
onClick={(e) => e.stopPropagation()} 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" 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 <input
autoFocus 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" 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)} onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios" 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"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => onCreate(name, branchFromPhaseId)} disabled={saving}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover" 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>
<button <button
type="button" type="button"
+9 -8
View File
@@ -20,11 +20,12 @@ interface PlanListItem {
export function Dashboard({ export function Dashboard({
plan, plan,
computed, computed,
allPlans, siblings,
}: { }: {
plan: PlanInput; plan: PlanInput;
computed: PlanComputed; computed: PlanComputed;
allPlans: PlanListItem[]; // Die uebrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
}) { }) {
const [compareIds, setCompareIds] = useState<string[]>([]); const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({}); const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
@@ -36,7 +37,7 @@ export function Dashboard({
} }
setCompareIds((prev) => [...prev, id]); setCompareIds((prev) => [...prev, id]);
if (!compareData[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 })); setCompareData((prev) => ({ ...prev, [id]: data.computed }));
} }
} }
@@ -45,11 +46,11 @@ export function Dashboard({
const result: TimelineSeries[] = [{ label: plan.name, color: PALETTE[0], computed }]; const result: TimelineSeries[] = [{ label: plan.name, color: PALETTE[0], computed }];
compareIds.forEach((id, i) => { compareIds.forEach((id, i) => {
const c = compareData[id]; 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 }); if (c) result.push({ label: name, color: PALETTE[(i + 1) % PALETTE.length], computed: c });
}); });
return result; 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"]; const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
@@ -87,7 +88,7 @@ export function Dashboard({
[computed] [computed]
); );
const otherPlans = allPlans.filter((p) => p.id !== plan.id); const otherPlans = siblings;
const lastPhase = computed.phases[computed.phases.length - 1]; const lastPhase = computed.phases[computed.phases.length - 1];
return ( return (
@@ -117,7 +118,7 @@ export function Dashboard({
Vermoegensverlauf nach Alter Vermoegensverlauf nach Alter
</h3> </h3>
<a <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" 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" /> <Download className="h-3.5 w-3.5" />
@@ -126,7 +127,7 @@ export function Dashboard({
</div> </div>
{otherPlans.length > 0 && ( {otherPlans.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2"> <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) => ( {otherPlans.map((p) => (
<label key={p.id} className="flex items-center gap-1 text-xs text-muted"> <label key={p.id} className="flex items-center gap-1 text-xs text-muted">
<input <input
+85 -14
View File
@@ -49,6 +49,7 @@ import {
type TransitionData, type TransitionData,
} from "@/lib/elements"; } from "@/lib/elements";
import type { PhaseComputed, PlanComputed } from "@/lib/calculations"; import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
import type { ScenarioDiff } from "@/lib/diff";
import type { ElementInput, PlanInput } from "@/lib/types"; import type { ElementInput, PlanInput } from "@/lib/types";
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = { const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
@@ -91,12 +92,28 @@ type Selection =
export function PlanView({ export function PlanView({
plan, plan,
computed, computed,
diff,
onChanged, onChanged,
}: { }: {
plan: PlanInput; plan: PlanInput;
computed: PlanComputed; computed: PlanComputed;
// Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
diff: ScenarioDiff | null;
onChanged: () => void; 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 [selected, setSelected] = useState<Selection | null>(null);
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set()); const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
@@ -275,7 +292,7 @@ export function PlanView({
} }
async function handleAddPhase(payload: { name?: string; durationYears?: number }) { 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); setShowAddPhase(false);
onChanged(); onChanged();
} }
@@ -307,8 +324,14 @@ export function PlanView({
<Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} /> <Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} />
{/* Plan-Profil */} {/* 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"> <div
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Grundprofil (Plan)</span> 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) => ( {plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted"> <span key={p.role} className="text-xs text-muted">
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge} {personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
@@ -385,6 +408,7 @@ export function PlanView({
phase={col.phase} phase={col.phase}
personLabel={personLabel} personLabel={personLabel}
mode={valueMode} mode={valueMode}
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })} onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
active={selected?.type === "phase" && selected.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} title={isFirst ? "Cash-Anfangswert bearbeiten" : undefined}
className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${ className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${
isFirst ? "cursor-pointer hover:bg-accent-soft" : "" 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"> <span className="whitespace-nowrap">
{valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "} {valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "}
@@ -435,7 +461,11 @@ export function PlanView({
onClick={() => setEditCashTransition(col.fromPhase.id)} onClick={() => setEditCashTransition(col.fromPhase.id)}
title="Einmalige Sonderein-/ausgaben" title="Einmalige Sonderein-/ausgaben"
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${ 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)} {cashTransitionSummary(ct)}
@@ -474,8 +504,13 @@ export function PlanView({
{!collapsed && {!collapsed &&
els.map((el) => ( els.map((el) => (
<tr key={el.id} className="hover:bg-surface-2"> <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"> <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="truncate text-xs font-medium text-fg">{el.name}</div> <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" && ( {el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div> <div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
)} )}
@@ -487,9 +522,10 @@ export function PlanView({
<td <td
key={col.phase.id} key={col.phase.id}
onClick={() => setEditPhaseCell({ elementId: el.id, phaseId: 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 ${ className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
ce?.locked ? "text-faint" : "text-fg" ce?.locked ? "text-faint" : "text-fg"
}`} } ${cellDiff(el.id, col.phase.id)}`}
> >
{phaseCellContent(ce, col.phase, valueMode)} {phaseCellContent(ce, col.phase, valueMode)}
</td> </td>
@@ -505,9 +541,19 @@ export function PlanView({
canTransition && canTransition &&
setEditTransition({ elementId: el.id, fromPhaseId: col.fromPhase.id }) 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] ${ className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
canTransition ? "cursor-pointer" : "text-faint" 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 ? "" : "→"} {canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : locked ? "" : "→"}
</td> </td>
@@ -518,7 +564,21 @@ export function PlanView({
</FragmentRows> </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> <tr>
<td className="sticky left-0 bg-surface px-3 py-4 text-xs text-faint" colSpan={columns.length + 1}> <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. Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
@@ -793,12 +853,14 @@ function PhaseHeader({
phase, phase,
personLabel, personLabel,
mode, mode,
diffKind,
onClick, onClick,
active, active,
}: { }: {
phase: PhaseComputed; phase: PhaseComputed;
personLabel: (role: string) => string; personLabel: (role: string) => string;
mode: ValueMode; mode: ValueMode;
diffKind: "changed" | "added" | "removed" | null;
onClick: () => void; onClick: () => void;
active: boolean; active: boolean;
}) { }) {
@@ -810,11 +872,20 @@ function PhaseHeader({
<th <th
onClick={onClick} onClick={onClick}
className={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${ 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"> <div className="flex items-center gap-1">
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span> <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 ? ( {phase.cashNegative ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" /> <AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
) : ( ) : (
@@ -973,7 +1044,7 @@ function AddElementDialog({
setSaving(true); setSaving(true);
setError(null); setError(null);
try { 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, category,
name: name.trim() || CATEGORY_LABELS[category], name: name.trim() || CATEGORY_LABELS[category],
ownerRole, ownerRole,
@@ -1128,7 +1199,7 @@ function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClo
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await api.patch(`/api/plans/${plan.id}`, draft); await api.patch(`/api/scenarios/${plan.id}`, draft);
onSaved(); onSaved();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
@@ -1324,7 +1395,7 @@ function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClos
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await api.patch(`/api/plans/${plan.id}`, { initialCash: value }); await api.patch(`/api/scenarios/${plan.id}`, { initialCash: value });
onSaved(); onSaved();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); 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" }, orderBy: { orderIndex: "asc" },
include: { phaseValues: true, transitionValues: true }, 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 { function parsePhaseData(raw: unknown): PhaseData {
const parsed = phaseDataSchema.safeParse(raw); const parsed = phaseDataSchema.safeParse(raw);
@@ -30,6 +30,7 @@ function parseCashTransition(raw: unknown): CashTransitionData {
return parsed.success ? parsed.data : {}; return parsed.success ? parsed.data : {};
} }
// Wandelt ein Szenario (DB) in die berechenbare Einheit (PlanInput) um.
export function toPlanInput(plan: PlanWithRelations): PlanInput { export function toPlanInput(plan: PlanWithRelations): PlanInput {
return { return {
id: plan.id, id: plan.id,
@@ -50,6 +51,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: phase.name, name: phase.name,
durationYears: phase.durationYears, durationYears: phase.durationYears,
cashTransition: parseCashTransition(phase.cashTransition), cashTransition: parseCashTransition(phase.cashTransition),
sourcePhaseId: phase.sourcePhaseId,
})), })),
elements: plan.elements.map((e) => { elements: plan.elements.map((e) => {
const phaseValues: Record<string, PhaseData> = {}; const phaseValues: Record<string, PhaseData> = {};
@@ -64,29 +66,49 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
orderIndex: e.orderIndex, orderIndex: e.orderIndex,
phaseValues, phaseValues,
transitionValues, 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) { export async function getOwnedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({ return prisma.plan.findFirst({
where: { id: planId, userId }, where: { id: planId, userId },
include: planInclude, include: { scenarios: { orderBy: { createdAt: "asc" } } },
}); });
} }
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert. // Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
export async function getOwnedPhase(phaseId: string, userId: string) { export async function getOwnedPhase(phaseId: string, userId: string) {
return prisma.phase.findFirst({ 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. // Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
export async function getOwnedElement(elementId: string, userId: string) { export async function getOwnedElement(elementId: string, userId: string) {
return prisma.financialElement.findFirst({ 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; durationYears: number;
// Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben). // Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
cashTransition: CashTransitionData; cashTransition: CashTransitionData;
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourcePhaseId?: string | null;
} }
export interface ElementInput { export interface ElementInput {
@@ -34,10 +36,31 @@ export interface ElementInput {
// Werte je Phase (Key = phaseId) bzw. je Uebergang (Key = fromPhaseId). // Werte je Phase (Key = phaseId) bzw. je Uebergang (Key = fromPhaseId).
phaseValues: Record<string, PhaseData>; phaseValues: Record<string, PhaseData>;
transitionValues: Record<string, TransitionData>; 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, // Kopf-Daten eines Szenarios (fuer Baum und Auswahl in der Seitenleiste).
// Inflationsannahme) plus die Phasenkette und die finanziellen Elemente. 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 { export interface PlanInput {
id: string; id: string;
name: string; name: string;