Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
Deploy App / deploy (push) Successful in 1m43s
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:
+163
-61
@@ -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` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
| **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 8–10, `src/lib/types.ts` Zeilen 38–4
|
|||||||
|
|
||||||
# 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
|
||||||
|
├── Person[] (1 bei SINGLE, 2 bei COUPLE — je Szenario eigen!)
|
||||||
|
├── Phase[] (Kette 1..n; sourcePhaseId = Gegenstück in der Vorlage)
|
||||||
|
└── FinancialElement[] (szenario-weit; sourceElementId = Gegenstück in der Vorlage)
|
||||||
├── ElementPhaseValue[] (Werte je Phase, JSON)
|
├── ElementPhaseValue[] (Werte je Phase, JSON)
|
||||||
└── ElementTransitionValue[] (Entscheide je Übergang, 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 1–10, 114–151.
|
**(1) Ein finanzielles Element ist über alle Lebensphasen hinweg dieselbe Entität.** Eine
|
||||||
|
Pensionskasse „PK Arbeitgeber" existiert einmal pro Szenario; sie hat pro Phase einen Werte-Satz
|
||||||
|
und pro Phasenübergang einen Entscheid-Satz. Dadurch bleibt die Identität eines
|
||||||
|
Vermögensgegenstands über die Zeit erhalten – Voraussetzung für die Fortschreibung (Carry) und
|
||||||
|
die Vermögensaufteilungs-Grafik.
|
||||||
|
|
||||||
|
**(2) Das Grundprofil liegt am Szenario, nicht am Plan** (V6). Nur so lassen sich die
|
||||||
|
wertvollsten Szenario-Fragen abbilden – allen voran ein abweichendes **Pensionsalter**
|
||||||
|
(„Was, wenn ich mit 62 statt 65 aufhöre?"), das in `Person` steckt. Wäre das Profil geteilt,
|
||||||
|
wären Frühpensionierungs-Szenarien unmöglich.
|
||||||
|
|
||||||
|
Der **Plan** ist damit ein reiner Behälter: Er bündelt Szenarien und trägt den Eigentümer
|
||||||
|
(`userId`). Ownership von Szenario/Phase/Element läuft über die Kette
|
||||||
|
`Scenario → Plan → User`.
|
||||||
|
|
||||||
|
Referenz: `prisma/schema.prisma`.
|
||||||
|
|
||||||
## 2.2 Die Matrix als Leitmetapher
|
## 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 | 1–80 |
|
| `durationYears` | Int | 1–80 |
|
||||||
| `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` 1–120; `inflationRateDefault` −20…50; `persons` 1–2 Einträge;
|
Validierung: `name` 1–120; `inflationRateDefault` −20…50; `persons` 1–2 Einträge;
|
||||||
`age` 0–120; `retirementAge` 30–100; `name` je Person ≤ 60. Zusätzlich Konsistenzregel
|
`age` 0–120; `retirementAge` 30–100; `name` je Person ≤ 60. Zusätzlich Konsistenzregel
|
||||||
SINGLE=1 / COUPLE=2 Personen. → 201 `{ plan: { id } }`
|
SINGLE=1 / COUPLE=2 Personen. Legt Plan **und Basisszenario** an.
|
||||||
|
→ 201 `{ plan: { id }, scenario: { id } }`
|
||||||
|
|
||||||
### `GET /api/plans/<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
|
||||||
|
|||||||
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
+54
-23
@@ -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
|
||||||
|
|
||||||
@@ -96,12 +121,15 @@ model Plan {
|
|||||||
// 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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
+25
-21
@@ -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,
|
||||||
|
scenarios: {
|
||||||
|
create: {
|
||||||
|
name: "Basisszenario",
|
||||||
|
isBase: true,
|
||||||
householdType: parsed.data.householdType,
|
householdType: parsed.data.householdType,
|
||||||
inflationRateDefault: parsed.data.inflationRateDefault,
|
inflationRateDefault: parsed.data.inflationRateDefault,
|
||||||
persons: { create: parsed.data.persons },
|
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 });
|
||||||
|
}
|
||||||
+8
-8
@@ -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,
|
||||||
+8
-8
@@ -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"`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+7
-7
@@ -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,
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
+228
-109
@@ -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) => (
|
||||||
|
<div key={p.id} className="mb-1">
|
||||||
|
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
|
||||||
|
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-faint" />
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
aria-label="Plan loeschen"
|
||||||
setSelectedPlanId(p.id);
|
onClick={() => handleDeletePlan(p.id)}
|
||||||
setShowSpec(false);
|
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||||
setSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
|
|
||||||
selectedPlanId === p.id && !showSpec
|
|
||||||
? "bg-accent-soft font-medium text-accent-soft-fg"
|
|
||||||
: "text-muted hover:bg-surface-2"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<FolderKanban className="h-4 w-4 shrink-0" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
|
||||||
<span className="text-[11px] text-faint">{p.phases.length}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<ScenarioTree
|
||||||
|
scenarios={p.scenarios}
|
||||||
|
parentId={null}
|
||||||
|
depth={0}
|
||||||
|
selectedId={selectedScenarioId}
|
||||||
|
onSelect={openScenario}
|
||||||
|
onCopy={setCopyFrom}
|
||||||
|
onDelete={handleDeleteScenario}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="mt-4 border-t border-border pt-3">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowScenario(true)}
|
onClick={() => setCopyFrom(detail.meta)}
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
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" />
|
<Copy className="h-4 w-4" />
|
||||||
Szenario
|
Neues Szenario aus diesem
|
||||||
</button>
|
</button>
|
||||||
)}
|
{!detail.meta.isBase && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeletePlan(selectedPlanId)}
|
onClick={() => handleDeleteScenario(detail.meta)}
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Plan loeschen
|
Szenario loeschen
|
||||||
</button>
|
</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,16 +417,20 @@ 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) => {
|
||||||
|
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
|
||||||
|
const others = p.scenarios.length - 1;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
|
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||||
onClick={() => onSelect(p.id)}
|
onClick={() => base && onSelect(base.id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
|
||||||
@@ -320,8 +439,7 @@ function DashboardHome({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate font-medium text-fg">{p.name}</div>
|
<div className="truncate font-medium text-fg">{p.name}</div>
|
||||||
<div className="text-xs text-muted">
|
<div className="text-xs text-muted">
|
||||||
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
|
Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""}
|
||||||
{p.parentPlanId ? " · Szenario" : ""}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -337,7 +455,8 @@ function DashboardHome({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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"
|
||||||
|
|||||||
@@ -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
@@ -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.");
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user