Memo 2026081701: Kapitalverwendung mit waehlbarem Tilgungsziel, AHV automatisch, Farb- und Textkorrekturen
Deploy App / deploy (push) Successful in 1m18s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:04:45 +02:00
parent f9d7ca07fd
commit 113616cc1f
9 changed files with 485 additions and 116 deletions
+30 -5
View File
@@ -75,6 +75,13 @@ export interface CellContext {
// 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.
@@ -750,11 +757,23 @@ export function ElementPhaseFields({
}
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 (
<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>
<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 (
@@ -786,7 +805,13 @@ export function ElementPhaseFields({
);
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>;
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
+32 -1
View File
@@ -481,7 +481,18 @@ function BaseDataCard({
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
/>
</div>
{kind?.baseFields(values, set)}
{kind ? (
kind.baseFields(values, set)
) : (
// Die AHV ist die einzige Kategorie ohne Bestand: Es gibt kein Guthaben zum
// Nachschlagen, nur eine Beitragskarriere. Eine leere Karte sähe nach einem Fehler
// aus -- deshalb steht hier, warum nichts zu tun ist.
<p className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Hier gibt es nichts zu erfassen. Die AHV-Rente entsteht aus deiner Beitragskarriere FPT leitet sie
aus den Einkommen dieses Plans ab. Beim Übergang in die Pension kannst du sie prüfen und um
Beitragslücken vor Planbeginn ergänzen.
</p>
)}
</div>
</div>
);
@@ -502,6 +513,26 @@ export async function saveInventoryDraft(plan: PlanInput, draft: InventoryDraft)
if (draft.cash !== null && draft.cash !== plan.initialCash) {
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: draft.cash });
}
// Die AHV entsteht am Schluss von selbst -- je Person eine.
//
// Sie hat als einzige Kategorie KEINEN Bestand: Es gibt kein Guthaben, das man nachschlagen
// könnte, nur eine Beitragskarriere. Ein Plus-Knopf mit einer leeren Karte darunter wäre
// deshalb sinnlos gewesen. Weglassen ist aber keine Option: In der Schweiz hat jede Person
// eine AHV, und ohne das Element rechnet der Plan ab der Pensionierung mit einer Rente von 0.
//
// IDEMPOTENT: Wer die Bestandsaufnahme später korrigiert und erneut abschliesst, bekommt
// kein zweites Element -- geprüft wird je Person, nicht global.
for (const person of plan.persons) {
const exists = plan.elements.some((e) => e.category === "AHV" && e.ownerRole === person.role);
if (exists) continue;
const name = person.name?.trim() ? `AHV ${person.name.trim()}` : "AHV";
await api.post(`/api/scenarios/${plan.id}/elements`, {
category: "AHV",
name,
ownerRole: person.role,
});
}
}
+76 -15
View File
@@ -333,6 +333,9 @@ export function PlanView({
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
capitalFromTransferSources: ce?.capitalFromTransferSources ?? [],
baseData: element.baseData ?? {},
computedSummary: ce?.summary ?? "",
computedNote: ce?.note ?? null,
computedValue: ce?.startValue ?? 0,
oneOffAmortization: num(prevTd.extraAmortization),
oneOffRepayment: num(prevTd.immediateRepayment),
onEditBase: () => setPanel({ kind: "base", elementId: element.id }),
@@ -367,6 +370,9 @@ export function PlanView({
capitalFromTransfer: 0,
capitalFromTransferSources: [],
baseData: element.baseData ?? {},
computedSummary: ce?.summary ?? "",
computedNote: ce?.note ?? null,
computedValue: ce?.startValue ?? 0,
// Einmalige Kapitalverwendungen gehören in die FOLGEphase, nicht an den Übergang selbst.
oneOffAmortization: 0,
oneOffRepayment: 0,
@@ -690,6 +696,11 @@ Erste Lebensphase anlegen
// Übergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben.
const ct = cashTransitionFor(col.fromPhase.id);
const open = !isCashTransitionAnswered(ct);
// Die Farbe folgt der RICHTUNG des Geldes, nicht dem Zustand der Zelle.
// Vorher trug jede beantwortete Zelle die Akzentfarbe -- im warmen Schema
// ein Orangerot, das eine Erbschaft wie einen Fehler aussehen liess.
const inflow = ct.mode === "INFLOW" || ct.mode === "BOTH";
const outflow = ct.mode === "OUTFLOW" || ct.mode === "BOTH";
return (
<td
key={`cash-t-${col.fromPhase.id}`}
@@ -700,7 +711,11 @@ Erste Lebensphase anlegen
? "bg-attention font-semibold text-attention-fg"
: diff?.cashTransitionCell.has(col.fromPhase.id)
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
: "bg-accent-soft/40 text-accent"
: inflow && !outflow
? "bg-success/10 font-semibold text-success"
: outflow
? "text-fg"
: "text-muted"
}`}
>
{cashTransitionSummary(ct)}
@@ -966,7 +981,7 @@ Erste Lebensphase anlegen
{showAddPhase && (
<AddPhaseDialog
maxDurationYears={nextPhaseCap()}
cap={nextPhaseCap()}
onClose={() => setShowAddPhase(false)}
onCreate={handleAddPhase}
/>
@@ -1140,13 +1155,22 @@ Erste Lebensphase anlegen
}
}
function nextPhaseCap(): number | null {
// Obergrenze der nächsten Phase UND ihr Grund. Die blosse Zahl («max. 12») beantwortet nicht,
// warum sie gilt -- und ohne diese Antwort wirkt die Sperre willkürlich.
function nextPhaseCap(): { years: number | null; reason: string | null } {
// Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern).
const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0);
const caps = plan.persons
.map((p) => p.retirementAge - (p.age + yearsBefore))
.filter((d) => d > 0);
return caps.length > 0 ? Math.min(...caps) : null;
.map((p) => ({ years: p.retirementAge - (p.age + yearsBefore), person: p }))
.filter((c) => c.years > 0)
.sort((a, b) => a.years - b.years);
if (caps.length === 0) return { years: null, reason: null };
const first = caps[0];
const name = first.person.name?.trim() || (first.person.role === "PERSON_B" ? "Person B" : "Person A");
return {
years: first.years,
reason: `${name} geht mit ${first.person.retirementAge} in Pension dort endet diese Phase zwingend.`,
};
}
// Inhalt des Inspector-Panels je nach Auswahl.
@@ -1458,6 +1482,11 @@ function PhaseHeader({
}) {
const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote";
const pot = capitalPot(phase);
// «Offen» heisst hier: Es ist Kapital da und noch nichts davon zugeteilt. Wer bewusst nur
// einen Teil verteilt und den Rest liegen lässt, hat entschieden -- das gilt als erledigt.
// Anders als bei der Quote gibt es dafür kein gespeichertes Kennzeichen; das Kapital ist
// aber auch keine Frage, die man beantworten MUSS, sondern eine Gelegenheit.
const capitalOpen = pot.total > 0 && pot.allocatedInvestments + pot.allocatedRepayments === 0;
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermögen)
const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote)
@@ -1576,15 +1605,27 @@ function PhaseHeader({
<span>Rest auf Cash</span>
<ValueSingle value={pot.rest} deflator={dS} mode={mode} />
</div>
{/* Grün, sobald nichts mehr offen ist: Der Knopf sagt damit auf einen Blick
«erledigt» statt bloss «hier kannst du klicken». Im warmen Schema war die
Akzentfarbe ein Orangerot und las sich wie eine Rüge. */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDistributeCapital();
}}
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
title={
capitalOpen
? "Noch nicht verteilt -- was du nicht zuteilst, bleibt auf dem Cash-Konto"
: "Verteilt"
}
className={`mt-1 w-full rounded border px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${
capitalOpen
? "border-attention bg-attention text-attention-fg hover:opacity-90"
: "border-success text-success hover:bg-success hover:text-white"
}`}
>
Kapital verteilen
Kapital verteilen{capitalOpen ? "" : " ✓"}
</button>
</div>
)}
@@ -1601,14 +1642,15 @@ function PhaseHeader({
e.stopPropagation();
onDistributeRates();
}}
title={ratesOpen ? "Noch nicht verteilt -- was du nicht zuteilst, bleibt auf dem Cash-Konto" : undefined}
className={`mt-1 w-full rounded px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${
title={ratesOpen ? "Noch nicht verteilt -- was du nicht zuteilst, bleibt auf dem Cash-Konto" : "Verteilt"}
className={`mt-1 w-full rounded border px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${
ratesOpen
? "border border-attention bg-attention text-attention-fg hover:opacity-90"
: "border border-accent text-accent hover:bg-accent hover:text-accent-fg"
? "border-attention bg-attention text-attention-fg hover:opacity-90"
: "border-success text-success hover:bg-success hover:text-white"
}`}
>
{phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
{ratesOpen ? "" : " ✓"}
</button>
</div>
@@ -1818,15 +1860,15 @@ function AddElementDialog({
// --- Dialog: neue Lebensphase ---
function AddPhaseDialog({
maxDurationYears,
cap: capInfo,
onClose,
onCreate,
}: {
maxDurationYears: number | null;
cap: { years: number | null; reason: string | null };
onClose: () => void;
onCreate: (payload: { name?: string; durationYears?: number }) => void;
}) {
const cap = maxDurationYears;
const cap = capInfo.years;
const [name, setName] = useState("");
const [durationYears, setDurationYears] = useState(cap ?? 10);
const [saving, setSaving] = useState(false);
@@ -1834,6 +1876,25 @@ function AddPhaseDialog({
return (
<DialogShell title="Neue Lebensphase" onClose={onClose}>
<div className="grid grid-cols-1 gap-3">
{/* Warum die Dauer begrenzt ist. Die Rechnung leitet Erwerbsstatus und Bezuege am
PHASENBEGINN ab -- faellt die Pensionierung mitten in eine Phase, rutscht sie auf
die naechste Grenze und die Zahlen waeren still falsch. */}
<div className="rounded-lg bg-surface-2 px-3 py-2 text-xs leading-relaxed text-muted">
{cap == null ? (
<>
Alle Personen sind bereits pensioniert diese Phase darf so lang sein, wie du willst. Der
Planungshorizont ergibt sich aus der Summe aller Lebensphasen.
</>
) : (
<>
Diese Phase kann <strong className="text-fg">höchstens {cap} {cap === 1 ? "Jahr" : "Jahre"}</strong>{" "}
dauern. {capInfo.reason} FPT bestimmt Erwerbsstatus und Renten immer am{" "}
<strong className="text-fg">Anfang</strong> einer Phase fiele die Pensionierung mitten hinein,
rutschte sie auf die nächste Phasengrenze und die Zahlen wären still falsch. Danach legst du einfach
die nächste Phase an.
</>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung (optional)</label>
<input
+144 -72
View File
@@ -60,6 +60,7 @@ export function RetirementFields({
if (el.category === "PILLAR_3A")
return (
<Pillar3aBlock
plan={plan}
el={el}
rd={rd}
patch={patch}
@@ -99,9 +100,12 @@ export function Pillar({
{open ? <ChevronDown className="h-4 w-4 text-faint" /> : <ChevronRight className="h-4 w-4 text-faint" />}
<span className="text-sm font-semibold text-fg">{title}</span>
<span className="flex-1 truncate text-xs text-muted">{subtitle}</span>
{/* Dieselbe Vokabel wie bei den Phasenzellen (SPEZIFIKATION 3.14.2): «angeschaut».
Es ist derselbe Gedanke -- dort auf den Werten der Vorphase, hier auf einer Vorgabe
des Tools -- und zwei Wörter für eine Sache haben nur verwirrt. */}
{confirmed ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success">
<Check className="h-3.5 w-3.5" /> bestätigt
<Check className="h-3.5 w-3.5" /> angeschaut
</span>
) : (
<span className="flex items-center gap-1 whitespace-nowrap rounded-full border border-border px-2 py-0.5 text-[11px] text-muted">
@@ -112,7 +116,7 @@ export function Pillar({
{open && (
<div className="border-t border-border px-3 py-3">
<div className="grid gap-3 sm:grid-cols-2">{children}</div>
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-muted">
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<input
type="checkbox"
checked={confirmed}
@@ -120,8 +124,11 @@ export function Pillar({
className="mt-0.5"
/>
<span>
Ich habe das angeschaut und bestätige es. Ohne Häkchen rechnet das Tool mit der Vorgabe sie wird als
«ungeprüft» ausgewiesen, damit sie nicht unbemerkt durchgeht.
<strong className="text-fg">Angeschaut.</strong> FPT muss hier etwas rechnen, auch bevor du entschieden
hast deshalb gibt es eine Vorgabe (volle Rente, Bezug im Pensionsjahr). Der Haken hält fest, dass du
sie gesehen und für richtig befunden hast. <strong className="text-fg">Er ändert keine Zahl.</strong>{" "}
Ohne ihn rechnet FPT genauso weiter, weist die Vorgabe aber als «ungeprüft» aus, damit sie nicht
unbemerkt durchgeht.
</span>
</label>
</div>
@@ -232,7 +239,6 @@ export function PkBlock({
defaultOpen?: boolean;
}) {
const share = rd.capitalSharePct ?? 0;
const targets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
return (
<Pillar
@@ -261,7 +267,12 @@ export function PkBlock({
step={5}
value={share}
onChange={(e) => patch(el.id, { capitalSharePct: Number(e.target.value) })}
className="h-2 flex-1 cursor-pointer appearance-none rounded-full bg-surface-2 accent-[var(--accent)]"
// KEIN appearance-none: Damit zeichnet der Browser die Schiene nicht mehr selbst,
// und accent-color greift nicht -- uebrig blieb der blosse Knopf. Im warmen Schema
// war die Schiene dadurch unsichtbar.
// Gleiche Schreibweise wie der Regler der Live-Simulation, der immer funktioniert
// hat: keine erzwungene Hoehe, kein appearance-none.
className="flex-1 cursor-pointer accent-[var(--accent)]"
/>
<span className="w-28 whitespace-nowrap text-right text-sm tabular-nums text-fg">
{share} % Kapital
@@ -273,15 +284,19 @@ export function PkBlock({
</div>
</div>
<NumberField
label="Umwandlungssatz (%)"
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
value={rd.conversionRate ?? 0}
step={0.1}
min={0}
max={20}
onChange={(v) => patch(el.id, { conversionRate: v })}
/>
{/* Bei 100 % Kapital wird nichts verrentet -- dann ist der Satz gegenstandslos, genau wie
die Kapitalbezugssteuer bei 0 % Kapital. */}
{share < 100 && (
<NumberField
label="Umwandlungssatz (%)"
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
value={rd.conversionRate ?? 0}
step={0.1}
min={0}
max={20}
onChange={(v) => patch(el.id, { conversionRate: v })}
/>
)}
{share > 0 && (
<NumberField
label="Kapitalbezugssteuer (%)"
@@ -309,7 +324,9 @@ export function PkBlock({
</span>
</label>
{rd.recentBuyIn && (
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention/10 px-3 py-2 text-xs text-attention-fg">
// attention-fg ist WEISS -- gedacht fuer vollflaechigen Attention-Grund. Auf
// 10 % Deckkraft war der Text im warmen und im hellen Schema unlesbar.
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein Kapitalbezug innerhalb von {PK_BUYIN_BLOCKING_YEARS} Jahren nach einem Einkauf lässt den
Steuerabzug für diesen Einkauf nachträglich entfallen (Art. 79b Abs. 3 BVG). Das Tool rechnet diesen
@@ -317,7 +334,7 @@ export function PkBlock({
</p>
)}
</div>
<CapitalUse rd={rd} elementId={el.id} patch={patch} targets={targets} plan={plan} />
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
</>
)}
</Pillar>
@@ -325,6 +342,7 @@ export function PkBlock({
}
export function Pillar3aBlock({
plan,
el,
rd,
patch,
@@ -332,6 +350,7 @@ export function Pillar3aBlock({
siblings,
defaultOpen,
}: {
plan: PlanInput;
el: ElementInput;
rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
@@ -379,90 +398,143 @@ export function Pillar3aBlock({
)}
</div>
{clash && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt ein
anderes Bezugsjahr senkt die Progression.
</p>
)}
{/* Die Frage stellt sich hier sogar zwingender als bei der PK: Ein 3a-Konto wird IMMER
vollständig bezogen, es fliesst also in jedem Fall ein grosser Betrag. Bis 0.40 gab
es die Maske nur bei der Pensionskasse -- das 3a-Kapital landete stumm auf dem Cash. */}
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
</Pillar>
);
}
// Eine Zeile der Kapitalverwendung: Quote plus, sobald sie > 0 ist, das Ziel. Eigene Komponente
// auf Modulebene -- innerhalb von CapitalUse deklariert wuerde sie bei jedem Tastendruck neu
// erzeugt und verloere ihren Zustand (der React Compiler weist das zu Recht zurueck).
function CapitalUseRow({
title,
help,
value,
max,
onChange,
targets,
targetValue,
onTarget,
targetLabel,
missing,
label,
}: {
title: string;
help: string;
value: number;
max: number;
onChange: (v: number) => void;
targets: ElementInput[];
targetValue?: string;
onTarget: (id: string) => void;
targetLabel: string;
missing: string;
label: (e: ElementInput) => string;
}) {
return (
<div className="rounded-lg border border-border p-3">
<div className="grid gap-3 sm:grid-cols-2">
<NumberField label={title} help={help} value={value} step={5} min={0} max={max} onChange={onChange} />
{value > 0 && targets.length > 0 && (
<SelectField
label={targetLabel}
value={targetValue ?? targets[0].id}
onChange={(v: string) => onTarget(v)}
options={targets.map((t) => ({ value: t.id, label: label(t) }))}
/>
)}
{value > 0 && targets.length === 0 && (
<p className="flex items-start gap-2 self-end text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{missing}
</p>
)}
</div>
</div>
);
}
// Verwendung des bezogenen Kapitals (Punkt C). Dieselbe Frage wie in der Matrix-Zelle -- hier
// nur an dem Ort, an dem man ohnehin über den Bezug nachdenkt.
//
// Drei gleichrangige Zeilen: Schuldentilgung, Investition, Cash. Cash ist rechnerisch der
// REST, wird aber wie eine Wahl dargestellt und nicht wie eine Fussnote -- «ich lasse es
// liegen und entscheide später» ist ein legitimer Entscheid. Alle drei frei eintippbar zu
// machen hiesse, dass beim Tippen still eine der anderen Zahlen wandert; das überrascht mehr,
// als es hilft.
function CapitalUse({
rd,
elementId,
patch,
targets,
plan,
}: {
rd: RetirementDecision;
elementId: string;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
targets: ElementInput[];
plan: PlanInput;
}) {
const amort = Math.max(0, Math.min(100, rd.capitalUseAmortizationPct ?? 0));
const invest = Math.max(0, Math.min(100 - amort, rd.capitalUseInvestPct ?? 0));
const cash = Math.max(0, 100 - amort - invest);
const hasMortgage = plan.elements.some((e) => e.category === "REAL_ESTATE");
// Tilgungs-Ziele: Hypotheken UND sonstige Schulden. Wer einen Konsumkredit zu 6 % neben
// einer Hypothek zu 1,5 % trägt, tilgt zuerst den Kredit.
const debtTargets = plan.elements.filter((e) => e.category === "REAL_ESTATE" || e.category === "OTHER_DEBT");
const investTargets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
const label = (e: ElementInput) => `${e.name} · ${ownerLabel(plan.persons, e.ownerRole)}`;
return (
<>
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst das bezogene Kapital?
</p>
<div className="grid gap-3 sm:grid-cols-2">
<NumberField
label="… in die Hypothek (%)"
help={
hasMortgage
? "Einmalige Amortisation. Am Restsaldo gekappt ist die Hypothek kleiner, bleibt der Rest Cash."
: "Es gibt keine Immobilie in diesem Szenario dieser Anteil bliebe wirkungslos auf dem Cash."
}
value={amort}
step={5}
min={0}
max={100}
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
/>
<NumberField
label="… in die Anlage (%)"
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
value={invest}
step={5}
min={0}
max={100 - amort}
onChange={(v) => patch(elementId, { 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"
value={rd.capitalUseTargetElementId ?? targets[0].id}
onChange={(v: string) => patch(elementId, { capitalUseTargetElementId: v })}
options={targets.map((t) => ({
value: t.id,
label: `${t.name} · ${ownerLabel(plan.persons, t.ownerRole)}`,
}))}
/>
</div>
)}
{invest > 0 && targets.length === 0 && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Es gibt kein Element «Sonstiges Vermögen», in das die Anlage-Quote fliessen könnte. Der Betrag bliebe auf
dem Cash-Konto liegen.
</p>
)}
<p className="sm:col-span-2 text-xs text-faint">
Nicht zugeteilt: <strong className="text-fg">{cash} %</strong> bleibt auf dem Cash-Konto.
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst das bezogene Kapital?
</p>
<div className="flex flex-col gap-2">
<CapitalUseRow
label={label}
title="Schuldentilgung (%)"
help="Einmalige Tilgung. Am Restsaldo gekappt ist die Schuld kleiner als der Anteil, bleibt der Rest Cash."
value={amort}
max={100}
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
targets={debtTargets}
targetValue={rd.capitalUseDebtTargetElementId}
onTarget={(v) => patch(elementId, { capitalUseDebtTargetElementId: v })}
targetLabel="Welche Schuld?"
missing="Es gibt weder Immobilie noch Schuld in diesem Szenario der Anteil bliebe auf dem Cash-Konto."
/>
<CapitalUseRow
label={label}
title="Investition (%)"
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
value={invest}
max={100 - amort}
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
targets={investTargets}
targetValue={rd.capitalUseTargetElementId}
onTarget={(v) => patch(elementId, { capitalUseTargetElementId: v })}
targetLabel="Wohin investieren?"
missing="Es gibt kein Element «Sonstiges Vermögen» der Anteil bliebe auf dem Cash-Konto."
/>
{/* Cash ist der Rest, aber als vollwertige Zeile dargestellt. */}
<div className="rounded-lg border border-dashed border-border bg-surface-2 p-3">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs font-medium text-muted">Cash (%)</span>
<span className="text-sm font-semibold tabular-nums text-fg">{cash} %</span>
</div>
<p className="mt-1 text-[11px] text-faint">
Was du nicht zuteilst, bleibt auf dem Cash-Konto unverzinst, aber jederzeit verfügbar.
</p>
</div>
</div>
</>
</div>
);
}
+2 -2
View File
@@ -38,7 +38,7 @@ export function ReviewTile({
{done ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<ClipboardList className="h-4 w-4 text-attention-fg" />
<ClipboardList className="h-4 w-4 text-attention" />
)}
<span className="text-xs font-semibold uppercase tracking-wide text-fg">Offene Punkte</span>
{!starting && (
@@ -99,7 +99,7 @@ export function ReviewTile({
>
<span className="flex items-baseline gap-1.5">
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{g.title}</span>
<span className="shrink-0 text-[11px] font-semibold text-attention-fg">{g.open}</span>
<span className="shrink-0 text-[11px] font-semibold text-attention">{g.open}</span>
</span>
<span className="block truncate text-[11px] text-muted">{g.reasons.join(" · ")}</span>
</button>