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
+5 -1
View File
@@ -120,7 +120,11 @@ function AppShellInner({ username }: { username: string }) {
const autoTourCheckedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!detail || detail.plan.phases.length === 0) return;
// Bis 0.39 stand hier zusätzlich `plan.phases.length === 0`: Die alte Spotlight-Tour
// beleuchtete echte DOM-Ziele in der Matrix und hatte ohne Phasen nichts zu zeigen. Die
// Tour ist seit 0.36 eine Attrappe mit erfundenen Zahlen -- sie braucht nichts, und sie
// gehört genau an den Anfang, wo man noch gar nicht weiss, wie der Plan aufgebaut ist.
if (!detail) return;
const sid = detail.meta.id;
if (pendingTourRef.current) {
pendingTourRef.current = false;
+64 -8
View File
@@ -21,6 +21,7 @@ import { CarryWarning } from "@/components/ElementDetail";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import { computePlan } from "@/lib/calculations";
import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants";
import {
CATEGORY_LABELS,
inheritedPhaseValues,
@@ -368,8 +369,14 @@ interface RateTarget {
owner: string;
field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment";
label: string;
direction: "out" | "in";
// "out"/"in" = fliesst aus der bzw. in die Quote. "gross" = weder noch: der PK-Beitrag
// stammt aus dem BRUTTOLOHN und berührt das Cash-Konto nicht. Er steht seit 0.40 trotzdem
// hier, weil er sonst nirgends mehr erfassbar wäre -- aber in einem eigenen Block, damit
// niemand ihn für einen Teil der Quote hält.
direction: "out" | "in" | "gross";
hint: string;
// Obergrenze (Säule 3a). Ohne sie liesse sich ein steuerlich unzulässiger Betrag erfassen.
max?: number;
}
export function RateDistributionDialog({
@@ -387,6 +394,13 @@ export function RateDistributionDialog({
}) {
const phase = computed.phases.find((p) => p.id === phaseId)!;
// «Grosse Säule 3a» je Element: bestimmt die Obergrenze der Einzahlung. Der Schalter steht
// seit 0.40 hier statt in der Phasenzelle -- getrennt von dem Feld, dessen Grenze er
// festlegt, war er eine Einstellung ohne sichtbare Wirkung.
const [flags, setFlags] = useState<Record<string, boolean>>(() =>
Object.fromEntries(plan.elements.map((e) => [e.id, !!e.phaseValues[phaseId]?.selfEmployed3a]))
);
const targets = useMemo<RateTarget[]>(() => {
const out: RateTarget[] = [];
for (const e of plan.elements) {
@@ -396,13 +410,23 @@ export function RateDistributionDialog({
// Die Zeile hier trotzdem anzubieten hiess, den Entscheid aus dem Übergang zu
// widersprechen -- inklusive eines geerbten Betrags, der wirkungslos weiterläuft.
if (ce.acceptsCapital === false) continue;
// Die PK ist bewusst NICHT dabei: Ihr Beitrag stammt aus dem Bruttolohn und belastet
// das Cash-Konto nicht -- er lässt sich also gar nicht aus der Quote verteilen.
if (e.category === "PILLAR_3A") {
if (e.category === "PENSION_FUND") {
// Der PK-Beitrag ist NICHT Teil der Quote (er stammt aus dem Bruttolohn). Er steht in
// einem eigenen Block -- aber er steht hier, weil dies seit 0.40 der einzige Ort ist,
// an dem jährliche Beträge entstehen. Ohne ihn wäre er unerreichbar und fiele still
// auf 0, obwohl er das Endvermögen um sechsstellige Beträge bewegt. Eine bereits
// bezogene PK ist oben über `acceptsCapital` schon ausgeschieden.
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
label: "Jährliche Einzahlung", direction: "gross",
hint: "Arbeitnehmer- und Arbeitgeberbeiträge aus dem Bruttolohn. Sie sind im Nettolohn bereits abgezogen und schmälern die Sparquote deshalb NICHT.",
});
} else if (e.category === "PILLAR_3A") {
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
label: "Jährliche Einzahlung", direction: "out",
hint: "Fliesst jährlich vom Cash in die Säule 3a.",
max: flags[e.id] ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL,
});
} else if (e.category === "OTHER_ASSET") {
out.push({
@@ -431,7 +455,7 @@ export function RateDistributionDialog({
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase folgt phaseId
}, [plan.elements, phaseId]);
}, [plan.elements, phaseId, flags]);
const key = (t: RateTarget) => `${t.elementId}:${t.field}`;
@@ -479,6 +503,7 @@ export function RateDistributionDialog({
const e = plan.elements.find((x) => x.id === t.elementId)!;
const merged = byElement.get(t.elementId) ?? { ...(e.phaseValues[phaseId] ?? {}) };
(merged as Record<string, number>)[t.field] = draft[key(t)] ?? 0;
if (t.category === "PILLAR_3A") merged.selfEmployed3a = !!flags[t.elementId];
byElement.set(t.elementId, merged);
}
for (const [elementId, data] of byElement) {
@@ -535,9 +560,9 @@ export function RateDistributionDialog({
</p>
) : (
// Bewusst nach RICHTUNG gruppiert: erst alles, was das Cash verlässt, dann alles, was
// hineinfliesst. Gemischt war die Liste schwer zu lesen.
// hineinfliesst, zuletzt der Bruttolohn-Block. Gemischt war die Liste schwer zu lesen.
<div className="flex flex-col gap-4">
{(["out", "in"] as const).map((dir) => {
{(["out", "in", "gross"] as const).map((dir) => {
const rows = targets.filter((t) => t.direction === dir);
if (rows.length === 0) return null;
return (
@@ -547,8 +572,19 @@ export function RateDistributionDialog({
dir === "in" ? "text-success" : "text-muted"
}`}
>
{dir === "in" ? "Ins Cash (Entnahmen)" : "Vom Cash (Einzahlungen und Tilgungen)"}
{dir === "in"
? "Ins Cash (Entnahmen)"
: dir === "gross"
? "Aus dem Bruttolohn (ausserhalb der Quote)"
: "Vom Cash (Einzahlungen und Tilgungen)"}
</div>
{dir === "gross" && (
<p className="rounded-lg bg-surface-2 px-3 py-2 text-[11px] leading-relaxed text-muted">
Diese Beiträge sind im Nettolohn <strong className="text-fg">bereits abgezogen</strong>. Sie
schmälern die Sparquote nicht und tauchen in der Bilanz unten deshalb nicht auf sie stehen
hier, weil sie das Vorsorgekapital gleichwohl jährlich erhöhen.
</p>
)}
{rows.map((t) => (
<div key={key(t)} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
@@ -559,9 +595,29 @@ export function RateDistributionDialog({
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span>
<InfoBubble text={t.hint} />
</div>
{/* Das Häkchen steht ÜBER dem Betrag, weil es dessen Obergrenze bestimmt. */}
{t.category === "PILLAR_3A" && (
<label className="mb-2 flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={!!flags[t.elementId]}
onChange={(e) => setFlags((prev) => ({ ...prev, [t.elementId]: 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>
)}
<MoneyField
label={`${t.label} (CHF/Jahr)`}
help={
t.max !== undefined
? `Höchstens CHF ${t.max.toLocaleString("de-CH")} (2026) und höchstens die Sparquote.`
: undefined
}
value={draft[key(t)] ?? 0}
max={t.max}
onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))}
/>
</div>
+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>
);
}
}
+28 -4
View File
@@ -239,10 +239,15 @@ const KINDS: Kind[] = [
export interface InventoryDraft {
base: Record<string, PhaseData>;
names: Record<string, string>;
// Cash ist kein Element, sondern eine Eigenschaft des Szenarios (`Scenario.initialCash`) --
// deshalb kein Plus-Knopf, sondern ein festes Feld. Es fehlte bis 0.39 ganz: Die Summe
// «Vermögen heute» rechnete es bereits mit, erfassen konnte man es hier aber nirgends.
// `null` = unberührt (dann wird beim Speichern auch nichts geschrieben).
cash: number | null;
}
export function emptyInventoryDraft(): InventoryDraft {
return { base: {}, names: {} };
return { base: {}, names: {}, cash: null };
}
// Bestand eines Elements aus Stammdaten -- dieselbe Rechnung wie in der Kachel «Kennzahlen»,
@@ -286,15 +291,27 @@ export function InventoryFields({
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
const [page, setPage] = useState(0);
const scope = scopes[page];
const cash = draft.cash ?? Math.round(plan.initialCash || 0);
// Live-Summe aus dem ENTWURF: Sie soll sich beim Tippen bewegen, nicht erst beim Speichern.
const today = plan.elements.reduce(
(sum, e) => sum + wealthOf(e.category, draft.base[e.id] ?? e.baseData ?? {}),
plan.initialCash || 0
cash
);
return (
<div className="flex flex-col gap-4">
{/* Cash steht ausserhalb der Reiter: Es ist kein Element und gehört keiner Person --
es ist der Topf, aus dem alles fliesst und in den alles zurückfliesst. */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<MoneyField
label="Cash heute (Konto- und Sparguthaben)"
help="Alles, was sofort verfügbar ist. Aus diesem Topf zahlt FPT die Ausgaben; was du sparst und nicht zuteilst, bleibt hier liegen."
value={cash}
onChange={(x) => setDraft((d) => ({ ...d, cash: x }))}
/>
</div>
{scopes.length > 1 && (
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
{scopes.map((sc, i) => (
@@ -436,7 +453,7 @@ function BaseDataCard({
const names = { ...d.names };
delete base[el.id];
delete names[el.id];
return { base, names };
return { ...d, base, names };
});
onChanged();
}
@@ -481,6 +498,10 @@ export async function saveInventoryDraft(plan: PlanInput, draft: InventoryDraft)
const base = draft.base[el.id];
if (base) await api.put(`/api/elements/${el.id}/base`, base);
}
// Cash hängt am Szenario, nicht an einem Element -- eigener Endpunkt.
if (draft.cash !== null && draft.cash !== plan.initialCash) {
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: draft.cash });
}
}
@@ -508,7 +529,10 @@ export function InventoryDialog({
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const dirty = Object.keys(draft.base).length > 0 || Object.keys(draft.names).length > 0;
const dirty =
Object.keys(draft.base).length > 0 ||
Object.keys(draft.names).length > 0 ||
(draft.cash !== null && draft.cash !== plan.initialCash);
async function closeGuarded() {
if (!dirty) return onClose();
+189 -81
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
AlertTriangle,
Building2,
CheckCircle2,
ChevronDown,
@@ -205,30 +206,10 @@ export function PlanView({
const cellOpen = (elementId: string, phaseId: string) =>
unconfirmedCells(plan, computed, phaseId).includes(elementId);
// Alle Annahmen einer Phase auf einmal bestaetigen. Bei sechs Elementen und fuenf Phasen
// waeren es sonst dreissig einzelne Klicks.
async function confirmPhase(phaseId: string, phaseName: string) {
const ids = unconfirmedCells(plan, computed, phaseId);
if (ids.length === 0) return;
const ok = await confirmDialog({
title: "Annahmen bestätigen",
message:
`In «${phaseName}» sind ${ids.length} ${ids.length === 1 ? "Annahme" : "Annahmen"} noch nicht bestätigt. ` +
"Damit hältst du fest, dass du sie angeschaut hast die Werte selbst bleiben unverändert und erben " +
"weiterhin aus der Vorphase.",
confirmLabel: "Alle bestätigen",
});
if (!ok) return;
for (const id of ids) {
const el = plan.elements.find((x) => x.id === id)!;
await api.put(`/api/elements/${id}/phase/${phaseId}`, {
...(el.phaseValues[phaseId] ?? {}),
confirmed: true,
});
}
toast("success", "Annahmen bestätigt.");
onChanged();
}
// Die Maske mit ALLEN offenen Annahmen einer Phase -- das Gegenstück zum Übergangs-Review.
// Bis 0.39 fragte das Abzeichen nur "alles bestätigen?", ohne zu zeigen, worum es geht:
// eine Zustimmung zu etwas Ungesehenem, also genau das, was der Mechanismus verhindern soll.
const [reviewPhaseId, setReviewPhaseId] = useState<string | null>(null);
// Was ist in diesem Plan noch offen? Abgeleitet, nicht gespeichert -- deshalb immer aktuell.
const review = reviewPlan(plan, computed);
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
@@ -321,6 +302,10 @@ export function PlanView({
// Baut den Kontext für eine Phasenzelle.
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
const ce = computedElement(phase.id, element.id);
// Einmalige Kapitalverwendungen liegen technisch am Übergang DAVOR, gehören sichtbar aber
// in diese Phase: Sie zehren von dem Topf, der hier verteilt wird.
const prevPhase = computed.phases[computed.phases.findIndex((p) => p.id === phase.id) - 1];
const prevTd = prevPhase ? element.transitionValues[prevPhase.id] ?? {} : {};
const ownerWorking =
element.ownerRole && element.ownerRole !== "HOUSEHOLD"
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
@@ -347,6 +332,12 @@ export function PlanView({
investTargets: [],
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
capitalFromTransferSources: ce?.capitalFromTransferSources ?? [],
baseData: element.baseData ?? {},
oneOffAmortization: num(prevTd.extraAmortization),
oneOffRepayment: num(prevTd.immediateRepayment),
onEditBase: () => setPanel({ kind: "base", elementId: element.id }),
onDistributeRates: () => setDistribute({ kind: "rates", phaseId: phase.id }),
onDistributeCapital: prevPhase ? () => setDistribute({ kind: "capital", phaseId: phase.id }) : undefined,
};
}
@@ -375,6 +366,10 @@ export function PlanView({
investTargets: investTargetsOf(plan, toPhase),
capitalFromTransfer: 0,
capitalFromTransferSources: [],
baseData: element.baseData ?? {},
// Einmalige Kapitalverwendungen gehören in die FOLGEphase, nicht an den Übergang selbst.
oneOffAmortization: 0,
oneOffRepayment: 0,
};
}
@@ -414,7 +409,6 @@ export function PlanView({
}
const hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null;
return (
<div className="flex flex-col gap-5">
@@ -624,7 +618,7 @@ export function PlanView({
ratesOpen={
plan.phases.find((x) => x.id === col.phase.id)?.ratesConfirmed !== true
}
onConfirmAll={() => void confirmPhase(col.phase.id, col.phase.name)}
onConfirmAll={() => setReviewPhaseId(col.phase.id)}
active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
/>
) : (
@@ -665,7 +659,7 @@ Erste Lebensphase anlegen
diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
}`}
>
{formatChf(Math.round(plan.initialCash || 0))}
<ValueSingle value={Math.round(plan.initialCash || 0)} deflator={1} mode={valueMode} />
</td>
{!hasPhases && <td className="border-b border-border" />}
{columns.map((col) => {
@@ -681,11 +675,15 @@ Erste Lebensphase anlegen
isFirst && diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
}`}
>
<span className="whitespace-nowrap">
{valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "}
<span className="text-faint"></span>{" "}
{valStr(col.phase.cashEnd, col.phase.cumulativeInflationEnd, valueMode)}
</span>
{/* Dieselbe Darstellung wie bei allen anderen Zeilen: der Realwert
steht im Modus «Beide» darunter, nicht daneben. */}
<ValuePair
start={col.phase.cashStart}
end={col.phase.cashEnd}
deflatorStart={col.phase.cumulativeInflationStart}
deflatorEnd={col.phase.cumulativeInflationEnd}
mode={valueMode}
/>
</td>
) : (
(() => {
@@ -851,25 +849,33 @@ Erste Lebensphase anlegen
{columns.map((col) => {
if (col.kind === "phase") {
const ce = computedElement(col.phase.id, el.id);
const open = cellOpen(el.id, col.phase.id);
return (
<td
key={col.phase.id}
onClick={() => setPanel({ kind: "cell", elementId: el.id, phaseId: col.phase.id })}
title={
cellOpen(el.id, col.phase.id)
open
? "Annahmen dieser Phase noch nicht bestätigt"
: cellDiff(el.id, col.phase.id)
? "Weicht von der Vorlage ab"
: undefined
}
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
// Der dünne Ring aus 0.39 ging in einer vollen Matrix unter.
// Unbestätigte Zellen tragen deshalb jetzt dieselbe Sprache
// wie offene Übergänge: getönter Grund, kräftiger linker
// Balken, Warnzeichen.
className={`relative cursor-pointer border-b border-r border-border py-1.5 pl-3 pr-2 text-center text-xs ${
ce?.locked ? "text-faint" : "text-fg"
} ${
cellOpen(el.id, col.phase.id)
? "ring-1 ring-inset ring-attention/50"
open
? "border-l-[3px] border-l-attention bg-attention-soft ring-1 ring-inset ring-attention/40"
: ""
} ${cellDiff(el.id, col.phase.id)}`}
>
{open && (
<AlertTriangle className="absolute left-0.5 top-1 h-3 w-3 text-attention" />
)}
{phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))}
</td>
);
@@ -939,10 +945,11 @@ Erste Lebensphase anlegen
übrig bleibt (gleiches Muster wie zuvor bei den Dialogen). */}
{panel && renderPanel()}
{showAdd && firstPhase && (
{/* Braucht seit 0.40 keine Lebensphase mehr: Der Dialog erfasst Stammdaten, und die
hängen am Element. Ein Bestand lässt sich damit vor jeder Zeitplanung aufnehmen. */}
{showAdd && (
<AddElementDialog
plan={plan}
firstPhase={firstPhase}
initialCategory={addCategory}
onClose={() => {
setShowAdd(false);
@@ -977,6 +984,31 @@ Erste Lebensphase anlegen
/>
)}
{reviewPhaseId && (() => {
const phase = computed.phases.find((p) => p.id === reviewPhaseId);
if (!phase) return null;
const openIds = unconfirmedCells(plan, computed, phase.id);
const els = plan.elements.filter((e) => openIds.includes(e.id));
return (
<PhaseReviewDialog
phase={phase}
elements={els}
buildContext={(el) => buildPhaseContext(phase, el)}
ratesOpen={plan.phases.find((x) => x.id === phase.id)?.ratesConfirmed !== true}
onDistributeRates={() => {
setReviewPhaseId(null);
setDistribute({ kind: "rates", phaseId: phase.id });
}}
onClose={() => setReviewPhaseId(null)}
onSaved={() => {
setReviewPhaseId(null);
toast("success", "Annahmen bestätigt.");
onChanged();
}}
/>
);
})()}
{reviewFromPhaseId && (() => {
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
if (!fromPhase) return null;
@@ -1520,8 +1552,10 @@ function PhaseHeader({
</div>
{/* Verfügbares Kapital -- die Grösse, die man für die Planung DIESER Phase braucht.
Nur zeigen, wenn überhaupt etwas da ist. */}
{onDistributeCapital && pot.total > 0 && (
Bis 0.39 nur sichtbar, solange ein Rest übrig war. Seit die Beträge im Element
read-only sind, ist dieser Knopf der einzige Weg dorthin: Wer alles verteilt hätte,
käme an seine eigene Zuteilung nie mehr heran. Deshalb immer sichtbar. */}
{onDistributeCapital && (
<div className="rounded-md bg-surface-2 px-1.5 py-1">
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap">
<span className="font-medium text-fg">Verfügbares Kapital</span>
@@ -1646,13 +1680,11 @@ function FragmentRows({ children }: { children: React.ReactNode }) {
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
function AddElementDialog({
plan,
firstPhase,
initialCategory,
onClose,
onCreated,
}: {
plan: PlanInput;
firstPhase: PhaseComputed;
initialCategory?: ElementCategory | null;
onClose: () => void;
onCreated: () => void;
@@ -1689,36 +1721,6 @@ function AddElementDialog({
{ value: "PERSON_A", label: pLabel("PERSON_A") },
];
// Kontext für die Phase-1-Felder des neuen Elements.
const owner = needsPerson || ownerRole !== "HOUSEHOLD" ? ownerRole : null;
const ownerWorking =
owner && owner !== "HOUSEHOLD"
? firstPhase.persons.find((p) => p.role === owner)?.working ?? false
: firstPhase.type !== "PENSION";
const context: CellContext = {
kind: "phase",
phaseId: firstPhase.id,
ownerWorking,
isConsumption: firstPhase.isConsumption,
durationYears: firstPhase.durationYears,
isRetirementTransition: false,
carriedEndValue: 0,
carried: false,
derivedStart: 0,
derivedMortgage: 0,
mortgageEnd: 0,
propertyValueEnd: 0,
deflatorStart: firstPhase.cumulativeInflationStart,
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
ahvCareer: null,
// Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere.
inheritedValues: {},
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
investTargets: [],
capitalFromTransfer: 0,
capitalFromTransferSources: [],
};
async function create() {
setSaving(true);
setError(null);
@@ -1728,9 +1730,9 @@ function AddElementDialog({
name: name.trim() || CATEGORY_LABELS[category],
ownerRole,
});
// Ist-Zustand direkt in Phase 1 speichern (sofern Felder ausgefuellt).
// Der Bestand gehört an das ELEMENT, nicht in Phase 1 (Kap. 3.14.3).
if (Object.keys(pd).length > 0) {
await api.put(`/api/elements/${element.id}/phase/${firstPhase.id}`, pd);
await api.put(`/api/elements/${element.id}/base`, pd);
}
onCreated();
} catch (e) {
@@ -1788,16 +1790,19 @@ function AddElementDialog({
</div>
</div>
{/* Erfasst wird der BESTAND von heute -- dieselben Felder wie in der Bestandsaufnahme,
und er landet auch am selben Ort (den Stammdaten). Bis 0.39 schrieb dieser Dialog in
die Phase 1 und legte damit einen zweiten Ort für denselben Betrag an. Die Annahmen
(Renditen, Teuerung, Zins) folgen in der Phasenzelle, wo sie hingehören. */}
<div className="mt-1 border-t border-border pt-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Werte (erste Lebensphase)</div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Bestand heute</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ElementPhaseFields
element={{ category }}
context={context}
pd={pd}
setP={(patch) => setPd((prev) => ({ ...prev, ...patch }))}
/>
<BaseFields category={category} values={pd} set={(patch) => setPd((prev) => ({ ...prev, ...patch }))} />
</div>
<p className="mt-2 text-[11px] text-muted">
Renditen, Lohnentwicklung und Teuerung erfasst du danach in der Lebensphase sie sind Annahmen und
gelten immer nur für einen Zeitraum.
</p>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
@@ -2074,6 +2079,109 @@ function TransitionReviewDialog({
);
}
// --- Dialog: alle offenen Annahmen einer Lebensphase --------------------------------------
//
// Das Gegenstück zum Übergangs-Review. Bis 0.39 fragte das Abzeichen im Phasenkopf nur
// "N Annahmen bestätigen?" -- eine Zustimmung zu etwas, das man gar nicht sah. Das ist genau
// die Bewegung, die der Bestätigungs-Mechanismus verhindern soll. Hier stehen sie alle,
// bearbeitbar, mit einem Abschluss am Ende.
function PhaseReviewDialog({
phase,
elements,
buildContext,
ratesOpen,
onDistributeRates,
onClose,
onSaved,
}: {
phase: PhaseComputed;
elements: ElementInput[];
buildContext: (el: ElementInput) => CellContext;
ratesOpen: boolean;
onDistributeRates: () => void;
onClose: () => void;
onSaved: () => void;
}) {
const [pds, setPds] = useState<Record<string, PhaseData>>(() =>
Object.fromEntries(elements.map((e) => [e.id, { ...(e.phaseValues[phase.id] ?? {}) }]))
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function saveAll() {
setSaving(true);
setError(null);
try {
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 });
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<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.
</p>
{/* Die Verteilung zählt eigens: Man kann jede Zelle angeschaut und die Quote trotzdem nie
verteilt haben -- dann bliebe der ganze Überschuss still auf dem Cash-Konto. */}
{ratesOpen && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-attention bg-attention-soft p-3">
<span className="flex items-start gap-2 text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Die {phase.isConsumption ? "Verzehrquote" : "Sparquote"} dieser Phase ist noch nicht verteilt.
Solange sie das nicht ist, bleibt der ganze Betrag unverzinst auf dem Cash-Konto liegen.
</span>
</span>
<Button variant="ghost" onClick={onDistributeRates}>
Jetzt verteilen
</Button>
</div>
)}
<div className="flex flex-col gap-3">
{elements.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
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>
</div>
))
)}
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={saveAll} onClose={onClose} confirmLabel="Alle bestätigen" />
</DialogShell>
);
}
// --- Panel: Cash-Übergang (einmalige Sonderein-/ausgaben) ---
function CashTransitionPanel({
fromPhase,