Ein Wert, ein Ort: Startwerte und Raten read-only, Verteil-Dialoge als einziger Eingabeort
Deploy App / deploy (push) Successful in 1m13s
Deploy App / deploy (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user