f41c3614b5
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1334 lines
57 KiB
TypeScript
1334 lines
57 KiB
TypeScript
"use client";
|
||
|
||
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 { formatChf } from "@/lib/format";
|
||
import { api } from "@/lib/api-client";
|
||
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
|
||
// werden hier nur weitergereicht, damit bestehende Importe unveraendert bleiben.
|
||
export {
|
||
withTransitionDefaults,
|
||
isTransitionAnswered,
|
||
withCashTransitionDefaults,
|
||
isCashTransitionAnswered,
|
||
cashTransitionSummary,
|
||
} from "@/lib/transitions";
|
||
import {
|
||
buildRateWrites,
|
||
changedRateFields,
|
||
scopeQuestion,
|
||
SCOPE_OPTIONS,
|
||
type PhaseRef,
|
||
type RateScope,
|
||
} from "@/lib/ratefields";
|
||
import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations";
|
||
import { CATEGORY_LABELS, num, type InheritableKey } from "@/lib/elements";
|
||
import {
|
||
AHV_REFERENCE_AGE,
|
||
DEFAULT_CAPITAL_TAX_RATE,
|
||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||
PILLAR_3A_MAX_ANNUAL,
|
||
PILLAR_3A_MAX_SELF_EMPLOYED,
|
||
} from "@/lib/constants";
|
||
import type {
|
||
CashTransitionData,
|
||
CashTransitionMode,
|
||
ElementCategory,
|
||
PhaseData,
|
||
TransitionData,
|
||
} from "@/lib/elements";
|
||
|
||
export interface CellContext {
|
||
kind: "phase" | "transition";
|
||
phaseId: string; // bei transition: die fromPhaseId
|
||
ownerWorking: boolean;
|
||
isConsumption: boolean;
|
||
durationYears: number;
|
||
isRetirementTransition: boolean;
|
||
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, für Bezugs-Maxima
|
||
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
|
||
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
|
||
derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn
|
||
mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (für die Sonderamortisation)
|
||
// nur Immobilie: modellierter VERKEHRSWERT am Phasenende (Eigenkapital + Resthypothek),
|
||
// inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert für den Verkaufspreis.
|
||
propertyValueEnd: number;
|
||
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
|
||
// Warnhinweis: Anzahl Phasen NACH dieser (Änderungen schreiben sich dorthin fort).
|
||
laterPhaseCount: number;
|
||
// AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang).
|
||
ahvCareer: AhvCareer | null;
|
||
// Werte, die in dieser Phase gelten würden, wenn das jeweilige Feld leer bleibt
|
||
// (Roadmap Nr. 44, Punkt A). Leer in der ersten Phase.
|
||
inheritedValues: Record<string, number>;
|
||
// Alter des Element-Besitzers im ersten Jahr dieser Phase. Entscheidet bei der AHV, ob
|
||
// das Referenzalter in diese Phase fällt (Kap. 4.4.6).
|
||
ownerAgeStart: number;
|
||
// Mögliche Ziele der Anlage-Quote beim Kapitalbezug (Punkt C): die in der FOLGEphase noch
|
||
// aktiven Vermögens-Elemente.
|
||
investTargets: { id: string; name: string }[];
|
||
// 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;
|
||
// Der TATSÄCHLICH gerechnete Stand dieser Zelle. In einer Phase, in der die Person schon
|
||
// pensioniert ist, stand hier bis 0.40 eine Regelbeschreibung im Futur («wird berechnet»,
|
||
// «wird bezogen»), obwohl der Bezug längst geschehen war. Was die Zelle braucht, ist der
|
||
// Zustand, nicht die Regel.
|
||
computedSummary: string;
|
||
computedNote: string | null;
|
||
computedValue: number;
|
||
// 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 {
|
||
element: { id: string; category: ElementCategory; name: string; ownerRole: string | null };
|
||
context: CellContext;
|
||
phaseData: PhaseData;
|
||
transitionData: TransitionData;
|
||
// Alle Phasen des Szenarios und die dort bereits erfassten Werte -- Grundlage für die
|
||
// Übernahme einer geänderten Rate auf weitere Phasen (siehe SPEZIFIKATION 3.6.11).
|
||
allPhases?: PhaseRef[];
|
||
phaseDataByPhase?: Record<string, PhaseData>;
|
||
onSaved: () => void;
|
||
onDeleteElement: () => void;
|
||
// In der Kartenansicht steht Name und Kategorie schon im Zeilenkopf -- dann waere die
|
||
// eigene Ueberschrift eine Wiederholung, und der Loeschen-Knopf sitzt dort im Menue.
|
||
compact?: boolean;
|
||
}
|
||
|
||
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
|
||
// fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Änderung hier wirkt also bis
|
||
// ans Planende durch, was ohne Hinweis leicht übersehen wird.
|
||
export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) {
|
||
if (laterPhaseCount <= 0) return null;
|
||
const phasen = laterPhaseCount === 1 ? "die nachfolgende Lebensphase" : `die ${laterPhaseCount} nachfolgenden Lebensphasen`;
|
||
return (
|
||
<div className="col-span-full flex items-start gap-2 rounded-lg border border-danger bg-danger-soft px-3 py-2 text-xs text-danger">
|
||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||
<span>
|
||
<strong>Achtung: Diese Lebensphase ist nicht die letzte.</strong> Werte werden fortgeschrieben
|
||
(Endwert = Startwert der Folgephase). Eine Änderung hier wirkt sich auf {phasen} aus und
|
||
verändert deren Startwerte, Kennzahlen sowie Endvermögen, Cash-Verlauf und Ruinalter.
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Read-only Anzeige eines abgeleiteten (fortgeschriebenen) Wertes.
|
||
function DerivedField({ label, value, help }: { label: string; value: number; help?: string }) {
|
||
return (
|
||
<div>
|
||
<FieldLabel label={label} help={help} />
|
||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||
{formatChf(value)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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.
|
||
function WithdrawalDecision({
|
||
td,
|
||
setT,
|
||
max,
|
||
label,
|
||
}: {
|
||
td: TransitionData;
|
||
setT: (patch: Partial<TransitionData>) => void;
|
||
max: number;
|
||
label: string;
|
||
}) {
|
||
const mode = td.withdrawalMode ?? "NONE";
|
||
const gross = num(td.withdrawal);
|
||
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
|
||
return (
|
||
<>
|
||
<SelectField
|
||
label="Bezug?"
|
||
value={mode}
|
||
onChange={(v: "NONE" | "AMOUNT") => setT(v === "NONE" ? { withdrawalMode: v, withdrawal: 0 } : { withdrawalMode: v })}
|
||
options={[
|
||
{ value: "NONE", label: "Kein Bezug" },
|
||
{ value: "AMOUNT", label: "Bezug" },
|
||
]}
|
||
/>
|
||
{mode === "AMOUNT" && (
|
||
<>
|
||
<MoneyField
|
||
label={label}
|
||
help={`Bruttobetrag, dem Kapital entnommen. Maximal ${formatChf(max)} (Endwert der Vorphase).`}
|
||
value={gross}
|
||
max={max}
|
||
onChange={(v) => setT({ withdrawal: v })}
|
||
/>
|
||
<NumberField
|
||
label="Kapitalbezugssteuer (%)"
|
||
help="Ein Vorbezug ist wie der Bezug bei Pensionierung kapitalbezugssteuerpflichtig."
|
||
step={0.5}
|
||
value={taxRate}
|
||
onChange={(v) => setT({ capitalTaxRate: v })}
|
||
/>
|
||
<DerivedField
|
||
label="Auszahlung netto (ins Cash)"
|
||
value={Math.round(gross * (1 - taxRate / 100))}
|
||
help="Bruttobezug abzüglich Kapitalbezugssteuer. Wird automatisch berechnet."
|
||
/>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default
|
||
// (Halten / Kein Bezug / Rente) auch tatsächlich persistiert.
|
||
// AHV-Prüfung am Pensions-Übergang: geplantes Durchschnittseinkommen aus dem Plan plus --
|
||
// nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurückreicht -- die Jahre davor.
|
||
export function AhvReviewFields({
|
||
career,
|
||
td,
|
||
setT,
|
||
}: {
|
||
career: AhvCareer;
|
||
td: TransitionData;
|
||
setT: (patch: Partial<TransitionData>) => void;
|
||
}) {
|
||
const brauchtVorgeschichte = career.yearsBeforePlan > 0;
|
||
// Vorbelegung mit dem geplanten Durchschnitt: besser als 0, wenn der Nutzer nichts weiss.
|
||
const avgBefore =
|
||
typeof td.avgIncomeBefore === "number" ? td.avgIncomeBefore : Math.round(career.plannedAvgGrossIncome);
|
||
const gapBefore = Math.max(0, Math.round(num(td.gapYearsBefore)));
|
||
const mdJE = ahvMdje(career, brauchtVorgeschichte ? avgBefore : 0, gapBefore);
|
||
const rente = ahvAnnualPension(mdJE, career.gapYearsInPlan + gapBefore);
|
||
|
||
return (
|
||
<>
|
||
<p className="col-span-full rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||
Die AHV-Rente hängt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "}
|
||
über die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der <strong className="text-fg">Bruttolohn</strong>;
|
||
das Tool rechnet die im Plan erfassten Nettöinkommen dafür automatisch hoch. Alle Beträge sind
|
||
REAL (heutige Kaufkraft) – die AHV wertet vergangene Einkommen auf und indexiert die Schwellen,
|
||
was sich real weitgehend aufhebt.
|
||
</p>
|
||
|
||
<DerivedField
|
||
label="Geplantes Bruttöinkommen (aus dem Plan)"
|
||
value={Math.round(career.plannedAvgGrossIncome)}
|
||
help={`Reales Mittel über ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans, hochgerechnet vom Netto- auf den Bruttolohn. Nur Einkommen, die dieser Person zugeordnet sind.`}
|
||
/>
|
||
<DerivedField
|
||
label="Beitragsjahre im Plan"
|
||
value={career.planYears}
|
||
help="Dauer der Erwerbsphasen abzüglich der dort erfassten Ausfalljahre."
|
||
/>
|
||
|
||
{brauchtVorgeschichte && (
|
||
<>
|
||
<p className="col-span-full rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted">
|
||
Der Plan beginnt erst im Alter {career.yearsBeforePlan + 21}. Die {career.yearsBeforePlan} Beitragsjahre
|
||
davor kennt das Tool nicht – bitte ergänzen.
|
||
</p>
|
||
<MoneyField
|
||
label="Durchschnittliches Bruttöinkommen vor Planbeginn (real)"
|
||
help="BRUTTO und in HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung übernehmen – dort ist die Aufwertung bereits enthalten. Die Rohbeträge im Kontoauszug sind historisch-nominal und daher zu tief."
|
||
value={avgBefore}
|
||
onChange={(v) => setT({ avgIncomeBefore: v })}
|
||
/>
|
||
<NumberField
|
||
label="Ausfalljahre vor Planbeginn"
|
||
help="Jahre ohne AHV-Beiträge zwischen Alter 21 und Planbeginn (z. B. Ausland, Studium). Jedes kürzt die Rente um 1/44."
|
||
value={gapBefore}
|
||
min={0}
|
||
max={career.yearsBeforePlan}
|
||
onChange={(v) => setT({ gapYearsBefore: Math.max(0, Math.min(career.yearsBeforePlan, Math.round(v))) })}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
<DerivedField
|
||
label="Massgebendes durchschnittliches Jahreseinkommen"
|
||
value={Math.round(mdJE)}
|
||
help="Gewichtetes Mittel über alle Beitragsjahre (vor Planbeginn und im Plan). Ab 90'720 gibt es die Maximalrente."
|
||
/>
|
||
<DerivedField
|
||
label="Resultierende AHV-Rente pro Jahr"
|
||
value={rente}
|
||
help="Vollrente zum mdJE (Skala 44), inkl. 13. Altersrente, gekürzt um die Ausfalljahre. Bei Ehepaaren zusätzlich plafoniert."
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Eingabefelder für den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den
|
||
// laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben).
|
||
export function CashTransitionFields({
|
||
ct,
|
||
setC,
|
||
deflatorEnd,
|
||
}: {
|
||
ct: CashTransitionData;
|
||
setC: (patch: Partial<CashTransitionData>) => void;
|
||
deflatorEnd: number; // Bestands-Deflator an der Phasengrenze
|
||
}) {
|
||
const mode = ct.mode ?? "NONE";
|
||
const showIn = mode === "INFLOW" || mode === "BOTH";
|
||
const showOut = mode === "OUTFLOW" || mode === "BOTH";
|
||
const d = deflatorEnd || 1;
|
||
const inflowGross = num(ct.inflowAmount);
|
||
const taxRate = num(ct.inflowTaxRate, 0);
|
||
const inflowNet = Math.round(inflowGross * (1 - taxRate / 100));
|
||
|
||
return (
|
||
<>
|
||
<div className="sm:col-span-2">
|
||
<SelectField
|
||
label="Entscheid für das Cash-Konto"
|
||
help="Was passiert beim Übergang in die nächste Lebensphase mit dem Cash-Bestand?"
|
||
value={mode}
|
||
onChange={(v: CashTransitionMode) =>
|
||
setC(
|
||
v === "NONE"
|
||
? { mode: v, inflowAmount: 0, outflowAmount: 0 }
|
||
: v === "INFLOW"
|
||
? { mode: v, outflowAmount: 0 }
|
||
: v === "OUTFLOW"
|
||
? { mode: v, inflowAmount: 0 }
|
||
: { mode: v }
|
||
)
|
||
}
|
||
options={[
|
||
{ value: "NONE", label: "1:1 übernehmen" },
|
||
{ value: "INFLOW", label: "Einmaliger Zufluss" },
|
||
{ value: "OUTFLOW", label: "Einmalige Kosten" },
|
||
{ value: "BOTH", label: "Zufluss und Kosten" },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
{showIn && (
|
||
<>
|
||
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||
<strong className="text-fg">Einmaliger Zufluss</strong> (z. B. Erbschaft). Wird NOMINAL erfasst –
|
||
der Betrag, der zu diesem Zeitpunkt tatsächlich aufs Konto kommt.
|
||
</div>
|
||
<TextField
|
||
label="Bezeichnung"
|
||
value={ct.inflowLabel ?? ""}
|
||
placeholder="z. B. Erbschaft"
|
||
onChange={(v) => setC({ inflowLabel: v })}
|
||
/>
|
||
<MoneyField
|
||
label="Betrag NOMINAL (CHF)"
|
||
value={inflowGross}
|
||
onChange={(v) => setC({ inflowAmount: v })}
|
||
/>
|
||
<NumberField
|
||
label="Steuer (%)"
|
||
help="Z. B. Erbschafts-/Schenkungssteuer. Direkte Nachkommen sind in den meisten Kantonen befreit – Default 0 %."
|
||
step={0.5}
|
||
value={taxRate}
|
||
onChange={(v) => setC({ inflowTaxRate: v })}
|
||
/>
|
||
<DerivedField
|
||
label="≈ real (heutige Kaufkraft)"
|
||
value={Math.round(inflowNet / d)}
|
||
help="Netto-Zufluss nach Steuer, zurückgerechnet auf die Kaufkraft bei Planbeginn. Nur zur Info."
|
||
/>
|
||
{taxRate > 0 && (
|
||
<DerivedField
|
||
label="Netto aufs Cash (nominal)"
|
||
value={inflowNet}
|
||
help="Betrag abzüglich Steuer. Wird automatisch berechnet."
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{showOut && (
|
||
<>
|
||
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||
<strong className="text-fg">Einmalige Kosten</strong> (z. B. Poolbau). Werden REAL erfasst –
|
||
in heutiger Kaufkraft. Die Inflation rechnet daraus automatisch den nominalen Betrag.
|
||
</div>
|
||
<TextField
|
||
label="Bezeichnung"
|
||
value={ct.outflowLabel ?? ""}
|
||
placeholder="z. B. Poolbau"
|
||
onChange={(v) => setC({ outflowLabel: v })}
|
||
/>
|
||
<MoneyField
|
||
label="Betrag REAL (CHF, heutige Kaufkraft)"
|
||
value={num(ct.outflowAmount)}
|
||
onChange={(v) => setC({ outflowAmount: v })}
|
||
/>
|
||
<DerivedField
|
||
label="≈ nominal (zum Zeitpunkt)"
|
||
value={Math.round(num(ct.outflowAmount) * d)}
|
||
help="Der Betrag, der zu diesem Zeitpunkt tatsächlich fällig ist. Nur zur Info."
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Verwendung des BEZOGENEN ALTERSKAPITALS (Roadmap Nr. 44, Punkt C).
|
||
//
|
||
// Steht am Bezugs-Entscheid des jeweiligen Guthabens (PK bzw. Säule 3a) -- bis 0.32 hing die
|
||
// Frage am Cash-Übergang, wo sie sich mit zwei Guthaben gar nicht getrennt beantworten liess.
|
||
//
|
||
// In PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich das bezogene
|
||
// Kapital. Ein Frankenbetrag müsste dann von Hand nachgezogen werden -- und würde bis dahin
|
||
// still eine falsche Aufteilung rechnen. Eine Quote skaliert mit.
|
||
export function CapitalUseFields({
|
||
td,
|
||
setT,
|
||
netAmount,
|
||
targets,
|
||
}: {
|
||
td: TransitionData;
|
||
setT: (patch: Partial<TransitionData>) => void;
|
||
// Netto-Auszahlung, auf die sich die Prozente beziehen.
|
||
netAmount: number;
|
||
targets: { id: string; name: string }[];
|
||
}) {
|
||
const amort = Math.max(0, Math.min(100, num(td.capitalUseAmortizationPct)));
|
||
const invest = Math.max(0, Math.min(100 - amort, num(td.capitalUseInvestPct)));
|
||
const cash = Math.max(0, 100 - amort - invest);
|
||
|
||
return (
|
||
<>
|
||
<div className="sm:col-span-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-muted">
|
||
<strong className="text-fg">Wohin fliesst das bezogene Kapital?</strong> Von den{" "}
|
||
<strong className="text-fg">{formatChf(netAmount)}</strong> netto teilst du hier in Prozent zu –
|
||
so bleibt die Aufteilung richtig, wenn sich der Betrag durch ein anderes Pensionsalter ändert.
|
||
Was nicht zugeteilt ist, bleibt auf dem Cash-Konto.
|
||
</div>
|
||
<NumberField
|
||
label="… in die Hypothek (Amortisation, %)"
|
||
help="Reduziert die Resthypothek der Immobilie einmalig. Am Restsaldo gekappt – ist die Hypothek kleiner, bleibt der Rest Cash."
|
||
step={5}
|
||
min={0}
|
||
max={100}
|
||
value={amort}
|
||
onChange={(v) => setT({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
|
||
/>
|
||
<NumberField
|
||
label="… in die Anlage (%)"
|
||
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element der Folgephase und wächst dort mit dessen Rendite weiter."
|
||
step={5}
|
||
min={0}
|
||
max={100 - amort}
|
||
value={invest}
|
||
onChange={(v) => setT({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
|
||
/>
|
||
{invest > 0 && targets.length > 0 && (
|
||
<div className="sm:col-span-2">
|
||
<SelectField
|
||
label="Ziel der Anlage-Quote"
|
||
help="Welches Vermögens-Element wird aufgestockt?"
|
||
value={td.capitalUseTargetElementId ?? targets[0].id}
|
||
onChange={(v: string) => setT({ capitalUseTargetElementId: v })}
|
||
options={targets.map((t) => ({ value: t.id, label: t.name }))}
|
||
/>
|
||
</div>
|
||
)}
|
||
{invest > 0 && (
|
||
<DerivedField
|
||
label="… in die Anlage (Betrag)"
|
||
value={Math.round((netAmount * invest) / 100)}
|
||
help="Erscheint in der Folgephase als Zusatzeinlage beim gewählten Element."
|
||
/>
|
||
)}
|
||
<DerivedField
|
||
label={`… bleibt Cash (${cash} %)`}
|
||
value={Math.round((netAmount * cash) / 100)}
|
||
help="Der nicht zugeteilte Rest. Wird automatisch berechnet."
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Übergangs-Review) ---
|
||
|
||
export function ElementPhaseFields({
|
||
element,
|
||
context,
|
||
pd,
|
||
setP,
|
||
}: {
|
||
element: { category: ElementCategory };
|
||
context: CellContext;
|
||
pd: PhaseData;
|
||
setP: (patch: Partial<PhaseData>) => void;
|
||
}) {
|
||
const carried = context.carried;
|
||
|
||
// Punkt A (Roadmap Nr. 44): Wiederkehr-Parameter werden aus der Vorphase übernommen,
|
||
// solange das Feld leer bleibt. `inh` baut die Hülle mit dem sichtbaren Häkchen.
|
||
const inh = (
|
||
key: InheritableKey,
|
||
label: string,
|
||
render: (value: number, set: (v: number) => void) => React.ReactNode,
|
||
format: (v: number) => string,
|
||
help?: string
|
||
) => {
|
||
const own = pd[key];
|
||
const fallback = context.inheritedValues[key] ?? 0;
|
||
const isInheriting = carried && typeof own !== "number";
|
||
return (
|
||
<InheritableField
|
||
label={label}
|
||
help={help}
|
||
canInherit={carried}
|
||
isInheriting={isInheriting}
|
||
inheritedDisplay={format(fallback)}
|
||
onToggle={(inherit) => setP({ [key]: inherit ? undefined : fallback } as Partial<PhaseData>)}
|
||
>
|
||
{render(typeof own === "number" ? own : fallback, (v) => setP({ [key]: v } as Partial<PhaseData>))}
|
||
</InheritableField>
|
||
);
|
||
};
|
||
const asPct = (v: number) => `${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.
|
||
// Dieselbe Vorrangregel wie im Rechenkern (`firstPhaseValues`): Wo die Stammdaten einen Wert
|
||
// tragen, gewinnen SIE -- ein Phasenwert darf sie nicht überschatten. Der Rückfall auf den
|
||
// Phasenwert bleibt nur, solange die Stammdaten leer sind.
|
||
const bd = context.baseData;
|
||
const start = (key: "amount" | "currentValue" | "startValue" | "purchasePrice" | "mortgage", label: string, help?: string) => (
|
||
<LinkedField
|
||
label={label}
|
||
help={help}
|
||
value={Math.round(typeof bd[key] === "number" ? (bd[key] as number) : num(pd[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":
|
||
case "EXPENSE": {
|
||
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 = carried
|
||
? typeof pd.amount === "number"
|
||
? pd.amount
|
||
: context.derivedStart
|
||
: typeof bd.amount === "number"
|
||
? bd.amount
|
||
: num(pd.amount);
|
||
const d = context.deflatorStart || 1;
|
||
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
|
||
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
|
||
return (
|
||
<>
|
||
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||
{isIncome
|
||
? "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>
|
||
{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}
|
||
help="Nur zur Info, wird automatisch berechnet."
|
||
/>
|
||
{inh(
|
||
"teuerungsausgleich",
|
||
isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)",
|
||
(v, set) => (
|
||
<NumberField
|
||
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
||
step={0.1}
|
||
value={v}
|
||
onChange={set}
|
||
/>
|
||
),
|
||
asPct,
|
||
isIncome
|
||
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
|
||
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
case "AHV": {
|
||
// Seit Roadmap Nr. 44 beginnt die AHV-Rente IMMER mit dem Referenzalter -- unabhängig
|
||
// davon, wann die Person aufhört zu arbeiten (Kap. 4.4.6). Daraus folgen zwei Fälle,
|
||
// die es vorher nicht gab und die hier sichtbar gemacht werden müssen:
|
||
// * Pensionierung VOR 65 -> beitragspflichtig als Nichterwerbstätige(r) bis 65.
|
||
// * Erwerbstätig ÜBER 65 -> die Rente fliesst trotzdem ab 65.
|
||
const ageStart = context.ownerAgeStart;
|
||
const ageEnd = ageStart + context.durationYears - 1;
|
||
const reachesReference = ageEnd >= AHV_REFERENCE_AGE;
|
||
const startsBeforeReference = ageStart < AHV_REFERENCE_AGE;
|
||
|
||
if (!context.ownerWorking) {
|
||
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang,
|
||
// an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase).
|
||
if (!carried && context.ahvCareer && context.ahvCareer.planYears === 0) {
|
||
return (
|
||
<>
|
||
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||
Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente hängt vom
|
||
massgebenden durchschnittlichen Jahreseinkommen über die ganze Beitragsdauer ab –
|
||
bitte hier erfassen (REAL, heutige Kaufkraft).
|
||
</p>
|
||
<MoneyField
|
||
label="Durchschnittliches Bruttöinkommen der Beitragsjahre (real)"
|
||
help="BRUTTO und in heutiger Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfügung. Ab 90'720 gibt es die Maximalrente."
|
||
value={num(pd.avgIncomeBefore)}
|
||
onChange={(v) => setP({ avgIncomeBefore: v })}
|
||
/>
|
||
<NumberField
|
||
label="Ausfalljahre total"
|
||
help="Jahre ohne AHV-Beiträge. Jedes kürzt die Rente um 1/44."
|
||
value={num(pd.gapYearsBefore)}
|
||
min={0}
|
||
onChange={(v) => setP({ gapYearsBefore: Math.max(0, Math.round(v)) })}
|
||
/>
|
||
<DerivedField
|
||
label="Resultierende AHV-Rente pro Jahr"
|
||
value={ahvAnnualPension(num(pd.avgIncomeBefore), num(pd.gapYearsBefore))}
|
||
help="Inkl. 13. Altersrente. Bei Ehepaaren zusätzlich plafoniert."
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
<p className="col-span-2 text-sm text-muted">
|
||
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
|
||
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
|
||
Plafonierung auf 150% der Maximalrente. Sie fliesst ab Alter {AHV_REFERENCE_AGE}
|
||
{reachesReference && startsBeforeReference
|
||
? ` – also erst im Verlauf dieser Lebensphase (Alter ${ageStart} bis ${ageEnd}).`
|
||
: "."}
|
||
</p>
|
||
{startsBeforeReference && (
|
||
<>
|
||
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||
<strong>Frühpensionierung:</strong> Wer vor Alter {AHV_REFERENCE_AGE} aufhört zu
|
||
arbeiten, bleibt bis dahin AHV-beitragspflichtig – als Nichterwerbstätige(r). Der
|
||
Beitrag ist eine laufende Ausgabe und fällt mit {AHV_REFERENCE_AGE} weg, wenn die
|
||
Rente einsetzt.
|
||
</p>
|
||
<MoneyField
|
||
label="AHV-Beitrag als Nichterwerbstätige(r) (CHF/Jahr)"
|
||
help={
|
||
"Der Beitrag bemisst sich am Vermögen und am Renteneinkommen, nicht am Lohn – die Bandbreite ist deshalb enorm: " +
|
||
"vom Mindestbeitrag von rund 530 CHF pro Jahr bis zum Höchstbeitrag von rund 26'500 CHF pro Jahr. " +
|
||
"Faustregel: Bis etwa 300'000 CHF Vermögen liegt man nahe am Minimum; ab rund 8.7 Mio. am Maximum. " +
|
||
"Verheiratete zahlen den Beitrag je einzeln, wenn beide nicht erwerbstätig sind; ist der Partner erwerbstätig " +
|
||
"und zahlt genug, entfällt er. Die AHV-Ausgleichskasse rechnet den Betrag auf Anfrage aus."
|
||
}
|
||
value={num(pd.ahvContribution)}
|
||
onChange={(v) => setP({ ahvContribution: v })}
|
||
/>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
<NumberField
|
||
label="Geplante Ausfalljahre"
|
||
help={`Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44. Nur Jahre VOR dem Referenzalter ${AHV_REFERENCE_AGE} zählen.`}
|
||
value={num(pd.gapYears)}
|
||
min={0}
|
||
max={context.durationYears}
|
||
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
|
||
/>
|
||
{reachesReference && (
|
||
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||
<strong>Weiterarbeiten über {AHV_REFERENCE_AGE} hinaus:</strong> Die AHV-Rente
|
||
beginnt trotzdem mit {AHV_REFERENCE_AGE} und fliesst neben dem Lohn. Ein Aufschub
|
||
der Rente wird nicht abgebildet. Erfasse deshalb nur die Ausfalljahre bis zum
|
||
Referenzalter.
|
||
</p>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
case "PENSION_FUND":
|
||
if (!context.ownerWorking) {
|
||
// Zustand statt Regel: entweder läuft eine Rente, oder das Guthaben ist bezogen.
|
||
const hasPension = context.computedValue > 0;
|
||
return (
|
||
<div className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted">
|
||
{hasPension ? (
|
||
<>
|
||
<strong className="text-fg">Rente: {formatChf(context.computedValue)} pro Jahr.</strong> Das
|
||
Guthaben wurde beim Übergang in die Pension verrentet – hier gibt es nichts mehr einzustellen.
|
||
Den Umwandlungssatz und den Kapitalanteil änderst du in der Übergangsspalte davor.
|
||
</>
|
||
) : (
|
||
<>
|
||
<strong className="text-fg">Vollständig bezogen.</strong> Das Guthaben wurde beim Übergang in die
|
||
Pension als Kapital ausbezahlt; wohin es geflossen ist, steht dort im Bezugs-Entscheid.
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
{carried ? (
|
||
<>
|
||
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
||
{capital(
|
||
"Zusatzeinlage aus Kapital (CHF)",
|
||
num(pd.additionalInvestment),
|
||
"Aufstockung aus dem verfügbaren Kapital dieser Phase."
|
||
)}
|
||
</>
|
||
) : (
|
||
start("currentValue", "Aktueller PK-Wert (CHF)")
|
||
)}
|
||
{rate(
|
||
"annualContribution",
|
||
"Jährliche Einzahlung (CHF)",
|
||
"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",
|
||
"Erwartete Rendite (%/Jahr)",
|
||
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||
asPct
|
||
)}
|
||
</>
|
||
);
|
||
case "PILLAR_3A": {
|
||
if (!context.ownerWorking) {
|
||
return (
|
||
<div className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted">
|
||
<strong className="text-fg">Vollständig bezogen.</strong> Ein 3a-Konto lässt sich nur ganz auflösen –
|
||
das ist beim Übergang geschehen. Bezugsalter, Steuersatz und die Verwendung des Kapitals stehen in der
|
||
Übergangsspalte davor.
|
||
</div>
|
||
);
|
||
}
|
||
// «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." />
|
||
{capital(
|
||
"Zusatzeinlage aus Kapital (CHF)",
|
||
num(pd.additionalInvestment),
|
||
"Aufstockung aus dem verfügbaren Kapital dieser Phase."
|
||
)}
|
||
</>
|
||
) : (
|
||
start("currentValue", "Aktueller 3a-Wert (CHF)")
|
||
)}
|
||
{rate(
|
||
"annualContribution",
|
||
"Jährliche Einzahlung (CHF)",
|
||
`Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote.`
|
||
)}
|
||
{inh(
|
||
"expectedReturn",
|
||
"Erwartete Rendite (%/Jahr)",
|
||
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||
asPct
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
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
|
||
: typeof bd.mortgage === "number"
|
||
? bd.mortgage
|
||
: num(pd.mortgage);
|
||
const amortEff = num(pd.amortization, num(context.inheritedValues.amortization));
|
||
const hypEnde = Math.max(0, hypStart - amortEff * context.durationYears);
|
||
const zinsStart = Math.round((hypStart * num(pd.interestRate)) / 100);
|
||
const zinsEnde = Math.round((hypEnde * num(pd.interestRate)) / 100);
|
||
const handling = pd.interestHandling ?? "INCLUDED";
|
||
return (
|
||
<>
|
||
{carried ? (
|
||
<>
|
||
<DerivedField
|
||
label="Startwert Netto (fortgeschrieben)"
|
||
value={context.derivedStart}
|
||
help="Verkehrswert (inkl. bisheriger Wertsteigerung) minus fortgeschriebene Resthypothek."
|
||
/>
|
||
<DerivedField label="Resthypothek (fortgeschrieben)" value={hypStart} help="Aus der Vorphase fortgeschrieben." />
|
||
</>
|
||
) : (
|
||
<>
|
||
{start("purchasePrice", "Kaufpreis (CHF)")}
|
||
{start("mortgage", "Hypothek (CHF)")}
|
||
</>
|
||
)}
|
||
{rate(
|
||
"amortization",
|
||
"Amortisation (CHF/Jahr)",
|
||
"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)",
|
||
(v, set) => <NumberField label="Hypothekarzins (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||
asPct,
|
||
"Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
|
||
)}
|
||
<div>
|
||
<FieldLabel
|
||
label="Hypothekarzins-Betrag (Beginn → Ende)"
|
||
help="Restschuld x Zinssatz, im ersten und im letzten Jahr dieser Phase. Wird automatisch berechnet."
|
||
/>
|
||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||
{formatChf(zinsStart)} <span className="text-faint">→</span> {formatChf(zinsEnde)}
|
||
</div>
|
||
</div>
|
||
{inh(
|
||
"valueGrowth",
|
||
"Geschätzte Wertsteigerung (%/Jahr)",
|
||
(v, set) => <NumberField label="Geschätzte Wertsteigerung (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||
asPct,
|
||
"Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr – bei 100'000 Eigenkapital also 10% darauf (Hebel)."
|
||
)}
|
||
<div className="sm:col-span-2">
|
||
<SelectField
|
||
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
|
||
help="Damit die Zinsen nicht doppelt zählen. Standard: bereits enthalten – so verhalten sich bestehende Pläne unverändert."
|
||
value={handling}
|
||
onChange={(v: "INCLUDED" | "ADD") => setP({ interestHandling: v })}
|
||
options={[
|
||
{ value: "INCLUDED", label: "Ja – bereits im Ausgaben-Element berücksichtigt" },
|
||
{ value: "ADD", label: "Nein – bitte zu den Ausgaben dazuzählen" },
|
||
]}
|
||
/>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
case "OTHER_ASSET":
|
||
return (
|
||
<>
|
||
{carried ? (
|
||
<>
|
||
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
||
{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
|
||
aufgeschlüsselt: sonst liesse sich nicht pruefen, ob wirklich alles
|
||
angekommen ist. */}
|
||
{context.capitalFromTransfer > 0 && (
|
||
<div>
|
||
<FieldLabel
|
||
label="Zusatzinvestition aus Kapitalbezug"
|
||
help="Beim letzten Übergang wurde ein Teil des bezogenen Alterskapitals hierher umgeleitet. Die Quote änderst du im Bezugs-Entscheid der Pensionskasse bzw. der Säule 3a."
|
||
/>
|
||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||
{formatChf(context.capitalFromTransfer)}
|
||
{context.capitalFromTransferSources.length > 0 && (
|
||
<div className="mt-1 flex flex-col gap-0.5 border-t border-border pt-1 text-[11px] text-faint">
|
||
{context.capitalFromTransferSources.map((src) => (
|
||
<div key={src.name} className="flex justify-between gap-3">
|
||
<span>aus {src.name}</span>
|
||
<span className="tabular-nums">{formatChf(src.amount)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
start("startValue", "Startwert (CHF)")
|
||
)}
|
||
{inh(
|
||
"expectedReturn",
|
||
"Erwartete Rendite (%/Jahr)",
|
||
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||
asPct
|
||
)}
|
||
{rate(
|
||
"annualContribution",
|
||
"Jährlicher Sparbeitrag (CHF)",
|
||
"Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
||
)}
|
||
{rate(
|
||
"annualWithdrawal",
|
||
"Jährliche Bezugsrate (CHF)",
|
||
"Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
|
||
)}
|
||
</>
|
||
);
|
||
case "OTHER_DEBT":
|
||
return (
|
||
<>
|
||
{carried ? (
|
||
<DerivedField label="Restschuld (fortgeschrieben)" value={Math.abs(context.derivedStart)} help="Fortgeschriebene Restschuld aus der Vorphase." />
|
||
) : (
|
||
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."
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
}
|
||
|
||
export function ElementTransitionFields({
|
||
element,
|
||
context,
|
||
td,
|
||
setT,
|
||
retirement,
|
||
}: {
|
||
element: { category: ElementCategory };
|
||
context: CellContext;
|
||
td: TransitionData;
|
||
setT: (patch: Partial<TransitionData>) => void;
|
||
// Der fertig gerenderte Pensionierungs-Baustein (AHV/PK/3a). Wird vom Aufrufer geliefert,
|
||
// damit dieses Modul nichts über den Pensionierungs-Bildschirm wissen muss.
|
||
retirement?: React.ReactNode;
|
||
}) {
|
||
switch (element.category) {
|
||
case "INCOME":
|
||
case "EXPENSE":
|
||
return (
|
||
<p className="col-span-2 text-sm text-muted">
|
||
Für diese Kategorie gibt es im Übergang keine Eingaben. Die Werte werden 1:1 in die nächste
|
||
Lebensphase übernommen und können dort angepasst werden.
|
||
</p>
|
||
);
|
||
case "AHV":
|
||
// Der Bezugs-Entscheid liegt seit 0.34 am Element und wird hier nur ANDERS ANGESCHAUT --
|
||
// es ist dieselbe Komponente wie im Pensionierungs-Bildschirm, kein Duplikat.
|
||
if (context.isRetirementTransition && retirement) {
|
||
return <div className="col-span-2">{retirement}</div>;
|
||
}
|
||
return (
|
||
<p className="col-span-2 text-sm text-muted">
|
||
Die Beitragskarriere wird erst beim Übergang in die Pensionierung geprüft.
|
||
</p>
|
||
);
|
||
case "PENSION_FUND": {
|
||
if (context.isRetirementTransition && retirement) {
|
||
return <div className="col-span-2">{retirement}</div>;
|
||
}
|
||
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="PK-Bezug (CHF)" />;
|
||
}
|
||
case "PILLAR_3A": {
|
||
if (context.isRetirementTransition && retirement) {
|
||
return <div className="col-span-2">{retirement}</div>;
|
||
}
|
||
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />;
|
||
}
|
||
case "REAL_ESTATE": {
|
||
const decision = td.decision ?? "HOLD";
|
||
const marktwert = Math.round(context.propertyValueEnd);
|
||
const preis = num(td.salePrice);
|
||
// Abweichung zwischen erfasstem Verkaufspreis und modelliertem Verkehrswert. Beide
|
||
// Grössen sind unabhängig erfassbar -- ohne diesen Vergleich könnte man 2 %
|
||
// Wertsteigerung annehmen und trotzdem zum Kaufpreis verkaufen, ohne es zu merken.
|
||
const abweichung = preis - marktwert;
|
||
const abweichungPct = marktwert > 0 ? (abweichung / marktwert) * 100 : 0;
|
||
const deutlich = marktwert > 0 && Math.abs(abweichungPct) >= 10;
|
||
return (
|
||
<>
|
||
<SelectField
|
||
label="Entscheidung"
|
||
value={decision === "SELL" ? "SELL" : "HOLD"}
|
||
onChange={(v: "HOLD" | "SELL") =>
|
||
// Beim Wechsel auf "Verkaufen" den Verkaufspreis mit dem modellierten
|
||
// Verkehrswert vorbelegen -- aber nur, wenn noch keiner erfasst ist.
|
||
setT(v === "SELL" && td.salePrice === undefined ? { decision: v, salePrice: marktwert } : { decision: v })
|
||
}
|
||
options={[
|
||
{ value: "HOLD", label: "Halten" },
|
||
{ value: "SELL", label: "Verkaufen" },
|
||
]}
|
||
/>
|
||
{decision === "SELL" && (
|
||
<>
|
||
<MoneyField
|
||
label="Verkaufspreis (CHF)"
|
||
help="Vorbelegt mit dem modellierten Verkehrswert am Phasenende. Bewusst änderbar – der erfasste Preis ist massgebend, nicht der modellierte Wert."
|
||
value={preis}
|
||
onChange={(v) => setT({ salePrice: v })}
|
||
/>
|
||
<DerivedField
|
||
label="Modellierter Verkehrswert (Phasenende)"
|
||
value={marktwert}
|
||
help="Kaufpreis zuzüglich der bis hierhin aufgelaufenen Wertsteigerung. Nur zum Vergleich – für den Erlös zählt der oben erfasste Verkaufspreis."
|
||
/>
|
||
{marktwert > 0 && (
|
||
<p
|
||
className={`col-span-full rounded-lg px-3 py-2 text-xs ${
|
||
deutlich ? "border border-danger bg-danger-soft text-danger" : "bg-surface-2 text-muted"
|
||
}`}
|
||
>
|
||
{Math.abs(abweichung) < 1 ? (
|
||
<>Verkaufspreis und modellierter Verkehrswert stimmen überein.</>
|
||
) : (
|
||
<>
|
||
Der Verkaufspreis liegt <strong>{formatChf(Math.abs(abweichung))} CHF</strong> (
|
||
{abweichung > 0 ? "+" : "−"}
|
||
{Math.abs(Math.round(abweichungPct * 10) / 10)} %) {abweichung > 0 ? "über" : "unter"} dem
|
||
modellierten Verkehrswert.
|
||
{deutlich && (
|
||
<>
|
||
{" "}
|
||
Das ist eine deutliche Abweichung – prüfe, ob sie gewollt ist oder ob die angenommene
|
||
Wertsteigerung nicht zum erwarteten Verkaufspreis passt.
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</p>
|
||
)}
|
||
<NumberField label="Grundstückgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
|
||
</>
|
||
)}
|
||
{/* 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)}
|
||
help="Einmaltilgung aus dem Kapital der nächsten Phase. Geändert wird sie dort unter «Kapital verteilen»."
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
case "OTHER_ASSET": {
|
||
const decision = td.decision ?? "HOLD";
|
||
return (
|
||
<>
|
||
<SelectField
|
||
label="Entscheidung"
|
||
value={decision}
|
||
onChange={(v: "HOLD" | "SELL" | "PARTIAL") => setT({ decision: v })}
|
||
options={[
|
||
{ value: "HOLD", label: "Halten" },
|
||
{ value: "SELL", label: "Verkaufen (ganz)" },
|
||
{ value: "PARTIAL", label: "Teilverkauf" },
|
||
]}
|
||
/>
|
||
{decision === "PARTIAL" && (
|
||
<MoneyField
|
||
label="Teilverkauf (CHF)"
|
||
help={`Betrag, der am Übergang ins Cash fliesst. Der Rest bleibt investiert. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
|
||
value={num(td.partialSaleAmount)}
|
||
max={context.carriedEndValue}
|
||
onChange={(v) => setT({ partialSaleAmount: v })}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
case "OTHER_DEBT":
|
||
// 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)}
|
||
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>
|
||
);
|
||
}
|
||
}
|
||
|
||
export function ElementDetail({
|
||
element,
|
||
context,
|
||
phaseData,
|
||
transitionData,
|
||
allPhases = [],
|
||
phaseDataByPhase = {},
|
||
onSaved,
|
||
onDeleteElement,
|
||
compact,
|
||
}: Props) {
|
||
const [pd, setPd] = useState<PhaseData>({ ...phaseData });
|
||
const [td, setTd] = useState<TransitionData>({ ...transitionData });
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
// Reichweite einer geänderten Rate. Vorgabe ist das bisherige Verhalten: nur diese Phase.
|
||
const [scope, setScope] = useState<RateScope>("THIS");
|
||
|
||
const isTransition = context.kind === "transition";
|
||
|
||
// Welche Ratenfelder hat der Nutzer verändert? Nur dann wird überhaupt gefragt.
|
||
const changedRates = isTransition ? [] : changedRateFields(element.category, phaseData, pd);
|
||
const showScope = changedRates.length > 0 && allPhases.length > 1;
|
||
|
||
async function save() {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
if (isTransition) {
|
||
await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td);
|
||
} else {
|
||
// Speichern heisst zugleich BESTAETIGEN: Wer die Zelle geoeffnet und gespeichert hat,
|
||
// hat hingeschaut. Der Haken steht neben den Werten und friert nichts ein -- leere
|
||
// Felder erben weiterhin live aus der Vorphase (SPEZIFIKATION 3.12.4).
|
||
const payload: PhaseData = { ...pd, confirmed: true };
|
||
// Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert
|
||
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Änderungen in
|
||
// früheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
|
||
if (
|
||
(element.category === "INCOME" || element.category === "EXPENSE") &&
|
||
context.carried &&
|
||
typeof payload.amount === "number" &&
|
||
payload.amount === Math.round(context.derivedStart)
|
||
) {
|
||
delete payload.amount;
|
||
}
|
||
await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, payload);
|
||
|
||
// Geänderte Raten auf die gewählten weiteren Phasen übertragen. Die bestehenden
|
||
// Werte der Zielphasen bleiben erhalten -- übernommen wird ausschliesslich die Rate.
|
||
// Alle Schreibvorgänge fallen in dieselbe Bearbeitungssitzung und ergeben daher
|
||
// EINE Nebenversion, nicht eine je Phase (siehe 3.8.1).
|
||
for (const w of buildRateWrites(
|
||
allPhases,
|
||
context.phaseId,
|
||
scope,
|
||
changedRates,
|
||
pd,
|
||
phaseDataByPhase
|
||
)) {
|
||
await api.put(`/api/elements/${element.id}/phase/${w.phaseId}`, w.data);
|
||
}
|
||
}
|
||
onSaved();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
function setP(patch: Partial<PhaseData>) {
|
||
setPd((prev) => ({ ...prev, ...patch }));
|
||
}
|
||
function setT(patch: Partial<TransitionData>) {
|
||
setTd((prev) => ({ ...prev, ...patch }));
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
{!compact && (
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div>
|
||
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
|
||
{CATEGORY_LABELS[element.category]}
|
||
{isTransition ? " · Übergang" : ""}
|
||
</div>
|
||
<div className="text-base font-semibold text-fg">{element.name}</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onDeleteElement}
|
||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" /> Element löschen
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */}
|
||
{!isTransition && <CarryWarning laterPhaseCount={context.laterPhaseCount} />}
|
||
{isTransition ? (
|
||
<ElementTransitionFields element={element} context={context} td={td} setT={setT} />
|
||
) : (
|
||
<ElementPhaseFields element={element} context={context} pd={pd} setP={setP} />
|
||
)}
|
||
</div>
|
||
|
||
{/* Rückfrage zur Reichweite. Erscheint erst, wenn eine Rate tatsächlich verändert
|
||
wurde -- und bewusst hier statt als Modal beim Tippen: Das Zahlenfeld löst bei
|
||
jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. */}
|
||
{showScope && (
|
||
<div className="rounded-xl border border-attention bg-attention-soft p-3">
|
||
<p className="mb-2 text-xs text-attention-soft-fg">
|
||
{scopeQuestion(changedRates, allPhases.length)}
|
||
</p>
|
||
<div className="inline-flex flex-wrap gap-0.5 rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||
{SCOPE_OPTIONS.map((o) => (
|
||
<button
|
||
key={o.value}
|
||
type="button"
|
||
onClick={() => setScope(o.value)}
|
||
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
|
||
scope === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||
}`}
|
||
>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="mt-1.5 text-[11px] text-attention-soft-fg/80">
|
||
{scope === "THIS"
|
||
? "Andere Phasen bleiben unverändert."
|
||
: `Wird beim Speichern in ${
|
||
buildRateWrites(allPhases, context.phaseId, scope, changedRates, pd, phaseDataByPhase).length
|
||
} weitere Phase(n) übertragen. Beträge und Raten dort bleiben erhalten – nur der geänderte Wert wird gesetzt.`}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{error && <p className="text-sm text-danger">{error}</p>}
|
||
<div>
|
||
<button
|
||
type="button"
|
||
disabled={saving}
|
||
onClick={save}
|
||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||
>
|
||
{saving ? "Speichern..." : "Speichern"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|