Raten ueber Lebensphasen uebernehmen + Rate in der Verlaufsgrafik
Deploy App / deploy (push) Successful in 1m6s
Deploy App / deploy (push) Successful in 1m6s
(1) Beim Aendern eines Ratenfelds fragt das Panel nach der Reichweite: nur diese Phase (Vorgabe), diese + folgende, alle Phasen. Gilt fuer expectedReturn, valueGrowth, interestRate und teuerungsausgleich. Inline statt Modal -- das Zahlenfeld loest je Tastendruck aus. Die Zielphasen behalten ihre uebrigen Werte (der Endpunkt ersetzt den ganzen Satz; ein Kopieren des Entwurfs haette dort Betraege geloescht). (2) ElementYearPoint fuehrt neu `rate` mit -- additiv, nur durchgereicht. Verlaufsgrafik zeigt sie auf zweiter Y-Achse als Stufenlinie. Golden Tests unveraendert. Spezifikation 0.20, 17 Tests (164 -> 181). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+59
-3
@@ -4,7 +4,7 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||
| **Version** | 0.19 |
|
||||
| **Version** | 0.20 |
|
||||
| **Datum** | 2026-07-18 |
|
||||
| **Status** | Lebendes Dokument |
|
||||
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
| Version | Datum | Autor | Ä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.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.17 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo: zwei Welten, vier Fälle.** Behebt einen Darstellungs-Widerspruch: Zuvor konnte «Planung 69 % erreicht» neben «Ziel 3 Mio nur 41 %» stehen, obwohl 3 Mio unter dem Plan-Endbetrag von 3.7 Mio lag – die beiden Zahlen stammten aus **verschiedenen simulierten Welten**. Neu läuft die Simulation **immer zweimal** (historische Renditen / geplante Werte, gemeinsamer Seed) und liest aus **jeder** Verteilung **beide** Schwellen ab: Plan-Endbetrag und Zielbetrag. Fall 1 und Fall 3 stammen damit aus derselben Verteilung, wodurch ein tieferes Ziel **nie** unwahrscheinlicher sein kann als ein höheres – der Widerspruch ist strukturell ausgeschlossen (Test). Zweite Korrektur: Der Nullpunkt für das Urteil ist **nicht 50 %**, sondern **Fall 2** (derselbe Schwellwert in der eigenen geplanten Welt); durch den Volatilitäts-Drag liegt der je nach Streuung bei 27–48 %. Verglichen wird Fall 1 gegen Fall 2 mit ± 5 pp Toleranzband → «zurückhaltend / realistisch / zu optimistisch». Darstellung: Fall 1 prominent mit Urteil, Fall 3+4 als Satzpaar untergeordnet, Fall 2 und die Mediane klein als Referenz. Der Drei-Wege-Umschalter aus 0.16 entfällt; historische Mittelwerte **und** Zielbetrag sind jetzt beide Pflicht. Technisch: `MonteCarloResult.finalWealthSorted` (alle Endvermögen sortiert) plus neuer Helfer `probabilityAtLeast` (Binärsuche) – vier Zahlen aus zwei Läufen statt vier Läufen. Kapitel 4.12.7 und 9.26 neu gefasst; 3 Tests ergänzt (121 → 124). Keine Änderung am Rechenkern. |
|
||||
@@ -978,7 +979,7 @@ Jede Element-Zeile und jeder Phasenkopf trägt oben rechts ein **Expand-Icon** (
|
||||
|
||||
| Reiter | Inhalt |
|
||||
|---|---|
|
||||
| Verlauf | Liniendiagramm über **alle Planjahre**, nicht nur die Phasengrenzen. Bei einer Immobilie drei Reihen (Eigenkapital, Verkehrswert, Restschuld). Darunter eine Tabelle Beginn/Ende je Lebensphase. |
|
||||
| Verlauf | Liniendiagramm über **alle Planjahre**, nicht nur die Phasengrenzen. Bei einer Immobilie drei Reihen (Eigenkapital, Verkehrswert, Restschuld). Zusätzlich die wirksame **Rate** auf einer zweiten Y-Achse rechts in Prozent – als **Stufenlinie**, weil sie innerhalb einer Phase konstant ist und an der Phasengrenze springt; eine interpolierte Kurve würde einen gleitenden Übergang suggerieren, den die Berechnung nicht macht. Die Achse erscheint nur, wenn das Element überhaupt eine Rate trägt. Darunter eine Tabelle Beginn/Ende je Lebensphase. |
|
||||
| Rechenweg | Die Herleitung je Phase und je Übergang (siehe [4.14](#414-verlaufswerte-brücken-und-rechenwege)) |
|
||||
|
||||
**Lebensphase** – drei Reiter:
|
||||
@@ -1081,6 +1082,59 @@ wenn Folgephasen existieren.
|
||||
|
||||
Referenz: `src/components/DistributionDialogs.tsx`, `src/lib/distribution.ts`.
|
||||
|
||||
### 3.6.11 Raten über Lebensphasen übernehmen
|
||||
|
||||
Werte liegen **je Lebensphase** vor. Bei Beträgen ist das richtig – das Einkommen ändert sich,
|
||||
der Vermögensstand ohnehin. Bei **Raten** ist es meist nicht gemeint: Wer die erwartete Rendite
|
||||
seines ETF auf 5 % setzt, meint fast nie «nur in Phase 2». Ohne Übernahme muss derselbe Wert
|
||||
vier- oder fünfmal eingetippt werden, und dabei wird zuverlässig eine Phase übersehen.
|
||||
|
||||
Ändert man ein Ratenfeld, erscheint deshalb im Bearbeitungspanel eine Rückfrage mit drei
|
||||
Möglichkeiten:
|
||||
|
||||
| Auswahl | Wirkung |
|
||||
|---|---|
|
||||
| **Nur diese Phase** (Vorgabe) | bisheriges Verhalten, andere Phasen bleiben unberührt |
|
||||
| **Diese + folgende** | ab der bearbeiteten Phase vorwärts – Vergangenes bleibt stehen |
|
||||
| **Alle Phasen** | der Wert gilt für den ganzen Plan |
|
||||
|
||||
Als **Ratenfelder** gelten:
|
||||
|
||||
| Feld | Kategorien |
|
||||
|---|---|
|
||||
| `expectedReturn` | Pensionskasse, Säule 3a, Sonstiges Vermögen |
|
||||
| `valueGrowth` | Immobilie (Wertsteigerung) |
|
||||
| `interestRate` | Immobilie (Hypothekarzins) |
|
||||
| `teuerungsausgleich` | Einkommen (Lohnentwicklung), Ausgaben (reale Mehrausgaben) |
|
||||
|
||||
**AHV und Schulden haben keine**: Die AHV-Rente folgt der amtlichen Formel
|
||||
([4.4](#44-ahv-rente)), Schulden tragen ihren Zins nicht als eigenes Feld.
|
||||
|
||||
**Vier Entwurfsentscheide:**
|
||||
|
||||
**Die Rückfrage erscheint beim Bearbeiten, nicht als Modal.** Das Zahlenfeld löst bei *jedem
|
||||
Tastendruck* aus – ein Dialog erschiene bei der Eingabe «5.2» viermal. Stattdessen taucht die
|
||||
Auswahl inline unter den Feldern auf, sobald sich eine Rate tatsächlich vom gespeicherten Wert
|
||||
unterscheidet, und wird beim **Speichern** ausgeführt.
|
||||
|
||||
**Nur veränderte Raten lösen sie aus.** Wer bloss einen Betrag anpasst, bekommt keine
|
||||
Rückfrage. «Nicht gesetzt» und «0» gelten dabei als gleich – sonst meldete schon das Öffnen
|
||||
eines Panels mit leerem Feld eine Änderung.
|
||||
|
||||
**Die Zielphasen behalten ihre übrigen Werte.** Der Endpunkt ersetzt den *ganzen* Werte-Satz
|
||||
einer Phase. Würde man den Entwurf der bearbeiteten Phase einfach hinüberkopieren, verlöre jede
|
||||
andere Phase ihre Beträge, Sparraten und Bezüge. Übertragen wird deshalb ausschliesslich das
|
||||
geänderte Ratenfeld, in die bestehenden Werte hineingemischt. Ein Test sichert genau das ab.
|
||||
|
||||
**Phasen, in denen der Wert schon stimmt, werden übersprungen** – das spart Schreibvorgänge und
|
||||
verhindert eine Version, obwohl sich inhaltlich nichts geändert hat.
|
||||
|
||||
Technisch ist die Übernahme ein Schreibvorgang **je Zielphase** über den bestehenden Endpunkt;
|
||||
es gibt keinen neuen Schreibpfad. Alle fallen in dieselbe Bearbeitungssitzung und ergeben
|
||||
deshalb **eine** Nebenversion, nicht eine je Phase ([3.8.1](#381-eine-version-je-sitzung--nicht-je-änderung)).
|
||||
|
||||
Referenz: `src/lib/ratefields.ts`, `src/components/ElementDetail.tsx`.
|
||||
|
||||
## 3.7 Bedienoberfläche
|
||||
|
||||
### 3.7.1 Layout
|
||||
@@ -2546,6 +2600,7 @@ PlanComputed ← an den Client geliefert
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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-db.ts` | Datenbank-Anbindung der Versionierung: Snapshot festhalten, Hauptversion, Wiederherstellen. Führt den Bauplan aus `versioning.ts` nur aus. |
|
||||
| `livesim.ts` | Live-Simulation: Reglerkatalog mit Standardbereichen, Anwenden mehrerer Regler, Kennzahlen (Kap. 4.15). Rein, läuft im Browser. |
|
||||
@@ -3081,6 +3136,7 @@ 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 |
|
||||
| `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 |
|
||||
| `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-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus |
|
||||
| `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung |
|
||||
@@ -3088,7 +3144,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 |
|
||||
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
|
||||
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
|
||||
| **Total** | **164** | |
|
||||
| **Total** | **181** | |
|
||||
|
||||
## 8.2 Testfälle
|
||||
|
||||
|
||||
@@ -396,6 +396,17 @@ export function ElementDetailDialog({
|
||||
? "Eigenkapital"
|
||||
: "Wert";
|
||||
|
||||
// Zweite Achse nur, wenn dieses Element überhaupt eine Rate trägt (AHV und Schulden nicht).
|
||||
const rateLabel =
|
||||
category === "REAL_ESTATE"
|
||||
? "Wertsteigerung"
|
||||
: isFlow
|
||||
? category === "INCOME"
|
||||
? "Lohnentwicklung"
|
||||
: "Reale Mehrausgaben"
|
||||
: "Erwartete Rendite";
|
||||
const hasRate = points.some((p) => typeof p.rate === "number");
|
||||
|
||||
const verlauf = (
|
||||
<>
|
||||
{points.length === 0 ? (
|
||||
@@ -406,16 +417,49 @@ export function ElementDetailDialog({
|
||||
<LineChart data={points} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||
<Tooltip formatter={(v, n) => [typeof v === "number" ? formatChf(v) : v, n]} labelFormatter={(v) => `Alter ${v}`} />
|
||||
<YAxis yAxisId="chf" tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||
{hasRate && (
|
||||
<YAxis
|
||||
yAxisId="rate"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => `${v} %`}
|
||||
// Etwas Luft nach oben und unten, damit eine konstante Rate nicht als
|
||||
// Linie direkt auf der Achse klebt.
|
||||
domain={([min, max]: readonly [number, number]) => [Math.min(0, min - 1), max + 1]}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
formatter={(v, n) =>
|
||||
typeof v !== "number" ? [v, n] : n === rateLabel ? [`${v} %`, n] : [formatChf(v), n]
|
||||
}
|
||||
labelFormatter={(v) => `Alter ${v}`}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="chf" dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
{MULTI_SERIES.includes(category) && (
|
||||
<>
|
||||
<Line dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||
<Line dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="chf" dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="chf" dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
|
||||
</>
|
||||
)}
|
||||
{hasRate && (
|
||||
// Stufenlinie, nicht interpoliert: Die Rate ist innerhalb einer Phase
|
||||
// konstant und springt an der Phasengrenze. Eine weiche Kurve würde einen
|
||||
// gleitenden Übergang suggerieren, den die Berechnung nicht macht.
|
||||
<Line
|
||||
yAxisId="rate"
|
||||
type="stepAfter"
|
||||
dataKey="rate"
|
||||
name={rateLabel}
|
||||
stroke="#d97706"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 2"
|
||||
dot={false}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,14 @@ import { AlertTriangle } from "lucide-react";
|
||||
import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { api } from "@/lib/api-client";
|
||||
import {
|
||||
buildRateWrites,
|
||||
changedRateFields,
|
||||
scopeQuestion,
|
||||
SCOPE_OPTIONS,
|
||||
type PhaseRef,
|
||||
type RateScope,
|
||||
} from "@/lib/ratefields";
|
||||
import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations";
|
||||
import { CATEGORY_LABELS, num } from "@/lib/elements";
|
||||
import {
|
||||
@@ -49,6 +57,10 @@ interface Props {
|
||||
context: CellContext;
|
||||
phaseData: PhaseData;
|
||||
transitionData: TransitionData;
|
||||
// Alle Phasen des Szenarios und die dort bereits erfassten Werte -- Grundlage für die
|
||||
// Übernahme einer geänderten Rate auf weitere Phasen (siehe SPEZIFIKATION 3.6.11).
|
||||
allPhases?: PhaseRef[];
|
||||
phaseDataByPhase?: Record<string, PhaseData>;
|
||||
onSaved: () => void;
|
||||
onDeleteElement: () => void;
|
||||
}
|
||||
@@ -886,14 +898,29 @@ export function ElementTransitionFields({
|
||||
}
|
||||
}
|
||||
|
||||
export function ElementDetail({ element, context, phaseData, transitionData, onSaved, onDeleteElement }: Props) {
|
||||
export function ElementDetail({
|
||||
element,
|
||||
context,
|
||||
phaseData,
|
||||
transitionData,
|
||||
allPhases = [],
|
||||
phaseDataByPhase = {},
|
||||
onSaved,
|
||||
onDeleteElement,
|
||||
}: Props) {
|
||||
const [pd, setPd] = useState<PhaseData>({ ...phaseData });
|
||||
const [td, setTd] = useState<TransitionData>({ ...transitionData });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Reichweite einer geänderten Rate. Vorgabe ist das bisherige Verhalten: nur diese Phase.
|
||||
const [scope, setScope] = useState<RateScope>("THIS");
|
||||
|
||||
const isTransition = context.kind === "transition";
|
||||
|
||||
// Welche Ratenfelder hat der Nutzer verändert? Nur dann wird überhaupt gefragt.
|
||||
const changedRates = isTransition ? [] : changedRateFields(element.category, phaseData, pd);
|
||||
const showScope = changedRates.length > 0 && allPhases.length > 1;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -914,6 +941,21 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
||||
delete payload.amount;
|
||||
}
|
||||
await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, payload);
|
||||
|
||||
// Geänderte Raten auf die gewählten weiteren Phasen übertragen. Die bestehenden
|
||||
// Werte der Zielphasen bleiben erhalten -- übernommen wird ausschliesslich die Rate.
|
||||
// Alle Schreibvorgänge fallen in dieselbe Bearbeitungssitzung und ergeben daher
|
||||
// EINE Nebenversion, nicht eine je Phase (siehe 3.8.1).
|
||||
for (const w of buildRateWrites(
|
||||
allPhases,
|
||||
context.phaseId,
|
||||
scope,
|
||||
changedRates,
|
||||
pd,
|
||||
phaseDataByPhase
|
||||
)) {
|
||||
await api.put(`/api/elements/${element.id}/phase/${w.phaseId}`, w.data);
|
||||
}
|
||||
}
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
@@ -959,6 +1001,38 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rückfrage zur Reichweite. Erscheint erst, wenn eine Rate tatsächlich verändert
|
||||
wurde -- und bewusst hier statt als Modal beim Tippen: Das Zahlenfeld löst bei
|
||||
jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. */}
|
||||
{showScope && (
|
||||
<div className="rounded-xl border border-attention bg-attention-soft p-3">
|
||||
<p className="mb-2 text-xs text-attention-soft-fg">
|
||||
{scopeQuestion(changedRates, allPhases.length)}
|
||||
</p>
|
||||
<div className="inline-flex flex-wrap gap-0.5 rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||||
{SCOPE_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => setScope(o.value)}
|
||||
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
|
||||
scope === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-attention-soft-fg/80">
|
||||
{scope === "THIS"
|
||||
? "Andere Phasen bleiben unverändert."
|
||||
: `Wird beim Speichern in ${
|
||||
buildRateWrites(allPhases, context.phaseId, scope, changedRates, pd, phaseDataByPhase).length
|
||||
} weitere Phase(n) übertragen. Beträge und Raten dort bleiben erhalten – nur der geänderte Wert wird gesetzt.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -863,6 +863,10 @@ export function PlanView({
|
||||
element={element}
|
||||
context={buildPhaseContext(phase, element)}
|
||||
phaseData={element.phaseValues[phase.id] ?? {}}
|
||||
// Für die Übernahme von Raten über Phasen hinweg: alle Phasen und die dort
|
||||
// bereits erfassten Werte (sie müssen beim Übertragen erhalten bleiben).
|
||||
allPhases={plan.phases.map((p) => ({ id: p.id, sequenceNumber: p.sequenceNumber }))}
|
||||
phaseDataByPhase={element.phaseValues}
|
||||
transitionData={{}}
|
||||
onSaved={closeAndRefresh}
|
||||
onDeleteElement={() => {
|
||||
|
||||
+14
-2
@@ -62,6 +62,11 @@ export interface ElementYearPoint {
|
||||
value: number; // Haupt-Kennzahl (Saldo, Eigenkapital, Flow, Rente)
|
||||
propertyValue?: number; // nur REAL_ESTATE: Verkehrswert der Liegenschaft
|
||||
mortgage?: number; // nur REAL_ESTATE: Restschuld
|
||||
// Die in DIESEM Jahr wirksame Rate in Prozent -- erwartete Rendite (PK/3a/Vermögen),
|
||||
// Wertsteigerung (Immobilie) bzw. jährliche Anpassung (Einkommen/Ausgaben). Rein für die
|
||||
// Darstellung; die Rechnung benutzt denselben Wert, der hier nur mitgeführt wird.
|
||||
// Innerhalb einer Phase konstant, an der Phasengrenze springt sie.
|
||||
rate?: number;
|
||||
}
|
||||
|
||||
export interface ElementPhaseComputed {
|
||||
@@ -794,16 +799,22 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const age = personA.age + yearsBefore + t;
|
||||
const yr = yearsBefore + t;
|
||||
for (const inc of incomes) {
|
||||
inc.ec.yearly.push({ year: yr, age, value: Math.round(inc.basis * Math.pow(1 + inc.idx / 100, t - 1)) });
|
||||
inc.ec.yearly.push({
|
||||
year: yr,
|
||||
age,
|
||||
value: Math.round(inc.basis * Math.pow(1 + inc.idx / 100, t - 1)),
|
||||
rate: inc.idx,
|
||||
});
|
||||
}
|
||||
for (const exp of expenses) {
|
||||
exp.ec.yearly.push({
|
||||
year: yr,
|
||||
age,
|
||||
value: Math.round(exp.basis * Math.pow(1 + exp.idx / 100, t - 1) * inflFactor),
|
||||
rate: exp.idx,
|
||||
});
|
||||
}
|
||||
for (const a of assets) a.ec.yearly.push({ year: yr, age, value: Math.round(a.value) });
|
||||
for (const a of assets) a.ec.yearly.push({ year: yr, age, value: Math.round(a.value), rate: a.r });
|
||||
for (const re of realEstates) {
|
||||
re.ec.yearly.push({
|
||||
year: yr,
|
||||
@@ -811,6 +822,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
value: Math.round(re.value - re.mortgage), // Eigenkapital
|
||||
propertyValue: Math.round(re.value),
|
||||
mortgage: Math.round(re.mortgage),
|
||||
rate: re.growth,
|
||||
});
|
||||
}
|
||||
for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) });
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildRateWrites,
|
||||
changedRateFields,
|
||||
rateFieldsOf,
|
||||
targetPhases,
|
||||
type PhaseRef,
|
||||
} from "@/lib/ratefields";
|
||||
import type { PhaseData } from "@/lib/elements";
|
||||
|
||||
const PHASES: PhaseRef[] = [
|
||||
{ id: "p1", sequenceNumber: 1 },
|
||||
{ id: "p2", sequenceNumber: 2 },
|
||||
{ id: "p3", sequenceNumber: 3 },
|
||||
];
|
||||
|
||||
describe("rateFieldsOf", () => {
|
||||
it("kennt die Ratenfelder je Kategorie", () => {
|
||||
expect(rateFieldsOf("OTHER_ASSET")).toEqual(["expectedReturn"]);
|
||||
expect(rateFieldsOf("REAL_ESTATE")).toEqual(["valueGrowth", "interestRate"]);
|
||||
expect(rateFieldsOf("INCOME")).toEqual(["teuerungsausgleich"]);
|
||||
});
|
||||
|
||||
it("hat für AHV und Schulden keine – dort gibt es keine frei wählbare Rate", () => {
|
||||
expect(rateFieldsOf("AHV")).toEqual([]);
|
||||
expect(rateFieldsOf("OTHER_DEBT")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("targetPhases", () => {
|
||||
it("«nur diese» trifft genau die bearbeitete Phase", () => {
|
||||
expect(targetPhases(PHASES, "p2", "THIS").map((p) => p.id)).toEqual(["p2"]);
|
||||
});
|
||||
|
||||
it("«diese + folgende» lässt frühere Phasen unangetastet", () => {
|
||||
// Der Punkt der Option: Vergangenes soll nicht rückwirkend umgeschrieben werden.
|
||||
expect(targetPhases(PHASES, "p2", "FOLLOWING").map((p) => p.id)).toEqual(["p2", "p3"]);
|
||||
});
|
||||
|
||||
it("«alle» trifft alle, aufsteigend sortiert", () => {
|
||||
const unsorted: PhaseRef[] = [
|
||||
{ id: "p3", sequenceNumber: 3 },
|
||||
{ id: "p1", sequenceNumber: 1 },
|
||||
{ id: "p2", sequenceNumber: 2 },
|
||||
];
|
||||
expect(targetPhases(unsorted, "p2", "ALL").map((p) => p.id)).toEqual(["p1", "p2", "p3"]);
|
||||
});
|
||||
|
||||
it("liefert nichts, wenn die bearbeitete Phase unbekannt ist", () => {
|
||||
expect(targetPhases(PHASES, "weg", "ALL")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changedRateFields", () => {
|
||||
it("meldet nur tatsächlich veränderte Raten", () => {
|
||||
const changed = changedRateFields("OTHER_ASSET", { expectedReturn: 4, startValue: 1000 }, { expectedReturn: 5, startValue: 1000 });
|
||||
expect(changed).toEqual(["expectedReturn"]);
|
||||
});
|
||||
|
||||
it("meldet nichts, wenn nur ein Betrag geändert wurde", () => {
|
||||
// Sonst käme die Rückfrage bei jeder Betragsänderung – reiner Lärm.
|
||||
const changed = changedRateFields("OTHER_ASSET", { expectedReturn: 4, startValue: 1000 }, { expectedReturn: 4, startValue: 2000 });
|
||||
expect(changed).toEqual([]);
|
||||
});
|
||||
|
||||
it("behandelt «nicht gesetzt» und 0 als gleich", () => {
|
||||
// Ein leeres Feld wird im Formular auf 0 vorbelegt. Ohne diese Gleichsetzung würde das
|
||||
// blosse Öffnen eines Dialogs eine Änderung melden.
|
||||
expect(changedRateFields("OTHER_ASSET", {}, { expectedReturn: 0 })).toEqual([]);
|
||||
expect(changedRateFields("OTHER_ASSET", {}, { expectedReturn: 3 })).toEqual(["expectedReturn"]);
|
||||
});
|
||||
|
||||
it("erfasst bei Immobilien beide Raten unabhängig", () => {
|
||||
const changed = changedRateFields(
|
||||
"REAL_ESTATE",
|
||||
{ valueGrowth: 1, interestRate: 2 },
|
||||
{ valueGrowth: 1, interestRate: 2.5 }
|
||||
);
|
||||
expect(changed).toEqual(["interestRate"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRateWrites", () => {
|
||||
const existing: Record<string, PhaseData> = {
|
||||
p1: { expectedReturn: 4, startValue: 200000, annualContribution: 6000 },
|
||||
p2: { expectedReturn: 4, annualContribution: 6000 },
|
||||
p3: { expectedReturn: 4, annualWithdrawal: 30000 },
|
||||
};
|
||||
|
||||
it("schreibt bei «nur diese Phase» gar nichts zusätzlich", () => {
|
||||
expect(buildRateWrites(PHASES, "p2", "THIS", ["expectedReturn"], { expectedReturn: 5 }, existing)).toEqual([]);
|
||||
});
|
||||
|
||||
it("lässt die bearbeitete Phase aus – die speichert der Dialog selbst", () => {
|
||||
const writes = buildRateWrites(PHASES, "p2", "ALL", ["expectedReturn"], { expectedReturn: 5 }, existing);
|
||||
expect(writes.map((w) => w.phaseId)).toEqual(["p1", "p3"]);
|
||||
});
|
||||
|
||||
it("ERHÄLT die übrigen Werte der Zielphase", () => {
|
||||
// Der wichtigste Test: Der Endpunkt ersetzt den ganzen Werte-Satz einer Phase. Würde man
|
||||
// den Entwurf kopieren statt zu mischen, verlöre p3 seine Bezugsrate und p1 seinen
|
||||
// Startwert -- ein stiller Datenverlust.
|
||||
const writes = buildRateWrites(PHASES, "p2", "ALL", ["expectedReturn"], { expectedReturn: 5, annualContribution: 9999 }, existing);
|
||||
|
||||
const p1 = writes.find((w) => w.phaseId === "p1")!;
|
||||
expect(p1.data).toEqual({ expectedReturn: 5, startValue: 200000, annualContribution: 6000 });
|
||||
|
||||
const p3 = writes.find((w) => w.phaseId === "p3")!;
|
||||
expect(p3.data).toEqual({ expectedReturn: 5, annualWithdrawal: 30000 });
|
||||
// Der Beitrag aus dem Entwurf darf NICHT mitwandern -- nur die Rate war gemeint.
|
||||
expect(p3.data.annualContribution).toBeUndefined();
|
||||
});
|
||||
|
||||
it("überspringt Phasen, in denen der Wert schon stimmt", () => {
|
||||
// Spart Schreibvorgänge und verhindert eine Version ohne inhaltliche Änderung.
|
||||
const alreadyRight: Record<string, PhaseData> = {
|
||||
p1: { expectedReturn: 5 },
|
||||
p2: { expectedReturn: 4 },
|
||||
p3: { expectedReturn: 4 },
|
||||
};
|
||||
const writes = buildRateWrites(PHASES, "p2", "ALL", ["expectedReturn"], { expectedReturn: 5 }, alreadyRight);
|
||||
expect(writes.map((w) => w.phaseId)).toEqual(["p3"]);
|
||||
});
|
||||
|
||||
it("schreibt bei «folgende» nicht in frühere Phasen", () => {
|
||||
const writes = buildRateWrites(PHASES, "p2", "FOLLOWING", ["expectedReturn"], { expectedReturn: 5 }, existing);
|
||||
expect(writes.map((w) => w.phaseId)).toEqual(["p3"]);
|
||||
});
|
||||
|
||||
it("überträgt mehrere Raten gleichzeitig", () => {
|
||||
const re: Record<string, PhaseData> = {
|
||||
p1: { valueGrowth: 1, interestRate: 2, purchasePrice: 800000 },
|
||||
p2: { valueGrowth: 1, interestRate: 2 },
|
||||
p3: { valueGrowth: 1, interestRate: 2 },
|
||||
};
|
||||
const writes = buildRateWrites(
|
||||
PHASES,
|
||||
"p1",
|
||||
"ALL",
|
||||
["valueGrowth", "interestRate"],
|
||||
{ valueGrowth: 2, interestRate: 3 },
|
||||
re
|
||||
);
|
||||
expect(writes).toHaveLength(2);
|
||||
expect(writes[0].data.valueGrowth).toBe(2);
|
||||
expect(writes[0].data.interestRate).toBe(3);
|
||||
});
|
||||
|
||||
it("legt Werte auch in Phasen an, die bisher keinen Satz hatten", () => {
|
||||
const writes = buildRateWrites(PHASES, "p1", "ALL", ["expectedReturn"], { expectedReturn: 5 }, { p1: {} });
|
||||
expect(writes.map((w) => w.phaseId)).toEqual(["p2", "p3"]);
|
||||
expect(writes[0].data).toEqual({ expectedReturn: 5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// Ratenfelder und ihre Übernahme über Lebensphasen hinweg.
|
||||
//
|
||||
// Hintergrund: Werte liegen je Lebensphase vor. Bei Beträgen ist das richtig (das Einkommen
|
||||
// ändert sich), bei RATEN meist nicht: Wer die erwartete Rendite seines ETF auf 5 % setzt,
|
||||
// meint fast nie "nur in Phase 2". Ohne Übernahme muss man denselben Wert vier- oder fünfmal
|
||||
// eintippen -- und übersieht dabei zuverlässig eine Phase.
|
||||
//
|
||||
// Reines Modul ohne I/O: Es entscheidet nur, WAS wohin geschrieben wird. Die Schreibvorgänge
|
||||
// selbst löst der Dialog aus (ein PUT je Zielphase, über die bestehenden Endpunkte).
|
||||
|
||||
import type { ElementCategory, PhaseData } from "@/lib/elements";
|
||||
|
||||
// Ein Feld, das eine Rate beschreibt -- im Gegensatz zu einem Betrag.
|
||||
export type RateField = "expectedReturn" | "valueGrowth" | "interestRate" | "teuerungsausgleich";
|
||||
|
||||
// Welche Ratenfelder hat eine Kategorie? Nur diese lösen die Rückfrage aus.
|
||||
export const RATE_FIELDS_BY_CATEGORY: Record<ElementCategory, RateField[]> = {
|
||||
PENSION_FUND: ["expectedReturn"],
|
||||
PILLAR_3A: ["expectedReturn"],
|
||||
OTHER_ASSET: ["expectedReturn"],
|
||||
REAL_ESTATE: ["valueGrowth", "interestRate"],
|
||||
INCOME: ["teuerungsausgleich"],
|
||||
EXPENSE: ["teuerungsausgleich"],
|
||||
AHV: [], // die Rente folgt der amtlichen Formel, es gibt keine frei wählbare Rate
|
||||
OTHER_DEBT: [], // Schulden tragen ihren Zins nicht als eigenes Feld
|
||||
};
|
||||
|
||||
export const RATE_FIELD_LABELS: Record<RateField, string> = {
|
||||
expectedReturn: "Erwartete Rendite",
|
||||
valueGrowth: "Wertsteigerung",
|
||||
interestRate: "Hypothekarzins",
|
||||
teuerungsausgleich: "Jährliche Anpassung",
|
||||
};
|
||||
|
||||
export function rateFieldsOf(category: ElementCategory): RateField[] {
|
||||
return RATE_FIELDS_BY_CATEGORY[category] ?? [];
|
||||
}
|
||||
|
||||
// Reichweite der Übernahme. Vorgabe ist bewusst THIS -- das ist das bisherige Verhalten,
|
||||
// und eine Rückfrage darf nie stillschweigend mehr verändern als bisher.
|
||||
export type RateScope = "THIS" | "FOLLOWING" | "ALL";
|
||||
|
||||
export const SCOPE_OPTIONS: { value: RateScope; label: string }[] = [
|
||||
{ value: "THIS", label: "Nur diese Phase" },
|
||||
{ value: "FOLLOWING", label: "Diese + folgende" },
|
||||
{ value: "ALL", label: "Alle Phasen" },
|
||||
];
|
||||
|
||||
export interface PhaseRef {
|
||||
id: string;
|
||||
sequenceNumber: number;
|
||||
}
|
||||
|
||||
// Welche Phasen sind Ziel der Übernahme? Die bearbeitete Phase ist immer dabei -- sie wird
|
||||
// ohnehin gespeichert.
|
||||
export function targetPhases(phases: PhaseRef[], currentPhaseId: string, scope: RateScope): PhaseRef[] {
|
||||
const current = phases.find((p) => p.id === currentPhaseId);
|
||||
if (!current) return [];
|
||||
const sorted = [...phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
switch (scope) {
|
||||
case "THIS":
|
||||
return [current];
|
||||
case "FOLLOWING":
|
||||
return sorted.filter((p) => p.sequenceNumber >= current.sequenceNumber);
|
||||
case "ALL":
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
|
||||
// Welche Ratenfelder hat der Nutzer tatsächlich verändert? Nur dafür wird gefragt -- eine
|
||||
// Rückfrage zu einem unveränderten Feld wäre Lärm.
|
||||
export function changedRateFields(
|
||||
category: ElementCategory,
|
||||
original: PhaseData,
|
||||
draft: PhaseData
|
||||
): RateField[] {
|
||||
return rateFieldsOf(category).filter((f) => {
|
||||
const a = original[f];
|
||||
const b = draft[f];
|
||||
// Nicht gesetzt und 0 sind für eine Rate dasselbe -- sonst meldet das Öffnen eines
|
||||
// Dialogs, in dem ein leeres Feld auf 0 vorbelegt wird, fälschlich eine Änderung.
|
||||
return (a ?? 0) !== (b ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
export interface RateWrite {
|
||||
phaseId: string;
|
||||
data: PhaseData;
|
||||
}
|
||||
|
||||
// Baut die Schreibvorgänge für die ÜBRIGEN Phasen (die bearbeitete speichert der Dialog
|
||||
// selbst). Die bestehenden Werte der Zielphase bleiben erhalten -- übernommen werden
|
||||
// ausschliesslich die veränderten Ratenfelder.
|
||||
//
|
||||
// Das ist wesentlich: Der Endpunkt ersetzt den ganzen Werte-Satz einer Phase. Würde man hier
|
||||
// den Entwurf der bearbeiteten Phase einfach kopieren, überschriebe man in allen anderen
|
||||
// Phasen auch Beträge, Sparraten und Bezüge.
|
||||
export function buildRateWrites(
|
||||
phases: PhaseRef[],
|
||||
currentPhaseId: string,
|
||||
scope: RateScope,
|
||||
fields: RateField[],
|
||||
draft: PhaseData,
|
||||
existingByPhase: Record<string, PhaseData>
|
||||
): RateWrite[] {
|
||||
if (scope === "THIS" || fields.length === 0) return [];
|
||||
|
||||
const patch: Partial<PhaseData> = {};
|
||||
for (const f of fields) patch[f] = draft[f] ?? 0;
|
||||
|
||||
return targetPhases(phases, currentPhaseId, scope)
|
||||
.filter((p) => p.id !== currentPhaseId)
|
||||
.map((p) => ({ phaseId: p.id, data: { ...(existingByPhase[p.id] ?? {}), ...patch } }))
|
||||
// Phasen, in denen sich nichts ändert, gar nicht erst anfassen: spart Schreibvorgänge
|
||||
// und verhindert, dass eine Version entsteht, obwohl inhaltlich nichts passiert ist.
|
||||
.filter((w) => fields.some((f) => (existingByPhase[w.phaseId]?.[f] ?? 0) !== (draft[f] ?? 0)));
|
||||
}
|
||||
|
||||
// Text für die Rückfrage im Dialog.
|
||||
export function scopeQuestion(fields: RateField[], phaseCount: number): string {
|
||||
const names = fields.map((f) => RATE_FIELD_LABELS[f].toLowerCase());
|
||||
const list =
|
||||
names.length === 1
|
||||
? names[0]
|
||||
: `${names.slice(0, -1).join(", ")} und ${names[names.length - 1]}`;
|
||||
return `Du hast ${names.length === 1 ? "die" : "die"} ${list} geändert. Für welche der ${phaseCount} Lebensphasen soll der Wert gelten?`;
|
||||
}
|
||||
Reference in New Issue
Block a user