Kapitalbezug: Personenzuordnung, Herkunft und Filter fuer bezogene Guthaben
Deploy App / deploy (push) Successful in 1m9s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 23:33:47 +02:00
parent 0953880a80
commit 7979fed7bd
7 changed files with 284 additions and 52 deletions
+77 -30
View File
@@ -21,7 +21,14 @@ import { CarryWarning } from "@/components/ElementDetail";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import { computePlan } from "@/lib/calculations";
import { CATEGORY_LABELS, inheritedPhaseValues, num, type PhaseData, type TransitionData } from "@/lib/elements";
import {
CATEGORY_LABELS,
inheritedPhaseValues,
num,
ownerLabel,
type PhaseData,
type TransitionData,
} from "@/lib/elements";
import {
applyPhasePatches,
applyTransitionPatches,
@@ -66,6 +73,7 @@ function SummaryRow({
interface CapitalTarget {
elementId: string;
name: string;
owner: string;
category: string;
kind: "investment" | "amortization" | "repayment";
max: number | undefined; // Kappung (Restschuld); bei Investitionen unbegrenzt
@@ -98,11 +106,16 @@ export function CapitalDistributionDialog({
const ce = phase?.elements.find((x) => x.elementId === e.id);
const prevCe = prevPhase?.elements.find((x) => x.elementId === e.id);
if (!ce || ce.status !== "ACTIVE") continue;
// Die Rechnung ignoriert Einlagen in eine bezogene PK bzw. 3a. Solche Zeilen dürfen
// deshalb gar nicht erst erscheinen -- sonst trägt man Beträge ein, die folgenlos
// bleiben, und der Entscheid aus dem Übergang wirkt hier scheinbar nicht.
if (ce.acceptsCapital === false) continue;
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A" || e.category === "OTHER_ASSET") {
out.push({
elementId: e.id,
name: e.name,
owner: ownerLabel(plan.persons, e.ownerRole),
category: e.category,
kind: "investment",
max: undefined,
@@ -116,6 +129,7 @@ export function CapitalDistributionDialog({
out.push({
elementId: e.id,
name: e.name,
owner: ownerLabel(plan.persons, e.ownerRole),
category: e.category,
kind: "amortization",
max: rest,
@@ -128,6 +142,7 @@ export function CapitalDistributionDialog({
out.push({
elementId: e.id,
name: e.name,
owner: ownerLabel(plan.persons, e.ownerRole),
category: e.category,
kind: "repayment",
max: owed,
@@ -237,24 +252,59 @@ export function CapitalDistributionDialog({
</p>
) : (
<div className="flex flex-col gap-2">
{targets.map((t) => (
<div key={t.elementId} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-fg">{t.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">
{t.kind === "investment" ? "Zusatzeinlage" : t.kind === "amortization" ? "Sonderamortisation" : "Sofortige Tilgung"}
</span>
<InfoBubble text={t.hint} />
{targets.map((t) => {
const pc = preview.elements.find((x) => x.elementId === t.elementId);
const sources = pc?.capitalFromTransferSources ?? [];
const fromTransfer = pc?.capitalFromTransfer ?? 0;
const manual = draft[t.elementId] ?? 0;
return (
<div key={t.elementId} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-fg">{t.name}</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span>
<span className="text-xs text-faint">
{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}
</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">
{t.kind === "investment"
? "Zusatzeinlage"
: t.kind === "amortization"
? "Sonderamortisation"
: "Sofortige Tilgung"}
</span>
<InfoBubble text={t.hint} />
</div>
{/* Was der Übergang bereits hierher geleitet hat. Ohne diese Zeile sieht man im
Feld eine 0 und hält die prozentuale Zuteilung für wirkungslos. */}
{fromTransfer > 0 && (
<div className="mb-2 rounded-lg border border-dashed border-border bg-surface-2 p-2">
<SummaryRow
label="Bereits aus Kapitalbezug zugeteilt"
value={fromTransfer}
help="Stammt aus der prozentualen Verteilung, die du beim Bezug der Pensionskasse bzw. der Säule 3a festgelegt hast. Der Betrag ist hier nicht editierbar ändere ihn dort."
/>
{sources.map((src) => (
<div key={src.name} className="flex justify-between gap-3 pl-3 text-[11px] text-faint">
<span>aus {src.name}</span>
<span className="tabular-nums">{formatChf(src.amount)}</span>
</div>
))}
</div>
)}
<MoneyField
label={fromTransfer > 0 ? "Zusätzlich aus dem Kapital (CHF)" : "Betrag (CHF)"}
value={manual}
max={t.max}
onChange={(v) => setDraft((prev) => ({ ...prev, [t.elementId]: v }))}
/>
{fromTransfer > 0 && (
<div className="mt-1 pl-1">
<SummaryRow label="Zusatzeinlage total" value={fromTransfer + manual} strong />
</div>
)}
</div>
<MoneyField
label="Betrag (CHF)"
value={draft[t.elementId] ?? 0}
max={t.max}
onChange={(v) => setDraft((prev) => ({ ...prev, [t.elementId]: v }))}
/>
</div>
))}
);
})}
</div>
)}
@@ -322,13 +372,6 @@ interface RateTarget {
hint: string;
}
// Zuordnung eines Elements als Klartext.
function ownerLabel(plan: PlanInput, role: string | null): string {
if (role !== "PERSON_A" && role !== "PERSON_B") return "Gemeinsam";
const p = plan.persons.find((x) => x.role === role);
return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B");
}
export function RateDistributionDialog({
plan,
computed,
@@ -349,34 +392,38 @@ export function RateDistributionDialog({
for (const e of plan.elements) {
const ce = phase?.elements.find((x) => x.elementId === e.id);
if (!ce || ce.status !== "ACTIVE") continue;
// Eine bezogene Säule 3a nimmt keine Einzahlung mehr auf; die Rechnung ignoriert sie.
// Die Zeile hier trotzdem anzubieten hiess, den Entscheid aus dem Übergang zu
// widersprechen -- inklusive eines geerbten Betrags, der wirkungslos weiterläuft.
if (ce.acceptsCapital === false) continue;
// Die PK ist bewusst NICHT dabei: Ihr Beitrag stammt aus dem Bruttolohn und belastet
// das Cash-Konto nicht -- er lässt sich also gar nicht aus der Quote verteilen.
if (e.category === "PILLAR_3A") {
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualContribution",
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
label: "Jährliche Einzahlung", direction: "out",
hint: "Fliesst jährlich vom Cash in die Säule 3a.",
});
} else if (e.category === "OTHER_ASSET") {
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualContribution",
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
label: "Jährlicher Sparbeitrag", direction: "out",
hint: "Fliesst jährlich vom Cash ins Vermögen.",
});
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualWithdrawal",
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualWithdrawal",
label: "Jährliche Bezugsrate", direction: "in",
hint: "Entnahme aus dem Vermögen ins Cash. Wird jährlich am vorhandenen Bestand gekappt.",
});
} else if (e.category === "REAL_ESTATE") {
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "amortization",
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "amortization",
label: "Amortisation pro Jahr", direction: "out",
hint: "Reduziert die Hypothek. Endet automatisch, sobald sie abbezahlt ist.",
});
} else if (e.category === "OTHER_DEBT") {
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualRepayment",
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualRepayment",
label: "Tilgung pro Jahr", direction: "out",
hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.",
});
+24 -6
View File
@@ -73,6 +73,7 @@ export interface CellContext {
investTargets: { id: string; name: string }[];
// Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C).
capitalFromTransfer: number;
capitalFromTransferSources: { name: string; amount: number }[];
}
interface Props {
@@ -804,13 +805,30 @@ export function ElementPhaseFields({
onChange={(v) => setP({ additionalInvestment: v })}
/>
{/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier
erfasst, sondern dort als Quote entschieden (Punkt C). */}
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 && (
<DerivedField
label="Davon aus Kapitalbezug (PK/3a)"
value={context.capitalFromTransfer}
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>
<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>
)}
</>
) : (
+12 -3
View File
@@ -60,6 +60,7 @@ import {
PERSON_ONLY_CATEGORIES,
inheritedPhaseValues,
num,
ownerLabel,
type CashTransitionData,
type ElementCategory,
type PhaseData,
@@ -284,6 +285,7 @@ export function PlanView({
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
investTargets: [],
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
capitalFromTransferSources: ce?.capitalFromTransferSources ?? [],
};
}
@@ -309,8 +311,9 @@ export function PlanView({
inheritedValues: {},
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
// Ziele fuer die Anlage-Quote beim Kapitalbezug (Punkt C).
investTargets: investTargetsOf(toPhase),
investTargets: investTargetsOf(plan, toPhase),
capitalFromTransfer: 0,
capitalFromTransferSources: [],
};
}
@@ -1657,6 +1660,7 @@ function AddElementDialog({
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
investTargets: [],
capitalFromTransfer: 0,
capitalFromTransferSources: [],
};
async function create() {
@@ -1806,10 +1810,15 @@ function AddPhaseDialog({
// Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle
// Vermögens-Elemente, die in der Folgephase noch aktiv sind.
function investTargetsOf(toPhase: PhaseComputed | undefined): { id: string; name: string }[] {
// Der Name allein genuegt hier nicht: Zwei Personen nennen ihr Wertschriftendepot beide "ETF",
// und im Dropdown waehlt man dann blind das falsche -- was erst Phasen spaeter auffaellt.
function investTargetsOf(plan: PlanInput, toPhase: PhaseComputed | undefined): { id: string; name: string }[] {
return (toPhase?.elements ?? [])
.filter((e) => e.category === "OTHER_ASSET" && e.status === "ACTIVE")
.map((e) => ({ id: e.elementId, name: e.name }));
.map((e) => {
const owner = plan.elements.find((x) => x.id === e.elementId)?.ownerRole ?? null;
return { id: e.elementId, name: `${e.name} · ${ownerLabel(plan.persons, owner)}` };
});
}
// --- Panel: Szenario-Profil (Grundprofil bearbeiten) ---