Stammdaten gewinnen in Phase 1, Bestaetigung je Zeile statt pauschal
Deploy App / deploy (push) Successful in 1m10s
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+32
-8
@@ -4,10 +4,10 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||
| **Version** | 0.40 |
|
||||
| **Version** | 0.40.1 |
|
||||
| **Datum** | 2026-07-25 |
|
||||
| **Status** | Lebendes Dokument |
|
||||
| **Codestand** | Arbeitsstand nach `5ff9bc0` inkl. «ein Wert, ein Ort» (Branch `main`) |
|
||||
| **Codestand** | Arbeitsstand nach `ce987b9` inkl. «ein Wert, ein Ort» (Branch `main`) |
|
||||
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
| Version | Datum | Autor | Änderung |
|
||||
|---|---|---|---|
|
||||
| 0.40.1 | 2026-08-16 | Claude (Opus 5) | **Zwei Fehler aus der Testrunde.** (1) **«Ein Wert, ein Ort» gilt jetzt im Rechenkern, nicht nur in der Oberfläche.** Eine Immobilie zeigte in der ersten Lebensphase Startwert 0 statt Kaufpreis minus Hypothek. Ursache: Der Rechenkern legte die Phasenwerte über die Stammdaten (`{...baseData, ...phaseValues}`) – eine in 0.39 versehentlich gespeicherte 0 gewann damit gegen die Bestandsaufnahme. Seit 0.40 ist das Feld read-only, wodurch dieser Altwert **unerreichbar** war und den Kaufpreis dauerhaft verdeckt hätte. Neu gewinnen bei den fünf Bestandsfeldern (`amount`, `currentValue`, `startValue`, `purchasePrice`, `mortgage`) **immer die Stammdaten**, wo sie einen Wert tragen (`firstPhaseValues`); der Rückfall auf den Phasenwert bleibt nur, solange die Stammdaten leer sind – sonst fielen Pläne aus der Zeit vor 0.36 schlagartig auf 0. Die Annahmen (Rendite, Zins, Wertsteigerung, Teuerung) bleiben in Phase 1 überschreibbar: Sie gelten für einen Zeitraum, nicht für den Anfangsbestand. Damit heilen bestehende Daten von selbst. (2) **«Alle bestätigen» war zu grob.** Der Knopf in der Annahmen-Maske schrieb `confirmed: true` auf jede Zeile – man konnte ihn drücken, ohne je gescrollt zu haben, also genau das Durchwinken, das der Mechanismus verhindern soll. Neu trägt **jede Zeile ein Häkchen «Angeschaut»**, anfangs leer; wer ein Feld ändert, hakt es automatisch an (bearbeiten IST anschauen); oben markiert ein Klick alle auf einmal, aber als sichtbarer Akt. Gespeichert werden die Werte aller Zeilen, bestätigt nur die angehakten – der Knopf nennt die Zahl. 4 Tests ergänzt (330 → 334). |
|
||||
| 0.40 | 2026-08-16 | Claude (Opus 5) | **Ein Wert, ein Ort** (neues Kap. 3.14.3, aus einer Testrunde des Nutzers). Der rote Faden aller acht Punkte: Für dieselbe Zahl gab es an mehreren Stellen ein Eingabefeld, und der Rechenkern entschied still, welches gewinnt. (1) **Startwerte sind in der ersten Lebensphase read-only.** Sie standen dort als Eingabefeld, das `phaseValues[phase1]` schrieb – der Rechenkern legt die Phase über die Stammdaten (`{...baseData, ...phaseValues}`), also überschattete jede Eingabe still die Bestandsaufnahme. Neu wird der Stammdatenwert angezeigt, mit Absprung in die Spalte «Start». Ab Phase 2 bleibt der Betrag bei Einkommen und Ausgaben **änderbar** – Teilzeit, Beförderung, Jobwechsel sind echte Entscheide dieser Phase; die Bestände sind dort ohnehin schon fortgeschrieben und read-only. (2) **Jährliche Raten entstehen nur noch im Verteil-Dialog**, einmalige Kapitalverwendungen nur noch im Kapital-Dialog. In der Zelle stehen sie weiterhin, aber read-only mit Absprung. Nur im Dialog sieht man, wie viel überhaupt zu verteilen ist und ob die Summe aufgeht. (3) **Der PK-Beitrag wandert in den Verteil-Dialog** – in einen **eigenen Block** «Aus dem Bruttolohn (ausserhalb der Quote)». Er ist bewusst nicht Teil der Quote ([4.6.3](#463-pension_fund)), aber der Dialog ist neu der einzige Ort für jährliche Beträge; ohne ihn wäre er unerreichbar und fiele still auf 0. (4) **Deckel und Schalter «grosse Säule 3a» stehen im Verteil-Dialog**, direkt über der Einzahlung, deren Obergrenze sie bestimmen – getrennt davon war der Schalter eine Einstellung ohne sichtbare Wirkung. (5) **Sonderamortisation und Sofort-Tilgung verlassen die Übergangszelle.** Sie zehren vom Kapital der FOLGEphase und werden dort entschieden; angezeigt werden sie in der Phasenzelle, wo auch die Zusatzeinlage steht. (6) **Der «Kapital verteilen»-Knopf ist immer sichtbar.** Er hing an `pot.total > 0` – wer alles verteilt hätte, käme an seine eigene Zuteilung nie mehr heran, sobald die Elementfelder read-only sind. (7) **Cash in der Bestandsaufnahme.** Es fehlte ganz: Die Summe «Vermögen heute» rechnete es bereits mit, erfassen konnte man es dort nirgends. Kein Plus-Knopf, sondern ein festes Feld – Cash ist kein Element, sondern `Scenario.initialCash`. (8) **Die Tour startet direkt nach dem Anlegen des Plans.** Sie hing an `phases.length > 0`, einem Überbleibsel der Spotlight-Tour, die echte DOM-Ziele brauchte; die Attrappe braucht nichts – und die Tour gehört genau dorthin, wo man noch nicht weiss, wie der Plan aufgebaut ist. (9) **Das Abzeichen im Phasenkopf öffnet eine Maske mit allen offenen Annahmen** (`PhaseReviewDialog`), analog zum Übergangs-Review. Bis 0.39 fragte es nur «N Annahmen bestätigen?» – eine Zustimmung zu etwas Ungesehenem, also genau die Bewegung, die der Mechanismus verhindern soll. (10) **Unbestätigte Zellen sind deutlicher markiert**: getönter Grund, kräftiger linker Balken, Warnzeichen. Der dünne Ring aus 0.39 ging in einer vollen Matrix unter. (11) **Cash-Zeile mit `ValuePair`**: der Realwert steht im Modus «Beide» darunter statt daneben, wie in jeder anderen Zeile. (12) Zwei Folgen daraus: Der Dialog **«Element anlegen» schreibt neu in die Stammdaten** statt in Phase 1 und **braucht keine Lebensphase mehr**; und `needsConfirmation` verlangt keine Bestätigung mehr für Zellen **ohne jede Annahme** – eine «Sonstige Schuld» trägt seit (2) nichts mehr in der Phasenzelle. 1 Test ergänzt (329 → 330). |
|
||||
| 0.39.1 | 2026-08-16 | Claude (Opus 5) | **Fehlerbehebung: Die Bestandsaufnahme schloss sich beim ersten Plus-Knopf.** `InventoryDialog` kannte nur einen Rückkanal nach oben (`onSaved`) und benutzte ihn für zwei verschiedene Ereignisse: «Element angelegt, bitte Plan neu laden» und «Dialog fertig». In `PlanView` hängt an `onSaved` aber das Schliessen -- ein Klick auf «Ausgaben» legte das Element korrekt an und beendete den Dialog sofort, womit sich genau der eine Bildschirm nicht bedienen liess, der alles erfassen soll. Neu trägt der Dialog beide Rückkanäle getrennt: `onChanged` lädt nur nach (der Entwurf im Dialog überlebt das, weil `loadDetail(id, true)` still nachlädt und die `PlanView` montiert bleibt), `onSaved` schliesst. Betraf auch das Löschen einer Position aus dem Dialog heraus. |
|
||||
| 0.39 | 2026-08-16 | Claude (Opus 5) | **Aus dem Assistenten wird eine Übersicht der offenen Punkte** (Kap. 3.14 neu geschrieben). Der Unterschied ist grundsätzlich: Ein Assistent ist ein **Ablauf** («tu dies, dann das») und trägt nur beim ersten Aufsetzen -- wer einen bestehenden Plan öffnete, bekam Schritte angeboten, die längst erledigt waren, und die Kachel wusste nichts davon. Die Übersicht ist ein **Zustand** («das ist noch offen»); sie wird aus dem Plan abgeleitet und trägt bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario. (1) Der Assistent samt Schritten, gespeichertem Fortschritt (`Scenario.assistantProgress`) und Endpunkt entfällt. Oben rechts steht neu die Übersicht: je Lebensphase und je Übergang, was dort fehlt, jeweils mit Sprung dorthin. Ist nichts offen, meldet sie das ausdrücklich -- **grün und in Worten**, nicht als «0». (2) **Die Bestätigung gilt neu auch für Phasenwerte** (`PhaseData.confirmed`). Eine neue Lebensphase übernimmt die Werte der Vorphase, aber sie gelten als unbestätigt, bis jemand hingeschaut hat: vorbelegen ja, stillschweigend übernehmen nein. Betroffen sind die phasenspezifischen Annahmen -- Renditen, Lohnentwicklung, Teuerung, Hypothekarzins, Wertsteigerung. Damit gilt in der ganzen Matrix derselbe Mechanismus, den die Übergänge seit 0.35 haben. **Bestätigen heisst «ich habe hingeschaut», nicht «festnageln»**: Der Haken steht NEBEN den Werten und kopiert nichts -- die Feld-Vererbung ([3.12.4](#3124-punkt-a-aus-vorphase-übernehmen)) bleibt unberührt, sonst wäre jede bestätigte Phase eingefroren und der ganze Punkt-A-Mechanismus hinfällig. Ein Test sichert genau das. (3) **Die Spar-/Verzehrquote zählt eigens** (`Phase.ratesConfirmed`). Man kann jede Zelle angeschaut und die Verteilung trotzdem nie getroffen haben -- dann bliebe der ganze Überschuss still auf dem Cash-Konto liegen, und die Übersicht meldete «alles erledigt». (4) **Sammel-Bestätigung je Phase:** Bei sechs Elementen und fünf Phasen wären es dreissig Klicks; der Phasenkopf trägt deshalb ein Abzeichen mit der Anzahl offener Annahmen, das alle auf einmal bestätigt. Unbestätigte Zellen tragen dieselbe Attention-Markierung wie offene Übergänge. (5) **Die Bestandsaufnahme bleibt als eigener Dialog** (`InventoryDialog`, vormals `AssistantSteps`) -- als grosser Knopf im leeren Plan und dauerhaft unter den Schnellaktionen. Sie ist der einzige Sammel-Dialog, der geblieben ist, weil sie als einzige etwas leistet, das die Matrix nicht kann: sieben Kategorien in einem Zug erfassen, bevor man weiss, wie das Tool aufgebaut ist. (6) Zwei **Startzustände** statt einer Zahl: Ohne Elemente und ohne Lebensphasen gibt es naturgemäss nichts Offenes, obwohl der Plan leer ist -- eine «0» wäre dort eine Lüge. Die Kachel fordert stattdessen zum nächsten Handgriff auf. Neues Modul `review.ts`, neue Komponenten `ReviewTile` und `InventoryDialog`; entfallen sind `assistant.ts`, `Assistant` und `AssistantStepDialog`. **Keine Datenmigration** (Pläne wurden vorgängig gelöscht). 7 Tests ergänzt (322 → 329). |
|
||||
@@ -2234,10 +2235,20 @@ Sie listet jede unbestätigte Zelle mit ihren Feldern, bearbeitbar, und schliess
|
||||
bestätigen».
|
||||
|
||||
Bis 0.39 fragte das Abzeichen nur «N Annahmen bestätigen?». Das war eine Zustimmung zu etwas,
|
||||
das man gar nicht sah -- also genau die Bewegung, die der Mechanismus verhindern soll. Der
|
||||
Sammel-Abschluss bleibt trotzdem wichtig: Bei sechs Elementen und fünf Phasen wären dreissig
|
||||
einzelne Klicks nötig, und das erzieht zum Durchklicken. Wer eine einzelne Zelle öffnet und
|
||||
speichert, bestätigt sie dabei ohnehin.
|
||||
das man gar nicht sah -- also genau die Bewegung, die der Mechanismus verhindern soll.
|
||||
|
||||
**Bestätigt wird zeilenweise.** Jede Zeile trägt ein Häkchen «Angeschaut», anfangs leer;
|
||||
gespeichert werden die Werte aller Zeilen, `confirmed` aber nur bei den angehakten. Ein Knopf,
|
||||
der pauschal alles bestätigt, liesse sich drücken, ohne je gescrollt zu haben -- der erste
|
||||
Anlauf in 0.40 tat genau das. Zwei Dinge halten den Aufwand trotzdem klein:
|
||||
|
||||
* **Wer ein Feld ändert, hakt es automatisch an.** Bearbeiten IST anschauen.
|
||||
* Oben markiert **ein** Klick alle Zeilen -- aber als sichtbarer Akt, nicht als Nebenwirkung
|
||||
des Speicherns.
|
||||
|
||||
Das ist nötig, weil es sonst bei sechs Elementen und fünf Phasen dreissig einzelne Klicks
|
||||
wären, und das erzieht wieder zum Durchklicken. Wer eine einzelne Zelle in der Matrix öffnet
|
||||
und speichert, bestätigt sie dabei ohnehin.
|
||||
|
||||
Ist zusätzlich die Quote dieser Phase noch nicht verteilt, steht das oben in der Maske mit
|
||||
einem Absprung in den Verteil-Dialog -- die beiden zählen getrennt, gehören aber zusammen.
|
||||
@@ -2263,12 +2274,25 @@ Die Regel, die seit 0.40 durchgehend gilt: **Für jede Zahl gibt es genau EIN Ei
|
||||
| Annahme (Rendite, Lohnentwicklung, Teuerung, Hypothekarzins, Wertsteigerung) | Phasenzelle | – |
|
||||
| Cash bei Planbeginn | Bestandsaufnahme bzw. Spalte «Start» der Cash-Zeile | – |
|
||||
|
||||
**Warum das kein Kosmetikpunkt ist.** Der Rechenkern legt in der ersten Phase die Phasenwerte
|
||||
**Warum das kein Kosmetikpunkt ist.** Der Rechenkern legte in der ersten Phase die Phasenwerte
|
||||
über die Stammdaten (`{...baseData, ...phaseValues}`). Ein zweites Eingabefeld für den
|
||||
Startwert bedeutete also: Man tippt in Phase 1 eine Zahl, sie **gewinnt still** gegen die
|
||||
Bestandsaufnahme, und in der Spalte «Start» steht weiterhin die alte. Niemand sieht den
|
||||
Konflikt – man sieht nur, dass eine Korrektur wirkungslos bleibt.
|
||||
|
||||
**Die Regel steht deshalb seit 0.40.1 im Rechenkern** (`firstPhaseValues`), nicht nur in der
|
||||
Oberfläche: Bei den fünf Bestandsfeldern (`amount`, `currentValue`, `startValue`,
|
||||
`purchasePrice`, `mortgage`) gewinnen in der ersten Phase **immer die Stammdaten**, wo sie
|
||||
einen Wert tragen. Der Anlass war ein handfester Fehler: Eine in 0.39 versehentlich
|
||||
gespeicherte 0 verdeckte den Kaufpreis einer Immobilie – und weil das Feld seit 0.40 read-only
|
||||
ist, war sie **nicht mehr zu entfernen**. Eine Sperre in der Oberfläche schützt eben nur vor
|
||||
neuen Eingaben, nicht vor alten.
|
||||
|
||||
Der Rückfall auf den Phasenwert bleibt, solange die Stammdaten leer sind; sonst fielen Pläne
|
||||
aus der Zeit vor 0.36 schlagartig auf 0. Die **Annahmen** sind ausdrücklich nicht betroffen:
|
||||
Eine Rendite gilt für einen Zeitraum, nicht für den Anfangsbestand, und darf in Phase 1
|
||||
abweichen.
|
||||
|
||||
Bei den Raten ist der Grund ein anderer, aber nicht kleiner: **Nur der Verteil-Dialog kennt
|
||||
die Quote.** Er zeigt, wie viel überhaupt zu verteilen ist, ob die Summe aufgeht und ob das
|
||||
Cash-Konto dabei ins Minus fällt. Ein Eingabefeld am Element liess sich beliebig darüber
|
||||
@@ -4470,7 +4494,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
||||
| `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
|
||||
| `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP |
|
||||
| `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` |
|
||||
| **Total** | **330** | |
|
||||
| **Total** | **334** | |
|
||||
|
||||
## 8.2 Testfälle
|
||||
|
||||
|
||||
@@ -545,12 +545,15 @@ export function ElementPhaseFields({
|
||||
// wird er in der Spalte «Start». Bis 0.39 stand hier ein Eingabefeld, das
|
||||
// `phaseValues[phase1]` schrieb und die Stammdaten still überschattete (der Rechenkern
|
||||
// legt sie als `{...baseData, ...phaseValues}` übereinander): dieselbe Zahl an zwei Orten.
|
||||
// Dieselbe Vorrangregel wie im Rechenkern (`firstPhaseValues`): Wo die Stammdaten einen Wert
|
||||
// tragen, gewinnen SIE -- ein Phasenwert darf sie nicht überschatten. Der Rückfall auf den
|
||||
// Phasenwert bleibt nur, solange die Stammdaten leer sind.
|
||||
const bd = context.baseData;
|
||||
const start = (key: "amount" | "currentValue" | "startValue" | "purchasePrice" | "mortgage", label: string, help?: string) => (
|
||||
<LinkedField
|
||||
label={label}
|
||||
help={help}
|
||||
value={Math.round(num(pd[key], num(bd[key])))}
|
||||
value={Math.round(typeof bd[key] === "number" ? (bd[key] as number) : num(pd[key]))}
|
||||
actionLabel="Startwert bearbeiten"
|
||||
onAction={context.onEditBase}
|
||||
/>
|
||||
@@ -589,8 +592,13 @@ export function ElementPhaseFields({
|
||||
const isIncome = element.category === "INCOME";
|
||||
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
|
||||
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
|
||||
const baseValue =
|
||||
typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : num(bd.amount);
|
||||
const baseValue = carried
|
||||
? typeof pd.amount === "number"
|
||||
? pd.amount
|
||||
: context.derivedStart
|
||||
: typeof bd.amount === "number"
|
||||
? bd.amount
|
||||
: num(pd.amount);
|
||||
const d = context.deflatorStart || 1;
|
||||
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
|
||||
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
|
||||
@@ -815,7 +823,11 @@ export function ElementPhaseFields({
|
||||
case "REAL_ESTATE": {
|
||||
// Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation,
|
||||
// der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung).
|
||||
const hypStart = carried ? context.derivedMortgage : num(pd.mortgage, num(bd.mortgage));
|
||||
const hypStart = carried
|
||||
? context.derivedMortgage
|
||||
: typeof bd.mortgage === "number"
|
||||
? bd.mortgage
|
||||
: num(pd.mortgage);
|
||||
const amortEff = num(pd.amortization, num(context.inheritedValues.amortization));
|
||||
const hypEnde = Math.max(0, hypStart - amortEff * context.durationYears);
|
||||
const zinsStart = Math.round((hypStart * num(pd.interestRate)) / 100);
|
||||
|
||||
+66
-13
@@ -1000,9 +1000,14 @@ Erste Lebensphase anlegen
|
||||
setDistribute({ kind: "rates", phaseId: phase.id });
|
||||
}}
|
||||
onClose={() => setReviewPhaseId(null)}
|
||||
onSaved={() => {
|
||||
onSaved={(n) => {
|
||||
setReviewPhaseId(null);
|
||||
toast("success", "Annahmen bestätigt.");
|
||||
toast(
|
||||
"success",
|
||||
n === 0
|
||||
? "Gespeichert. Bestätigt wurde nichts – hake an, was du angeschaut hast."
|
||||
: `Gespeichert, ${n} ${n === 1 ? "Annahme" : "Annahmen"} bestätigt.`
|
||||
);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
@@ -2100,14 +2105,25 @@ function PhaseReviewDialog({
|
||||
ratesOpen: boolean;
|
||||
onDistributeRates: () => void;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onSaved: (confirmedCount: number) => void;
|
||||
}) {
|
||||
const [pds, setPds] = useState<Record<string, PhaseData>>(() =>
|
||||
Object.fromEntries(elements.map((e) => [e.id, { ...(e.phaseValues[phase.id] ?? {}) }]))
|
||||
);
|
||||
// Je Element ein Haken «Angeschaut», anfangs leer. Ein Sammel-Knopf, der ALLES bestätigt,
|
||||
// liesse sich drücken, ohne je gescrollt zu haben -- also genau das Durchwinken, das der
|
||||
// Mechanismus verhindern soll. Bestätigt wird deshalb nur, was hier angehakt ist.
|
||||
const [seen, setSeen] = useState<Record<string, boolean>>({});
|
||||
const seenCount = elements.filter((e) => seen[e.id]).length;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Wer ein Feld ändert, hat hingeschaut -- der Haken folgt der Bearbeitung von selbst.
|
||||
function patch(elementId: string, p: Partial<PhaseData>) {
|
||||
setPds((prev) => ({ ...prev, [elementId]: { ...prev[elementId], ...p } }));
|
||||
setSeen((prev) => ({ ...prev, [elementId]: true }));
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -2115,9 +2131,12 @@ function PhaseReviewDialog({
|
||||
for (const e of elements) {
|
||||
// `confirmed` steht NEBEN den Werten: Der Haken hält fest, dass jemand hingeschaut
|
||||
// hat. Er kopiert nichts -- was leer bleibt, erbt weiterhin aus der Vorphase.
|
||||
await api.put(`/api/elements/${e.id}/phase/${phase.id}`, { ...(pds[e.id] ?? {}), confirmed: true });
|
||||
// Geschrieben werden die Werte aller Zeilen; bestätigt nur die angehakten.
|
||||
const body: PhaseData = { ...(pds[e.id] ?? {}) };
|
||||
if (seen[e.id]) body.confirmed = true;
|
||||
await api.put(`/api/elements/${e.id}/phase/${phase.id}`, body);
|
||||
}
|
||||
onSaved();
|
||||
onSaved(seenCount);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
@@ -2129,8 +2148,8 @@ function PhaseReviewDialog({
|
||||
<DialogShell title={`Annahmen prüfen: ${phase.name}`} onClose={onClose} wide>
|
||||
<p className="text-sm text-muted">
|
||||
Diese Werte gelten für die {phase.durationYears} Jahre dieser Lebensphase. Sie sind aus der Vorphase
|
||||
übernommen – geh sie durch und passe an, was hier anders ist. Mit «Alle bestätigen» hältst du fest,
|
||||
dass du sie angeschaut hast; die Werte selbst bleiben unverändert und erben weiterhin.
|
||||
übernommen – geh sie durch und passe an, was hier anders ist. Hake an, was du angeschaut hast; die
|
||||
Werte selbst bleiben unverändert und erben weiterhin aus der Vorphase.
|
||||
</p>
|
||||
|
||||
{/* Die Verteilung zählt eigens: Man kann jede Zelle angeschaut und die Quote trotzdem nie
|
||||
@@ -2156,28 +2175,62 @@ function PhaseReviewDialog({
|
||||
In dieser Lebensphase gibt es keine offenen Annahmen mehr.
|
||||
</p>
|
||||
) : (
|
||||
elements.map((el) => (
|
||||
<div key={el.id} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<>
|
||||
{/* Der Sammel-Weg bleibt EIN Klick -- aber er ist ein sichtbarer Akt und keine
|
||||
Nebenwirkung des Speicherns. */}
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-muted">
|
||||
<span>
|
||||
{seenCount} von {elements.length} angeschaut
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSeen(Object.fromEntries(elements.map((e) => [e.id, seenCount < elements.length])))}
|
||||
className="rounded border border-accent px-2 py-0.5 font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
|
||||
>
|
||||
{seenCount < elements.length ? "Alle als angeschaut markieren" : "Alle Haken entfernen"}
|
||||
</button>
|
||||
</div>
|
||||
{elements.map((el) => (
|
||||
<div
|
||||
key={el.id}
|
||||
className={`rounded-xl border p-3 ${
|
||||
seen[el.id] ? "border-border bg-surface-2" : "border-attention bg-attention-soft/30"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-accent">{CATEGORY_ICON[el.category]}</span>
|
||||
<span className="text-sm font-semibold text-fg">{el.name}</span>
|
||||
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
|
||||
<label className="ml-auto flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!seen[el.id]}
|
||||
onChange={(e) => setSeen((prev) => ({ ...prev, [el.id]: e.target.checked }))}
|
||||
/>
|
||||
Angeschaut
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<ElementPhaseFields
|
||||
element={el}
|
||||
context={buildContext(el)}
|
||||
pd={pds[el.id] ?? {}}
|
||||
setP={(patch) => setPds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))}
|
||||
setP={(p) => patch(el.id, p)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<DialogActions saving={saving} onConfirm={saveAll} onClose={onClose} confirmLabel="Alle bestätigen" />
|
||||
<DialogActions
|
||||
saving={saving}
|
||||
onConfirm={saveAll}
|
||||
onClose={onClose}
|
||||
confirmLabel={seenCount > 0 ? `Speichern · ${seenCount} bestätigen` : "Speichern"}
|
||||
/>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// «Ein Wert, ein Ort» -- durchgesetzt im Rechenkern, nicht nur in der Oberfläche
|
||||
// (SPEZIFIKATION 3.14.3).
|
||||
//
|
||||
// Der Bestand bei Planbeginn gehört in die Stammdaten. In der ersten Phase stand dafür bis
|
||||
// 0.39 zusätzlich ein Eingabefeld; eine dort gespeicherte Zahl überschattete die Stammdaten
|
||||
// still. Seit 0.40 ist das Feld read-only -- womit so ein Altwert unerreichbar WÄRE, wenn er
|
||||
// weiterhin gewönne. Deshalb entscheidet die Regel der Rechenkern.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan, firstPhaseValues } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
function plan(elements: PlanInput["elements"]): PlanInput {
|
||||
return {
|
||||
id: "plan",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 0,
|
||||
initialCash: 0,
|
||||
startYear: 2026,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "P1", durationYears: 10, cashTransition: { mode: "NONE" } }],
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
function haus(phaseValues: Record<string, Record<string, unknown>>) {
|
||||
return [
|
||||
{
|
||||
id: "e1",
|
||||
category: "REAL_ESTATE" as never,
|
||||
name: "Haus",
|
||||
ownerRole: "HOUSEHOLD" as never,
|
||||
orderIndex: 1,
|
||||
baseData: { purchasePrice: 800000, mortgage: 500000 } as Record<string, number>,
|
||||
phaseValues: phaseValues as never,
|
||||
transitionValues: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("Bestandsfelder in der ersten Phase", () => {
|
||||
it("nimmt den Bestand aus den Stammdaten", () => {
|
||||
const c = computePlan(plan(haus({})));
|
||||
expect(c.phases[0].elements[0].startValue).toBe(300000);
|
||||
});
|
||||
|
||||
it("lässt sich von einem Phasenwert NICHT überschatten", () => {
|
||||
// Genau der Fall aus der Testrunde: In 0.39 schrieb das Speichern der Zelle eine 0 --
|
||||
// seither stand das Haus mit Startwert 0 da, ohne dass man es noch korrigieren konnte.
|
||||
const c = computePlan(plan(haus({ p1: { confirmed: true, purchasePrice: 0, mortgage: 0 } })));
|
||||
expect(c.phases[0].elements[0].startValue).toBe(300000);
|
||||
});
|
||||
|
||||
it("fällt auf den Phasenwert zurück, solange die Stammdaten leer sind", () => {
|
||||
// Ohne diesen Rückfall wären Pläne aus der Zeit vor den Stammdaten schlagartig auf 0.
|
||||
const els = haus({ p1: { purchasePrice: 600000, mortgage: 400000 } });
|
||||
els[0].baseData = {};
|
||||
expect(computePlan(plan(els)).phases[0].elements[0].startValue).toBe(200000);
|
||||
});
|
||||
|
||||
it("lässt die ANNAHMEN in der ersten Phase überschreibbar", () => {
|
||||
// Eine Rendite gilt für einen Zeitraum, kein Anfangsbestand -- sie darf je Phase abweichen.
|
||||
const merged = firstPhaseValues(
|
||||
{ startValue: 100000, expectedReturn: 3 },
|
||||
{ expectedReturn: 7, startValue: 0 }
|
||||
);
|
||||
expect(merged.expectedReturn).toBe(7);
|
||||
expect(merged.startValue).toBe(100000);
|
||||
});
|
||||
});
|
||||
+27
-1
@@ -455,6 +455,32 @@ function st(
|
||||
): TraceStep {
|
||||
return { label, formula, substituted, result, unit, note };
|
||||
}
|
||||
// Bestandsfelder: der Stand bei PLANBEGINN. Sie gehoeren ausschliesslich in die Stammdaten
|
||||
// (SPEZIFIKATION 3.14.3, «Ein Wert, ein Ort») -- ein Startwert ist nicht «phase-1-spezifisch»,
|
||||
// sondern schlicht der Stand am Anfang.
|
||||
const BASE_ONLY_KEYS = ["amount", "currentValue", "startValue", "purchasePrice", "mortgage"] as const;
|
||||
|
||||
// Die wirksamen Werte der ERSTEN Phase: Stammdaten als Wurzel, Phasenwerte darueber.
|
||||
//
|
||||
// Mit einer Ausnahme, und die ist der Grund, warum es diese Funktion gibt: Bei den
|
||||
// Bestandsfeldern gewinnen die STAMMDATEN, wo sie einen Wert tragen. Ein blosses
|
||||
// `{...bd, ...raw}` liess einen Phasenwert die Stammdaten ueberschatten -- und weil das Feld
|
||||
// in Phase 1 seit 0.40 read-only ist, waere so ein Wert danach unerreichbar: Eine in einer
|
||||
// aelteren Version versehentlich gespeicherte 0 haette den Kaufpreis fuer immer verdeckt.
|
||||
// Die Regel «ein Wert, ein Ort» steht damit im Rechenkern und nicht nur in der Oberflaeche.
|
||||
//
|
||||
// Der Rueckfall auf den Phasenwert bleibt fuer den Fall, dass die Stammdaten (noch) leer sind.
|
||||
// Die ANNAHMEN (Rendite, Zins, Wertsteigerung, Teuerung) bleiben in Phase 1 unveraendert
|
||||
// ueberschreibbar -- sie gelten fuer einen Zeitraum, nicht fuer den Anfangsbestand.
|
||||
export function firstPhaseValues(bd: PhaseData, raw: PhaseData): PhaseData {
|
||||
const merged: PhaseData = { ...bd, ...raw };
|
||||
for (const key of BASE_ONLY_KEYS) {
|
||||
const root = bd[key];
|
||||
if (typeof root === "number") (merged as Record<string, unknown>)[key] = root;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function pct(v: number): string {
|
||||
return `${Math.round(v * 1000) / 1000} %`;
|
||||
}
|
||||
@@ -666,7 +692,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
// Phase 1 nichts Besonderes mehr, sondern erbt schlicht von der Wurzel.
|
||||
const bd = e.baseData ?? {};
|
||||
const raw = e.phaseValues[phase.id] ?? {};
|
||||
const pd: PhaseData = isFirstPhase ? { ...bd, ...raw } : raw;
|
||||
const pd: PhaseData = isFirstPhase ? firstPhaseValues(bd, raw) : raw;
|
||||
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
|
||||
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user