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:
@@ -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);
|
||||
|
||||
+77
-24
@@ -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">
|
||||
<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>
|
||||
</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 } }))}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* 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={(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