Kapitalbezug: Personenzuordnung, Herkunft und Filter fuer bezogene Guthaben
Deploy App / deploy (push) Successful in 1m9s
Deploy App / deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
Reference in New Issue
Block a user