Files
FPT/src/components/ElementDetail.tsx
T
admGitAICDS 52c48a9956
Deploy App / deploy (push) Successful in 1m10s
Modul-Review 2: Onboarding-Umbau, Tour, Assistenten-Redesign
Ergebnis der zweiten Test- und Review-Runde (Modul "Onboarding & Plan-Erstellung"):

Layout:
- Szenario-Ansicht neu geordnet: schlanke Funktions-Leiste oben (Historie,
  Tour, Neues Szenario, Rechenwege, CSV-Export, Loeschen, Diff-Badge), dann
  Naechste Schritte -> Grundprofil -> Zeitachse -> Anzeige -> Matrix
- Grafiken/Effektive Werte/Live-Sim/MC/Einflussfaktoren aus der Leiste
  entfernt (laufen ueber die eigenen Menuepunkte)
- Tour nach AppShell gehoben; startet neu bei JEDER Plan-Erstellung (F14)

Assistent:
- durchgehend Du-Form im Einzelmodus
- Schritt "Vorsorge & Vermoegen" nur noch Bestandswerte
- neuer Schritt "Sparen & Verteilen": Sparquote (Netto - Ausgaben) auf
  3a/Wertschriften/Amortisation/Tilgung verteilen, Rest bleibt Cash; PK
  separat mit Hinweis "vor Netto, reduziert Sparquote nicht"
- Immobilie: Wertsteigerung + Zins-in-Ausgaben-Schalter; Lohn 1% Default

Grosse Saeule 3a (Selbststaendige ohne PK):
- Schalter am 3a-Element + im Assistenten hebt Obergrenze 7258 -> 36288
- neue Konstante PILLAR_3A_MAX_SELF_EMPLOYED (2026-Wert zu verifizieren)
- neues Feld selfEmployed3a

Nebenbei: POST /plans liefert lesbare Fehlermeldung statt zod-Objekt.

Kein Eingriff in den Rechenkern; 267 Tests unveraendert. SPEZIFIKATION 0.28.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:57:56 +02:00

1194 lines
51 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 { InfoBubble } from "@/components/InfoBubble";
import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client";
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
// 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_PK_CONVERSION_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;
}
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;
}
// 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>
);
}
// 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,
// Roadmap Nr. 44, Punkt C: Nur am Pensions-Übergang gefragt -- dort kommt das Kapital
// (PK, 3a, Immobilienverkauf) auf einmal herein und will verwendet werden.
showCapitalUse = false,
capitalInflow = 0,
investTargets = [],
}: {
ct: CashTransitionData;
setC: (patch: Partial<CashTransitionData>) => void;
deflatorEnd: number; // Bestands-Deflator an der Phasengrenze
showCapitalUse?: boolean;
capitalInflow?: number;
investTargets?: { id: string; name: string }[];
}) {
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."
/>
</>
)}
{showCapitalUse && <CapitalUseFields ct={ct} setC={setC} inflow={capitalInflow} targets={investTargets} />}
</>
);
}
// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C).
//
// 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.
function CapitalUseFields({
ct,
setC,
inflow,
targets,
}: {
ct: CashTransitionData;
setC: (patch: Partial<CashTransitionData>) => void;
inflow: number;
targets: { id: string; name: string }[];
}) {
const amort = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct)));
const invest = Math.max(0, Math.min(100 - amort, num(ct.capitalUseInvestPct)));
const cash = Math.max(0, 100 - amort - invest);
return (
<>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<strong className="text-fg">Verwendung des Kapitalzuflusses</strong> (PK-Kapital, Säule 3a,
Verkaufserlös). Bei diesem Übergang fliessen{" "}
<strong className="text-fg">{formatChf(inflow)}</strong> netto herein. Erfasst wird die
Aufteilung in Prozent so bleibt sie richtig, wenn du das Pensionsalter verschiebst und
sich der Betrag dadurch ändert. Was nicht zugeteilt ist, bleibt Cash.
</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) => setC({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
/>
<NumberField
label="… in die Anlage (%)"
help="Fliesst als Zusatzinvestition in das gewählte Vermögens-Element und wächst dort mit dessen Rendite weiter."
step={5}
min={0}
max={100 - amort}
value={invest}
onChange={(v) => setC({ 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={ct.capitalUseTargetElementId ?? targets[0].id}
onChange={(v: string) => setC({ capitalUseTargetElementId: v })}
options={targets.map((t) => ({ value: t.id, label: t.name }))}
/>
</div>
)}
<DerivedField
label={`… bleibt Cash (${cash} %)`}
value={Math.round((inflow * 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} %`;
const asChf = (v: number) => formatChf(v);
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 = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
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>
<MoneyField
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar." : undefined}
value={baseValue}
onChange={(v) => setP({ amount: v })}
/>
<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) {
return (
<p className="col-span-2 text-sm text-muted">
Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollständig bezogen&quot;.
</p>
);
}
return (
<>
{carried ? (
<>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfügbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)}
{inh(
"annualContribution",
"Jährliche Einzahlung (CHF)",
(v, set) => <MoneyField label="Jährliche Einzahlung (CHF)" value={v} onChange={set} />,
asChf,
"Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
)}
{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 <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
}
// «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c).
const max3a = pd.selfEmployed3a ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL;
return (
<>
{carried ? (
<>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfügbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)}
<label className="col-span-2 flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={!!pd.selfEmployed3a}
onChange={(e) => setP({ selfEmployed3a: e.target.checked })}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
</label>
{inh(
"annualContribution",
"Jährliche Einzahlung (CHF)",
(v, set) => (
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={max3a} onChange={set} />
),
asChf,
`Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
)}
{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 : num(pd.mortgage);
const hypEnde = Math.max(0, hypStart - num(pd.amortization) * 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." />
</>
) : (
<>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
</>
)}
{inh(
"amortization",
"Amortisation (CHF/Jahr)",
(v, set) => <MoneyField label="Amortisation (CHF/Jahr)" value={v} onChange={set} />,
asChf,
"Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
)}
{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." />
<MoneyField
label="Zusatzinvestition aus Kapital (CHF)"
help="Neuinvestition aus dem verfügbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)}
{inh(
"expectedReturn",
"Erwartete Rendite (%/Jahr)",
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct
)}
{inh(
"annualContribution",
"Jährlicher Sparbeitrag (CHF)",
(v, set) => <MoneyField label="Jährlicher Sparbeitrag (CHF)" value={v} onChange={set} />,
asChf,
"Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
)}
{inh(
"annualWithdrawal",
"Jährliche Bezugsrate (CHF)",
(v, set) => <MoneyField label="Jährliche Bezugsrate (CHF)" value={v} onChange={set} />,
asChf,
"Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
)}
</>
);
case "OTHER_DEBT":
return (
<>
{carried ? (
<DerivedField label="Restschuld (fortgeschrieben)" value={Math.abs(context.derivedStart)} help="Fortgeschriebene Restschuld aus der Vorphase." />
) : (
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)}
{inh(
"annualRepayment",
"Jährliche Tilgung (CHF)",
(v, set) => <MoneyField label="Jährliche Tilgung (CHF)" value={v} onChange={set} />,
asChf,
"Zählt gegen die Sparquote (max. die Sparquote)."
)}
</>
);
}
}
export function ElementTransitionFields({
element,
context,
td,
setT,
}: {
element: { category: ElementCategory };
context: CellContext;
td: TransitionData;
setT: (patch: Partial<TransitionData>) => void;
}) {
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":
if (context.isRetirementTransition && context.ahvCareer) {
return <AhvReviewFields career={context.ahvCareer} td={td} setT={setT} />;
}
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) {
const mode = td.payoutMode ?? "PENSION";
return (
<>
<SelectField
label="Bezugsart bei Pensionierung"
value={mode}
onChange={(v: "CAPITAL" | "PENSION" | "COMBI") => setT({ payoutMode: v })}
options={[
{ value: "PENSION", label: "Rente" },
{ value: "CAPITAL", label: "Kapitalbezug" },
{ value: "COMBI", label: "Kombination" },
]}
/>
{(mode === "PENSION" || mode === "COMBI") && (
<NumberField
label="Umwandlungssatz (%)"
help="Jährliche Rente = verrentetes Kapital x Umwandlungssatz."
step={0.1}
value={num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)}
onChange={(v) => setT({ conversionRate: v })}
/>
)}
{(mode === "CAPITAL" || mode === "COMBI") && (
<NumberField label="Kapitalbezugssteuer (%)" step={0.5} value={num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)} onChange={(v) => setT({ capitalTaxRate: v })} />
)}
{mode === "COMBI" && (
<MoneyField
label="Davon Kapitalbezug (CHF)"
help={`Der Rest wird verrentet. Maximal ${formatChf(context.carriedEndValue)}.`}
value={num(td.capitalAmount)}
max={context.carriedEndValue}
onChange={(v) => setT({ capitalAmount: v })}
/>
)}
</>
);
}
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="PK-Bezug (CHF)" />;
case "PILLAR_3A":
if (context.isRetirementTransition) {
return (
<NumberField
label="Kapitalbezugssteuer (%)"
help="Die Säule 3a wird bei Pensionierung vollständig bezogen."
step={0.5}
value={num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
);
}
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 })} />
</>
)}
{decision !== "SELL" && context.mortgageEnd > 0 && (
<MoneyField
label="Sonderamortisation (CHF)"
help={`Einmaltilgung der Hypothek aus dem Cash der nächsten Phase (z. B. aus einem 3a-Bezug). Senkt Restschuld und künftige Zinsen. Maximal ${formatChf(context.mortgageEnd)} (Resthypothek am Phasenende).`}
value={num(td.extraAmortization)}
max={context.mortgageEnd}
onChange={(v) => setT({ extraAmortization: v })}
/>
)}
</>
);
}
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":
return (
<MoneyField
label="Sofortige Tilgung (CHF)"
help="Wird sofort getilgt und vom verfügbaren Kapital der nächsten Phase abgezogen."
value={num(td.immediateRepayment)}
onChange={(v) => setT({ immediateRepayment: v })}
/>
);
}
}
export function ElementDetail({
element,
context,
phaseData,
transitionData,
allPhases = [],
phaseDataByPhase = {},
onSaved,
onDeleteElement,
}: 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 {
const payload: PhaseData = { ...pd };
// 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">
<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>
);
}