Files
FPT/src/components/ElementDetail.tsx
T
admGitAICDS f41c3614b5
Deploy App / deploy (push) Successful in 1m10s
Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:29:42 +02:00

1334 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}