Neuer Knopf auf Plan-Ebene: Liste plus Wizard in zwei Schritten. Ein
Ist-Satz haengt am PLAN, nicht am Szenario -- die Zuordnung laeuft ueber
die Herkunfts-Kette sourceElementId.
computePlan nimmt neu { actuals }: Die Werte schnappen in jedem erfassten
Jahr auf die Realitaet und laufen von dort planmaessig weiter. Luecken
fallen auf die Plandaten zurueck. Ohne die Option unveraendert -- die 43
Golden Tests laufen durch.
Der Sprung ist keine Rendite: eigene Brueckenposition actualsCorrection
in Vermoegens- und Cash-Bruecke, sonst ginge die Zerlegung nicht auf.
Matrix: Umschalter Plan/Effektiv, im Ist-Modus mit farbiger Abweichung
statt acht Zahlen je Zelle. Zeitachse: Marker je Jahr, juengster farbig.
Vier Analysewerkzeuge mit einheitlicher Leiste (nominal/real als
Einfachauswahl, Plan/Effektiv). MC: Zielbetrag dreht mit, Startjahr
abgeleitet statt eingebbar.
Neue Tabelle ActualsSet (gegen echtes Postgres verifiziert), Module
actuals.ts und dataview.ts. Spezifikation 0.21, 27 Tests (181 -> 208).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+179
-4
@@ -4,7 +4,7 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.20 |
|
| **Version** | 0.21 |
|
||||||
| **Datum** | 2026-07-18 |
|
| **Datum** | 2026-07-18 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| Version | Datum | Autor | Änderung |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 0.21 | 2026-07-20 | Claude (Opus 4.8) | **Effektive Werte / Plan-Ist-Vergleich** (Roadmap Nr. 5, neue Kapitel 3.9 und 9.29). Macht aus dem Planer ein Monitoring-Werkzeug. Neuer Knopf **«Effektive Werte»** auf Plan-Ebene: Liste der Erfassungen plus Wizard in zwei Schritten (Stichtag, dann alle Elemente **aller** Szenarien inkl. **Cash**, Einkommen und Ausgaben, vorbelegt mit dem Planwert für dieses Jahr). Ein Ist-Satz hängt am **Plan**, nicht am Szenario – die Wirklichkeit ist dieselbe, egal wogegen man sie hält; die Zuordnung läuft über die Herkunfts-Kette `sourceElementId`. Das exakte Datum steht in Liste und Zeitachse, für die Rechnung zählt nur die **Jahreszahl**. **Zweiter Rechenlauf:** `computePlan` nimmt neu `{ actuals }`; die Werte schnappen in **jedem** erfassten Jahr auf die Realität und laufen von dort planmässig weiter (Lücken fallen auf die Plandaten zurück). Ohne die Option verhält sich die Funktion exakt wie bisher – die 43 Golden Tests laufen unverändert. Der Sprung wird als eigene Brückenposition `actualsCorrection` geführt (Vermögens- **und** Cash-Brücke): Eine Planabweichung ist keine Rendite, und ohne diese Zeile ginge die Zerlegung im Ist-Jahr nicht mehr auf. **Matrix:** zweiter Umschalter «Plan» / «Effektiv»; im Ist-Modus steht neben dem Wert die **Abweichung** zum Plan, farbig – bewusst kein «beide», das wären mit nominal/real acht Zahlen je Zelle (Begründung 9.29). **Zeitachse:** Marker je erfasstem Jahr, der jüngste farbig, ältere blass. **Alle vier Analysewerkzeuge** erhalten eine einheitliche Leiste (nominal/real als **Einfach**auswahl, Plan/Effektiv); im Vermögensverlauf kommt die Planlinie **gestrichelt** als Referenz dazu, max. vier Serien. **Monte-Carlo:** Der Zielbetrag dreht mit real/nominal mit und wird entsprechend beschriftet; eine Zeile weist aus, ab welchem Jahr simuliert wird – die Jahre davor sind durch Ist-Werte belegt und werden nicht gewürfelt. Das Startjahr ist **abgeleitet, nicht eingebbar**: Ein frei gesetztes Jahr würde Jahre als sicher behandeln, die nie erfasst wurden. Ein Ist-Satz erzeugt **keine** Szenario-Version – er ist eine Beobachtung, keine Planänderung. Neue Tabelle `ActualsSet` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/plans/<id>/actuals`, neue Module `actuals.ts` und `dataview.ts`; 27 Tests ergänzt (181 → 208). |
|
||||||
| 0.20 | 2026-07-19 | Claude (Opus 4.8) | **Raten über Lebensphasen übernehmen** (neues Kapitel 3.6.11) und **Rate in der Verlaufsgrafik**. (1) Ändert man ein **Ratenfeld**, fragt das Bearbeitungspanel neu nach der Reichweite: **nur diese Phase** (Vorgabe, bisheriges Verhalten), **diese + folgende** oder **alle Phasen**. Anlass war, dass eine geänderte Rendite bisher nur für die eine Phase galt und viermal eingetippt werden musste. Als Ratenfelder gelten `expectedReturn` (PK, 3a, Sonstiges Vermögen), `valueGrowth` und `interestRate` (Immobilie) sowie `teuerungsausgleich` (Einkommen, Ausgaben); AHV und Schulden haben keine. Die Rückfrage erscheint **inline und erst beim Speichern wirksam**, nicht als Modal – das Zahlenfeld löst bei jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. Sie erscheint nur bei **tatsächlich veränderten** Raten («nicht gesetzt» und 0 gelten als gleich). Beim Übertragen bleiben die **übrigen Werte der Zielphasen erhalten** – der Endpunkt ersetzt den ganzen Werte-Satz, ein blosses Kopieren des Entwurfs hätte dort Beträge, Sparraten und Bezüge gelöscht (durch Test abgesichert). Phasen, in denen der Wert schon stimmt, werden übersprungen. Kein neuer Schreibpfad: ein PUT je Zielphase über den bestehenden Endpunkt, alle in einer Bearbeitungssitzung und damit in **einer** Nebenversion. (2) `ElementYearPoint` führt neu ein Feld **`rate`** mit – additiv, es wird nur durchgereicht, was die Rechnung ohnehin benutzt; die 43 Golden Tests laufen unverändert. Die Verlaufsgrafik der Element-Detailansicht zeigt die Rate damit auf einer **zweiten Y-Achse rechts** in Prozent, als **Stufenlinie** (innerhalb einer Phase konstant, Sprung an der Phasengrenze). Neues Modul `ratefields.ts`; 17 Tests ergänzt (164 → 181). Keine DB- oder API-Änderung. |
|
| 0.20 | 2026-07-19 | Claude (Opus 4.8) | **Raten über Lebensphasen übernehmen** (neues Kapitel 3.6.11) und **Rate in der Verlaufsgrafik**. (1) Ändert man ein **Ratenfeld**, fragt das Bearbeitungspanel neu nach der Reichweite: **nur diese Phase** (Vorgabe, bisheriges Verhalten), **diese + folgende** oder **alle Phasen**. Anlass war, dass eine geänderte Rendite bisher nur für die eine Phase galt und viermal eingetippt werden musste. Als Ratenfelder gelten `expectedReturn` (PK, 3a, Sonstiges Vermögen), `valueGrowth` und `interestRate` (Immobilie) sowie `teuerungsausgleich` (Einkommen, Ausgaben); AHV und Schulden haben keine. Die Rückfrage erscheint **inline und erst beim Speichern wirksam**, nicht als Modal – das Zahlenfeld löst bei jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. Sie erscheint nur bei **tatsächlich veränderten** Raten («nicht gesetzt» und 0 gelten als gleich). Beim Übertragen bleiben die **übrigen Werte der Zielphasen erhalten** – der Endpunkt ersetzt den ganzen Werte-Satz, ein blosses Kopieren des Entwurfs hätte dort Beträge, Sparraten und Bezüge gelöscht (durch Test abgesichert). Phasen, in denen der Wert schon stimmt, werden übersprungen. Kein neuer Schreibpfad: ein PUT je Zielphase über den bestehenden Endpunkt, alle in einer Bearbeitungssitzung und damit in **einer** Nebenversion. (2) `ElementYearPoint` führt neu ein Feld **`rate`** mit – additiv, es wird nur durchgereicht, was die Rechnung ohnehin benutzt; die 43 Golden Tests laufen unverändert. Die Verlaufsgrafik der Element-Detailansicht zeigt die Rate damit auf einer **zweiten Y-Achse rechts** in Prozent, als **Stufenlinie** (innerhalb einer Phase konstant, Sprung an der Phasengrenze). Neues Modul `ratefields.ts`; 17 Tests ergänzt (164 → 181). Keine DB- oder API-Änderung. |
|
||||||
| 0.19 | 2026-07-19 | Claude (Opus 4.8) | **Versionierung und Änderungshistorie je Szenario** (neues Kapitel 3.8). Jedes Szenario trägt eine Version **A.B**: **B** entsteht automatisch, **A** manuell mit Pflichtkommentar. **Der zentrale Entwurfsentscheid:** FPT hat keinen Speichern-Knopf – jede Änderung schreibt sofort, ein Assistenten-Durchlauf macht ~14 Schreibvorgänge, ein Verteil-Klick einen je Zielelement. Eine Version je Schreibvorgang wäre ein Tastenprotokoll gewesen; stattdessen werden alle Schreibvorgänge innerhalb von **10 Minuten zu einer** Nebenversion zusammengefasst, inhaltlich unveränderte Stände erzeugen gar keine, und verschiedene Benutzer laufen nie in einer Version zusammen. Eine Version hält den **vollständigen** Zustand als JSON in der Form `PlanInput` – dadurch ist die **Versionsauswahl in allen vier Analysewerkzeugen** (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) fast kostenlos; bei Monte-Carlo **je Szenario einzeln**, weil dort mehrere gleichzeitig laufen. **Wiederherstellen** ist ungefährlich gebaut: Es legt den zurückgesetzten Stand selbst als neue Version an («Wiederhergestellt aus A.B»), löscht also nichts, und **erhält die IDs** von Phasen und Elementen – sonst verlören alle Kind-Szenarien ihre Diff-Basis und zeigten schlagartig alles als «neu». Wo ein Bezug trotzdem bricht (der alte Stand kannte das Element noch nicht), **warnt der Dialog vorher namentlich**. Die destruktive Logik liegt als reine Funktion `planRestore` vor und ist dort getestet; `versioning-db.ts` führt sie nur aus. Ein **statischer Wächter-Test** liest alle Route-Dateien und verlangt, dass jeder schreibende Endpunkt eine Version auslöst – eine vergessene Stelle wäre eine stille Lücke. Neue Tabelle `ScenarioVersion` + `Scenario.currentMajor` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/scenarios/<id>/versions`. Neue Kapitel 3.8 und 9.28; 25 Tests ergänzt (139 → 164). |
|
| 0.19 | 2026-07-19 | Claude (Opus 4.8) | **Versionierung und Änderungshistorie je Szenario** (neues Kapitel 3.8). Jedes Szenario trägt eine Version **A.B**: **B** entsteht automatisch, **A** manuell mit Pflichtkommentar. **Der zentrale Entwurfsentscheid:** FPT hat keinen Speichern-Knopf – jede Änderung schreibt sofort, ein Assistenten-Durchlauf macht ~14 Schreibvorgänge, ein Verteil-Klick einen je Zielelement. Eine Version je Schreibvorgang wäre ein Tastenprotokoll gewesen; stattdessen werden alle Schreibvorgänge innerhalb von **10 Minuten zu einer** Nebenversion zusammengefasst, inhaltlich unveränderte Stände erzeugen gar keine, und verschiedene Benutzer laufen nie in einer Version zusammen. Eine Version hält den **vollständigen** Zustand als JSON in der Form `PlanInput` – dadurch ist die **Versionsauswahl in allen vier Analysewerkzeugen** (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) fast kostenlos; bei Monte-Carlo **je Szenario einzeln**, weil dort mehrere gleichzeitig laufen. **Wiederherstellen** ist ungefährlich gebaut: Es legt den zurückgesetzten Stand selbst als neue Version an («Wiederhergestellt aus A.B»), löscht also nichts, und **erhält die IDs** von Phasen und Elementen – sonst verlören alle Kind-Szenarien ihre Diff-Basis und zeigten schlagartig alles als «neu». Wo ein Bezug trotzdem bricht (der alte Stand kannte das Element noch nicht), **warnt der Dialog vorher namentlich**. Die destruktive Logik liegt als reine Funktion `planRestore` vor und ist dort getestet; `versioning-db.ts` führt sie nur aus. Ein **statischer Wächter-Test** liest alle Route-Dateien und verlangt, dass jeder schreibende Endpunkt eine Version auslöst – eine vergessene Stelle wäre eine stille Lücke. Neue Tabelle `ScenarioVersion` + `Scenario.currentMajor` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/scenarios/<id>/versions`. Neue Kapitel 3.8 und 9.28; 25 Tests ergänzt (139 → 164). |
|
||||||
| 0.18 | 2026-07-19 | Claude (Opus 4.8) | **Live-Simulation** (Roadmap Nr. 22). Neuer Button und Dialog als Zweispalter: links Schieberegler, rechts eine wählbare Grafik, darüber eine Kennzahlenleiste. Dreht man an einem Regler, wird der Plan **sofort** neu gerechnet – ohne für jede Variante eine Szenario-Kopie anzulegen. **Keine eigene Rechenlogik:** Die Regler benutzen dieselben Transformationen wie der Tornado (`applyDriver`), können also gar nicht etwas anderes zeigen als die Einflussfaktoren-Analyse. Neu ist nur `applyElementDriver` – dieselbe Verschiebung auf ein **einzelnes** Element statt auf eine ganze Kategorie: Standardmässig gibt es einen Sammelregler «Rendite», ein Klick auf «Renditen einzeln aufschlüsseln» ersetzt ihn durch je einen Regler pro Anlage (der Sammelregler wird dabei **entfernt**, nicht ergänzt, sonst zählte eine Bewegung doppelt; ein Test sichert ab, dass beide Ansichten denselben Plan beschreiben). Der unveränderte Plan wird als **Referenzlinie** mitgezeichnet, und die Kennzahlenleiste weist Endvermögen nominal/real **mit Differenz zum Plan** aus sowie – als eigene Karte – ob das Kapital reicht; ein gekippter Plan ist einer Verlaufslinie sonst nicht anzusehen. Gemessene Laufzeit von `computePlan`: **0.2 ms** auf einem 60-Jahres-Plan mit 10 Elementen, also rund 1 % des 16-ms-Frame-Budgets – deshalb wird synchron gerechnet, **ohne Debounce und ohne Worker**. Anders als der Tornado haben die Regler **Standardbereiche** (Begründung des scheinbaren Widerspruchs zu 9.18: neues Kapitel 9.27), beide Enden editierbar. **Das Pensionsalter fehlt weiterhin** (9.18, eigener Roadmap-Punkt); «Als Szenario speichern» ist bewusst zurückgestellt, ersatzweise zeigt der Dialog die aktive Einstellung als lesbare Zeile. Die Vermögensaufteilung wurde als `AllocationChart` aus dem Dashboard herausgelöst, damit beide sie nutzen. Neue Kapitel 4.15 und 9.27; 15 Tests ergänzt (124 → 139). Keine API-, DB- oder Schreib-Änderung – das Feature liest ausschliesslich. Nebenbei dieselbe vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) wie in 0.17, diesmal im Dashboard. |
|
| 0.18 | 2026-07-19 | Claude (Opus 4.8) | **Live-Simulation** (Roadmap Nr. 22). Neuer Button und Dialog als Zweispalter: links Schieberegler, rechts eine wählbare Grafik, darüber eine Kennzahlenleiste. Dreht man an einem Regler, wird der Plan **sofort** neu gerechnet – ohne für jede Variante eine Szenario-Kopie anzulegen. **Keine eigene Rechenlogik:** Die Regler benutzen dieselben Transformationen wie der Tornado (`applyDriver`), können also gar nicht etwas anderes zeigen als die Einflussfaktoren-Analyse. Neu ist nur `applyElementDriver` – dieselbe Verschiebung auf ein **einzelnes** Element statt auf eine ganze Kategorie: Standardmässig gibt es einen Sammelregler «Rendite», ein Klick auf «Renditen einzeln aufschlüsseln» ersetzt ihn durch je einen Regler pro Anlage (der Sammelregler wird dabei **entfernt**, nicht ergänzt, sonst zählte eine Bewegung doppelt; ein Test sichert ab, dass beide Ansichten denselben Plan beschreiben). Der unveränderte Plan wird als **Referenzlinie** mitgezeichnet, und die Kennzahlenleiste weist Endvermögen nominal/real **mit Differenz zum Plan** aus sowie – als eigene Karte – ob das Kapital reicht; ein gekippter Plan ist einer Verlaufslinie sonst nicht anzusehen. Gemessene Laufzeit von `computePlan`: **0.2 ms** auf einem 60-Jahres-Plan mit 10 Elementen, also rund 1 % des 16-ms-Frame-Budgets – deshalb wird synchron gerechnet, **ohne Debounce und ohne Worker**. Anders als der Tornado haben die Regler **Standardbereiche** (Begründung des scheinbaren Widerspruchs zu 9.18: neues Kapitel 9.27), beide Enden editierbar. **Das Pensionsalter fehlt weiterhin** (9.18, eigener Roadmap-Punkt); «Als Szenario speichern» ist bewusst zurückgestellt, ersatzweise zeigt der Dialog die aktive Einstellung als lesbare Zeile. Die Vermögensaufteilung wurde als `AllocationChart` aus dem Dashboard herausgelöst, damit beide sie nutzen. Neue Kapitel 4.15 und 9.27; 15 Tests ergänzt (124 → 139). Keine API-, DB- oder Schreib-Änderung – das Feature liest ausschliesslich. Nebenbei dieselbe vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) wie in 0.17, diesmal im Dashboard. |
|
||||||
@@ -1357,6 +1358,126 @@ Referenz: `src/lib/versioning.ts` (reine Logik), `src/lib/versioning-db.ts` (Dat
|
|||||||
`src/components/VersionHistoryDialog.tsx`, `src/components/VersionMatrix.tsx`,
|
`src/components/VersionHistoryDialog.tsx`, `src/components/VersionMatrix.tsx`,
|
||||||
`src/components/VersionPicker.tsx`.
|
`src/components/VersionPicker.tsx`.
|
||||||
|
|
||||||
|
## 3.9 Effektive Werte (Plan-/Ist-Vergleich)
|
||||||
|
|
||||||
|
Roadmap Nr. 5. Macht aus dem Planer ein **Monitoring-Werkzeug**: Was ist tatsächlich
|
||||||
|
eingetreten, und was heisst das für den Rest der Planung?
|
||||||
|
|
||||||
|
Knopf **«Effektive Werte»** auf Plan-Ebene → Liste der bisherigen Erfassungen → Wizard in zwei
|
||||||
|
Schritten.
|
||||||
|
|
||||||
|
### 3.9.1 Ein Ist-Satz gehört zum Plan, nicht zum Szenario
|
||||||
|
|
||||||
|
Das tatsächliche PK-Guthaben am 18.8.2026 ist **eine Zahl** – unabhängig davon, gegen welches
|
||||||
|
Szenario man sie hält. Ein Ist-Satz hängt deshalb am `Plan` und wird über dieselbe
|
||||||
|
Herkunfts-Kette (`sourceElementId`) auf die szenario-eigenen Element-IDs abgebildet, die auch
|
||||||
|
der Diff ([3.2.6](#326-abweichungs-markierung-diff)) und die Monte-Carlo-Gruppierung benutzen.
|
||||||
|
Erfasst wird also je **Wurzel-Element**.
|
||||||
|
|
||||||
|
### 3.9.2 Der Wizard
|
||||||
|
|
||||||
|
**Schritt 1 – Stichtag.** Exaktes Datum (z. B. 18. August 2026) plus optionale Notiz. Das
|
||||||
|
Datum erscheint in der Liste und auf der Zeitachse; für die Rechnung zählt **nur die
|
||||||
|
Jahreszahl**, weil der Rechenkern in ganzen Jahren ab Planbeginn arbeitet. Der Dialog sagt
|
||||||
|
das ausdrücklich.
|
||||||
|
|
||||||
|
**Schritt 2 – Werte.** Alle Elemente **aller Szenarien** dieses Plans, zusammengefasst auf
|
||||||
|
ihre Wurzel, dazu das **Cash-Konto**. Vorbelegt mit dem Stand, den der Plan für dieses Jahr
|
||||||
|
vorsieht (aus dem Basisszenario; fehlt das Element dort, aus dem erstbesten Szenario, das es
|
||||||
|
kennt). Der Nutzer überschreibt nur, was tatsächlich abweicht.
|
||||||
|
|
||||||
|
Zwei Arten von Werten, die sich verschieden verhalten:
|
||||||
|
|
||||||
|
| Art | Elemente | Wirkung |
|
||||||
|
|---|---|---|
|
||||||
|
| **Bestand** | PK, 3a, Sonstiges Vermögen, Schulden, Cash | ersetzt den laufenden Stand |
|
||||||
|
| **Verkehrswert + Schuld** | Immobilie | zwei Felder: Wert und Resthypothek getrennt |
|
||||||
|
| **Fluss** | Einkommen, Ausgaben | nominaler **Jahresbetrag**; ersetzt die Basis für alle Folgejahre |
|
||||||
|
|
||||||
|
**Die AHV erscheint nur, wenn die Rente zum Stichtag bereits läuft.** Vorher gibt es keinen
|
||||||
|
Stand, den man ablesen könnte – die Rente folgt der amtlichen Formel aus der Beitragskarriere
|
||||||
|
([4.4](#44-ahv-rente)).
|
||||||
|
|
||||||
|
**Ist-Werte erfassen Werte, keine Entscheide.** Wenn der Plan die Immobilie verkauft, du sie
|
||||||
|
aber behalten hast, lässt sich das hier nicht ausdrücken – dafür ist ein Szenario da.
|
||||||
|
|
||||||
|
### 3.9.3 Die zweite Berechnung
|
||||||
|
|
||||||
|
Der Plan-Lauf bleibt **unangetastet**. Parallel läuft ein zweiter mit derselben Mechanik, aber
|
||||||
|
korrigierter Ausgangsbasis: In jedem Jahr, für das ein Ist-Satz erfasst wurde, schnappen die
|
||||||
|
Werte auf die Realität und laufen von dort planmässig weiter. **Alle** Sätze gehen ein, nicht
|
||||||
|
nur der jüngste.
|
||||||
|
|
||||||
|
Beispiel aus der Anforderung: Fonds startet 2020 mit 100'000 bei 5 %. Ohne Ist-Daten steht
|
||||||
|
2022 rechnerisch 115'763. Wird für 2022 ein Ist-Wert von 120'000 erfasst, rechnet die Ist-Sicht
|
||||||
|
ab dort weiter und steht 2024 bei 132'300. Kommt für 2024 ein Wert von 140'000 dazu, springt
|
||||||
|
sie erneut. Genau das ist als Test hinterlegt.
|
||||||
|
|
||||||
|
**Lücken fallen auf die Plandaten zurück.** Ein Element ohne erfassten Ist-Wert läuft
|
||||||
|
unverändert auf seiner Planlinie weiter – man muss nicht alles wissen, um etwas zu erfassen.
|
||||||
|
|
||||||
|
Technisch: `computePlan(plan, sample?, { actuals })`. Ohne die Option verhält sich die
|
||||||
|
Funktion exakt wie bisher; die 43 Golden Tests laufen unverändert.
|
||||||
|
|
||||||
|
**Der Sprung ist keine Rendite.** Die Differenz zwischen Plan und Wirklichkeit wird als eigene
|
||||||
|
Brückenposition `actualsCorrection` geführt (Vermögens- **und** Cash-Brücke). Würde man sie den
|
||||||
|
Kapitalerträgen zuschlagen, erschiene ein Planrückstand als Anlageverlust – und die Zerlegung
|
||||||
|
ginge im Ist-Jahr nicht mehr auf ([3.6.8](#367-detailansichten-je-element-und-je-lebensphase)).
|
||||||
|
|
||||||
|
### 3.9.4 Anzeige in der Matrix
|
||||||
|
|
||||||
|
Ein zweiter Umschalter neben nominal/real/beide, aber mit nur **zwei** Möglichkeiten:
|
||||||
|
|
||||||
|
| Auswahl | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| **Plan** | wie bisher |
|
||||||
|
| **Effektiv** | die Ist-Zahlen, jeweils mit der **Abweichung** zum Plan daneben |
|
||||||
|
|
||||||
|
Warum keine dritte Möglichkeit «beide»: Plan und Ist als Rohwerte nebeneinander wären bei
|
||||||
|
zusätzlich aktivem nominal/real **acht Zahlen je Zelle**. Stattdessen zeigt die Ist-Ansicht den
|
||||||
|
Wert und daneben klein die Differenz, grün oder rot ([9.29](#929-acht-zahlen-je-zelle--und-wie-wir-sie-vermeiden)).
|
||||||
|
Der Umschalter erscheint nur, wenn überhaupt Ist-Werte erfasst sind.
|
||||||
|
|
||||||
|
**Nur die Abweichung trägt Farbe.** Die Beträge selbst bleiben neutral – sonst wird die Matrix
|
||||||
|
zum Ampelteppich, in dem nichts mehr heraussticht.
|
||||||
|
|
||||||
|
### 3.9.5 Zeitachse
|
||||||
|
|
||||||
|
Je erfasstem Jahr ein Marker. Der **jüngste** ist farbig, ältere blass – sie sind überholt,
|
||||||
|
aber nicht bedeutungslos. Ohne gesetztes Planstartjahr entfallen die Marker, weil es dann
|
||||||
|
keinen Kalenderbezug gibt.
|
||||||
|
|
||||||
|
### 3.9.6 Die vier Analysewerkzeuge
|
||||||
|
|
||||||
|
Grafiken, Live-Simulation, Monte-Carlo und Einflussfaktoren erhalten dieselbe Leiste:
|
||||||
|
**Werte** (nominal/real) und **Grundlage** (Plan/Effektiv), dazu die schon bestehende
|
||||||
|
Versionswahl. «Effektiv» ist deaktiviert, solange nichts erfasst ist.
|
||||||
|
|
||||||
|
**Nominal/real ist in den Werkzeugen eine Einfachauswahl**, kein «beide» – anders als in der
|
||||||
|
Matrix. Begründung siehe [9.29](#929-acht-zahlen-je-zelle--und-wie-wir-sie-vermeiden).
|
||||||
|
|
||||||
|
Besonderheiten:
|
||||||
|
|
||||||
|
- **Vermögensverlauf:** Im Ist-Modus kommt die reine Planlinie **gestrichelt** als Referenz
|
||||||
|
dazu. Maximal vier Serien.
|
||||||
|
- **Monte-Carlo:** Der Zielbetrag **dreht mit** der gewählten Grösse (real/nominal) und wird
|
||||||
|
entsprechend beschriftet – sonst prüft man einen nominalen Zielbetrag gegen ein reales
|
||||||
|
Endvermögen. Ausserdem eine Zeile: *Simuliert ab ‹Jahr›; die Jahre davor sind durch deine
|
||||||
|
effektiven Werte belegt und werden nicht gewürfelt.* Das Startjahr ist **abgeleitet, nicht
|
||||||
|
eingebbar** – ein frei gesetztes Jahr würde Jahre als sicher behandeln, die nie erfasst
|
||||||
|
wurden.
|
||||||
|
- **Einflussfaktoren:** Mit Ist-Werten wirken die Treiber nur noch auf die nicht belegten
|
||||||
|
Jahre. Die Balken fallen dadurch zu Recht kürzer aus.
|
||||||
|
|
||||||
|
### 3.9.7 Verhältnis zur Versionierung
|
||||||
|
|
||||||
|
Ein Ist-Satz ist eine **Beobachtung, keine Planänderung**: Er erzeugt **keine** Szenario-Version
|
||||||
|
([3.8](#38-versionierung-und-änderungshistorie)), und es gibt kein Wiederherstellen. Löschen
|
||||||
|
entfernt ihn ersatzlos; der Plan bleibt unberührt. Die beiden Historien bleiben getrennt.
|
||||||
|
|
||||||
|
Referenz: `src/lib/actuals.ts`, `src/lib/dataview.ts`, `src/components/ActualsDialog.tsx`,
|
||||||
|
`src/components/AnalysisControls.tsx`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 4. Berechnungsmodell
|
# 4. Berechnungsmodell
|
||||||
@@ -2559,7 +2680,7 @@ Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`,
|
|||||||
FPT/
|
FPT/
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ ├── schema.prisma Datenmodell
|
│ ├── schema.prisma Datenmodell
|
||||||
│ └── migrations/ 13 Migrationen (chronologisch, siehe 5.4.6)
|
│ └── migrations/ 14 Migrationen (chronologisch, siehe 5.4.6)
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
|
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
|
||||||
@@ -2600,6 +2721,8 @@ PlanComputed ← an den Client geliefert
|
|||||||
| `constants.ts` | Schweizer Systemparameter |
|
| `constants.ts` | Schweizer Systemparameter |
|
||||||
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
|
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
|
||||||
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`; zusätzlich `applyElementDriver` / `tunableElements` für die einzeln regelbaren Element-Renditen. Rein, läuft im Browser. |
|
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`; zusätzlich `applyElementDriver` / `tunableElements` für die einzeln regelbaren Element-Renditen. Rein, läuft im Browser. |
|
||||||
|
| `actuals.ts` | Effektive Werte: Zuordnung auf die Szenario-Elemente über die Herkunfts-Kette, Einspielen in den Rechenkern, Bestand/Fluss. Rein. |
|
||||||
|
| `dataview.ts` | Bündelt Plan-Sicht und Ist-Sicht für Matrix, Grafiken und Analysewerkzeuge. Rein. |
|
||||||
| `ratefields.ts` | Ratenfelder je Kategorie, Reichweite der Übernahme über Lebensphasen, Aufbau der Schreibvorgänge (Kap. 3.6.11). Rein. |
|
| `ratefields.ts` | Ratenfelder je Kategorie, Reichweite der Übernahme über Lebensphasen, Aufbau der Schreibvorgänge (Kap. 3.6.11). Rein. |
|
||||||
| `versioning.ts` | Versionierung: Nummerierung A.B, Zusammenfassung je Sitzung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien. Rein, ohne I/O. |
|
| `versioning.ts` | Versionierung: Nummerierung A.B, Zusammenfassung je Sitzung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien. Rein, ohne I/O. |
|
||||||
| `versioning-db.ts` | Datenbank-Anbindung der Versionierung: Snapshot festhalten, Hauptversion, Wiederherstellen. Führt den Bauplan aus `versioning.ts` nur aus. |
|
| `versioning-db.ts` | Datenbank-Anbindung der Versionierung: Snapshot festhalten, Hauptversion, Wiederherstellen. Führt den Bauplan aus `versioning.ts` nur aus. |
|
||||||
@@ -2815,6 +2938,7 @@ sondern zu leeren Werten.
|
|||||||
| `20260718090000_plan_scenario_hierarchy` | **V6**: `Plan` → `Scenario` (IDs erhalten), neuer Behälter `Plan`, `planId` → `scenarioId`, Herkunfts-Verweise |
|
| `20260718090000_plan_scenario_hierarchy` | **V6**: `Plan` → `Scenario` (IDs erhalten), neuer Behälter `Plan`, `planId` → `scenarioId`, Herkunfts-Verweise |
|
||||||
| `20260718140000_scenario_start_year` | `Scenario.startYear` (Kalenderjahr des Planbeginns), bestehende auf das laufende Jahr gesetzt |
|
| `20260718140000_scenario_start_year` | `Scenario.startYear` (Kalenderjahr des Planbeginns), bestehende auf das laufende Jahr gesetzt |
|
||||||
| `20260719210000_scenario_versioning` | Tabelle `ScenarioVersion` (Snapshot als JSONB, A.B eindeutig je Szenario) und `Scenario.currentMajor` |
|
| `20260719210000_scenario_versioning` | Tabelle `ScenarioVersion` (Snapshot als JSONB, A.B eindeutig je Szenario) und `Scenario.currentMajor` |
|
||||||
|
| `20260720090000_actuals` | Tabelle `ActualsSet` (effektive Werte je Plan, Werte als JSONB, Cash separat) |
|
||||||
|
|
||||||
**Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um – dadurch
|
**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
|
bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige
|
||||||
@@ -2968,7 +3092,23 @@ SINGLE=1 / COUPLE=2 Personen. Legt Plan **und Basisszenario** an.
|
|||||||
`{ name }` – der Plan trägt nur noch den Namen. → 200 `{ plan: { id, name } }`
|
`{ name }` – der Plan trägt nur noch den Namen. → 200 `{ plan: { id, name } }`
|
||||||
|
|
||||||
### `DELETE /api/plans/<planId>`
|
### `DELETE /api/plans/<planId>`
|
||||||
→ 200 `{ ok: true }`, Cascade über alle Szenarien.
|
→ 200 `{ ok: true }`, Cascade über alle Szenarien (inkl. Versionen und Ist-Sätzen).
|
||||||
|
|
||||||
|
### `GET /api/plans/<planId>/actuals`
|
||||||
|
Alle erfassten Ist-Sätze, neueste zuerst ([3.9](#39-effektive-werte-plan-ist-vergleich)):
|
||||||
|
```json
|
||||||
|
{ "sets": [ { "id", "recordedOn": "2026-08-18", "year": 2026, "comment",
|
||||||
|
"cash", "values": { "<rootElementId>": { "value", "mortgage" } },
|
||||||
|
"author", "createdAt" } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/plans/<planId>/actuals`
|
||||||
|
`{ recordedOn: "JJJJ-MM-TT", comment?, cash?, values }` – `year` wird aus dem Datum abgeleitet.
|
||||||
|
→ 201 `{ set: { id, year } }`
|
||||||
|
|
||||||
|
### `DELETE /api/plans/<planId>/actuals/<setId>`
|
||||||
|
→ 200 `{ ok: true }`. Ein Ist-Satz ist eine Beobachtung – es gibt weder Versionierung noch
|
||||||
|
Wiederherstellung.
|
||||||
|
|
||||||
## 6.3 Szenarien
|
## 6.3 Szenarien
|
||||||
|
|
||||||
@@ -3136,6 +3276,8 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
|||||||
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
|
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
|
||||||
| `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % |
|
| `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % |
|
||||||
| `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder |
|
| `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder |
|
||||||
|
| `actuals.test.ts` | 20 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung |
|
||||||
|
| `dataview.test.ts` | 7 | beide Sichten in einem Zug, Rückfall ohne Ist-Werte, Abweichungsmessung |
|
||||||
| `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen |
|
| `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen |
|
||||||
| `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien |
|
| `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien |
|
||||||
| `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus |
|
| `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus |
|
||||||
@@ -3144,7 +3286,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
|||||||
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
|
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
|
||||||
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
|
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
|
||||||
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
|
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
|
||||||
| **Total** | **181** | |
|
| **Total** | **208** | |
|
||||||
|
|
||||||
## 8.2 Testfälle
|
## 8.2 Testfälle
|
||||||
|
|
||||||
@@ -3639,6 +3781,39 @@ Benutzer werden nie in einer Version zusammengefasst).
|
|||||||
verschwindet seine Historie mit ihm (Cascade). Das ist gewollt: Eine Historie ohne das Objekt,
|
verschwindet seine Historie mit ihm (Cascade). Das ist gewollt: Eine Historie ohne das Objekt,
|
||||||
das sie beschreibt, wäre nicht wiederherstellbar.
|
das sie beschreibt, wäre nicht wiederherstellbar.
|
||||||
|
|
||||||
|
## 9.29 Acht Zahlen je Zelle – und wie wir sie vermeiden
|
||||||
|
|
||||||
|
Mit den effektiven Werten ([3.9](#39-effektive-werte-plan-ist-vergleich)) bekommt die Matrix
|
||||||
|
eine zweite Achse. Naiv kombiniert ergibt das je Zelle: nominal **und** real, Plan **und** Ist,
|
||||||
|
Phasenbeginn **und** Phasenende – **acht Zahlen**. Das ist keine Tabelle mehr, das ist ein
|
||||||
|
Zahlenfeld.
|
||||||
|
|
||||||
|
Zwei Entscheide halten es lesbar, und sie fallen an den zwei Orten **verschieden** aus.
|
||||||
|
|
||||||
|
**In der Matrix: Wert und Abweichung statt zweier Rohwerte.** Es gibt nur «Plan» oder
|
||||||
|
«Effektiv», kein «beide». Im Ist-Modus steht der Ist-Wert und daneben klein die Differenz zum
|
||||||
|
Plan, grün oder rot. Das beantwortet auch die bessere Frage: nicht «wie lauteten die zwei
|
||||||
|
Zahlen», sondern «wie weit bin ich weg». Nominal/real bleibt dort bei drei Möglichkeiten – es
|
||||||
|
sind Zahlen in einer Zelle, keine Linien in einem Bild.
|
||||||
|
|
||||||
|
**Nur die Abweichung trägt Farbe.** Würde man die Beträge selbst einfärben, entstünde ein
|
||||||
|
Ampelteppich, in dem die eigentliche Aussage untergeht.
|
||||||
|
|
||||||
|
**In den Grafiken: nominal/real wird zur Einfachauswahl.** Der Vermögensverlauf zeichnete
|
||||||
|
bisher je Serie **zwei** Linien (nominal durchgezogen, real gestrichelt). Mit Plan/Ist wären es
|
||||||
|
vier, bei zwei Szenarien acht. Das Stilbudget geht deshalb an die **wichtigere** Unterscheidung:
|
||||||
|
Plan gestrichelt, Ist durchgezogen – genau die «Plan-Linie vs. Ist-Linie», die die Roadmap
|
||||||
|
verlangt. Wer real sehen will, schaltet um, statt eine zweite Linie dazuzubekommen.
|
||||||
|
|
||||||
|
**Nicht jede Grafik verträgt beides.** «Plan und Ist gleichzeitig» gibt es nur beim
|
||||||
|
Vermögensverlauf. Die Vermögensaufteilung zeigt schon Beginn **und** Ende je Phase als
|
||||||
|
gestapelte Balken – Plan und Ist daneben vervierfachte sie. Und die Grafik «Einkommen vs.
|
||||||
|
Ausgaben» lebt vom Band zwischen zwei Linien; ein zweites Paar darüber macht genau diese
|
||||||
|
Aussage unkenntlich. Beide zeigen deshalb nur die gewählte Grundlage.
|
||||||
|
|
||||||
|
**Serienobergrenze vier.** Szenario mal Version mal Datenquelle wächst schnell; darüber hinaus
|
||||||
|
hilft keine Farbpalette mehr.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 10. Glossar
|
# 10. Glossar
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- Effektive (Ist-)Werte, Roadmap Nr. 5 (SPEZIFIKATION 3.9).
|
||||||
|
--
|
||||||
|
-- Ein Ist-Satz gehoert zum PLAN, nicht zum Szenario: Die Realitaet ist dieselbe, egal gegen
|
||||||
|
-- welches Szenario man sie haelt. Die Zuordnung auf die szenario-eigenen Element-IDs laeuft
|
||||||
|
-- ueber die Herkunfts-Kette (sourceElementId) in der Applikationsschicht.
|
||||||
|
|
||||||
|
CREATE TABLE "ActualsSet" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"planId" TEXT NOT NULL,
|
||||||
|
-- Exaktes Erfassungsdatum: erscheint in der Liste und auf der Zeitachse.
|
||||||
|
"recordedOn" DATE NOT NULL,
|
||||||
|
-- Kalenderjahr. NUR dieses geht in die Berechnung ein (der Rechenkern arbeitet in
|
||||||
|
-- ganzen Jahren ab Planbeginn).
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"comment" TEXT,
|
||||||
|
-- Effektiver Cash-Bestand. Eigenes Feld, weil Cash kein FinancialElement ist.
|
||||||
|
"cash" DOUBLE PRECISION,
|
||||||
|
-- Werte je WURZEL-Element: { "<rootElementId>": { "value": 120000, "mortgage": 400000 } }
|
||||||
|
"values" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ActualsSet_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Die Saetze werden immer nach Jahr sortiert gelesen (aeltester zuerst fuer die Rechnung).
|
||||||
|
CREATE INDEX "ActualsSet_planId_year_idx" ON "ActualsSet"("planId", "year");
|
||||||
|
|
||||||
|
ALTER TABLE "ActualsSet" ADD CONSTRAINT "ActualsSet_planId_fkey"
|
||||||
|
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "ActualsSet" ADD CONSTRAINT "ActualsSet_createdById_fkey"
|
||||||
|
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -34,6 +34,7 @@ model User {
|
|||||||
|
|
||||||
plans Plan[]
|
plans Plan[]
|
||||||
versions ScenarioVersion[]
|
versions ScenarioVersion[]
|
||||||
|
actuals ActualsSet[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HouseholdType {
|
enum HouseholdType {
|
||||||
@@ -88,6 +89,36 @@ model Plan {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
scenarios Scenario[]
|
scenarios Scenario[]
|
||||||
|
actuals ActualsSet[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein erfasster Stand der WIRKLICHKEIT zu einem Stichtag (Roadmap Nr. 5).
|
||||||
|
//
|
||||||
|
// Haengt am PLAN, nicht am Szenario: Das tatsaechliche PK-Guthaben am 18.8.2026 ist eine
|
||||||
|
// Zahl, unabhaengig davon, gegen welches Szenario man sie haelt. Die Zuordnung auf die
|
||||||
|
// szenario-eigenen Element-IDs erfolgt ueber die Herkunfts-Kette (lib/actuals.ts).
|
||||||
|
model ActualsSet {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
planId String
|
||||||
|
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Exaktes Datum fuer Liste und Zeitachse; fuer die Rechnung zaehlt nur `year`.
|
||||||
|
recordedOn DateTime @db.Date
|
||||||
|
year Int
|
||||||
|
comment String?
|
||||||
|
|
||||||
|
// Effektiver Cash-Bestand (Cash ist kein FinancialElement).
|
||||||
|
cash Float?
|
||||||
|
// Werte je Wurzel-Element: Record<rootElementId, { value?, mortgage? }>
|
||||||
|
values Json @default("{}")
|
||||||
|
|
||||||
|
createdById String
|
||||||
|
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([planId, year])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die berechenbare Einheit: Grundprofil + Phasenkette + Elemente. Genau ein Szenario je
|
// Die berechenbare Einheit: Grundprofil + Phasenkette + Elemente. Genau ein Szenario je
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
|
// Löscht einen Ist-Satz. Ein Ist-Satz ist eine Beobachtung, keine Planänderung -- deshalb
|
||||||
|
// gibt es hier weder Versionierung noch Wiederherstellung.
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ planId: string; setId: string }> }
|
||||||
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
const { planId, setId } = await params;
|
||||||
|
|
||||||
|
const set = await prisma.actualsSet.findFirst({
|
||||||
|
where: { id: setId, planId, plan: { userId } },
|
||||||
|
});
|
||||||
|
if (!set) return NextResponse.json({ error: "Datensatz nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
await prisma.actualsSet.delete({ where: { id: setId } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
|
// Ein Ist-Wert je Wurzel-Element. Beide Felder optional: Wer eine Zahl nicht kennt, lässt sie
|
||||||
|
// weg -- die Lücke fällt in der Berechnung auf die Plandaten zurück.
|
||||||
|
const valueSchema = z.object({
|
||||||
|
value: z.number().min(-1_000_000_000).max(1_000_000_000).optional(),
|
||||||
|
mortgage: z.number().min(0).max(1_000_000_000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
recordedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Datum im Format JJJJ-MM-TT"),
|
||||||
|
comment: z.string().max(500).optional(),
|
||||||
|
cash: z.number().min(-1_000_000_000).max(1_000_000_000).nullable().optional(),
|
||||||
|
values: z.record(z.string(), valueSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function ownedPlan(planId: string, userId: string) {
|
||||||
|
return prisma.plan.findFirst({ where: { id: planId, userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Ist-Sätze eines Plans, neueste zuerst.
|
||||||
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
const { planId } = await params;
|
||||||
|
|
||||||
|
const plan = await ownedPlan(planId, userId);
|
||||||
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const rows = await prisma.actualsSet.findMany({
|
||||||
|
where: { planId },
|
||||||
|
orderBy: [{ year: "desc" }, { recordedOn: "desc" }],
|
||||||
|
include: { createdBy: { select: { username: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
sets: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
recordedOn: r.recordedOn.toISOString().slice(0, 10),
|
||||||
|
year: r.year,
|
||||||
|
comment: r.comment,
|
||||||
|
cash: r.cash,
|
||||||
|
values: r.values,
|
||||||
|
author: r.createdBy.username,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
const { planId } = await params;
|
||||||
|
|
||||||
|
const plan = await ownedPlan(planId, userId);
|
||||||
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const parsed = createSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
|
const { recordedOn, comment, cash, values } = parsed.data;
|
||||||
|
// Für die Berechnung zählt nur die Jahreszahl -- der Rechenkern arbeitet in ganzen Jahren
|
||||||
|
// ab Planbeginn. Das exakte Datum bleibt für Liste und Zeitachse erhalten.
|
||||||
|
const year = Number(recordedOn.slice(0, 4));
|
||||||
|
|
||||||
|
const created = await prisma.actualsSet.create({
|
||||||
|
data: {
|
||||||
|
planId,
|
||||||
|
recordedOn: new Date(`${recordedOn}T00:00:00.000Z`),
|
||||||
|
year,
|
||||||
|
comment: comment?.trim() || null,
|
||||||
|
cash: typeof cash === "number" ? cash : null,
|
||||||
|
values,
|
||||||
|
createdById: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ set: { id: created.id, year: created.year } }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -26,10 +26,33 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (parent) base = toPlanInput(parent);
|
if (parent) base = toPlanInput(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ist-Werte des Plans plus die Element-Herkunft ALLER Szenarien: Nur damit lässt sich die
|
||||||
|
// auf Wurzel-IDs erfasste Realität auf dieses Szenario abbilden (siehe lib/actuals.ts).
|
||||||
|
// Die Ist-Rechnung selbst passiert im Browser -- computePlan ist rein.
|
||||||
|
const [actualsRows, siblings] = await Promise.all([
|
||||||
|
prisma.actualsSet.findMany({
|
||||||
|
where: { planId: scenario.planId },
|
||||||
|
orderBy: [{ year: "asc" }, { recordedOn: "asc" }],
|
||||||
|
}),
|
||||||
|
prisma.scenario.findMany({
|
||||||
|
where: { planId: scenario.planId },
|
||||||
|
select: { elements: { select: { id: true, sourceElementId: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
plan: planInput,
|
plan: planInput,
|
||||||
computed,
|
computed,
|
||||||
base,
|
base,
|
||||||
|
actuals: actualsRows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
recordedOn: r.recordedOn.toISOString().slice(0, 10),
|
||||||
|
year: r.year,
|
||||||
|
comment: r.comment,
|
||||||
|
cash: r.cash,
|
||||||
|
values: r.values,
|
||||||
|
})),
|
||||||
|
elementOrigins: siblings.flatMap((s) => s.elements),
|
||||||
meta: {
|
meta: {
|
||||||
id: scenario.id,
|
id: scenario.id,
|
||||||
planId: scenario.planId,
|
planId: scenario.planId,
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { ArrowLeft, ArrowRight, CalendarClock, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
|
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { computePlan } from "@/lib/calculations";
|
||||||
|
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements";
|
||||||
|
import { formatChf } from "@/lib/format";
|
||||||
|
import { actualKindOf, resolveActuals, toPlanYear, type ActualsSetInput } from "@/lib/actuals";
|
||||||
|
import { resolveRootElementId } from "@/lib/montecarlo";
|
||||||
|
import type { ElementCategory } from "@/lib/elements";
|
||||||
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
interface StoredSet extends ActualsSetInput {
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Eingabefeld im Wizard -- je Wurzel-Element eines oder (bei Immobilien) zwei.
|
||||||
|
interface Row {
|
||||||
|
rootId: string;
|
||||||
|
name: string;
|
||||||
|
category: ElementCategory;
|
||||||
|
scenarioNames: string[];
|
||||||
|
planValue: number; // Vorbelegung aus dem Basisszenario für das gewählte Jahr
|
||||||
|
planMortgage?: number;
|
||||||
|
kind: ReturnType<typeof actualKindOf>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = (iso: string) =>
|
||||||
|
new Date(`${iso}T00:00:00Z`).toLocaleDateString("de-CH", { day: "2-digit", month: "long", year: "numeric" });
|
||||||
|
|
||||||
|
interface LoadedScenario {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isBase: boolean;
|
||||||
|
plan: PlanInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActualsDialog({
|
||||||
|
planId,
|
||||||
|
planName,
|
||||||
|
scenarioMetas,
|
||||||
|
initial,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
planId: string;
|
||||||
|
planName: string;
|
||||||
|
// Alle Szenarien dieses Plans (nur Kopfdaten) -- die Pläne werden hier nachgeladen.
|
||||||
|
scenarioMetas: { id: string; name: string; isBase: boolean }[];
|
||||||
|
// Das bereits geöffnete Szenario, damit der Dialog sofort etwas anzeigen kann.
|
||||||
|
initial: LoadedScenario;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({ [initial.id]: initial }));
|
||||||
|
|
||||||
|
// Die übrigen Szenarien nachladen: Ein Ist-Satz gilt für ALLE, also müssen auch Elemente
|
||||||
|
// erscheinen, die es nur in einem Nebenszenario gibt.
|
||||||
|
useEffect(() => {
|
||||||
|
const missing = scenarioMetas.filter((m) => m.id !== initial.id);
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
missing.map(async (m) => {
|
||||||
|
const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${m.id}`);
|
||||||
|
return [m.id, { id: m.id, name: m.name, isBase: m.isBase, plan: data.plan }] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!cancelled) setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) }));
|
||||||
|
} catch {
|
||||||
|
// Fehlende Nebenszenarien sind verschmerzbar -- der Wizard zeigt dann weniger Zeilen.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [scenarioMetas.map((m) => m.id).join(","), initial.id]);
|
||||||
|
|
||||||
|
const scenarios = useMemo(
|
||||||
|
() => scenarioMetas.map((m) => loaded[m.id]).filter((s): s is LoadedScenario => !!s),
|
||||||
|
[scenarioMetas, loaded]
|
||||||
|
);
|
||||||
|
const [sets, setSets] = useState<StoredSet[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [mode, setMode] = useState<"list" | "wizard">("list");
|
||||||
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Schritt 1
|
||||||
|
const [recordedOn, setRecordedOn] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
// Schritt 2
|
||||||
|
const [values, setValues] = useState<Record<string, { value?: number; mortgage?: number }>>({});
|
||||||
|
const [cash, setCash] = useState<number>(0);
|
||||||
|
|
||||||
|
const base = scenarios.find((s) => s.isBase) ?? scenarios[0];
|
||||||
|
const year = Number(recordedOn.slice(0, 4));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`);
|
||||||
|
if (!cancelled) setSets(data.sets);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [planId]);
|
||||||
|
|
||||||
|
// Herkunft aller Elemente über alle Szenarien -- Grundlage der Wurzel-Auflösung.
|
||||||
|
const origins = useMemo(
|
||||||
|
() => scenarios.flatMap((s) => s.plan.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId ?? null }))),
|
||||||
|
[scenarios]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alle Elemente ALLER Szenarien, zusammengefasst auf ihre Wurzel. Vorbelegt mit dem
|
||||||
|
// berechneten Stand des Basisszenarios im gewählten Jahr; fehlt das Element dort, wird es
|
||||||
|
// aus dem erstbesten Szenario geholt, das es kennt.
|
||||||
|
const rows: Row[] = useMemo(() => {
|
||||||
|
const sourceById = new Map(origins.map((o) => [o.id, o.sourceElementId]));
|
||||||
|
const byRoot = new Map<string, Row>();
|
||||||
|
|
||||||
|
const ordered = [base, ...scenarios.filter((s) => s.id !== base?.id)].filter(Boolean);
|
||||||
|
for (const sc of ordered) {
|
||||||
|
const planYear = toPlanYear(year, sc.plan.startYear);
|
||||||
|
const computed = computePlan(sc.plan);
|
||||||
|
for (const el of sc.plan.elements) {
|
||||||
|
const kind = actualKindOf(el.category);
|
||||||
|
const root = resolveRootElementId(el.id, sourceById);
|
||||||
|
|
||||||
|
// Jahresstand aus dem Verlauf: genau der Wert, den der Plan für dieses Jahr vorsieht.
|
||||||
|
const yearly = computed.phases
|
||||||
|
.flatMap((p) => p.elements.filter((e) => e.elementId === el.id).flatMap((e) => e.yearly))
|
||||||
|
.find((y) => y.year === planYear);
|
||||||
|
|
||||||
|
// Die AHV ist nur erfassbar, wenn die Rente zum Stichtag bereits läuft -- vorher gibt
|
||||||
|
// es keinen Stand, den man ablesen könnte.
|
||||||
|
if (el.category === "AHV" && !(yearly && yearly.value > 0)) continue;
|
||||||
|
|
||||||
|
const existing = byRoot.get(root);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.scenarioNames.includes(sc.name)) existing.scenarioNames.push(sc.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byRoot.set(root, {
|
||||||
|
rootId: root,
|
||||||
|
name: el.name,
|
||||||
|
category: el.category,
|
||||||
|
scenarioNames: [sc.name],
|
||||||
|
planValue: Math.round(Math.abs(yearly?.value ?? 0)),
|
||||||
|
planMortgage: kind === "PROPERTY" ? Math.round(yearly?.mortgage ?? 0) : undefined,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byRoot.values()].sort((a, b) => {
|
||||||
|
const ca = CATEGORY_ORDER.indexOf(a.category);
|
||||||
|
const cb = CATEGORY_ORDER.indexOf(b.category);
|
||||||
|
return ca !== cb ? ca - cb : a.name.localeCompare(b.name, "de-CH");
|
||||||
|
});
|
||||||
|
}, [scenarios, base, origins, year]);
|
||||||
|
|
||||||
|
// Der geplante Cash-Bestand im gewählten Jahr -- Vorbelegung für das Cash-Feld.
|
||||||
|
const planCash = useMemo(() => {
|
||||||
|
if (!base) return 0;
|
||||||
|
const planYear = toPlanYear(year, base.plan.startYear);
|
||||||
|
if (planYear === null) return 0;
|
||||||
|
const computed = computePlan(base.plan);
|
||||||
|
const ph = computed.phases.find(
|
||||||
|
(p) => planYear <= computed.phases.slice(0, p.sequenceNumber).reduce((s, x) => s + x.durationYears, 0)
|
||||||
|
);
|
||||||
|
return Math.round(ph?.cashBridge.cashEnd ?? 0);
|
||||||
|
}, [base, year]);
|
||||||
|
|
||||||
|
function startWizard() {
|
||||||
|
setValues({});
|
||||||
|
setCash(planCash);
|
||||||
|
setComment("");
|
||||||
|
setStep(1);
|
||||||
|
setMode("wizard");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Wechsel auf Schritt 2 mit den Planwerten vorbelegen -- der Nutzer überschreibt nur,
|
||||||
|
// was tatsächlich abweicht.
|
||||||
|
function goToStep2() {
|
||||||
|
const prefill: Record<string, { value?: number; mortgage?: number }> = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
prefill[r.rootId] =
|
||||||
|
r.kind === "PROPERTY" ? { value: r.planValue, mortgage: r.planMortgage ?? 0 } : { value: r.planValue };
|
||||||
|
}
|
||||||
|
setValues(prefill);
|
||||||
|
setCash(planCash);
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`);
|
||||||
|
setSets(data.sets);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/api/plans/${planId}/actuals`, { recordedOn, comment: comment.trim() || undefined, cash, values });
|
||||||
|
toast("success", `Effektive Werte für ${dt(recordedOn)} erfasst.`);
|
||||||
|
await reload();
|
||||||
|
setMode("list");
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
toast("error", e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(set: StoredSet) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: "Datensatz löschen?",
|
||||||
|
message: `Die effektiven Werte vom ${dt(set.recordedOn)} werden entfernt. Der Plan selbst bleibt unverändert.`,
|
||||||
|
confirmLabel: "Löschen",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/plans/${planId}/actuals/${set.id}`);
|
||||||
|
await reload();
|
||||||
|
onChanged();
|
||||||
|
toast("success", "Datensatz gelöscht.");
|
||||||
|
} catch (e) {
|
||||||
|
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wie viele Elemente weichen ab? Kleine Orientierungshilfe in der Liste.
|
||||||
|
const deviationCount = (set: ActualsSetInput) => {
|
||||||
|
const resolved = resolveActuals([set], base?.plan ?? scenarios[0].plan, origins);
|
||||||
|
return resolved.length === 0 ? 0 : Object.keys(resolved[0].byElementId).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRow = (rootId: string, patch: { value?: number; mortgage?: number }) =>
|
||||||
|
setValues((prev) => ({ ...prev, [rootId]: { ...prev[rootId], ...patch } }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||||
|
<CalendarClock className="h-5 w-5 text-accent" /> Effektive Werte
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-muted">
|
||||||
|
Plan «{planName}». Was tatsächlich eingetreten ist – der Plan selbst bleibt unverändert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "list" && (
|
||||||
|
<>
|
||||||
|
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||||||
|
Ein Datensatz hält fest, wie es an einem Stichtag <strong className="text-fg">wirklich</strong> aussah.
|
||||||
|
Die Berechnung läuft dann ein zweites Mal: gleiche Mechanik, aber ab jedem erfassten Jahr mit den
|
||||||
|
echten Zahlen. Werte, die du weglässt, laufen unverändert auf ihrer Planlinie weiter.
|
||||||
|
Ein Datensatz gilt für <strong className="text-fg">alle Szenarien</strong> dieses Plans – die
|
||||||
|
Wirklichkeit ist dieselbe, egal wogegen man sie hält.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
|
{!sets && !error && <p className="text-xs text-muted">Wird geladen…</p>}
|
||||||
|
|
||||||
|
{sets && sets.length === 0 && (
|
||||||
|
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
|
||||||
|
Noch keine effektiven Werte erfasst.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sets && sets.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sets.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
|
||||||
|
i === 0 ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-fg">
|
||||||
|
{dt(s.recordedOn)}
|
||||||
|
{i === 0 && (
|
||||||
|
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">
|
||||||
|
aktuellster
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted">
|
||||||
|
rechnet ab {s.year} · {deviationCount(s)} Werte · {s.author}
|
||||||
|
{typeof s.cash === "number" && ` · Cash ${formatChf(s.cash)}`}
|
||||||
|
</div>
|
||||||
|
{s.comment && <div className="mt-0.5 truncate text-xs text-fg">«{s.comment}»</div>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(s)}
|
||||||
|
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button onClick={startWizard}>
|
||||||
|
<Plus className="h-4 w-4" /> Effektive Werte erfassen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "wizard" && step === 1 && (
|
||||||
|
<>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-faint">Schritt 1 von 2 · Stichtag</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||||
|
Datum der Erfassung
|
||||||
|
<InfoBubble text="Das exakte Datum erscheint in der Liste und auf der Zeitachse. Für die Berechnung zählt nur die Jahreszahl – der Rechenkern arbeitet in ganzen Jahren ab Planbeginn." />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={recordedOn}
|
||||||
|
onChange={(e) => setRecordedOn(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted">Notiz (optional)</label>
|
||||||
|
<input
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="z. B. «nach Jahresabschluss»"
|
||||||
|
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||||
|
Gerechnet wird ab dem Jahr <strong className="text-fg">{year}</strong>. Die Vorbelegung im nächsten
|
||||||
|
Schritt zeigt, was dein Plan für dieses Jahr vorsieht – du überschreibst nur, was tatsächlich anders ist.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => setMode("list")}>
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||||
|
</Button>
|
||||||
|
<Button onClick={goToStep2}>
|
||||||
|
Weiter <ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "wizard" && step === 2 && (
|
||||||
|
<>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Schritt 2 von 2 · Werte per {dt(recordedOn)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[52vh] overflow-y-auto rounded-xl border border-border">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 bg-surface-2">
|
||||||
|
<tr className="text-xs text-faint">
|
||||||
|
<th className="px-3 py-2 text-left font-semibold">Element</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Laut Plan {year}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Effektiv</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-t border-border bg-surface-2/60">
|
||||||
|
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Cash
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-t border-border">
|
||||||
|
<td className="px-3 py-2 font-medium text-fg">Cash-Konto</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted">{formatChf(planCash)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<NumInput value={cash} onChange={setCash} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{rows.map((r, i) => {
|
||||||
|
const header = i === 0 || rows[i - 1].category !== r.category ? r.category : null;
|
||||||
|
const v = values[r.rootId] ?? {};
|
||||||
|
return (
|
||||||
|
<FragmentRow
|
||||||
|
key={r.rootId}
|
||||||
|
header={header}
|
||||||
|
row={r}
|
||||||
|
value={v}
|
||||||
|
onChange={(patch) => setRow(r.rootId, patch)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Einkommen und Ausgaben bitte als <strong>Jahresbetrag</strong> erfassen (nominal, wie tatsächlich
|
||||||
|
geflossen). Bei Immobilien zählt der Verkehrswert und die Restschuld getrennt. Was du auf dem
|
||||||
|
Planwert stehen lässt, wird als «keine Abweichung» gewertet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => setStep(1)}>
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy} onClick={save}>
|
||||||
|
{busy ? "Speichern…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FragmentRow({
|
||||||
|
header,
|
||||||
|
row,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
header: ElementCategory | null;
|
||||||
|
row: Row;
|
||||||
|
value: { value?: number; mortgage?: number };
|
||||||
|
onChange: (patch: { value?: number; mortgage?: number }) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header && (
|
||||||
|
<tr className="border-t border-border bg-surface-2/60">
|
||||||
|
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
|
||||||
|
{CATEGORY_LABELS[header]}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr className="border-t border-border">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium text-fg">{row.name}</div>
|
||||||
|
{row.scenarioNames.length > 1 && (
|
||||||
|
<div className="text-[10px] text-faint">gilt für {row.scenarioNames.join(", ")}</div>
|
||||||
|
)}
|
||||||
|
{row.kind === "FLOW" && <div className="text-[10px] text-faint">Jahresbetrag</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted">
|
||||||
|
{formatChf(row.planValue)}
|
||||||
|
{row.kind === "PROPERTY" && (
|
||||||
|
<div className="text-[11px] text-faint">Hypothek {formatChf(row.planMortgage ?? 0)}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<NumInput value={value.value ?? 0} onChange={(v) => onChange({ value: v })} />
|
||||||
|
{row.kind === "PROPERTY" && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<NumInput
|
||||||
|
value={value.mortgage ?? 0}
|
||||||
|
onChange={(v) => onChange({ mortgage: v })}
|
||||||
|
label="Hypothek"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumInput({ value, onChange, label }: { value: number; onChange: (v: number) => void; label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{label && <span className="text-[10px] text-faint">{label}</span>}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={Number.isFinite(value) ? value : 0}
|
||||||
|
onChange={(e) => onChange(Math.round(Number(e.target.value) || 0))}
|
||||||
|
className="w-32 rounded border border-border bg-surface px-2 py-1 text-right text-sm text-fg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
|
import { computePlan } from "@/lib/calculations";
|
||||||
|
import {
|
||||||
|
resolveActuals,
|
||||||
|
latestPlanYear,
|
||||||
|
type ActualsSetInput,
|
||||||
|
type ElementOrigin,
|
||||||
|
type ResolvedActuals,
|
||||||
|
} from "@/lib/actuals";
|
||||||
|
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
|
||||||
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
// In den Analysewerkzeugen ist nominal/real eine EINFACHauswahl, kein "beide".
|
||||||
|
//
|
||||||
|
// Grund: Der Vermögensverlauf zeichnet je Serie ohnehin schon eine Linie; mit "beide" wären
|
||||||
|
// es zwei, mit Plan/Ist vier und bei zwei Szenarien acht. Das Stilbudget wird stattdessen
|
||||||
|
// für Plan (gestrichelt) gegen Ist (durchgezogen) ausgegeben -- das ist der Vergleich, um
|
||||||
|
// den es geht (siehe SPEZIFIKATION 9.29).
|
||||||
|
export type Metric = "nominal" | "real";
|
||||||
|
|
||||||
|
export const METRIC_OPTIONS: { value: Metric; label: string }[] = [
|
||||||
|
{ value: "nominal", label: "Nominal" },
|
||||||
|
{ value: "real", label: "Real" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface AnalysisBasis {
|
||||||
|
metric: Metric;
|
||||||
|
setMetric: (m: Metric) => void;
|
||||||
|
source: DataSource;
|
||||||
|
setSource: (s: DataSource) => void;
|
||||||
|
// Die für die gewählte Quelle massgebende Rechnung.
|
||||||
|
computed: PlanComputed;
|
||||||
|
// Immer die reine Plan-Rechnung -- Referenzlinie in den Grafiken.
|
||||||
|
planComputed: PlanComputed;
|
||||||
|
// Ist-Rechnung, null solange nichts erfasst ist.
|
||||||
|
actualComputed: PlanComputed | null;
|
||||||
|
hasActuals: boolean;
|
||||||
|
// Auf dieses Szenario aufgeloeste Ist-Saetze -- fuer Werkzeuge, die selbst rechnen.
|
||||||
|
resolvedActuals: ResolvedActuals[];
|
||||||
|
// Erstes Jahr, das nicht mehr durch Ist-Werte belegt ist (Monte-Carlo-Startpunkt).
|
||||||
|
simStartPlanYear: number;
|
||||||
|
simStartCalendarYear: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnalysisBasis(
|
||||||
|
plan: PlanInput,
|
||||||
|
actuals: ActualsSetInput[],
|
||||||
|
origins: ElementOrigin[],
|
||||||
|
initialMetric: Metric = "nominal"
|
||||||
|
): AnalysisBasis {
|
||||||
|
const [metric, setMetric] = useState<Metric>(initialMetric);
|
||||||
|
const [source, setSource] = useState<DataSource>("PLAN");
|
||||||
|
|
||||||
|
const resolved = useMemo(() => resolveActuals(actuals, plan, origins), [actuals, plan, origins]);
|
||||||
|
const planComputed = useMemo(() => computePlan(plan), [plan]);
|
||||||
|
const actualComputed = useMemo(
|
||||||
|
() => (resolved.length === 0 ? null : computePlan(plan, undefined, { actuals: resolved })),
|
||||||
|
[plan, resolved]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasActuals = actualComputed !== null;
|
||||||
|
const effectiveSource: DataSource = hasActuals ? source : "PLAN";
|
||||||
|
const latest = latestPlanYear(resolved);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metric,
|
||||||
|
setMetric,
|
||||||
|
resolvedActuals: effectiveSource === "ACTUAL" ? resolved : [],
|
||||||
|
source: effectiveSource,
|
||||||
|
setSource,
|
||||||
|
computed: effectiveSource === "ACTUAL" && actualComputed ? actualComputed : planComputed,
|
||||||
|
planComputed,
|
||||||
|
actualComputed,
|
||||||
|
hasActuals,
|
||||||
|
// Im Ist-Modus beginnt die Simulation NACH dem jüngsten erfassten Jahr: Was erfasst ist,
|
||||||
|
// ist bekannt und darf nicht gewürfelt werden.
|
||||||
|
simStartPlanYear: effectiveSource === "ACTUAL" && latest !== null ? latest + 1 : 1,
|
||||||
|
simStartCalendarYear:
|
||||||
|
plan.startYear == null
|
||||||
|
? null
|
||||||
|
: plan.startYear + (effectiveSource === "ACTUAL" && latest !== null ? latest : 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einheitliche Leiste für alle vier Werkzeuge, damit die Bedienung überall dieselbe ist.
|
||||||
|
export function AnalysisBar({
|
||||||
|
basis,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
basis: AnalysisBasis;
|
||||||
|
// Wird nach jeder Umstellung gerufen -- die Werkzeuge verwerfen damit alte Ergebnisse.
|
||||||
|
onChange?: () => void;
|
||||||
|
}) {
|
||||||
|
const pick = (fn: () => void) => () => {
|
||||||
|
fn();
|
||||||
|
onChange?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
<span className="font-medium">Werte</span>
|
||||||
|
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||||
|
{METRIC_OPTIONS.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
onClick={pick(() => basis.setMetric(o.value))}
|
||||||
|
className={`rounded-md px-2 py-0.5 font-medium ${
|
||||||
|
basis.metric === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<InfoBubble text="Nominal = Frankenbeträge im jeweiligen Jahr. Real = kaufkraftbereinigt auf den Planbeginn. In den Grafiken ist das bewusst eine Entweder-oder-Wahl: Beides gleichzeitig verdoppelt die Linien." />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
<span className="font-medium">Grundlage</span>
|
||||||
|
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||||
|
{DATA_SOURCE_OPTIONS.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
disabled={o.value === "ACTUAL" && !basis.hasActuals}
|
||||||
|
onClick={pick(() => basis.setSource(o.value))}
|
||||||
|
title={
|
||||||
|
o.value === "ACTUAL" && !basis.hasActuals
|
||||||
|
? "Für diesen Plan sind noch keine effektiven Werte erfasst."
|
||||||
|
: o.label
|
||||||
|
}
|
||||||
|
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
|
||||||
|
basis.source === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.short}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<InfoBubble text="Planzahlen rechnen rein mit deinen Annahmen. Effektiv rechnet dieselbe Mechanik, springt aber in jedem Jahr, für das du Ist-Werte erfasst hast, auf die Realität." />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Dices,
|
Dices,
|
||||||
FileText,
|
FileText,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
|
CalendarClock,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
History,
|
History,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -26,6 +27,9 @@ import { Dashboard } from "@/components/Dashboard";
|
|||||||
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
||||||
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
||||||
import { LiveSimDialog } from "@/components/LiveSimDialog";
|
import { LiveSimDialog } from "@/components/LiveSimDialog";
|
||||||
|
import { ActualsDialog } from "@/components/ActualsDialog";
|
||||||
|
import { buildViews } from "@/lib/dataview";
|
||||||
|
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||||
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
|
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
|
||||||
import { SpecView } from "@/components/SpecView";
|
import { SpecView } from "@/components/SpecView";
|
||||||
import { SystemParametersView } from "@/components/SystemParametersView";
|
import { SystemParametersView } from "@/components/SystemParametersView";
|
||||||
@@ -56,6 +60,10 @@ interface ScenarioDetail {
|
|||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
base: PlanInput | null; // Eltern-Szenario als Vergleichsbasis
|
base: PlanInput | null; // Eltern-Szenario als Vergleichsbasis
|
||||||
meta: ScenarioMeta & { planName: string };
|
meta: ScenarioMeta & { planName: string };
|
||||||
|
// Effektive Werte des PLANS plus die Element-Herkunft aller Szenarien -- daraus entsteht
|
||||||
|
// im Browser der zweite Rechenlauf (siehe lib/dataview.ts).
|
||||||
|
actuals: ActualsSetInput[];
|
||||||
|
elementOrigins: ElementOrigin[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die Provider (Toast, Bestätigung) müssen UM die Shell liegen, damit deren Hooks
|
// Die Provider (Toast, Bestätigung) müssen UM die Shell liegen, damit deren Hooks
|
||||||
@@ -90,6 +98,7 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
const [showSensitivity, setShowSensitivity] = useState(false);
|
const [showSensitivity, setShowSensitivity] = useState(false);
|
||||||
const [showLiveSim, setShowLiveSim] = useState(false);
|
const [showLiveSim, setShowLiveSim] = useState(false);
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const [showActuals, setShowActuals] = useState(false);
|
||||||
const [showSystemParams, setShowSystemParams] = useState(false);
|
const [showSystemParams, setShowSystemParams] = useState(false);
|
||||||
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
||||||
const [showPalette, setShowPalette] = useState(false);
|
const [showPalette, setShowPalette] = useState(false);
|
||||||
@@ -210,6 +219,14 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
|
|
||||||
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
|
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
|
||||||
|
|
||||||
|
// Plan-Sicht und Ist-Sicht in einem Zug. Ohne erfasste Ist-Werte bleibt `actual` null und
|
||||||
|
// die Oberflaeche verhaelt sich exakt wie bisher.
|
||||||
|
const views = useMemo(
|
||||||
|
() =>
|
||||||
|
detail ? buildViews(detail.plan, detail.actuals ?? [], detail.elementOrigins ?? []) : null,
|
||||||
|
[detail]
|
||||||
|
);
|
||||||
|
|
||||||
// Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario).
|
// Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario).
|
||||||
const paletteActions = useMemo<PaletteAction[]>(() => {
|
const paletteActions = useMemo<PaletteAction[]>(() => {
|
||||||
const base: PaletteAction[] = [
|
const base: PaletteAction[] = [
|
||||||
@@ -426,6 +443,10 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Grafiken
|
Grafiken
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setShowActuals(true)}>
|
||||||
|
<CalendarClock className="h-4 w-4" />
|
||||||
|
Effektive Werte
|
||||||
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => setShowHistory(true)}>
|
<Button variant="secondary" onClick={() => setShowHistory(true)}>
|
||||||
<History className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
Änderungshistorie
|
Änderungshistorie
|
||||||
@@ -465,6 +486,8 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
<PlanView
|
<PlanView
|
||||||
plan={detail.plan}
|
plan={detail.plan}
|
||||||
computed={detail.computed}
|
computed={detail.computed}
|
||||||
|
actualComputed={views?.actual ?? null}
|
||||||
|
actualYears={views?.actualYears ?? []}
|
||||||
diff={diff}
|
diff={diff}
|
||||||
onChanged={refreshCurrent}
|
onChanged={refreshCurrent}
|
||||||
onOpenSpec={openSpecAt}
|
onOpenSpec={openSpecAt}
|
||||||
@@ -525,6 +548,8 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
plan={detail.plan}
|
plan={detail.plan}
|
||||||
computed={detail.computed}
|
computed={detail.computed}
|
||||||
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
|
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
|
||||||
|
actuals={detail.actuals ?? []}
|
||||||
|
origins={detail.elementOrigins ?? []}
|
||||||
/>
|
/>
|
||||||
</ChartsDialog>
|
</ChartsDialog>
|
||||||
)}
|
)}
|
||||||
@@ -535,16 +560,39 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
computed={detail.computed}
|
computed={detail.computed}
|
||||||
meta={detail.meta}
|
meta={detail.meta}
|
||||||
scenarios={activePlan?.scenarios ?? [detail.meta]}
|
scenarios={activePlan?.scenarios ?? [detail.meta]}
|
||||||
|
actuals={detail.actuals ?? []}
|
||||||
|
origins={detail.elementOrigins ?? []}
|
||||||
onClose={() => setShowMonteCarlo(false)}
|
onClose={() => setShowMonteCarlo(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSensitivity && detail && (
|
{showSensitivity && detail && (
|
||||||
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
|
<SensitivityDialog
|
||||||
|
plan={detail.plan}
|
||||||
|
actuals={detail.actuals ?? []}
|
||||||
|
origins={detail.elementOrigins ?? []}
|
||||||
|
onClose={() => setShowSensitivity(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showLiveSim && detail && (
|
{showLiveSim && detail && (
|
||||||
<LiveSimDialog plan={detail.plan} onClose={() => setShowLiveSim(false)} />
|
<LiveSimDialog
|
||||||
|
plan={detail.plan}
|
||||||
|
actuals={detail.actuals ?? []}
|
||||||
|
origins={detail.elementOrigins ?? []}
|
||||||
|
onClose={() => setShowLiveSim(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showActuals && detail && activePlan && (
|
||||||
|
<ActualsDialog
|
||||||
|
planId={detail.meta.planId}
|
||||||
|
planName={detail.meta.planName}
|
||||||
|
scenarioMetas={activePlan.scenarios}
|
||||||
|
initial={{ id: detail.meta.id, name: detail.meta.name, isBase: detail.meta.isBase, plan: detail.plan }}
|
||||||
|
onClose={() => setShowActuals(false)}
|
||||||
|
onChanged={refreshCurrent}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showHistory && detail && (
|
{showHistory && detail && (
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useMemo, useState } from "react";
|
|||||||
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
|
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
|
||||||
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
|
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
|
||||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||||
|
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||||
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
|
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
|
||||||
import { SparquoteChart } from "@/components/SparquoteChart";
|
import { SparquoteChart } from "@/components/SparquoteChart";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
@@ -21,20 +22,26 @@ export function Dashboard({
|
|||||||
plan: currentPlan,
|
plan: currentPlan,
|
||||||
computed: currentComputed,
|
computed: currentComputed,
|
||||||
siblings,
|
siblings,
|
||||||
|
actuals = [],
|
||||||
|
origins = [],
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
||||||
siblings: PlanListItem[];
|
siblings: PlanListItem[];
|
||||||
|
actuals?: ActualsSetInput[];
|
||||||
|
origins?: ElementOrigin[];
|
||||||
}) {
|
}) {
|
||||||
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
|
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
|
||||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||||
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
|
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
|
||||||
|
const basis = useAnalysisBasis(plan, actuals, origins);
|
||||||
|
// Version UND Datenquelle bestimmen, was gezeigt wird. Beim Arbeitsstand mit Plandaten
|
||||||
|
// bleibt es beim bereits vom Server Gerechneten -- kein zweiter Lauf nötig.
|
||||||
const computed = useMemo(
|
const computed = useMemo(
|
||||||
() => (versionId === "current" ? currentComputed : computePlan(plan)),
|
() => (versionId === "current" && basis.source === "PLAN" ? currentComputed : basis.computed),
|
||||||
[versionId, currentComputed, plan]
|
[versionId, currentComputed, basis.source, basis.computed]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [compareIds, setCompareIds] = useState<string[]>([]);
|
const [compareIds, setCompareIds] = useState<string[]>([]);
|
||||||
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
|
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
|
||||||
|
|
||||||
@@ -52,13 +59,24 @@ export function Dashboard({
|
|||||||
|
|
||||||
const series: TimelineSeries[] = useMemo(() => {
|
const series: TimelineSeries[] = useMemo(() => {
|
||||||
const result: TimelineSeries[] = [{ label: plan.name, color: CHART_PALETTE[0], computed }];
|
const result: TimelineSeries[] = [{ label: plan.name, color: CHART_PALETTE[0], computed }];
|
||||||
|
// Im Ist-Modus die reine Planlinie als gestrichelte Referenz daneben -- ohne Anker sagt
|
||||||
|
// eine Ist-Linie allein nichts aus.
|
||||||
|
if (basis.source === "ACTUAL") {
|
||||||
|
result.push({
|
||||||
|
label: `${plan.name} (Plan)`,
|
||||||
|
color: CHART_PALETTE[0],
|
||||||
|
computed: basis.planComputed,
|
||||||
|
dashed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
compareIds.forEach((id, i) => {
|
compareIds.forEach((id, i) => {
|
||||||
const c = compareData[id];
|
const c = compareData[id];
|
||||||
const name = siblings.find((p) => p.id === id)?.name ?? id;
|
const name = siblings.find((p) => p.id === id)?.name ?? id;
|
||||||
if (c) result.push({ label: name, color: CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
|
if (c) result.push({ label: name, color: CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
|
||||||
});
|
});
|
||||||
return result;
|
// Vier Serien sind die Grenze der Lesbarkeit -- darüber hilft keine Farbpalette mehr.
|
||||||
}, [plan.name, computed, compareIds, compareData, siblings]);
|
return result.slice(0, 4);
|
||||||
|
}, [plan.name, computed, compareIds, compareData, siblings, basis.source, basis.planComputed]);
|
||||||
|
|
||||||
const otherPlans = siblings;
|
const otherPlans = siblings;
|
||||||
const lastPhase = computed.phases[computed.phases.length - 1];
|
const lastPhase = computed.phases[computed.phases.length - 1];
|
||||||
@@ -72,6 +90,8 @@ export function Dashboard({
|
|||||||
loading={versionLoading}
|
loading={versionLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AnalysisBar basis={basis} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
||||||
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
||||||
@@ -125,7 +145,7 @@ export function Dashboard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<WealthChart series={series} />
|
<WealthChart series={series} metric={basis.metric} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { SparquoteChart } from "@/components/SparquoteChart";
|
|||||||
import { WealthChart } from "@/components/WealthChart";
|
import { WealthChart } from "@/components/WealthChart";
|
||||||
import { InfoBubble } from "@/components/InfoBubble";
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||||
|
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||||
|
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
buildSliders,
|
buildSliders,
|
||||||
@@ -44,9 +46,20 @@ const CHARTS: { id: ChartId; label: string; hint: string }[] = [
|
|||||||
const BASE_COLOR = "#9ca3af";
|
const BASE_COLOR = "#9ca3af";
|
||||||
const LIVE_COLOR = "#4f46e5";
|
const LIVE_COLOR = "#4f46e5";
|
||||||
|
|
||||||
export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
|
export function LiveSimDialog({
|
||||||
|
plan: currentPlan,
|
||||||
|
actuals = [],
|
||||||
|
origins = [],
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
actuals?: ActualsSetInput[];
|
||||||
|
origins?: ElementOrigin[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
// Geregelt wird wahlweise am Arbeitsstand oder an einer festgehaltenen Version.
|
// Geregelt wird wahlweise am Arbeitsstand oder an einer festgehaltenen Version.
|
||||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||||
|
const basis = useAnalysisBasis(plan, actuals, origins);
|
||||||
const [expandReturns, setExpandReturns] = useState(false);
|
const [expandReturns, setExpandReturns] = useState(false);
|
||||||
const [values, setValues] = useState<SliderValues>({});
|
const [values, setValues] = useState<SliderValues>({});
|
||||||
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
|
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
|
||||||
@@ -64,12 +77,15 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
|
|||||||
|
|
||||||
// Referenz: der unveränderte Plan. Wird nur neu gerechnet, wenn sich der Plan ändert.
|
// Referenz: der unveränderte Plan. Wird nur neu gerechnet, wenn sich der Plan ändert.
|
||||||
const base = useMemo(() => {
|
const base = useMemo(() => {
|
||||||
const computed = computePlan(plan);
|
const computed = basis.computed;
|
||||||
return { computed, kpis: kpisOf(computed) };
|
return { computed, kpis: kpisOf(computed) };
|
||||||
}, [plan]);
|
}, [basis.computed]);
|
||||||
|
|
||||||
// Der Kern: bei JEDER Reglerbewegung synchron neu rechnen (~0.2 ms, siehe livesim.ts).
|
// Der Kern: bei JEDER Reglerbewegung synchron neu rechnen (~0.2 ms, siehe livesim.ts).
|
||||||
const live = useMemo(() => runLive(plan, sliders, values), [plan, sliders, values]);
|
const live = useMemo(
|
||||||
|
() => runLive(plan, sliders, values, basis.resolvedActuals),
|
||||||
|
[plan, sliders, values, basis.resolvedActuals]
|
||||||
|
);
|
||||||
|
|
||||||
const neutral = isNeutral(sliders, values);
|
const neutral = isNeutral(sliders, values);
|
||||||
const settings = describeSettings(sliders, values);
|
const settings = describeSettings(sliders, values);
|
||||||
@@ -121,6 +137,8 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
|
|||||||
loading={versionLoading}
|
loading={versionLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AnalysisBar basis={basis} onChange={() => setValues({})} />
|
||||||
|
|
||||||
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||||||
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
|
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
|
||||||
gespeichert</strong> – dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
|
gespeichert</strong> – dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
|
||||||
@@ -129,8 +147,16 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
|
|||||||
|
|
||||||
{/* Kennzahlenleiste: die eigentliche Antwort. Die Grafik zeigt WANN, das hier WIE VIEL. */}
|
{/* Kennzahlenleiste: die eigentliche Antwort. Die Grafik zeigt WANN, das hier WIE VIEL. */}
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<DeltaCard label="Endvermögen (nominal)" base={base.kpis.endNominal} live={live.kpis.endNominal} />
|
<DeltaCard
|
||||||
<DeltaCard label="Endvermögen (real)" base={base.kpis.endReal} live={live.kpis.endReal} />
|
label={basis.metric === "real" ? "Endvermögen (real)" : "Endvermögen (nominal)"}
|
||||||
|
base={basis.metric === "real" ? base.kpis.endReal : base.kpis.endNominal}
|
||||||
|
live={basis.metric === "real" ? live.kpis.endReal : live.kpis.endNominal}
|
||||||
|
/>
|
||||||
|
<DeltaCard
|
||||||
|
label={basis.metric === "real" ? "Endvermögen (nominal)" : "Endvermögen (real)"}
|
||||||
|
base={basis.metric === "real" ? base.kpis.endNominal : base.kpis.endReal}
|
||||||
|
live={basis.metric === "real" ? live.kpis.endNominal : live.kpis.endReal}
|
||||||
|
/>
|
||||||
<RuinCard baseAge={base.kpis.ruinAge} liveAge={live.kpis.ruinAge} />
|
<RuinCard baseAge={base.kpis.ruinAge} liveAge={live.kpis.ruinAge} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Dices, X } from "lucide-react";
|
|||||||
import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField";
|
import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField";
|
||||||
import { InfoBubble } from "@/components/InfoBubble";
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
|
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
|
||||||
|
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||||
|
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { computePlan } from "@/lib/calculations";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
@@ -111,11 +113,15 @@ export function MonteCarloDialog({
|
|||||||
computed,
|
computed,
|
||||||
meta,
|
meta,
|
||||||
scenarios,
|
scenarios,
|
||||||
|
actuals = [],
|
||||||
|
origins = [],
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
meta: ScenarioMeta;
|
meta: ScenarioMeta;
|
||||||
|
actuals?: ActualsSetInput[];
|
||||||
|
origins?: ElementOrigin[];
|
||||||
// Alle Szenarien dieses Plans (auch nicht ausgewählte) -- sie werden geladen, damit sich
|
// Alle Szenarien dieses Plans (auch nicht ausgewählte) -- sie werden geladen, damit sich
|
||||||
// die Herkunfts-Kette der Elemente auch über übersprungene Zwischen-Szenarien auflöst.
|
// die Herkunfts-Kette der Elemente auch über übersprungene Zwischen-Szenarien auflöst.
|
||||||
scenarios: ScenarioMeta[];
|
scenarios: ScenarioMeta[];
|
||||||
@@ -124,6 +130,7 @@ export function MonteCarloDialog({
|
|||||||
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({
|
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({
|
||||||
[meta.id]: { id: meta.id, name: meta.name, plan, computed },
|
[meta.id]: { id: meta.id, name: meta.name, plan, computed },
|
||||||
}));
|
}));
|
||||||
|
const basis = useAnalysisBasis(plan, actuals, origins);
|
||||||
const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id));
|
const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id));
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
|
||||||
@@ -349,6 +356,18 @@ export function MonteCarloDialog({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AnalysisBar basis={basis} onChange={clearResults} />
|
||||||
|
|
||||||
|
{/* Startpunkt: bewusst abgeleitet statt eingebbar. Ein frei gesetztes Jahr würde
|
||||||
|
Jahre als sicher behandeln, die nie erfasst wurden. */}
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
{basis.source === "ACTUAL" && basis.simStartCalendarYear
|
||||||
|
? `Simuliert ab ${basis.simStartCalendarYear}. Die Jahre davor sind durch deine effektiven Werte belegt und werden nicht gewürfelt.`
|
||||||
|
: plan.startYear
|
||||||
|
? `Simuliert ab ${plan.startYear} (Planbeginn).`
|
||||||
|
: "Simuliert ab Planbeginn."}
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Erklärung */}
|
{/* Erklärung */}
|
||||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
@@ -419,8 +438,18 @@ export function MonteCarloDialog({
|
|||||||
{/* Zielbetrag */}
|
{/* Zielbetrag */}
|
||||||
<div className="sm:max-w-xs">
|
<div className="sm:max-w-xs">
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Zielbetrag (Endvermögen)"
|
// Das Feld dreht mit der gewählten Grösse mit -- sonst prüft man einen nominalen
|
||||||
help="Dein frei gewähltes Ziel, nominal. Geprüft wird, wie oft es in beiden Welten mindestens erreicht wird. Gilt für alle ausgewählten Szenarien."
|
// Zielbetrag gegen ein reales Endvermögen und vergleicht Äpfel mit Birnen.
|
||||||
|
label={
|
||||||
|
basis.metric === "real"
|
||||||
|
? "Zielbetrag (Endvermögen, REAL)"
|
||||||
|
: "Zielbetrag (Endvermögen, NOMINAL)"
|
||||||
|
}
|
||||||
|
help={
|
||||||
|
basis.metric === "real"
|
||||||
|
? "Dein frei gewähltes Ziel in HEUTIGER Kaufkraft. Geprüft wird gegen das reale Endvermögen."
|
||||||
|
: "Dein frei gewähltes Ziel in Franken des Zieljahres (nominal). Geprüft wird gegen das nominale Endvermögen."
|
||||||
|
}
|
||||||
value={manualTarget}
|
value={manualTarget}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setManualTarget(v);
|
setManualTarget(v);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFi
|
|||||||
import { MoneyField } from "@/components/FormField";
|
import { MoneyField } from "@/components/FormField";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
|
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
|
||||||
import {
|
import {
|
||||||
CATEGORY_LABELS,
|
CATEGORY_LABELS,
|
||||||
CATEGORY_ORDER,
|
CATEGORY_ORDER,
|
||||||
@@ -107,7 +108,9 @@ type Panel =
|
|||||||
|
|
||||||
export function PlanView({
|
export function PlanView({
|
||||||
plan,
|
plan,
|
||||||
computed,
|
computed: planComputed,
|
||||||
|
actualComputed = null,
|
||||||
|
actualYears = [],
|
||||||
diff,
|
diff,
|
||||||
onChanged,
|
onChanged,
|
||||||
onOpenSpec,
|
onOpenSpec,
|
||||||
@@ -115,12 +118,32 @@ export function PlanView({
|
|||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
|
// Zweiter Rechenlauf mit den effektiven Werten; null, wenn keine erfasst sind.
|
||||||
|
actualComputed?: PlanComputed | null;
|
||||||
|
// Kalenderjahre mit Ist-Satz (Marker auf der Zeitachse).
|
||||||
|
actualYears?: number[];
|
||||||
// Abweichungen gegenüber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
|
// Abweichungen gegenüber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
|
||||||
diff: ScenarioDiff | null;
|
diff: ScenarioDiff | null;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
onOpenSpec?: (anchor: string) => void;
|
onOpenSpec?: (anchor: string) => void;
|
||||||
onOpenSensitivity?: () => void;
|
onOpenSensitivity?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
// Planzahlen oder effektive Zahlen (inkl. Abweichung). Bewusst zwei Möglichkeiten statt
|
||||||
|
// dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je
|
||||||
|
// Zelle (siehe SPEZIFIKATION 9.29).
|
||||||
|
const [dataSource, setDataSource] = useState<DataSource>("PLAN");
|
||||||
|
const hasActuals = actualComputed !== null;
|
||||||
|
const computed = dataSource === "ACTUAL" && actualComputed ? actualComputed : planComputed;
|
||||||
|
|
||||||
|
// Planwert derselben Zelle -- Grundlage der Abweichung. In der Plan-Sicht null, dann zeigt
|
||||||
|
// die Zelle gar keine Abweichung an (statt eine von 0 zu behaupten).
|
||||||
|
const showDeviation = dataSource === "ACTUAL" && actualComputed !== null;
|
||||||
|
const planEndOf = (elementId: string | undefined, phaseId: string): number | null => {
|
||||||
|
if (!showDeviation || !elementId) return null;
|
||||||
|
const ph = planComputed.phases.find((p) => p.id === phaseId);
|
||||||
|
const el = ph?.elements.find((e) => e.elementId === elementId);
|
||||||
|
return el ? el.endValue : null;
|
||||||
|
};
|
||||||
const confirmDialog = useConfirm();
|
const confirmDialog = useConfirm();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
// Markierungs-Klassen: geändert = gelb, neu = grün, entfernt = grau.
|
// Markierungs-Klassen: geändert = gelb, neu = grün, entfernt = grau.
|
||||||
@@ -366,6 +389,32 @@ export function PlanView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
|
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
|
||||||
|
|
||||||
|
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
|
||||||
|
sonst wäre es ein Umschalter ohne Gegenstück. */}
|
||||||
|
{hasActuals && (
|
||||||
|
<>
|
||||||
|
<span className="ml-2 text-xs text-muted">Zahlen</span>
|
||||||
|
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||||||
|
{DATA_SOURCE_OPTIONS.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDataSource(o.value)}
|
||||||
|
title={o.label}
|
||||||
|
className={`rounded-md px-2.5 py-1 font-medium ${
|
||||||
|
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.short}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{dataSource === "ACTUAL" && (
|
||||||
|
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-tour="timeline">
|
<div data-tour="timeline">
|
||||||
@@ -373,6 +422,7 @@ export function PlanView({
|
|||||||
phases={computed.phases}
|
phases={computed.phases}
|
||||||
persons={personAxes}
|
persons={personAxes}
|
||||||
ruinAge={computed.ruinAge}
|
ruinAge={computed.ruinAge}
|
||||||
|
actualYears={actualYears}
|
||||||
startYear={plan.startYear}
|
startYear={plan.startYear}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -629,7 +679,7 @@ export function PlanView({
|
|||||||
ce?.locked ? "text-faint" : "text-fg"
|
ce?.locked ? "text-faint" : "text-fg"
|
||||||
} ${cellDiff(el.id, col.phase.id)}`}
|
} ${cellDiff(el.id, col.phase.id)}`}
|
||||||
>
|
>
|
||||||
{phaseCellContent(ce, col.phase, valueMode)}
|
{phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -992,12 +1042,16 @@ function ValuePair({
|
|||||||
deflatorStart,
|
deflatorStart,
|
||||||
deflatorEnd,
|
deflatorEnd,
|
||||||
mode,
|
mode,
|
||||||
|
devEnd,
|
||||||
}: {
|
}: {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
deflatorStart: number;
|
deflatorStart: number;
|
||||||
deflatorEnd: number;
|
deflatorEnd: number;
|
||||||
mode: ValueMode;
|
mode: ValueMode;
|
||||||
|
// Abweichung des ENDwerts gegenüber dem Plan (nur in der Ist-Sicht gesetzt). Bewusst nur
|
||||||
|
// ein Wert statt Start und Ende: Die Zelle soll nicht zur zweiten Tabelle werden.
|
||||||
|
devEnd?: number | null;
|
||||||
}) {
|
}) {
|
||||||
const arrow = <span className="text-faint">→</span>;
|
const arrow = <span className="text-faint">→</span>;
|
||||||
return (
|
return (
|
||||||
@@ -1005,16 +1059,31 @@ function ValuePair({
|
|||||||
<span className="whitespace-nowrap tabular-nums">
|
<span className="whitespace-nowrap tabular-nums">
|
||||||
{mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "}
|
{mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "}
|
||||||
{mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)}
|
{mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)}
|
||||||
|
<Deviation value={devEnd} deflator={mode === "real" ? deflatorEnd : 1} />
|
||||||
</span>
|
</span>
|
||||||
{mode === "both" && (
|
{mode === "both" && (
|
||||||
<span className="whitespace-nowrap tabular-nums text-faint">
|
<span className="whitespace-nowrap tabular-nums text-faint">
|
||||||
({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))})
|
({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))})
|
||||||
|
<Deviation value={devEnd} deflator={deflatorEnd} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die Abweichung trägt als einziges Element Farbe -- würde man die Beträge selbst einfärben,
|
||||||
|
// entstünde ein Ampelteppich, in dem nichts mehr heraussticht.
|
||||||
|
function Deviation({ value, deflator = 1 }: { value?: number | null; deflator?: number }) {
|
||||||
|
if (value == null || Math.round(value / (deflator || 1)) === 0) return null;
|
||||||
|
const v = Math.round(value / (deflator || 1));
|
||||||
|
return (
|
||||||
|
<span className={`ml-1.5 font-semibold ${v > 0 ? "text-success" : "text-danger"}`}>
|
||||||
|
{v > 0 ? "▲ +" : "▼ −"}
|
||||||
|
{formatChf(Math.abs(v))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Einzelwert, gleiche Konvention.
|
// Einzelwert, gleiche Konvention.
|
||||||
function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) {
|
function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) {
|
||||||
return (
|
return (
|
||||||
@@ -1032,15 +1101,20 @@ function ValueSingle({ value, deflator, mode }: { value: number; deflator: numbe
|
|||||||
function phaseCellContent(
|
function phaseCellContent(
|
||||||
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
|
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
|
||||||
phase: PhaseComputed,
|
phase: PhaseComputed,
|
||||||
mode: ValueMode
|
mode: ValueMode,
|
||||||
|
// Endwert desselben Elements in derselben Phase laut PLAN -- null in der Plan-Sicht.
|
||||||
|
planEnd?: number | null
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
if (!ce) return "–";
|
if (!ce) return "–";
|
||||||
if (ce.note) return ce.note;
|
if (ce.note) return ce.note;
|
||||||
const isFlow = ce.category === "INCOME" || ce.category === "EXPENSE";
|
const isFlow = ce.category === "INCOME" || ce.category === "EXPENSE";
|
||||||
const dS = phase.cumulativeInflationStart;
|
const dS = phase.cumulativeInflationStart;
|
||||||
const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd;
|
const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd;
|
||||||
|
const devEnd = planEnd == null ? null : ce.endValue - planEnd;
|
||||||
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
|
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
|
||||||
return <ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} />;
|
return (
|
||||||
|
<ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} devEnd={devEnd} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
|
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import { Tornado, X } from "lucide-react";
|
import { Tornado, X } from "lucide-react";
|
||||||
import { RequiredNumberField, SelectField } from "@/components/FormField";
|
import { RequiredNumberField } from "@/components/FormField";
|
||||||
import { InfoBubble } from "@/components/InfoBubble";
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||||
|
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||||
|
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
computeTornado,
|
computeTornado,
|
||||||
@@ -35,12 +37,23 @@ function formatRange(low: number, high: number, unit: DriverDef["unit"]): string
|
|||||||
return `${sign(low)} ${suffix} → ${sign(high)} ${suffix}`;
|
return `${sign(low)} ${suffix} → ${sign(high)} ${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
|
export function SensitivityDialog({
|
||||||
|
plan: currentPlan,
|
||||||
|
actuals = [],
|
||||||
|
origins = [],
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
actuals?: ActualsSetInput[];
|
||||||
|
origins?: ElementOrigin[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
// Gerechnet wird wahlweise auf dem Arbeitsstand oder auf einer festgehaltenen Version.
|
// Gerechnet wird wahlweise auf dem Arbeitsstand oder auf einer festgehaltenen Version.
|
||||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||||
const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]);
|
const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]);
|
||||||
|
|
||||||
const [metric, setMetric] = useState<TornadoMetric>("real");
|
const basis = useAnalysisBasis(plan, actuals, origins, "real");
|
||||||
|
const metric: TornadoMetric = basis.metric;
|
||||||
const [drafts, setDrafts] = useState<Record<string, RangeDraft>>({});
|
const [drafts, setDrafts] = useState<Record<string, RangeDraft>>({});
|
||||||
const [result, setResult] = useState<TornadoResult | null>(null);
|
const [result, setResult] = useState<TornadoResult | null>(null);
|
||||||
|
|
||||||
@@ -75,7 +88,10 @@ export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanIn
|
|||||||
checked.map((d) => {
|
checked.map((d) => {
|
||||||
const dr = draftFor(d.id);
|
const dr = draftFor(d.id);
|
||||||
return { id: d.id, low: Number(dr.low), high: Number(dr.high) };
|
return { id: d.id, low: Number(dr.low), high: Number(dr.high) };
|
||||||
})
|
}),
|
||||||
|
// Mit Ist-Werten wirken die Treiber nur noch auf die NICHT belegten Jahre -- was
|
||||||
|
// erfasst ist, steht fest. Die Balken fallen dadurch zu Recht kuerzer aus.
|
||||||
|
basis.resolvedActuals
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,22 +159,8 @@ export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanIn
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zielgrösse */}
|
{/* Zielgrösse und Datengrundlage */}
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<AnalysisBar basis={basis} onChange={() => setResult(null)} />
|
||||||
<SelectField
|
|
||||||
label="Zielgrösse"
|
|
||||||
help="Woran die Wirkung gemessen wird: das Endvermögen der letzten Lebensphase. «Real» ist kaufkraftbereinigt auf den Planbeginn und damit die ehrlichere Grösse."
|
|
||||||
value={metric}
|
|
||||||
onChange={(v: TornadoMetric) => {
|
|
||||||
setMetric(v);
|
|
||||||
setResult(null);
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ value: "real", label: "Endvermögen real (kaufkraftbereinigt)" },
|
|
||||||
{ value: "nominal", label: "Endvermögen nominal" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Parameter */}
|
{/* Parameter */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Flag } from "lucide-react";
|
import { CalendarCheck, Flag } from "lucide-react";
|
||||||
import type { PhaseComputed } from "@/lib/calculations";
|
import type { PhaseComputed } from "@/lib/calculations";
|
||||||
|
|
||||||
interface PersonAxis {
|
interface PersonAxis {
|
||||||
@@ -19,11 +19,15 @@ export function Timeline({
|
|||||||
persons,
|
persons,
|
||||||
ruinAge,
|
ruinAge,
|
||||||
startYear,
|
startYear,
|
||||||
|
actualYears = [],
|
||||||
}: {
|
}: {
|
||||||
phases: PhaseComputed[];
|
phases: PhaseComputed[];
|
||||||
persons: PersonAxis[];
|
persons: PersonAxis[];
|
||||||
ruinAge?: number | null;
|
ruinAge?: number | null;
|
||||||
startYear?: number | null;
|
startYear?: number | null;
|
||||||
|
// Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz
|
||||||
|
// wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos.
|
||||||
|
actualYears?: number[];
|
||||||
}) {
|
}) {
|
||||||
if (phases.length === 0 || persons.length === 0) return null;
|
if (phases.length === 0 || persons.length === 0) return null;
|
||||||
|
|
||||||
@@ -91,6 +95,42 @@ export function Timeline({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Marker für erfasste effektive Werte. Nur mit bekanntem Planstartjahr platzierbar --
|
||||||
|
ohne Kalenderbezug gäbe es keine Position auf der Achse. */}
|
||||||
|
{startYear &&
|
||||||
|
actualYears.map((y) => {
|
||||||
|
const age = minAge + (y - startYear);
|
||||||
|
if (age < minAge || age > maxAge) return null;
|
||||||
|
const isLatest = y === actualYears[actualYears.length - 1];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={y}
|
||||||
|
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
||||||
|
style={{ left: pct(age), opacity: isLatest ? 1 : 0.35 }}
|
||||||
|
title={
|
||||||
|
isLatest
|
||||||
|
? `Effektive Werte erfasst für ${y} (aktuellster Stand)`
|
||||||
|
: `Effektive Werte erfasst für ${y} (überholt)`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CalendarCheck
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="whitespace-nowrap text-[10px] font-medium"
|
||||||
|
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
|
||||||
|
>
|
||||||
|
{y}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="mt-0.5 h-3 w-px"
|
||||||
|
style={{ backgroundColor: isLatest ? "var(--attention)" : "var(--muted)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
|
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
|
||||||
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
||||||
{segments.map((s, i) => {
|
{segments.map((s, i) => {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export interface TimelineSeries {
|
|||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
|
// Plandaten werden gestrichelt gezeichnet, effektive Daten durchgezogen. Das Stilbudget
|
||||||
|
// geht bewusst an Plan/Ist statt an nominal/real (siehe SPEZIFIKATION 9.29).
|
||||||
|
dashed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
|
// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
|
||||||
@@ -35,7 +38,13 @@ function pointsFor(computed: PlanComputed) {
|
|||||||
|
|
||||||
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
|
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
|
||||||
// überlagerte Pläne für den Szenario-Vergleich.
|
// überlagerte Pläne für den Szenario-Vergleich.
|
||||||
export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
export function WealthChart({
|
||||||
|
series,
|
||||||
|
metric = "nominal",
|
||||||
|
}: {
|
||||||
|
series: TimelineSeries[];
|
||||||
|
metric?: "nominal" | "real";
|
||||||
|
}) {
|
||||||
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
||||||
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
||||||
}
|
}
|
||||||
@@ -47,8 +56,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
|||||||
const row: Record<string, number | null> = { age };
|
const row: Record<string, number | null> = { age };
|
||||||
for (const s of withPoints) {
|
for (const s of withPoints) {
|
||||||
const pt = s.points.find((p) => p.age === age);
|
const pt = s.points.find((p) => p.age === age);
|
||||||
row[`${s.label} (nominal)`] = pt ? pt.nominal : null;
|
row[s.label] = pt ? (metric === "real" ? pt.real : pt.nominal) : null;
|
||||||
row[`${s.label} (real)`] = pt ? pt.real : null;
|
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
@@ -76,25 +84,15 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
|||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
{withPoints.map((s) => (
|
{withPoints.map((s) => (
|
||||||
<Line
|
<Line
|
||||||
key={`${s.label}-nominal`}
|
key={s.label}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={`${s.label} (nominal)`}
|
dataKey={s.label}
|
||||||
stroke={s.color}
|
stroke={s.color}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
strokeDasharray={s.dashed ? "5 3" : undefined}
|
||||||
dot={false}
|
dot={false}
|
||||||
connectNulls
|
connectNulls
|
||||||
/>
|
isAnimationActive={false}
|
||||||
))}
|
|
||||||
{withPoints.map((s) => (
|
|
||||||
<Line
|
|
||||||
key={`${s.label}-real`}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={`${s.label} (real)`}
|
|
||||||
stroke={s.color}
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeDasharray="5 3"
|
|
||||||
dot={false}
|
|
||||||
connectNulls
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computePlan } from "@/lib/calculations";
|
||||||
|
import {
|
||||||
|
actualKindOf,
|
||||||
|
actualsForYear,
|
||||||
|
latestPlanYear,
|
||||||
|
rebaseFlow,
|
||||||
|
resolveActuals,
|
||||||
|
toPlanYear,
|
||||||
|
type ActualsSetInput,
|
||||||
|
} from "@/lib/actuals";
|
||||||
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
// Plan ab 2020: ein Fonds mit 100'000 und 5 % Rendite, keine Zu-/Abflüsse. Das ist bewusst
|
||||||
|
// das Beispiel aus der Anforderung, nur in ganzen Franken.
|
||||||
|
function fundPlan(): PlanInput {
|
||||||
|
return {
|
||||||
|
id: "s",
|
||||||
|
name: "T",
|
||||||
|
householdType: "SINGLE",
|
||||||
|
inflationRateDefault: 0,
|
||||||
|
initialCash: 0,
|
||||||
|
startYear: 2020,
|
||||||
|
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||||
|
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }],
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: "fonds",
|
||||||
|
category: "OTHER_ASSET",
|
||||||
|
name: "Fonds",
|
||||||
|
ownerRole: "HOUSEHOLD",
|
||||||
|
orderIndex: 1,
|
||||||
|
phaseValues: { p1: { startValue: 100000, expectedReturn: 5 } },
|
||||||
|
transitionValues: {},
|
||||||
|
sourceElementId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as PlanInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueAt = (computed: ReturnType<typeof computePlan>, elementId: string, year: number) =>
|
||||||
|
computed.phases
|
||||||
|
.flatMap((p) => p.elements.filter((e) => e.elementId === elementId).flatMap((e) => e.yearly))
|
||||||
|
.find((y) => y.year === year)?.value ?? null;
|
||||||
|
|
||||||
|
const setAt = (year: number, values: Record<string, { value?: number; mortgage?: number }>, cash?: number): ActualsSetInput => ({
|
||||||
|
id: `set-${year}`,
|
||||||
|
recordedOn: `${year}-08-18`,
|
||||||
|
year,
|
||||||
|
cash: cash ?? null,
|
||||||
|
values,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toPlanYear", () => {
|
||||||
|
it("rechnet das Kalenderjahr auf das Planjahr um", () => {
|
||||||
|
expect(toPlanYear(2020, 2020)).toBe(1);
|
||||||
|
expect(toPlanYear(2026, 2020)).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert nichts ohne Planstartjahr", () => {
|
||||||
|
expect(toPlanYear(2026, null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("actualKindOf", () => {
|
||||||
|
it("trennt Bestände von Flüssen", () => {
|
||||||
|
expect(actualKindOf("OTHER_ASSET")).toBe("STOCK");
|
||||||
|
expect(actualKindOf("PENSION_FUND")).toBe("STOCK");
|
||||||
|
expect(actualKindOf("REAL_ESTATE")).toBe("PROPERTY");
|
||||||
|
expect(actualKindOf("INCOME")).toBe("FLOW");
|
||||||
|
expect(actualKindOf("EXPENSE")).toBe("FLOW");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveActuals", () => {
|
||||||
|
const plan = fundPlan();
|
||||||
|
|
||||||
|
it("bildet Wurzel-IDs auf die Elemente des Szenarios ab", () => {
|
||||||
|
const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
expect(res[0].planYear).toBe(3);
|
||||||
|
expect(res[0].byElementId.fonds.value).toBe(120000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findet das Element auch über eine Kopie-Kette hinweg", () => {
|
||||||
|
// Kind-Szenario: eigene Element-ID, zeigt über sourceElementId auf das Original.
|
||||||
|
const child: PlanInput = {
|
||||||
|
...plan,
|
||||||
|
id: "child",
|
||||||
|
elements: [{ ...plan.elements[0], id: "fonds-kopie", sourceElementId: "fonds" }],
|
||||||
|
} as unknown as PlanInput;
|
||||||
|
|
||||||
|
// Der Ist-Satz ist auf die WURZEL-ID erfasst -- er muss trotzdem greifen.
|
||||||
|
const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], child, [...plan.elements, ...child.elements]);
|
||||||
|
expect(res[0].byElementId["fonds-kopie"].value).toBe(120000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verwirft Sätze ausserhalb des Planzeitraums", () => {
|
||||||
|
const res = resolveActuals(
|
||||||
|
[setAt(2019, { fonds: { value: 1 } }), setAt(2099, { fonds: { value: 1 } })],
|
||||||
|
plan,
|
||||||
|
plan.elements
|
||||||
|
);
|
||||||
|
expect(res).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt Elemente ohne Ist-Wert weg -- sie laufen auf der Planlinie weiter", () => {
|
||||||
|
const res = resolveActuals([setAt(2022, {})], plan, plan.elements);
|
||||||
|
expect(res[0].byElementId).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sortiert nach Planjahr", () => {
|
||||||
|
const res = resolveActuals(
|
||||||
|
[setAt(2024, { fonds: { value: 140000 } }), setAt(2022, { fonds: { value: 120000 } })],
|
||||||
|
plan,
|
||||||
|
plan.elements
|
||||||
|
);
|
||||||
|
expect(res.map((r) => r.year)).toEqual([2022, 2024]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("actualsForYear / latestPlanYear", () => {
|
||||||
|
const plan = fundPlan();
|
||||||
|
const res = resolveActuals(
|
||||||
|
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })],
|
||||||
|
plan,
|
||||||
|
plan.elements
|
||||||
|
);
|
||||||
|
|
||||||
|
it("findet den Satz des Jahres", () => {
|
||||||
|
expect(actualsForYear(res, 3)!.year).toBe(2022);
|
||||||
|
expect(actualsForYear(res, 4)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kennt das jüngste erfasste Planjahr", () => {
|
||||||
|
expect(latestPlanYear(res)).toBe(5);
|
||||||
|
expect(latestPlanYear([])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Berechnung mit Ist-Werten", () => {
|
||||||
|
const plan = fundPlan();
|
||||||
|
|
||||||
|
it("lässt die Plan-Sicht unangetastet", () => {
|
||||||
|
// Der wichtigste Test überhaupt: Ohne actuals-Option muss auf die Zahl dasselbe
|
||||||
|
// herauskommen wie vorher.
|
||||||
|
const a = computePlan(plan);
|
||||||
|
const b = computePlan(plan, undefined, {});
|
||||||
|
expect(a.phases[0].endWealthNominal).toBe(b.phases[0].endWealthNominal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("springt im Ist-Jahr auf den erfassten Wert", () => {
|
||||||
|
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||||
|
const computed = computePlan(plan, undefined, { actuals });
|
||||||
|
|
||||||
|
// Planwert 2022 wäre 100'000 x 1.05^3 = 115'762. Erfasst sind 120'000.
|
||||||
|
expect(valueAt(computePlan(plan), "fonds", 3)).toBe(115763);
|
||||||
|
expect(valueAt(computed, "fonds", 3)).toBe(120000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rechnet ab dem Ist-Wert planmässig weiter", () => {
|
||||||
|
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||||
|
const computed = computePlan(plan, undefined, { actuals });
|
||||||
|
|
||||||
|
// Zwei Jahre nach dem Sprung: 120'000 x 1.05^2 = 132'300 -- genau die Erwartung aus der
|
||||||
|
// Anforderung ("in 2024 wäre man dann bei ca. 132").
|
||||||
|
expect(valueAt(computed, "fonds", 5)).toBe(132300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("springt bei mehreren Sätzen an jeder erfassten Stelle", () => {
|
||||||
|
const actuals = resolveActuals(
|
||||||
|
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })],
|
||||||
|
plan,
|
||||||
|
plan.elements
|
||||||
|
);
|
||||||
|
const computed = computePlan(plan, undefined, { actuals });
|
||||||
|
|
||||||
|
expect(valueAt(computed, "fonds", 3)).toBe(120000);
|
||||||
|
expect(valueAt(computed, "fonds", 5)).toBe(140000); // statt 132'300
|
||||||
|
expect(valueAt(computed, "fonds", 6)).toBe(147000); // 140'000 x 1.05
|
||||||
|
});
|
||||||
|
|
||||||
|
it("führt den Sprung als eigene Position, nicht als Rendite", () => {
|
||||||
|
// Sonst erschiene eine Planabweichung als Anlageerfolg -- und die Brücke ginge nicht auf.
|
||||||
|
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||||
|
const withActuals = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge;
|
||||||
|
const planOnly = computePlan(plan).phases[0].wealthBridge;
|
||||||
|
|
||||||
|
// Planwert 2022 exakt: 100'000 x 1.05^3 = 115'762.50 -> Korrektur 4'237.50, gerundet 4'238.
|
||||||
|
expect(withActuals.actualsCorrection).toBe(4238);
|
||||||
|
// Die Rendite selbst bleibt unberührt: Der Sprung ist KEIN Anlageerfolg. Sie ist nur
|
||||||
|
// grösser, weil ab 2022 auf einem höheren Kapital verzinst wird.
|
||||||
|
expect(withActuals.investmentReturn).toBeGreaterThan(planOnly.investmentReturn);
|
||||||
|
expect(planOnly.actualsCorrection).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hält die Vermögensbrücke auch bei mehreren Sprüngen geschlossen", () => {
|
||||||
|
const actuals = resolveActuals(
|
||||||
|
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 90000 } })],
|
||||||
|
plan,
|
||||||
|
plan.elements
|
||||||
|
);
|
||||||
|
const bridge = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge;
|
||||||
|
// Der zweite Sprung geht nach UNTEN -- die Korrektur muss negativ sein können.
|
||||||
|
expect(bridge.actualsCorrection).toBeLessThan(0);
|
||||||
|
// Der Restposten bleibt reine Rundung. Die Toleranz entspricht der, ab der die
|
||||||
|
// Detailansicht eine Fehlermeldung zeigt (ResidualNote: > 2 Franken).
|
||||||
|
expect(Math.abs(bridge.residual)).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übernimmt den effektiven Cash-Bestand und hält die Cash-Brücke geschlossen", () => {
|
||||||
|
const actuals = resolveActuals([setAt(2022, {}, 55000)], plan, plan.elements);
|
||||||
|
const computed = computePlan(plan, undefined, { actuals });
|
||||||
|
const cb = computed.phases[0].cashBridge;
|
||||||
|
|
||||||
|
expect(cb.actualsCorrection).not.toBe(0);
|
||||||
|
expect(Math.abs(cb.residual)).toBeLessThanOrEqual(2);
|
||||||
|
// Der Cash-Bestand am Phasenende trägt den Sprung wirklich mit.
|
||||||
|
expect(computed.phases[0].cashBridge.cashEnd).toBe(55000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rebaseFlow", () => {
|
||||||
|
it("trifft im Ist-Jahr genau den erfassten Betrag", () => {
|
||||||
|
// Basis so, dass basis * (1+idx)^(t-1) === Ist-Wert.
|
||||||
|
const basis = rebaseFlow(120000, 2, 5);
|
||||||
|
expect(basis * Math.pow(1.02, 4)).toBeCloseTo(120000, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("berücksichtigt bei Ausgaben zusätzlich die Teuerung", () => {
|
||||||
|
const basis = rebaseFlow(50000, 1, 3, 1.1);
|
||||||
|
expect(basis * Math.pow(1.01, 2) * 1.1).toBeCloseTo(50000, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weicht nicht auf NaN aus, wenn kein Wachstum vorliegt", () => {
|
||||||
|
expect(rebaseFlow(1000, 0, 1)).toBe(1000);
|
||||||
|
expect(rebaseFlow(1000, -100, 3)).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Effektive (Ist-)Werte -- Roadmap Nr. 5, Plan-/Ist-Vergleich.
|
||||||
|
//
|
||||||
|
// Grundgedanke: Der Plan bleibt unangetastet. Parallel dazu läuft eine ZWEITE Berechnung mit
|
||||||
|
// derselben Mechanik, aber korrigierter Ausgangsbasis: In jedem Jahr, für das ein Ist-Satz
|
||||||
|
// erfasst wurde, schnappen die Werte auf die Realität und laufen von dort planmässig weiter.
|
||||||
|
//
|
||||||
|
// Beispiel: Fonds startet 2020 mit 100 bei 5 % Rendite. Ohne Ist-Daten steht 2022 rechnerisch
|
||||||
|
// 110 da. Wird für 2022 ein Ist-Wert von 120 erfasst, rechnet die Ist-Sicht ab 2022 mit 120
|
||||||
|
// weiter und steht 2024 bei ~132. Kommt für 2024 ein Ist-Wert von 140 dazu, springt sie dort
|
||||||
|
// erneut.
|
||||||
|
//
|
||||||
|
// Ein Ist-Satz gehört zum PLAN, nicht zum Szenario: Die Realität ist dieselbe, egal gegen
|
||||||
|
// welches Szenario man sie hält. Die Zuordnung auf die szenario-eigenen Element-IDs läuft
|
||||||
|
// über dieselbe Herkunfts-Kette (`sourceElementId`), die auch der Diff und die
|
||||||
|
// Monte-Carlo-Gruppierung benutzen.
|
||||||
|
|
||||||
|
import { resolveRootElementId } from "@/lib/montecarlo";
|
||||||
|
import type { ElementCategory } from "@/lib/elements";
|
||||||
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
// Was für ein Ist-Wert je Element erfasst werden kann. Bestände und Flüsse verhalten sich
|
||||||
|
// verschieden: Ein Bestand ersetzt den laufenden Stand, ein Fluss die Basis für alle
|
||||||
|
// Folgejahre.
|
||||||
|
export type ActualKind = "STOCK" | "FLOW" | "PROPERTY" | "NONE";
|
||||||
|
|
||||||
|
export function actualKindOf(category: ElementCategory): ActualKind {
|
||||||
|
switch (category) {
|
||||||
|
case "PENSION_FUND":
|
||||||
|
case "PILLAR_3A":
|
||||||
|
case "OTHER_ASSET":
|
||||||
|
return "STOCK";
|
||||||
|
case "OTHER_DEBT":
|
||||||
|
return "STOCK"; // Restschuld
|
||||||
|
case "REAL_ESTATE":
|
||||||
|
return "PROPERTY"; // Verkehrswert UND Resthypothek
|
||||||
|
case "INCOME":
|
||||||
|
case "EXPENSE":
|
||||||
|
return "FLOW";
|
||||||
|
case "AHV":
|
||||||
|
// Die Rente folgt der amtlichen Formel aus der Beitragskarriere. Erfassbar ist sie nur,
|
||||||
|
// wenn sie zum Stichtag bereits LÄUFT -- vorher gibt es keinen Stand. Das entscheidet
|
||||||
|
// sich am Plan, nicht an der Kategorie (siehe `actualFieldsFor`).
|
||||||
|
return "FLOW";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein erfasster Wert je Element.
|
||||||
|
export interface ActualElementValue {
|
||||||
|
// Bestand bzw. Verkehrswert bei Immobilien; bei Flüssen der NOMINALE Jahresbetrag
|
||||||
|
// (Einkommen: was aufs Konto kam; Ausgaben: was tatsächlich ausgegeben wurde).
|
||||||
|
value?: number;
|
||||||
|
// Nur Immobilie: tatsächliche Restschuld.
|
||||||
|
mortgage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Ist-Satz, wie er gespeichert wird. Schlüssel sind WURZEL-Element-IDs.
|
||||||
|
export interface ActualsSetInput {
|
||||||
|
id: string;
|
||||||
|
recordedOn: string; // exaktes Datum (ISO) -- nur für Liste und Zeitachse
|
||||||
|
year: number; // Kalenderjahr; nur dieses geht in die Rechnung ein
|
||||||
|
comment?: string | null;
|
||||||
|
cash?: number | null;
|
||||||
|
values: Record<string, ActualElementValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auf ein konkretes Szenario aufgelöster Ist-Satz: Schlüssel sind die Element-IDs DIESES
|
||||||
|
// Szenarios, und das Kalenderjahr ist in ein Planjahr (1-basiert) umgerechnet.
|
||||||
|
export interface ResolvedActuals {
|
||||||
|
planYear: number; // 1 = erstes Planjahr
|
||||||
|
year: number; // Kalenderjahr (für Anzeige)
|
||||||
|
cash?: number;
|
||||||
|
byElementId: Record<string, ActualElementValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kalenderjahr -> Planjahr. `startYear` ist das Kalenderjahr des ersten Planjahres.
|
||||||
|
export function toPlanYear(year: number, startYear: number | null | undefined): number | null {
|
||||||
|
if (!startYear) return null;
|
||||||
|
return year - startYear + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Herkunft eines Elements: die lose Referenz auf sein Gegenstück im Eltern-Szenario.
|
||||||
|
export interface ElementOrigin {
|
||||||
|
id: string;
|
||||||
|
sourceElementId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bildet die gespeicherten Ist-Sätze auf ein Szenario ab.
|
||||||
|
//
|
||||||
|
// `origins` enthält die Elemente ALLER Szenarien des Plans -- nur so löst sich die
|
||||||
|
// Herkunfts-Kette auch über ein übersprungenes Zwischen-Szenario hinweg auf.
|
||||||
|
export function resolveActuals(
|
||||||
|
sets: ActualsSetInput[],
|
||||||
|
scenario: PlanInput,
|
||||||
|
origins: ElementOrigin[]
|
||||||
|
): ResolvedActuals[] {
|
||||||
|
const sourceById = new Map<string, string | null>();
|
||||||
|
for (const o of origins) sourceById.set(o.id, o.sourceElementId ?? null);
|
||||||
|
// Sicherheitsnetz: Elemente des betrachteten Szenarios sind immer dabei.
|
||||||
|
for (const e of scenario.elements) if (!sourceById.has(e.id)) sourceById.set(e.id, e.sourceElementId ?? null);
|
||||||
|
|
||||||
|
const totalYears = scenario.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||||
|
|
||||||
|
return sets
|
||||||
|
.flatMap<ResolvedActuals>((set) => {
|
||||||
|
const planYear = toPlanYear(set.year, scenario.startYear);
|
||||||
|
if (planYear === null) return [];
|
||||||
|
const byElementId: Record<string, ActualElementValue> = {};
|
||||||
|
for (const e of scenario.elements) {
|
||||||
|
const root = resolveRootElementId(e.id, sourceById);
|
||||||
|
const v = set.values[root];
|
||||||
|
// Lücken fallen bewusst auf die Plandaten zurück: Ein Element ohne Ist-Wert läuft
|
||||||
|
// unverändert auf seiner Planlinie weiter.
|
||||||
|
if (v && (typeof v.value === "number" || typeof v.mortgage === "number")) {
|
||||||
|
byElementId[e.id] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: ResolvedActuals = { planYear, year: set.year, byElementId };
|
||||||
|
if (typeof set.cash === "number") out.cash = set.cash;
|
||||||
|
return [out];
|
||||||
|
})
|
||||||
|
// Ausserhalb des Plans liegende Sätze werden ignoriert -- sie hätten keinen Angriffspunkt.
|
||||||
|
.filter((r) => r.planYear >= 1 && r.planYear <= totalYears)
|
||||||
|
// Bei zwei Sätzen im selben Planjahr gewinnt der zuletzt erfasste.
|
||||||
|
.sort((a, b) => a.planYear - b.planYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nachschlagen im Rechenkern: Gibt es für dieses Planjahr einen Ist-Satz?
|
||||||
|
export function actualsForYear(list: ResolvedActuals[], planYear: number): ResolvedActuals | null {
|
||||||
|
// Rückwärts, damit bei mehreren Sätzen im selben Jahr der letzte gewinnt.
|
||||||
|
for (let i = list.length - 1; i >= 0; i--) if (list[i].planYear === planYear) return list[i];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das jüngste erfasste Planjahr -- Startpunkt der Monte-Carlo-Simulation im Ist-Modus und
|
||||||
|
// Grundlage der Hervorhebung auf der Zeitachse.
|
||||||
|
export function latestPlanYear(list: ResolvedActuals[]): number | null {
|
||||||
|
return list.length === 0 ? null : Math.max(...list.map((r) => r.planYear));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechnet einen Ist-Fluss auf die Basis zurück, mit der der Rechenkern arbeitet.
|
||||||
|
//
|
||||||
|
// Einkommen laufen als `basis * (1 + idx/100)^(t-1)`. Ist für Jahr t ein Ist-Wert erfasst,
|
||||||
|
// muss die Basis so gesetzt werden, dass die Formel in genau diesem Jahr den Ist-Wert trifft
|
||||||
|
// -- danach wächst sie planmässig weiter. Das ist gleichbedeutend damit, den Bezugspunkt der
|
||||||
|
// Reihe auf das Ist-Jahr zu legen, kommt aber ohne Eingriff in die Struktur aus.
|
||||||
|
export function rebaseFlow(actualValue: number, idxPercent: number, tInPhase: number, deflator = 1): number {
|
||||||
|
const growth = Math.pow(1 + idxPercent / 100, tInPhase - 1);
|
||||||
|
const divisor = growth * (deflator || 1);
|
||||||
|
if (!Number.isFinite(divisor) || divisor === 0) return actualValue;
|
||||||
|
return actualValue / divisor;
|
||||||
|
}
|
||||||
+73
-2
@@ -11,6 +11,7 @@ import {
|
|||||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { num } from "@/lib/elements";
|
import { num } from "@/lib/elements";
|
||||||
|
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
|
||||||
import type { ElementCategory } from "@/lib/elements";
|
import type { ElementCategory } from "@/lib/elements";
|
||||||
import type { PersonRole, PlanInput } from "@/lib/types";
|
import type { PersonRole, PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
@@ -50,6 +51,9 @@ export interface ComputeOptions {
|
|||||||
// Standardmässig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
|
// Standardmässig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
|
||||||
// und darf von der Protokollierung nichts merken.
|
// und darf von der Protokollierung nichts merken.
|
||||||
explain?: boolean;
|
explain?: boolean;
|
||||||
|
// Effektive (Ist-)Werte, auf DIESES Szenario aufgelöst (siehe `actuals.ts`). Ohne sie
|
||||||
|
// rechnet die Funktion exakt wie bisher -- die Plan-Sicht bleibt unangetastet.
|
||||||
|
actuals?: ResolvedActuals[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
|
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
|
||||||
@@ -111,6 +115,10 @@ export interface WealthBridge {
|
|||||||
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermögen
|
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermögen
|
||||||
propertyAppreciation: number; // Wertsteigerung der Liegenschaft
|
propertyAppreciation: number; // Wertsteigerung der Liegenschaft
|
||||||
pensionFundContribution: number; // PK-Beiträge: erhöhen das Vermögen, ohne Cash zu kosten
|
pensionFundContribution: number; // PK-Beiträge: erhöhen das Vermögen, ohne Cash zu kosten
|
||||||
|
// Sprung auf die erfassten Ist-Werte (nur in der Ist-Sicht, sonst 0). Bewusst als eigene
|
||||||
|
// Position: Die Differenz zwischen Plan und Wirklichkeit ist KEINE Rendite und darf nicht
|
||||||
|
// als solche erscheinen -- ohne diese Zeile ginge die Brücke im Ist-Jahr nicht auf.
|
||||||
|
actualsCorrection: number;
|
||||||
endWealth: number; // = endWealthNominal
|
endWealth: number; // = endWealthNominal
|
||||||
residual: number; // Rundungsdifferenz (Kontrollgrösse, sollte nahe 0 sein)
|
residual: number; // Rundungsdifferenz (Kontrollgrösse, sollte nahe 0 sein)
|
||||||
}
|
}
|
||||||
@@ -127,6 +135,7 @@ export interface CashBridge {
|
|||||||
savingRates: number; // 3a + Sparbeiträge (Abgang)
|
savingRates: number; // 3a + Sparbeiträge (Abgang)
|
||||||
debtRates: number; // Amortisationen + Tilgungen (Abgang)
|
debtRates: number; // Amortisationen + Tilgungen (Abgang)
|
||||||
withdrawals: number; // Bezugsraten aus Sonstigem Vermögen (Zugang)
|
withdrawals: number; // Bezugsraten aus Sonstigem Vermögen (Zugang)
|
||||||
|
actualsCorrection: number; // Sprung auf den erfassten Ist-Cashbestand (sonst 0)
|
||||||
cashEnd: number;
|
cashEnd: number;
|
||||||
residual: number;
|
residual: number;
|
||||||
}
|
}
|
||||||
@@ -332,6 +341,8 @@ function pct(v: number): string {
|
|||||||
|
|
||||||
export function computePlan(plan: PlanInput, sample?: PlanSample, options?: ComputeOptions): PlanComputed {
|
export function computePlan(plan: PlanInput, sample?: PlanSample, options?: ComputeOptions): PlanComputed {
|
||||||
const explain = options?.explain === true;
|
const explain = options?.explain === true;
|
||||||
|
// Ohne Ist-Werte verhält sich die Funktion exakt wie bisher (die Golden Tests belegen es).
|
||||||
|
const actuals = options?.actuals;
|
||||||
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||||
const persons = plan.persons;
|
const persons = plan.persons;
|
||||||
const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0];
|
const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0];
|
||||||
@@ -703,6 +714,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
let savingRatesTotal = 0;
|
let savingRatesTotal = 0;
|
||||||
let debtRatesTotal = 0;
|
let debtRatesTotal = 0;
|
||||||
let withdrawalsTotal = 0;
|
let withdrawalsTotal = 0;
|
||||||
|
// Sprung auf die Ist-Werte. Ohne Ist-Daten bleiben beide 0 und die Brücken rechnen
|
||||||
|
// exakt wie bisher.
|
||||||
|
let actualsCorrectionTotal = 0;
|
||||||
|
let actualsCashCorrectionTotal = 0;
|
||||||
|
|
||||||
for (let t = 1; t <= duration; t++) {
|
for (let t = 1; t <= duration; t++) {
|
||||||
// Einkommen: nominal (Basis x (1+Lohnerhöhung)^(t-1)) + Renten (nominal fix).
|
// Einkommen: nominal (Basis x (1+Lohnerhöhung)^(t-1)) + Renten (nominal fix).
|
||||||
@@ -787,6 +802,58 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
if (t === 1) plannedSaveRate = fixedRatesTotal + debtRates;
|
if (t === 1) plannedSaveRate = fixedRatesTotal + debtRates;
|
||||||
|
|
||||||
cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
|
cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
|
||||||
|
|
||||||
|
// --- Effektive Werte einspielen (Roadmap Nr. 5) --------------------------------------
|
||||||
|
// Bewusst NACH Verzinsung, Tilgung und Cash-Fortschreibung: Der erfasste Wert ist der
|
||||||
|
// Stand AM ENDE des Ist-Jahres. Rechnet man 2022 mit 120 und wieder 2024 mit 140, so
|
||||||
|
// liegen dazwischen genau zwei Wachstumsjahre -- das entspricht der Erwartung.
|
||||||
|
//
|
||||||
|
// Die Differenz wird als eigene Grösse geführt und NICHT den Renditen zugeschlagen:
|
||||||
|
// Ein Rückstand gegenüber dem Plan ist keine negative Rendite, sondern eine Korrektur.
|
||||||
|
const act = actuals ? actualsForYear(actuals, yearsBefore + t) : null;
|
||||||
|
if (act) {
|
||||||
|
for (const a of assets) {
|
||||||
|
const v = act.byElementId[a.ec.elementId]?.value;
|
||||||
|
if (typeof v !== "number") continue; // Lücke -> Planlinie läuft weiter
|
||||||
|
actualsCorrectionTotal += v - a.value;
|
||||||
|
a.value = v;
|
||||||
|
}
|
||||||
|
for (const re of realEstates) {
|
||||||
|
const av = act.byElementId[re.ec.elementId];
|
||||||
|
if (typeof av?.value === "number") {
|
||||||
|
actualsCorrectionTotal += av.value - re.value;
|
||||||
|
re.value = av.value;
|
||||||
|
}
|
||||||
|
if (typeof av?.mortgage === "number") {
|
||||||
|
// Eine höhere Restschuld mindert das Vermögen -- Vorzeichen umgekehrt.
|
||||||
|
actualsCorrectionTotal -= av.mortgage - re.mortgage;
|
||||||
|
re.mortgage = av.mortgage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const d of debts) {
|
||||||
|
const v = act.byElementId[d.ec.elementId]?.value;
|
||||||
|
if (typeof v !== "number") continue;
|
||||||
|
actualsCorrectionTotal -= v - d.owed;
|
||||||
|
d.owed = v;
|
||||||
|
}
|
||||||
|
// Flüsse: Der erfasste Betrag gilt für DIESES Jahr; die Basis wird so zurückgerechnet,
|
||||||
|
// dass die Reihe hier den Ist-Wert trifft und danach planmässig weiterwächst.
|
||||||
|
for (const inc of incomes) {
|
||||||
|
const v = act.byElementId[inc.ec.elementId]?.value;
|
||||||
|
if (typeof v === "number") inc.basis = rebaseFlow(v, inc.idx, t);
|
||||||
|
}
|
||||||
|
for (const exp of expenses) {
|
||||||
|
const v = act.byElementId[exp.ec.elementId]?.value;
|
||||||
|
// Ausgaben werden real geführt, erfasst wird der nominale Ist-Betrag.
|
||||||
|
if (typeof v === "number") exp.basis = rebaseFlow(v, exp.idx, t, inflFactor);
|
||||||
|
}
|
||||||
|
if (typeof act.cash === "number") {
|
||||||
|
actualsCashCorrectionTotal += act.cash - cash;
|
||||||
|
actualsCorrectionTotal += act.cash - cash;
|
||||||
|
cash = act.cash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (cash < 0) cashNegative = true;
|
if (cash < 0) cashNegative = true;
|
||||||
|
|
||||||
quotaTotal += quote;
|
quotaTotal += quote;
|
||||||
@@ -1203,6 +1270,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
investmentReturn: Math.round(investmentReturnTotal),
|
investmentReturn: Math.round(investmentReturnTotal),
|
||||||
propertyAppreciation: Math.round(propertyAppreciationTotal),
|
propertyAppreciation: Math.round(propertyAppreciationTotal),
|
||||||
pensionFundContribution: Math.round(pensionFundContributionTotal),
|
pensionFundContribution: Math.round(pensionFundContributionTotal),
|
||||||
|
actualsCorrection: Math.round(actualsCorrectionTotal),
|
||||||
endWealth: endWealthNominal,
|
endWealth: endWealthNominal,
|
||||||
residual:
|
residual:
|
||||||
endWealthNominal -
|
endWealthNominal -
|
||||||
@@ -1215,7 +1283,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
Math.round(quotaTotal) +
|
Math.round(quotaTotal) +
|
||||||
Math.round(investmentReturnTotal) +
|
Math.round(investmentReturnTotal) +
|
||||||
Math.round(propertyAppreciationTotal) +
|
Math.round(propertyAppreciationTotal) +
|
||||||
Math.round(pensionFundContributionTotal)),
|
Math.round(pensionFundContributionTotal) +
|
||||||
|
Math.round(actualsCorrectionTotal)),
|
||||||
},
|
},
|
||||||
cashBridge: {
|
cashBridge: {
|
||||||
openingCash: isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd,
|
openingCash: isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd,
|
||||||
@@ -1229,6 +1298,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
savingRates: Math.round(savingRatesTotal),
|
savingRates: Math.round(savingRatesTotal),
|
||||||
debtRates: Math.round(debtRatesTotal),
|
debtRates: Math.round(debtRatesTotal),
|
||||||
withdrawals: Math.round(withdrawalsTotal),
|
withdrawals: Math.round(withdrawalsTotal),
|
||||||
|
actualsCorrection: Math.round(actualsCashCorrectionTotal),
|
||||||
cashEnd,
|
cashEnd,
|
||||||
residual:
|
residual:
|
||||||
cashEnd -
|
cashEnd -
|
||||||
@@ -1236,7 +1306,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
Math.round(quotaTotal) -
|
Math.round(quotaTotal) -
|
||||||
Math.round(savingRatesTotal) -
|
Math.round(savingRatesTotal) -
|
||||||
Math.round(debtRatesTotal) +
|
Math.round(debtRatesTotal) +
|
||||||
Math.round(withdrawalsTotal)),
|
Math.round(withdrawalsTotal) +
|
||||||
|
Math.round(actualsCashCorrectionTotal)),
|
||||||
},
|
},
|
||||||
traces: explain ? phaseTraces : undefined,
|
traces: explain ? phaseTraces : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildViews, deviation, viewFor } from "@/lib/dataview";
|
||||||
|
import type { ActualsSetInput } from "@/lib/actuals";
|
||||||
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
function plan(): PlanInput {
|
||||||
|
return {
|
||||||
|
id: "s",
|
||||||
|
name: "T",
|
||||||
|
householdType: "SINGLE",
|
||||||
|
inflationRateDefault: 0,
|
||||||
|
initialCash: 0,
|
||||||
|
startYear: 2020,
|
||||||
|
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||||
|
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }],
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: "fonds",
|
||||||
|
category: "OTHER_ASSET",
|
||||||
|
name: "Fonds",
|
||||||
|
ownerRole: "HOUSEHOLD",
|
||||||
|
orderIndex: 1,
|
||||||
|
phaseValues: { p1: { startValue: 100000, expectedReturn: 5 } },
|
||||||
|
transitionValues: {},
|
||||||
|
sourceElementId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as PlanInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
const set: ActualsSetInput = {
|
||||||
|
id: "a1",
|
||||||
|
recordedOn: "2022-08-18",
|
||||||
|
year: 2022,
|
||||||
|
cash: null,
|
||||||
|
values: { fonds: { value: 120000 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const endOf = (c: { phases: { endWealthNominal: number }[] }) => c.phases[c.phases.length - 1].endWealthNominal;
|
||||||
|
|
||||||
|
describe("buildViews", () => {
|
||||||
|
it("liefert ohne Ist-Werte gar keine Ist-Sicht", () => {
|
||||||
|
// Wichtig für die Oberfläche: Der Umschalter erscheint dann gar nicht erst.
|
||||||
|
const v = buildViews(plan(), [], plan().elements);
|
||||||
|
expect(v.actual).toBeNull();
|
||||||
|
expect(v.actualYears).toEqual([]);
|
||||||
|
expect(v.latestActualPlanYear).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rechnet beide Sichten und lässt die Plan-Sicht unberührt", () => {
|
||||||
|
const p = plan();
|
||||||
|
const v = buildViews(p, [set], p.elements);
|
||||||
|
const planOnly = buildViews(p, [], p.elements);
|
||||||
|
|
||||||
|
expect(v.actual).not.toBeNull();
|
||||||
|
expect(endOf(v.plan)).toBe(endOf(planOnly.plan));
|
||||||
|
expect(endOf(v.actual!)).toBeGreaterThan(endOf(v.plan));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merkt sich die erfassten Jahre für die Zeitachse", () => {
|
||||||
|
const p = plan();
|
||||||
|
const v = buildViews(p, [set, { ...set, id: "a2", recordedOn: "2024-01-05", year: 2024 }], p.elements);
|
||||||
|
expect(v.actualYears).toEqual([2022, 2024]);
|
||||||
|
expect(v.latestActualPlanYear).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("viewFor", () => {
|
||||||
|
it("fällt ohne Ist-Sicht auf den Plan zurück, statt leer zu bleiben", () => {
|
||||||
|
const v = buildViews(plan(), [], plan().elements);
|
||||||
|
expect(viewFor(v, "ACTUAL")).toBe(v.plan);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert mit Ist-Werten die Ist-Sicht", () => {
|
||||||
|
const p = plan();
|
||||||
|
const v = buildViews(p, [set], p.elements);
|
||||||
|
expect(viewFor(v, "ACTUAL")).toBe(v.actual);
|
||||||
|
expect(viewFor(v, "PLAN")).toBe(v.plan);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deviation", () => {
|
||||||
|
it("behauptet ohne Ist-Sicht keine Abweichung von 0", () => {
|
||||||
|
const v = buildViews(plan(), [], plan().elements);
|
||||||
|
expect(deviation(v, endOf)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("misst die Abweichung Ist gegenüber Plan", () => {
|
||||||
|
const p = plan();
|
||||||
|
const v = buildViews(p, [set], p.elements);
|
||||||
|
const d = deviation(v, endOf)!;
|
||||||
|
expect(d).toBeGreaterThan(0);
|
||||||
|
expect(d).toBe(endOf(v.actual!) - endOf(v.plan));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Datensicht: Plandaten oder effektive (Ist-)Daten.
|
||||||
|
//
|
||||||
|
// Der Plan-Lauf bleibt unangetastet -- die Ist-Sicht ist ein ZWEITER Lauf derselben Mechanik
|
||||||
|
// mit korrigierter Ausgangsbasis (siehe `actuals.ts`). Dieses Modul bündelt, was beide
|
||||||
|
// Sichten gemeinsam brauchen, damit sich Matrix, Grafiken und die drei Analysewerkzeuge
|
||||||
|
// identisch verhalten.
|
||||||
|
|
||||||
|
import { computePlan } from "@/lib/calculations";
|
||||||
|
import { resolveActuals, latestPlanYear, type ActualsSetInput, type ElementOrigin } from "@/lib/actuals";
|
||||||
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
// In der Matrix stehen genau zwei Möglichkeiten zur Wahl: die reinen Planzahlen oder die
|
||||||
|
// Ist-Zahlen MIT Abweichung. Ein Nebeneinander beider Rohwerte wäre bei zusätzlich
|
||||||
|
// nominal/real eine Zelle mit acht Zahlen (siehe SPEZIFIKATION 9.29).
|
||||||
|
export type DataSource = "PLAN" | "ACTUAL";
|
||||||
|
|
||||||
|
export const DATA_SOURCE_OPTIONS: { value: DataSource; label: string; short: string }[] = [
|
||||||
|
{ value: "PLAN", label: "Planzahlen", short: "Plan" },
|
||||||
|
{ value: "ACTUAL", label: "Effektive Zahlen inkl. Abweichung", short: "Effektiv" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface DataViews {
|
||||||
|
plan: PlanComputed;
|
||||||
|
// Null, solange für diesen Plan keine verwertbaren Ist-Werte erfasst sind.
|
||||||
|
actual: PlanComputed | null;
|
||||||
|
// Jüngstes erfasstes Planjahr -- Startpunkt der Monte-Carlo-Simulation im Ist-Modus und
|
||||||
|
// Grundlage der Hervorhebung auf der Zeitachse.
|
||||||
|
latestActualPlanYear: number | null;
|
||||||
|
// Kalenderjahre mit Ist-Satz, aufsteigend (für die Marker auf der Zeitachse).
|
||||||
|
actualYears: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beide Sichten in einem Zug. `computePlan` ist rein und kostet rund 0.2 ms -- zwei Läufe
|
||||||
|
// sind billiger als jede Zwischenspeicherung.
|
||||||
|
export function buildViews(
|
||||||
|
plan: PlanInput,
|
||||||
|
sets: ActualsSetInput[],
|
||||||
|
origins: ElementOrigin[]
|
||||||
|
): DataViews {
|
||||||
|
const planComputed = computePlan(plan);
|
||||||
|
const resolved = resolveActuals(sets, plan, origins);
|
||||||
|
|
||||||
|
if (resolved.length === 0) {
|
||||||
|
return { plan: planComputed, actual: null, latestActualPlanYear: null, actualYears: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
plan: planComputed,
|
||||||
|
actual: computePlan(plan, undefined, { actuals: resolved }),
|
||||||
|
latestActualPlanYear: latestPlanYear(resolved),
|
||||||
|
actualYears: [...new Set(resolved.map((r) => r.year))].sort((a, b) => a - b),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die für die gewählte Quelle massgebende Berechnung. Fehlen Ist-Werte, bleibt es beim Plan --
|
||||||
|
// eine leere Ansicht wäre die schlechtere Antwort als eine ehrliche Rückfallebene.
|
||||||
|
export function viewFor(views: DataViews, source: DataSource): PlanComputed {
|
||||||
|
return source === "ACTUAL" ? views.actual ?? views.plan : views.plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abweichung Ist gegenüber Plan. `null`, wenn es keine Ist-Sicht gibt -- dann zeigt die
|
||||||
|
// Oberfläche gar keine Abweichung an, statt eine von 0 zu behaupten.
|
||||||
|
export function deviation(views: DataViews, pick: (c: PlanComputed) => number): number | null {
|
||||||
|
if (!views.actual) return null;
|
||||||
|
return pick(views.actual) - pick(views.plan);
|
||||||
|
}
|
||||||
+8
-2
@@ -17,6 +17,7 @@ import { applyDriver, applyElementDriver, driverById, DRIVERS, tunableElements }
|
|||||||
import type { DriverId, DriverUnit } from "@/lib/sensitivity";
|
import type { DriverId, DriverUnit } from "@/lib/sensitivity";
|
||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
import type { PlanInput } from "@/lib/types";
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
import type { ResolvedActuals } from "@/lib/actuals";
|
||||||
|
|
||||||
// Ein Regler: entweder ein plan-weiter Treiber oder die Rendite eines einzelnen Elements.
|
// Ein Regler: entweder ein plan-weiter Treiber oder die Rendite eines einzelnen Elements.
|
||||||
export type SliderRef = { kind: "driver"; id: DriverId } | { kind: "element"; id: string };
|
export type SliderRef = { kind: "driver"; id: DriverId } | { kind: "element"; id: string };
|
||||||
@@ -147,9 +148,14 @@ export interface LiveResult {
|
|||||||
kpis: LiveKpis;
|
kpis: LiveKpis;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runLive(plan: PlanInput, sliders: SliderDef[], values: SliderValues): LiveResult {
|
export function runLive(
|
||||||
|
plan: PlanInput,
|
||||||
|
sliders: SliderDef[],
|
||||||
|
values: SliderValues,
|
||||||
|
actuals?: ResolvedActuals[]
|
||||||
|
): LiveResult {
|
||||||
const tuned = applySliders(plan, sliders, values);
|
const tuned = applySliders(plan, sliders, values);
|
||||||
const computed = computePlan(tuned);
|
const computed = computePlan(tuned, undefined, actuals ? { actuals } : undefined);
|
||||||
return { plan: tuned, computed, kpis: kpisOf(computed) };
|
return { plan: tuned, computed, kpis: kpisOf(computed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,5 +86,24 @@ describe("Datenbank-Migrationen", () => {
|
|||||||
)
|
)
|
||||||
).rows.map((r) => r.indexname);
|
).rows.map((r) => r.indexname);
|
||||||
expect(idx).toContain("ScenarioVersion_scenarioId_major_minor_key");
|
expect(idx).toContain("ScenarioVersion_scenarioId_major_minor_key");
|
||||||
|
|
||||||
|
// --- Effektive Werte ---
|
||||||
|
expect(tables, "Tabelle ActualsSet fehlt").toContain("ActualsSet");
|
||||||
|
const actCols = await cols("ActualsSet");
|
||||||
|
for (const c of ["planId", "recordedOn", "year", "comment", "cash", "values", "createdById"]) {
|
||||||
|
expect(actCols, `ActualsSet.${c} fehlt`).toContain(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Ist-Satz hängt am PLAN, nicht am Szenario -- sonst wäre die Realität pro Szenario
|
||||||
|
// verschieden erfasst.
|
||||||
|
expect(actCols).not.toContain("scenarioId");
|
||||||
|
|
||||||
|
// `cash` muss leer bleiben dürfen: Wer den Kontostand nicht kennt, soll den Satz trotzdem
|
||||||
|
// erfassen können (Lücken fallen auf die Plandaten zurück).
|
||||||
|
const cashCol = await db.query<{ is_nullable: string }>(
|
||||||
|
`SELECT is_nullable FROM information_schema.columns
|
||||||
|
WHERE table_name='ActualsSet' AND column_name='cash'`
|
||||||
|
);
|
||||||
|
expect(cashCol.rows[0].is_nullable).toBe("YES");
|
||||||
}, 60000);
|
}, 60000);
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-6
@@ -18,6 +18,7 @@ import { computePlan } from "@/lib/calculations";
|
|||||||
import { num } from "@/lib/elements";
|
import { num } from "@/lib/elements";
|
||||||
import type { ElementCategory, PhaseData } from "@/lib/elements";
|
import type { ElementCategory, PhaseData } from "@/lib/elements";
|
||||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||||
|
import type { ResolvedActuals } from "@/lib/actuals";
|
||||||
|
|
||||||
export type DriverId =
|
export type DriverId =
|
||||||
| "inflation"
|
| "inflation"
|
||||||
@@ -292,8 +293,8 @@ export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Zielgrösse: Endvermögen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
|
// Zielgrösse: Endvermögen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
|
||||||
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
|
export function planMetric(plan: PlanInput, metric: TornadoMetric, actuals?: ResolvedActuals[]): number {
|
||||||
const computed = computePlan(plan);
|
const computed = computePlan(plan, undefined, actuals ? { actuals } : undefined);
|
||||||
const last = computed.phases[computed.phases.length - 1];
|
const last = computed.phases[computed.phases.length - 1];
|
||||||
if (!last) return 0;
|
if (!last) return 0;
|
||||||
return Math.round(metric === "real" ? last.endWealthReal : last.endWealthNominal);
|
return Math.round(metric === "real" ? last.endWealthReal : last.endWealthNominal);
|
||||||
@@ -302,14 +303,17 @@ export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
|
|||||||
export function computeTornado(
|
export function computeTornado(
|
||||||
plan: PlanInput,
|
plan: PlanInput,
|
||||||
metric: TornadoMetric,
|
metric: TornadoMetric,
|
||||||
inputs: TornadoInput[]
|
inputs: TornadoInput[],
|
||||||
|
// Effektive Werte: Die Treiber wirken dann nur noch auf die NICHT belegten Jahre -- was
|
||||||
|
// erfasst ist, steht fest. Die Balken fallen dadurch zu Recht kuerzer aus.
|
||||||
|
actuals?: ResolvedActuals[]
|
||||||
): TornadoResult {
|
): TornadoResult {
|
||||||
const base = planMetric(plan, metric);
|
const base = planMetric(plan, metric, actuals);
|
||||||
|
|
||||||
const bars: TornadoBar[] = inputs.map((input) => {
|
const bars: TornadoBar[] = inputs.map((input) => {
|
||||||
const def = driverById(input.id);
|
const def = driverById(input.id);
|
||||||
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric);
|
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric, actuals);
|
||||||
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
|
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric, actuals);
|
||||||
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermögen). Der Balken spannt
|
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermögen). Der Balken spannt
|
||||||
// deshalb über min..max; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle.
|
// deshalb über min..max; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle.
|
||||||
const swing = Math.abs(highResult - lowResult);
|
const swing = Math.abs(highResult - lowResult);
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ const EXEMPT: Record<string, string> = {
|
|||||||
"plans/[planId]": "ändert nur den Plan-Namen bzw. löscht den ganzen Plan – kein Szenario-Inhalt",
|
"plans/[planId]": "ändert nur den Plan-Namen bzw. löscht den ganzen Plan – kein Szenario-Inhalt",
|
||||||
"scenarios/[scenarioId]/versions": "erzeugt Versionen selbst (Hauptversion / Wiederherstellen)",
|
"scenarios/[scenarioId]/versions": "erzeugt Versionen selbst (Hauptversion / Wiederherstellen)",
|
||||||
"scenarios/[scenarioId]/versions/[versionId]": "erzeugt Versionen selbst",
|
"scenarios/[scenarioId]/versions/[versionId]": "erzeugt Versionen selbst",
|
||||||
|
"plans/[planId]/actuals":
|
||||||
|
"Ist-Werte sind eine Beobachtung, keine Planänderung – sie verändern kein Szenario",
|
||||||
|
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Versionierung: Abdeckung der Schreibpfade", () => {
|
describe("Versionierung: Abdeckung der Schreibpfade", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user