Stammdaten gewinnen in Phase 1, Bestaetigung je Zeile statt pauschal
Deploy App / deploy (push) Successful in 1m10s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 21:41:07 +02:00
parent ce987b9978
commit f9d7ca07fd
5 changed files with 223 additions and 37 deletions
+16 -4
View File
@@ -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
View File
@@ -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>
);
}