Ein Wert, ein Ort: Startwerte und Raten read-only, Verteil-Dialoge als einziger Eingabeort
Deploy App / deploy (push) Successful in 1m13s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 21:17:39 +02:00
parent 5ff9bc0709
commit ce987b9978
8 changed files with 602 additions and 201 deletions
+181 -83
View File
@@ -4,7 +4,6 @@ import { useState } from "react";
import { Trash2 } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client";
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
@@ -73,6 +72,18 @@ export interface CellContext {
// Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C).
capitalFromTransfer: number;
capitalFromTransferSources: { name: string; amount: number }[];
// Stammdaten des Elements. In der ERSTEN Phase ist der Startwert genau das -- er wird dort
// nur noch ANGEZEIGT, denn sonst entstünde derselbe Betrag an zwei Orten (Kap. 3.14.3).
baseData: PhaseData;
// Einmalige Kapitalverwendungen, die am Übergang IN diese Phase beschlossen wurden. Sie
// liegen technisch an der Vorphase, gehören aber sichtbar hierher -- zusammen mit der
// Zusatzeinlage, die aus demselben Topf stammt.
oneOffAmortization: number;
oneOffRepayment: number;
// Absprünge. Ein Wert entsteht an genau EINEM Ort; angezeigt wird er überall, wo er wirkt.
onEditBase?: () => void;
onDistributeRates?: () => void;
onDistributeCapital?: () => void;
}
interface Props {
@@ -118,6 +129,51 @@ function DerivedField({ label, value, help }: { label: string; value: number; he
);
}
// Read-only Anzeige mit Absprung an den Ort, an dem der Wert ENTSTEHT.
//
// Seit 0.40 hat jede Zahl genau einen Entstehungsort: Bestände in den Stammdaten, jährliche
// Raten im Verteil-Dialog der Quote, einmalige Kapitalverwendungen im Kapital-Dialog. In der
// Zelle stehen sie trotzdem -- nur eben unveränderlich. Zwei Eingabefelder für dieselbe Zahl
// sind der Fehler, den dieser Umbau beseitigt: Man tippt in das eine und wundert sich, warum
// das andere gewinnt.
function LinkedField({
label,
value,
help,
actionLabel,
onAction,
suffix,
}: {
label: string;
value: number;
help?: string;
actionLabel: string;
onAction?: () => void;
// Zusatz hinter dem Betrag, z. B. "/Jahr".
suffix?: string;
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<div className="flex w-full items-center justify-between gap-2 rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
<span className="tabular-nums">
{formatChf(value)}
{suffix ? <span className="text-faint"> {suffix}</span> : null}
</span>
{onAction && (
<button
type="button"
onClick={onAction}
className="shrink-0 whitespace-nowrap rounded border border-accent px-1.5 py-0.5 text-[11px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
>
{actionLabel}
</button>
)}
</div>
</div>
);
}
// PK/3a-Bezugs-Entscheid im normalen Übergang (Vorbezug): Kein Bezug / Bezug (+ Betrag +
// Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst
// der Betrag nach Abzug der Steuer.
@@ -484,7 +540,48 @@ export function ElementPhaseFields({
);
};
const asPct = (v: number) => `${v} %`;
const asChf = (v: number) => formatChf(v);
// Der Startwert der ERSTEN Phase ist der Stammdatenwert -- hier nur angezeigt, geändert
// 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.
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])))}
actionLabel="Startwert bearbeiten"
onAction={context.onEditBase}
/>
);
// Jährliche Raten. Sie entstehen ausschliesslich im Verteil-Dialog der Spar- bzw.
// Verzehrquote: Nur dort sieht man, wie viel überhaupt zu verteilen ist, und dass die
// Summe aufgeht. Ein zweites Eingabefeld je Element hätte man auch über die Quote hinaus
// füllen können, ohne dass es jemand merkt.
const rate = (key: InheritableKey, label: string, help?: string) => (
<LinkedField
label={label}
help={help}
value={Math.round(num(pd[key], num(context.inheritedValues[key])))}
suffix="/Jahr"
actionLabel="Quote verteilen"
onAction={context.onDistributeRates}
/>
);
// Einmalige Verwendung des in dieser Phase verfügbaren Kapitals -- alle aus demselben Topf,
// deshalb auch alle im selben Dialog.
const capital = (label: string, value: number, help?: string) => (
<LinkedField
label={label}
help={help}
value={Math.round(value)}
actionLabel="Kapital verteilen"
onAction={context.onDistributeCapital}
/>
);
switch (element.category) {
case "INCOME":
@@ -492,7 +589,8 @@ 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 : 0;
const baseValue =
typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : num(bd.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);
@@ -503,12 +601,22 @@ export function ElementPhaseFields({
? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzügen (AHV/ALV, Pensionskasse, NBU) tatsächlich aufs Konto kommt nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Für die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurück."
: "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben nur zur Info."}
</p>
<MoneyField
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar." : undefined}
value={baseValue}
onChange={(v) => setP({ amount: v })}
/>
{carried ? (
// Ab Phase 2 bewusst änderbar: Teilzeit, Beförderung, Jobwechsel, anderer
// Lebensstandard -- das sind echte Entscheide dieser Phase.
<MoneyField
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
help="Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar."
value={baseValue}
onChange={(v) => setP({ amount: v })}
/>
) : (
start(
"amount",
isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)",
"In der ersten Lebensphase ist das der Wert aus der Bestandsaufnahme geändert wird er in der Spalte «Start»."
)
)}
<DerivedField
label={isIncome ? "≈ real (heutige Kaufkraft)" : "≈ nominal (in diesem Jahr)"}
value={otherValue}
@@ -646,23 +754,19 @@ export function ElementPhaseFields({
{carried ? (
<>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfügbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
onChange={(v) => setP({ additionalInvestment: v })}
/>
{capital(
"Zusatzeinlage aus Kapital (CHF)",
num(pd.additionalInvestment),
"Aufstockung aus dem verfügbaren Kapital dieser Phase."
)}
</>
) : (
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
start("currentValue", "Aktueller PK-Wert (CHF)")
)}
{inh(
{rate(
"annualContribution",
"Jährliche Einzahlung (CHF)",
(v, set) => <MoneyField label="Jährliche Einzahlung (CHF)" value={v} onChange={set} />,
asChf,
"Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
"Arbeitnehmer- und Arbeitgeberbeiträge. Stammen aus dem BRUTTOLOHN und schmälern die Sparquote nicht deshalb stehen sie im Verteil-Dialog in einem eigenen Block."
)}
{inh(
"expectedReturn",
@@ -677,40 +781,27 @@ export function ElementPhaseFields({
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
}
// «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c).
// Der Schalter steht seit 0.40 im Verteil-Dialog, weil er dort die Obergrenze der
// Einzahlung bestimmt -- getrennt von ihr wäre er eine Einstellung ohne sichtbare Wirkung.
const max3a = pd.selfEmployed3a ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL;
return (
<>
{carried ? (
<>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfügbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
onChange={(v) => setP({ additionalInvestment: v })}
/>
{capital(
"Zusatzeinlage aus Kapital (CHF)",
num(pd.additionalInvestment),
"Aufstockung aus dem verfügbaren Kapital dieser Phase."
)}
</>
) : (
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
start("currentValue", "Aktueller 3a-Wert (CHF)")
)}
<label className="col-span-2 flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={!!pd.selfEmployed3a}
onChange={(e) => setP({ selfEmployed3a: e.target.checked })}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
</label>
{inh(
{rate(
"annualContribution",
"Jährliche Einzahlung (CHF)",
(v, set) => (
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={max3a} onChange={set} />
),
asChf,
`Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
`Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote.`
)}
{inh(
"expectedReturn",
@@ -724,8 +815,9 @@ 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);
const hypEnde = Math.max(0, hypStart - num(pd.amortization) * context.durationYears);
const hypStart = carried ? context.derivedMortgage : num(pd.mortgage, num(bd.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);
const zinsEnde = Math.round((hypEnde * num(pd.interestRate)) / 100);
const handling = pd.interestHandling ?? "INCLUDED";
@@ -742,17 +834,21 @@ export function ElementPhaseFields({
</>
) : (
<>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
{start("purchasePrice", "Kaufpreis (CHF)")}
{start("mortgage", "Hypothek (CHF)")}
</>
)}
{inh(
{rate(
"amortization",
"Amortisation (CHF/Jahr)",
(v, set) => <MoneyField label="Amortisation (CHF/Jahr)" value={v} onChange={set} />,
asChf,
"Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
)}
{context.oneOffAmortization > 0 &&
capital(
"Sonderamortisation aus Kapital (CHF)",
context.oneOffAmortization,
"Einmaltilgung am Übergang in diese Phase, aus dem verfügbaren Kapital."
)}
{inh(
"interestRate",
"Hypothekarzins (%/Jahr)",
@@ -797,12 +893,11 @@ export function ElementPhaseFields({
{carried ? (
<>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzinvestition aus Kapital (CHF)"
help="Neuinvestition aus dem verfügbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
onChange={(v) => setP({ additionalInvestment: v })}
/>
{capital(
"Zusatzinvestition aus Kapital (CHF)",
num(pd.additionalInvestment),
"Neuinvestition aus dem verfügbaren Kapital dieser Phase."
)}
{/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier
erfasst, sondern dort als Quote entschieden (Punkt C). Kommt der Betrag aus
mehreren Bezügen (PK und 3a, ggf. beider Personen), wird er einzeln
@@ -831,7 +926,7 @@ export function ElementPhaseFields({
)}
</>
) : (
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
start("startValue", "Startwert (CHF)")
)}
{inh(
"expectedReturn",
@@ -839,18 +934,14 @@ export function ElementPhaseFields({
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct
)}
{inh(
{rate(
"annualContribution",
"Jährlicher Sparbeitrag (CHF)",
(v, set) => <MoneyField label="Jährlicher Sparbeitrag (CHF)" value={v} onChange={set} />,
asChf,
"Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
)}
{inh(
{rate(
"annualWithdrawal",
"Jährliche Bezugsrate (CHF)",
(v, set) => <MoneyField label="Jährliche Bezugsrate (CHF)" value={v} onChange={set} />,
asChf,
"Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
)}
</>
@@ -861,15 +952,15 @@ export function ElementPhaseFields({
{carried ? (
<DerivedField label="Restschuld (fortgeschrieben)" value={Math.abs(context.derivedStart)} help="Fortgeschriebene Restschuld aus der Vorphase." />
) : (
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)}
{inh(
"annualRepayment",
"Jährliche Tilgung (CHF)",
(v, set) => <MoneyField label="Jährliche Tilgung (CHF)" value={v} onChange={set} />,
asChf,
"Zählt gegen die Sparquote (max. die Sparquote)."
start("startValue", "Restschuld (CHF)")
)}
{rate("annualRepayment", "Jährliche Tilgung (CHF)", "Zählt gegen die Sparquote (max. die Sparquote).")}
{context.oneOffRepayment > 0 &&
capital(
"Sofort-Tilgung aus Kapital (CHF)",
context.oneOffRepayment,
"Einmalige Tilgung am Übergang in diese Phase, aus dem verfügbaren Kapital."
)}
</>
);
}
@@ -988,13 +1079,15 @@ export function ElementTransitionFields({
<NumberField label="Grundstückgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
</>
)}
{decision !== "SELL" && context.mortgageEnd > 0 && (
<MoneyField
label="Sonderamortisation (CHF)"
help={`Einmaltilgung der Hypothek aus dem Cash der nächsten Phase (z. B. aus einem 3a-Bezug). Senkt Restschuld und künftige Zinsen. Maximal ${formatChf(context.mortgageEnd)} (Resthypothek am Phasenende).`}
{/* Die Sonderamortisation stand bis 0.39 hier. Sie stammt aber aus dem Kapital, das
in der FOLGEphase verfügbar ist, und wird dort im Kapital-Dialog zusammen mit
allen anderen Verwendungen desselben Topfs entschieden. Zwei Eingaben für
denselben Franken -- hier und dort -- gab es damit nicht mehr. */}
{decision !== "SELL" && context.mortgageEnd > 0 && num(td.extraAmortization) > 0 && (
<DerivedField
label="Sonderamortisation aus Kapital (CHF)"
value={num(td.extraAmortization)}
max={context.mortgageEnd}
onChange={(v) => setT({ extraAmortization: v })}
help="Einmaltilgung aus dem Kapital der nächsten Phase. Geändert wird sie dort unter «Kapital verteilen»."
/>
)}
</>
@@ -1027,13 +1120,18 @@ export function ElementTransitionFields({
);
}
case "OTHER_DEBT":
return (
<MoneyField
label="Sofortige Tilgung (CHF)"
help="Wird sofort getilgt und vom verfügbaren Kapital der nächsten Phase abgezogen."
// Dito: Die Sofort-Tilgung zehrt vom Kapital der Folgephase und wird dort entschieden.
return num(td.immediateRepayment) > 0 ? (
<DerivedField
label="Sofort-Tilgung aus Kapital (CHF)"
value={num(td.immediateRepayment)}
onChange={(v) => setT({ immediateRepayment: v })}
help="Wird am Übergang getilgt, aus dem Kapital der nächsten Phase. Geändert wird sie dort unter «Kapital verteilen»."
/>
) : (
<p className="col-span-2 text-sm text-muted">
Am Übergang ist hier nichts zu entscheiden. Eine einmalige Tilgung aus dem verfügbaren Kapital
nimmst du in der nächsten Lebensphase unter «Kapital verteilen» vor.
</p>
);
}
}