Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
Deploy App / deploy (push) Successful in 1m10s
Deploy App / deploy (push) Successful in 1m10s
Kapitalverwendung (Punkt C) am richtigen Ort: - Prozent-Aufteilung des bezogenen Alterskapitals wandert vom Cash-Uebergang zum Bezugs-Entscheid der PK (nur bei Kapitalbezug) bzw. der Saeule 3a -- mit zwei Guthaben liess sie sich vorher gar nicht getrennt beantworten - Dialoge fuehren neu brutto -> Steuersatz -> netto -> Verteilung - BUGFIX: Der zugeteilte Betrag erhoehte still den internen Bestand, deshalb zeigten Ziel-Element und "Kapital verteilen" eine 0. Er laeuft jetzt ueber Carry.capitalIn als Zusatzeinlage der Folgephase und ist ueberall sichtbar - Saeule 3a ist am Pensions-Uebergang neu ein offener Entscheid Phasendauer (gemeldeter Fehler): - Die Folgephase gleicht eine geaenderte Dauer aus; Gesamtdauer bleibt gleich - Vorher kappte das Tool nur die bearbeitete Phase -> Phase 2 ueberspannte danach die Pensionierung und die Invariante aus Punkt 44 kippte - Rueckfrage vorher, Blockade wenn die Folgephase unter 1 Jahr fiele - neue reine Funktion planDurationChange Bedienung: - Element-Zeile und Phasenkopf: Stift (umbenennen, beim Element inkl. Zuordnung), Papierkorb, Expand -- alle immer sichtbar - PATCH /api/elements/<id> nimmt neu auch ownerRole - Hilfetexte via Portal (wurden in scrollenden Dialogen abgeschnitten) - Verteil-Dialoge: Zuordnung je Zeile, nach vom/ins Cash gruppiert, Vorbelegung mit dem EFFEKTIVEN Wert inkl. Vererbung (zeigte vorher 0) - Matrix: gleiche Spaltenbreiten + horizontales Scrollen, "Alle auf-/ zuklappen", Kategorie-Summe in der zugeklappten Zeile - Phasen-Detailansicht nutzt die neue Aufteilungs-Grafik - Uebersicht: "Leer starten" auch im leeren Zustand Nebenbei: dritte verstuemmelte Hex-Farbe (#7c3aed) repariert, Phasen-Panel nutzt den eigenen Bestaetigungs-Dialog statt window.confirm; mehrere veraltete Referenzen und die buildCarryData-Tabelle in der Spez nachgezogen. SPEZIFIKATION 0.33. 278 -> 288 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,14 @@ import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
|
||||
|
||||
const patchSchema = z.object({ name: z.string().min(1).max(120) });
|
||||
// Name UND Zuordnung sind änderbar. Die Zuordnung läuft über `ownerRole` (nicht über eine
|
||||
// Person-ID), deshalb überlebt sie auch ein Neuanlegen der Personen im Profil-Dialog.
|
||||
const patchSchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
@@ -22,7 +28,20 @@ export async function PATCH(
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } });
|
||||
// AHV, PK und 3a hängen am individuellen Pensionierungszeitpunkt und müssen deshalb einer
|
||||
// Person gehören -- «Gemeinsam» wäre dort fachlich sinnlos.
|
||||
const nextOwner = parsed.data.ownerRole;
|
||||
if (nextOwner === "HOUSEHOLD" && PERSON_ONLY_CATEGORIES.includes(element.category)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Diese Kategorie muss einer Person zugeordnet bleiben." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.financialElement.update({
|
||||
where: { id: element.id },
|
||||
data: { name: parsed.data.name ?? undefined, ownerRole: nextOwner ?? undefined },
|
||||
});
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { maxPhaseDuration } from "@/lib/calculations";
|
||||
import { planDurationChange } from "@/lib/phaseplan";
|
||||
|
||||
const updatePhaseSchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
@@ -27,29 +28,45 @@ export async function PUT(
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
let duration = parsed.data.durationYears;
|
||||
// Die Folgephase gleicht eine geänderte Dauer aus -- sonst verschieben sich alle späteren
|
||||
// Phasengrenzen und eine davon überspannt eine Pensionierung (siehe lib/phaseplan.ts).
|
||||
let neighbour: { id: string; durationYears: number } | null = null;
|
||||
|
||||
if (duration != null) {
|
||||
// Dauer ans nächste Pensionsereignis kappen (Jahre vor dieser Phase).
|
||||
const scenario = await getOwnedScenario(existing.scenarioId, userId);
|
||||
if (scenario) {
|
||||
const planInput = toPlanInput(scenario);
|
||||
// Dauer ans nächste Pensionsereignis kappen (Jahre vor dieser Phase).
|
||||
const yearsBefore = planInput.phases
|
||||
.filter((p) => p.sequenceNumber < existing.sequenceNumber)
|
||||
.reduce((s, p) => s + p.durationYears, 0);
|
||||
const cap = maxPhaseDuration(planInput.persons, yearsBefore);
|
||||
if (cap != null) duration = Math.min(duration, cap);
|
||||
duration = Math.max(1, duration);
|
||||
|
||||
const change = planDurationChange(planInput.phases, phaseId, duration);
|
||||
if (!change.ok) return NextResponse.json({ error: change.error }, { status: 400 });
|
||||
if (change.neighbourId && change.neighbourNewDuration != null) {
|
||||
neighbour = { id: change.neighbourId, durationYears: change.neighbourNewDuration };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const phase = await prisma.phase.update({
|
||||
where: { id: phaseId },
|
||||
data: {
|
||||
name: parsed.data.name ?? undefined,
|
||||
durationYears: duration ?? undefined,
|
||||
},
|
||||
const phase = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.phase.update({
|
||||
where: { id: phaseId },
|
||||
data: {
|
||||
name: parsed.data.name ?? undefined,
|
||||
durationYears: duration ?? undefined,
|
||||
},
|
||||
});
|
||||
if (neighbour) {
|
||||
await tx.phase.update({ where: { id: neighbour.id }, data: { durationYears: neighbour.durationYears } });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
await touchScenario(existing.scenarioId, userId);
|
||||
return NextResponse.json({ phase: { id: phase.id } });
|
||||
return NextResponse.json({ phase: { id: phase.id }, adjustedNeighbour: neighbour });
|
||||
}
|
||||
|
||||
// Nur die letzte Phase kann gelöscht werden (Verkettung bleibt intakt).
|
||||
|
||||
@@ -48,12 +48,26 @@ const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||||
// Phasengrenzen als feine senkrechte Linien,
|
||||
// * daneben ein RING für ein wählbares Jahr, der die RELATIVE Aufteilung zeigt.
|
||||
// Ein Klick in die Fläche wählt das Jahr des Rings.
|
||||
export function AllocationChart({ computed, height = 300 }: { computed: PlanComputed; height?: number }) {
|
||||
export function AllocationChart({
|
||||
computed,
|
||||
height = 300,
|
||||
// Auf eine einzelne Lebensphase einschraenken (Detailansicht der Phase). Ohne Angabe wird
|
||||
// der ganze Plan gezeigt.
|
||||
onlyPhaseId,
|
||||
}: {
|
||||
computed: PlanComputed;
|
||||
height?: number;
|
||||
onlyPhaseId?: string;
|
||||
}) {
|
||||
const phases = useMemo(
|
||||
() => (onlyPhaseId ? computed.phases.filter((p) => p.id === onlyPhaseId) : computed.phases),
|
||||
[computed, onlyPhaseId]
|
||||
);
|
||||
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
|
||||
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
|
||||
const assetEls = useMemo(() => {
|
||||
const info = new Map<string, { name: string; any: boolean }>();
|
||||
for (const phase of computed.phases) {
|
||||
for (const phase of phases) {
|
||||
for (const el of phase.elements) {
|
||||
if (!ASSET_CATS.includes(el.category)) continue;
|
||||
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
|
||||
@@ -63,14 +77,14 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp
|
||||
}
|
||||
}
|
||||
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
|
||||
}, [computed]);
|
||||
}, [phases]);
|
||||
|
||||
// Je Planjahr eine Zeile mit dem Wert jedes Elements. Die Jahreswerte liegen bereits in
|
||||
// `ElementPhaseComputed.yearly` (seit 0.11) -- hier wird nur umsortiert, nichts gerechnet.
|
||||
const areaData = useMemo(() => {
|
||||
const byYear = new Map<number, Record<string, number>>();
|
||||
const ageOf = new Map<number, number>();
|
||||
for (const phase of computed.phases) {
|
||||
for (const phase of phases) {
|
||||
for (const el of phase.elements) {
|
||||
if (!ASSET_CATS.includes(el.category)) continue;
|
||||
for (const y of el.yearly) {
|
||||
@@ -84,10 +98,11 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp
|
||||
return [...byYear.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([year, values]) => ({ year, age: ageOf.get(year) ?? 0, ...values }));
|
||||
}, [computed]);
|
||||
}, [phases]);
|
||||
|
||||
// Phasengrenzen (kumulierte Dauer) für die Trennlinien.
|
||||
// Phasengrenzen (kumulierte Dauer) für die Trennlinien. Bei einer einzelnen Phase gibt es keine.
|
||||
const boundaryAges = useMemo(() => {
|
||||
if (onlyPhaseId) return [];
|
||||
const out: number[] = [];
|
||||
let acc = 0;
|
||||
for (let i = 0; i < computed.phases.length - 1; i++) {
|
||||
@@ -96,7 +111,7 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp
|
||||
if (typeof age === "number") out.push(age);
|
||||
}
|
||||
return out;
|
||||
}, [computed, areaData]);
|
||||
}, [computed, areaData, onlyPhaseId]);
|
||||
|
||||
// Gewähltes Jahr für den Ring -- Vorgabe: das letzte (Endzustand).
|
||||
const [pickedYear, setPickedYear] = useState<number | null>(null);
|
||||
@@ -119,7 +134,8 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp
|
||||
{/* Verlauf */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 text-xs font-medium text-muted">
|
||||
Verlauf über alle Planjahre <span className="text-faint">· Klick wählt das Jahr für den Ring</span>
|
||||
{onlyPhaseId ? "Verlauf in dieser Lebensphase" : "Verlauf über alle Planjahre"}{" "}
|
||||
<span className="text-faint">· Klick wählt das Jahr für den Ring</span>
|
||||
</div>
|
||||
<div style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -635,6 +635,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
|
||||
onCreateGuided={() => setShowWizard(true)}
|
||||
onCreateChooser={() => setShowNewPlanChooser(true)}
|
||||
onCreateEmpty={() => setShowEmptyPlanDialog(true)}
|
||||
onCreateDemo={handleCreateDemo}
|
||||
onDelete={handleDeletePlan}
|
||||
/>
|
||||
@@ -1060,6 +1061,7 @@ function DashboardHome({
|
||||
onCreateGuided,
|
||||
onCreateChooser,
|
||||
onCreateDemo,
|
||||
onCreateEmpty,
|
||||
onDelete,
|
||||
}: {
|
||||
username: string;
|
||||
@@ -1069,6 +1071,7 @@ function DashboardHome({
|
||||
onCreateGuided: () => void;
|
||||
onCreateChooser: () => void;
|
||||
onCreateDemo: () => void;
|
||||
onCreateEmpty: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -1093,6 +1096,10 @@ function DashboardHome({
|
||||
<Button variant="secondary" onClick={onCreateDemo} disabled={creatingDemo}>
|
||||
<PiggyBank className="h-4 w-4" /> {creatingDemo ? "Wird angelegt…" : "Beispielplan ansehen"}
|
||||
</Button>
|
||||
{/* Dritter Weg -- sonst gaebe es "Leer starten" nur ueber das Plus in der Seitenleiste. */}
|
||||
<Button variant="ghost" onClick={onCreateEmpty}>
|
||||
<Plus className="h-4 w-4" /> Leer starten
|
||||
</Button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { BookOpen, X } from "lucide-react";
|
||||
import { AllocationChart } from "@/components/AllocationChart";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { CATEGORY_LABELS } from "@/lib/elements";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
@@ -523,26 +522,21 @@ export function ElementDetailDialog({
|
||||
// --- Phasen-Detailansicht ------------------------------------------------------------------
|
||||
|
||||
const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||||
const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
|
||||
|
||||
export function PhaseDetailDialog({
|
||||
phase,
|
||||
computed,
|
||||
isFirst,
|
||||
onClose,
|
||||
onOpenSpec,
|
||||
}: {
|
||||
phase: PhaseComputed;
|
||||
// Der ganze Plan -- die Aufteilungs-Grafik zeichnet daraus die Jahreswerte dieser Phase.
|
||||
computed: PlanComputed;
|
||||
isFirst: boolean;
|
||||
onClose: () => void;
|
||||
onOpenSpec?: (anchor: string) => void;
|
||||
}) {
|
||||
const alloc = useMemo(() => {
|
||||
const rows = phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0));
|
||||
return [
|
||||
{ label: "Beginn", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.startValue)])) },
|
||||
{ label: "Ende", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.endValue)])) },
|
||||
];
|
||||
}, [phase]);
|
||||
const allocEls = useMemo(
|
||||
() => phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)),
|
||||
[phase]
|
||||
@@ -559,24 +553,12 @@ export function PhaseDetailDialog({
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold text-fg">Vermögensaufteilung</h3>
|
||||
<p className="mb-2 text-[11px] text-muted">Zusammensetzung des Anlagevermögens zu Beginn und am Ende dieser Phase.</p>
|
||||
<p className="mb-2 text-[11px] text-muted">Zusammensetzung des Anlagevermögens über die Jahre dieser Phase; rechts die relative Aufteilung zu einem wählbaren Zeitpunkt.</p>
|
||||
{allocEls.length === 0 ? (
|
||||
<p className="text-sm text-muted">In dieser Phase gibt es kein Anlagevermögen.</p>
|
||||
) : (
|
||||
<div className="h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={alloc} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{allocEls.map((el, i) => (
|
||||
<Bar key={el.elementId} dataKey={el.elementId} name={el.name} stackId="a" fill={ALLOC_PALETTE[i % ALLOC_PALETTE.length]} isAnimationActive={false} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
// Dieselbe Darstellung wie im Grafiken-Bereich, nur auf diese Phase eingeschränkt.
|
||||
<AllocationChart computed={computed} onlyPhaseId={phase.id} height={240} />
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { CarryWarning } from "@/components/ElementDetail";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { CATEGORY_LABELS, num, type PhaseData, type TransitionData } from "@/lib/elements";
|
||||
import { CATEGORY_LABELS, inheritedPhaseValues, num, type PhaseData, type TransitionData } from "@/lib/elements";
|
||||
import {
|
||||
applyPhasePatches,
|
||||
applyTransitionPatches,
|
||||
@@ -313,12 +313,22 @@ interface RateTarget {
|
||||
elementId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
// Zuordnung als Klartext -- ohne sie sind zwei gleichnamige Elemente derselben Kategorie
|
||||
// (z. B. "ETF" bei Person A und Person B) in der Liste nicht unterscheidbar.
|
||||
owner: string;
|
||||
field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment";
|
||||
label: string;
|
||||
direction: "out" | "in";
|
||||
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,
|
||||
@@ -343,30 +353,30 @@ export function RateDistributionDialog({
|
||||
// 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, field: "annualContribution",
|
||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, 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, field: "annualContribution",
|
||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, 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, field: "annualWithdrawal",
|
||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, 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, field: "amortization",
|
||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, 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, field: "annualRepayment",
|
||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualRepayment",
|
||||
label: "Tilgung pro Jahr", direction: "out",
|
||||
hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.",
|
||||
});
|
||||
@@ -378,11 +388,21 @@ export function RateDistributionDialog({
|
||||
|
||||
const key = (t: RateTarget) => `${t.elementId}:${t.field}`;
|
||||
|
||||
// Vorbelegung mit dem EFFEKTIVEN Wert -- also inklusive der Vererbung aus der Vorphase
|
||||
// (Roadmap Nr. 44, Punkt A). Bis 0.32 wurde nur der roh gespeicherte Wert der Phase
|
||||
// gelesen: Eine 3a-Einzahlung von 7'000, die aus Phase 1 geerbt wurde, erschien hier als 0,
|
||||
// obwohl die Rechnung korrekt mit 7'000 lief.
|
||||
const orderedPhaseIds = useMemo(
|
||||
() => [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber).map((p) => p.id),
|
||||
[plan.phases]
|
||||
);
|
||||
const [draft, setDraft] = useState<Record<string, number>>(() => {
|
||||
const d: Record<string, number> = {};
|
||||
for (const t of targets) {
|
||||
const e = plan.elements.find((x) => x.id === t.elementId)!;
|
||||
d[key(t)] = Math.round(num((e.phaseValues[phaseId] ?? {})[t.field]));
|
||||
const own = (e.phaseValues[phaseId] ?? {})[t.field];
|
||||
const inherited = inheritedPhaseValues(e.phaseValues, orderedPhaseIds, phaseId)[t.field];
|
||||
d[key(t)] = Math.round(typeof own === "number" ? own : num(inherited));
|
||||
}
|
||||
return d;
|
||||
});
|
||||
@@ -464,28 +484,41 @@ export function RateDistributionDialog({
|
||||
In dieser Phase gibt es keine Elemente mit jährlichen Raten. Die ganze Quote läuft aufs Cash-Konto.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{targets.map((t) => (
|
||||
<div key={key(t)} 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 px-1.5 py-0.5 text-[10px] ${
|
||||
t.direction === "in" ? "bg-success/15 text-success" : "bg-surface-2 text-muted"
|
||||
// Bewusst nach RICHTUNG gruppiert: erst alles, was das Cash verlässt, dann alles, was
|
||||
// hineinfliesst. Gemischt war die Liste schwer zu lesen.
|
||||
<div className="flex flex-col gap-4">
|
||||
{(["out", "in"] as const).map((dir) => {
|
||||
const rows = targets.filter((t) => t.direction === dir);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div key={dir} className="flex flex-col gap-2">
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs font-semibold uppercase tracking-wide ${
|
||||
dir === "in" ? "text-success" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{t.direction === "in" ? "ins Cash" : "vom Cash"}
|
||||
</span>
|
||||
<InfoBubble text={t.hint} />
|
||||
{dir === "in" ? "Ins Cash (Entnahmen)" : "Vom Cash (Einzahlungen und Tilgungen)"}
|
||||
</div>
|
||||
{rows.map((t) => (
|
||||
<div key={key(t)} 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.owner}</span>
|
||||
<InfoBubble text={t.hint} />
|
||||
</div>
|
||||
<MoneyField
|
||||
label={`${t.label} (CHF/Jahr)`}
|
||||
value={draft[key(t)] ?? 0}
|
||||
onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<MoneyField
|
||||
label={`${t.label} (CHF/Jahr)`}
|
||||
value={draft[key(t)] ?? 0}
|
||||
onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -68,6 +68,11 @@ export interface CellContext {
|
||||
// Alter des Element-Besitzers im ersten Jahr dieser Phase. Entscheidet bei der AHV, ob
|
||||
// das Referenzalter in diese Phase fällt (Kap. 4.4.6).
|
||||
ownerAgeStart: number;
|
||||
// Mögliche Ziele der Anlage-Quote beim Kapitalbezug (Punkt C): die in der FOLGEphase noch
|
||||
// aktiven Vermögens-Elemente.
|
||||
investTargets: { id: string; name: string }[];
|
||||
// Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C).
|
||||
capitalFromTransfer: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -253,18 +258,10 @@ 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";
|
||||
@@ -365,39 +362,41 @@ export function CashTransitionFields({
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCapitalUse && <CapitalUseFields ct={ct} setC={setC} inflow={capitalInflow} targets={investTargets} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C).
|
||||
// Verwendung des BEZOGENEN ALTERSKAPITALS (Roadmap Nr. 44, Punkt C).
|
||||
//
|
||||
// Steht am Bezugs-Entscheid des jeweiligen Guthabens (PK bzw. Säule 3a) -- bis 0.32 hing die
|
||||
// Frage am Cash-Übergang, wo sie sich mit zwei Guthaben gar nicht getrennt beantworten liess.
|
||||
//
|
||||
// In PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich das bezogene
|
||||
// Kapital. Ein Frankenbetrag müsste dann von Hand nachgezogen werden -- und würde bis dahin
|
||||
// still eine falsche Aufteilung rechnen. Eine Quote skaliert mit.
|
||||
function CapitalUseFields({
|
||||
ct,
|
||||
setC,
|
||||
inflow,
|
||||
export function CapitalUseFields({
|
||||
td,
|
||||
setT,
|
||||
netAmount,
|
||||
targets,
|
||||
}: {
|
||||
ct: CashTransitionData;
|
||||
setC: (patch: Partial<CashTransitionData>) => void;
|
||||
inflow: number;
|
||||
td: TransitionData;
|
||||
setT: (patch: Partial<TransitionData>) => void;
|
||||
// Netto-Auszahlung, auf die sich die Prozente beziehen.
|
||||
netAmount: number;
|
||||
targets: { id: string; name: string }[];
|
||||
}) {
|
||||
const amort = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct)));
|
||||
const invest = Math.max(0, Math.min(100 - amort, num(ct.capitalUseInvestPct)));
|
||||
const amort = Math.max(0, Math.min(100, num(td.capitalUseAmortizationPct)));
|
||||
const invest = Math.max(0, Math.min(100 - amort, num(td.capitalUseInvestPct)));
|
||||
const cash = Math.max(0, 100 - amort - invest);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sm:col-span-2 rounded-lg 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 className="sm:col-span-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
<strong className="text-fg">Wohin fliesst das bezogene Kapital?</strong> Von den{" "}
|
||||
<strong className="text-fg">{formatChf(netAmount)}</strong> netto teilst du hier in Prozent zu –
|
||||
so bleibt die Aufteilung richtig, wenn sich der Betrag durch ein anderes Pensionsalter ändert.
|
||||
Was nicht zugeteilt ist, bleibt auf dem Cash-Konto.
|
||||
</div>
|
||||
<NumberField
|
||||
label="… in die Hypothek (Amortisation, %)"
|
||||
@@ -406,31 +405,38 @@ function CapitalUseFields({
|
||||
min={0}
|
||||
max={100}
|
||||
value={amort}
|
||||
onChange={(v) => setC({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
|
||||
onChange={(v) => setT({ 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."
|
||||
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element der Folgephase und wächst dort mit dessen Rendite weiter."
|
||||
step={5}
|
||||
min={0}
|
||||
max={100 - amort}
|
||||
value={invest}
|
||||
onChange={(v) => setC({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
|
||||
onChange={(v) => setT({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
|
||||
/>
|
||||
{invest > 0 && targets.length > 0 && (
|
||||
<div className="sm:col-span-2">
|
||||
<SelectField
|
||||
label="Ziel der Anlage-Quote"
|
||||
help="Welches Vermögens-Element wird aufgestockt?"
|
||||
value={ct.capitalUseTargetElementId ?? targets[0].id}
|
||||
onChange={(v: string) => setC({ capitalUseTargetElementId: v })}
|
||||
value={td.capitalUseTargetElementId ?? targets[0].id}
|
||||
onChange={(v: string) => setT({ capitalUseTargetElementId: v })}
|
||||
options={targets.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{invest > 0 && (
|
||||
<DerivedField
|
||||
label="… in die Anlage (Betrag)"
|
||||
value={Math.round((netAmount * invest) / 100)}
|
||||
help="Erscheint in der Folgephase als Zusatzeinlage beim gewählten Element."
|
||||
/>
|
||||
)}
|
||||
<DerivedField
|
||||
label={`… bleibt Cash (${cash} %)`}
|
||||
value={Math.round((inflow * cash) / 100)}
|
||||
value={Math.round((netAmount * cash) / 100)}
|
||||
help="Der nicht zugeteilte Rest. Wird automatisch berechnet."
|
||||
/>
|
||||
</>
|
||||
@@ -795,9 +801,17 @@ export function ElementPhaseFields({
|
||||
label="Zusatzinvestition aus Kapital (CHF)"
|
||||
help="Neuinvestition aus dem verfügbaren Kapital dieser Phase."
|
||||
value={num(pd.additionalInvestment)}
|
||||
|
||||
onChange={(v) => setP({ additionalInvestment: v })}
|
||||
/>
|
||||
{/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier
|
||||
erfasst, sondern dort als Quote entschieden (Punkt C). */}
|
||||
{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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||
@@ -873,9 +887,16 @@ export function ElementTransitionFields({
|
||||
Die Beitragskarriere wird erst beim Übergang in die Pensionierung geprüft.
|
||||
</p>
|
||||
);
|
||||
case "PENSION_FUND":
|
||||
case "PENSION_FUND": {
|
||||
if (context.isRetirementTransition) {
|
||||
const mode = td.payoutMode ?? "PENSION";
|
||||
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
|
||||
// Brutto = ganzes Guthaben (Kapitalbezug) bzw. der gewählte Teil (Kombination).
|
||||
const brutto =
|
||||
mode === "COMBI"
|
||||
? Math.min(context.carriedEndValue, Math.round(num(td.capitalAmount)))
|
||||
: context.carriedEndValue;
|
||||
const netto = Math.round(brutto * (1 - taxRate / 100));
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
@@ -897,9 +918,6 @@ export function ElementTransitionFields({
|
||||
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)"
|
||||
@@ -909,23 +927,67 @@ export function ElementTransitionFields({
|
||||
onChange={(v) => setT({ capitalAmount: v })}
|
||||
/>
|
||||
)}
|
||||
{/* Erst der Betrag, dann die Steuer, dann die Verwendung -- in der Reihenfolge,
|
||||
in der man die Entscheidung tatsächlich trifft. */}
|
||||
{(mode === "CAPITAL" || mode === "COMBI") && (
|
||||
<>
|
||||
<DerivedField
|
||||
label="Kapitalbezug brutto"
|
||||
value={brutto}
|
||||
help="Das Guthaben am Ende der Vorphase (bei einer Kombination der oben gewählte Anteil)."
|
||||
/>
|
||||
<NumberField
|
||||
label="Kapitalbezugssteuer (%)"
|
||||
help="Pauschalsatz – die tatsächliche Steuer ist kantonal und progressiv."
|
||||
step={0.5}
|
||||
value={taxRate}
|
||||
onChange={(v) => setT({ capitalTaxRate: v })}
|
||||
/>
|
||||
<DerivedField
|
||||
label="Auszahlung netto"
|
||||
value={netto}
|
||||
help="Brutto abzüglich Kapitalbezugssteuer. Dieser Betrag wird unten verteilt."
|
||||
/>
|
||||
<CapitalUseFields td={td} setT={setT} netAmount={netto} targets={context.investTargets} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="PK-Bezug (CHF)" />;
|
||||
case "PILLAR_3A":
|
||||
}
|
||||
case "PILLAR_3A": {
|
||||
if (context.isRetirementTransition) {
|
||||
// Die Säule 3a wird bei der Pensionierung IMMER vollständig bezogen -- entschieden
|
||||
// wird hier der Steuersatz und die Verwendung des Geldes.
|
||||
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
|
||||
const brutto = context.carriedEndValue;
|
||||
const netto = Math.round(brutto * (1 - taxRate / 100));
|
||||
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 })}
|
||||
/>
|
||||
<>
|
||||
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Die Säule 3a wird bei der Pensionierung <strong className="text-fg">vollständig bezogen</strong>. Zu
|
||||
entscheiden sind der Steuersatz und die Verwendung des Geldes.
|
||||
</p>
|
||||
<DerivedField label="Bezug brutto" value={brutto} help="Das Guthaben am Ende der Vorphase." />
|
||||
<NumberField
|
||||
label="Kapitalbezugssteuer (%)"
|
||||
help="Pauschalsatz – die tatsächliche Steuer ist kantonal und progressiv. Gestaffelte Bezüge (PK und 3a in verschiedenen Jahren) senken sie; das bildet das Tool noch nicht ab."
|
||||
step={0.5}
|
||||
value={taxRate}
|
||||
onChange={(v) => setT({ capitalTaxRate: v })}
|
||||
/>
|
||||
<DerivedField
|
||||
label="Auszahlung netto"
|
||||
value={netto}
|
||||
help="Brutto abzüglich Kapitalbezugssteuer. Dieser Betrag wird unten verteilt."
|
||||
/>
|
||||
<CapitalUseFields td={td} setT={setT} netAmount={netto} targets={context.investTargets} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -1,28 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
// Hilfetext-Bubble.
|
||||
//
|
||||
// Der Text wird über ein PORTAL an den <body> gehängt und `fixed` positioniert. Vorher lag er
|
||||
// als `absolute` Kind im Fluss -- in einem scrollenden Dialog (Verteil-Dialoge, Assistent)
|
||||
// schnitt dessen `overflow` die Box ab, teilweise nach wenigen Zeilen. Fix positioniert
|
||||
// bezieht sich auf den Viewport und entkommt damit jedem Overflow-Container.
|
||||
//
|
||||
// Zusätzlich: Die Box klappt nach OBEN, wenn unten kein Platz mehr ist, und wird an den
|
||||
// Bildschirmrändern geklemmt, damit sie nie halb ausserhalb steht.
|
||||
|
||||
const WIDTH = 256; // entspricht w-64
|
||||
const MARGIN = 8;
|
||||
|
||||
export function InfoBubble({ text }: { text: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Position aus der Lage des Knopfes berechnen -- erst wenn geöffnet, und erneut, sobald die
|
||||
// tatsächliche Höhe der Box bekannt ist (für das Hochklappen).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Position gilt nur im offenen Zustand
|
||||
setPos(null);
|
||||
return;
|
||||
}
|
||||
const place = () => {
|
||||
const btn = btnRef.current;
|
||||
if (!btn) return;
|
||||
const r = btn.getBoundingClientRect();
|
||||
const height = boxRef.current?.offsetHeight ?? 0;
|
||||
const spaceBelow = window.innerHeight - r.bottom;
|
||||
const above = height > 0 && spaceBelow < height + MARGIN && r.top > height + MARGIN;
|
||||
const left = Math.min(
|
||||
Math.max(MARGIN, r.left + r.width / 2 - WIDTH / 2),
|
||||
Math.max(MARGIN, window.innerWidth - WIDTH - MARGIN)
|
||||
);
|
||||
setPos({ top: above ? r.top - height - 6 : r.bottom + 6, left });
|
||||
};
|
||||
place();
|
||||
// Nach dem ersten Zeichnen erneut -- dann steht die Höhe fest.
|
||||
const raf = requestAnimationFrame(place);
|
||||
window.addEventListener("scroll", place, true);
|
||||
window.addEventListener("resize", place);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", place, true);
|
||||
window.removeEventListener("resize", place);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex align-middle ml-1">
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
aria-label="Hilfe anzeigen"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent-soft text-accent-soft-fg hover:opacity-80"
|
||||
>
|
||||
<Info className="h-2.5 w-2.5" strokeWidth={2.5} />
|
||||
</button>
|
||||
{open && (
|
||||
<span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg">
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
{open &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={boxRef}
|
||||
role="tooltip"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: pos?.top ?? -9999,
|
||||
left: pos?.left ?? -9999,
|
||||
width: WIDTH,
|
||||
// Vor der ersten Messung unsichtbar, damit die Box nicht kurz aufblitzt.
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
className="pointer-events-none z-[60] rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg"
|
||||
>
|
||||
{text}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { NumberField, TextField } from "@/components/FormField";
|
||||
import { CarryWarning } from "@/components/ElementDetail";
|
||||
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { planDurationChange } from "@/lib/phaseplan";
|
||||
import type { PhaseInput } from "@/lib/types";
|
||||
|
||||
export function PhaseDetail({
|
||||
phase,
|
||||
allPhases,
|
||||
maxDurationYears,
|
||||
isLast,
|
||||
laterPhaseCount,
|
||||
@@ -16,6 +19,9 @@ export function PhaseDetail({
|
||||
onDeleted,
|
||||
}: {
|
||||
phase: PhaseInput;
|
||||
// Alle Phasen des Szenarios -- nötig, um die Auswirkung einer geänderten Dauer auf die
|
||||
// Folgephase VORHER zu zeigen (siehe planDurationChange).
|
||||
allPhases: PhaseInput[];
|
||||
maxDurationYears: number | null;
|
||||
isLast: boolean;
|
||||
laterPhaseCount: number;
|
||||
@@ -26,10 +32,32 @@ export function PhaseDetail({
|
||||
const [durationYears, setDurationYears] = useState(phase.durationYears);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
const cap = maxDurationYears;
|
||||
// Vorschau: Was passiert mit der Folgephase? Wird live berechnet, damit die Auswirkung
|
||||
// sichtbar ist, BEVOR gespeichert wird.
|
||||
const change = planDurationChange(allPhases, phase.id, durationYears);
|
||||
|
||||
async function save() {
|
||||
// Verschiebt sich eine Folgephase, wird das vorher bestätigt -- sonst ändert ein Klick
|
||||
// stillschweigend zwei Phasen.
|
||||
if (change.ok && change.neighbourId && change.delta !== 0) {
|
||||
const jahre = Math.abs(change.delta);
|
||||
const ok = await confirm({
|
||||
title: "Folgephase wird angepasst",
|
||||
message:
|
||||
`Wenn du «${phase.name}» um ${jahre} Jahr${jahre === 1 ? "" : "e"} ` +
|
||||
`${change.delta > 0 ? "verlängerst" : "verkürzst"}, wird «${change.neighbourName}» automatisch auf ` +
|
||||
`${change.neighbourNewDuration} Jahr${change.neighbourNewDuration === 1 ? "" : "e"} ` +
|
||||
`${change.delta > 0 ? "gekürzt" : "verlängert"} – die Gesamtdauer des Plans bleibt gleich. ` +
|
||||
`Ohne diesen Ausgleich würde eine spätere Lebensphase eine Pensionierung überspannen.`,
|
||||
confirmLabel: "Fortfahren",
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -43,21 +71,25 @@ export function PhaseDetail({
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Phase "${phase.name}" wirklich löschen?`)) return;
|
||||
const ok = await confirm({
|
||||
title: "Lebensphase löschen?",
|
||||
message: `«${phase.name}» wird mit allen erfassten Werten und Übergangs-Entscheiden entfernt.`,
|
||||
confirmLabel: "Endgültig löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`/api/phases/${phase.id}`);
|
||||
onDeleted();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
|
||||
Lebensphase
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">Lebensphase</div>
|
||||
{isLast && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -82,18 +114,27 @@ export function PhaseDetail({
|
||||
onChange={(v) => setDurationYears(cap != null ? Math.min(v, cap) : v)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-faint">Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen gesetzt.</p>
|
||||
|
||||
{/* Auswirkung auf die Folgephase -- sichtbar, bevor gespeichert wird. */}
|
||||
{change.error && (
|
||||
<p className="rounded-lg border border-danger bg-danger-soft px-3 py-2 text-xs text-danger">{change.error}</p>
|
||||
)}
|
||||
{change.ok && change.neighbourId && change.delta !== 0 && (
|
||||
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
«{change.neighbourName}» wird dadurch auf <strong>{change.neighbourNewDuration} Jahre</strong>{" "}
|
||||
{change.delta > 0 ? "gekürzt" : "verlängert"} – die Gesamtdauer des Plans bleibt gleich.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-faint">
|
||||
Die Inflationsrate gilt für das ganze Szenario und wird im Grundprofil gesetzt.
|
||||
</p>
|
||||
|
||||
{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>
|
||||
<Button disabled={saving || !!change.error} onClick={save}>
|
||||
{saving ? "Speichern…" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+285
-30
@@ -12,12 +12,14 @@ import {
|
||||
Home,
|
||||
Landmark,
|
||||
ListTodo,
|
||||
Pencil,
|
||||
Maximize2,
|
||||
PiggyBank,
|
||||
Plus,
|
||||
Settings2,
|
||||
ShoppingCart,
|
||||
Tornado,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
@@ -48,7 +50,7 @@ import {
|
||||
import { PhaseDetail } from "@/components/PhaseDetail";
|
||||
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
||||
import { MoneyField } from "@/components/FormField";
|
||||
import { MoneyField, SelectField, TextField } from "@/components/FormField";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
|
||||
@@ -97,6 +99,7 @@ type Column =
|
||||
// Genau ein Panel kann offen sein -- deshalb eine Union statt sechs einzelner Zustände.
|
||||
type Panel =
|
||||
| { kind: "phase"; phaseId: string }
|
||||
| { kind: "elementMeta"; elementId: string }
|
||||
| { kind: "cell"; elementId: string; phaseId: string }
|
||||
| { kind: "transition"; elementId: string; fromPhaseId: string }
|
||||
| { kind: "cashTransition"; fromPhaseId: string }
|
||||
@@ -279,6 +282,8 @@ export function PlanView({
|
||||
ahvCareer: careerFor(element),
|
||||
inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id),
|
||||
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
|
||||
investTargets: [],
|
||||
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,6 +308,9 @@ export function PlanView({
|
||||
ahvCareer: careerFor(element),
|
||||
inheritedValues: {},
|
||||
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
|
||||
// Ziele fuer die Anlage-Quote beim Kapitalbezug (Punkt C).
|
||||
investTargets: investTargetsOf(toPhase),
|
||||
capitalFromTransfer: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -500,6 +508,26 @@ export function PlanView({
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Lebensphase
|
||||
</button>
|
||||
{/* Alle Kategoriegruppen auf einen Schlag auf- oder zuklappen. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCollapsedCats((prev) =>
|
||||
prev.size === 0 ? new Set(CATEGORY_ORDER.filter((c) => (elementsByCategory.get(c)?.length ?? 0) > 0)) : new Set()
|
||||
)
|
||||
}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
{collapsedCats.size === 0 ? (
|
||||
<>
|
||||
<ChevronRight className="h-3.5 w-3.5" /> Alle zuklappen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3.5 w-3.5" /> Alle aufklappen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -525,10 +553,13 @@ export function PlanView({
|
||||
beim Scrollen sichtbar bleiben. */}
|
||||
{hasPhases && (
|
||||
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
|
||||
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
|
||||
Ein w-full haette die Spalten je nach Anzahl unterschiedlich gedehnt. */}
|
||||
<table className="border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 top-0 z-40 min-w-44 border-b border-r border-border bg-surface-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
<th className="sticky left-0 top-0 z-40 w-56 min-w-56 border-b border-r border-border bg-surface-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Finanzielle Elemente
|
||||
</th>
|
||||
{columns.map((col) =>
|
||||
@@ -545,6 +576,11 @@ export function PlanView({
|
||||
col.phase.sequenceNumber > 1 ? () => setDistribute({ kind: "capital", phaseId: col.phase.id }) : null
|
||||
}
|
||||
onDistributeRates={() => setDistribute({ kind: "rates", phaseId: col.phase.id })}
|
||||
onDelete={
|
||||
col.phase.sequenceNumber === computed.phases.length
|
||||
? () => void deletePhase(col.phase.id, col.phase.name)
|
||||
: null
|
||||
}
|
||||
active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
|
||||
/>
|
||||
) : (
|
||||
@@ -634,7 +670,37 @@ export function PlanView({
|
||||
{CATEGORY_LABELS[cat]}
|
||||
</span>
|
||||
</td>
|
||||
<td colSpan={columns.length} className="border-b border-border" />
|
||||
{/* Zugeklappt: Summe der Elementwerte je Phase -- sonst verschwindet die
|
||||
Information beim Zuklappen. Aufgeklappt bleibt die Zeile leer, weil
|
||||
man die Einzelwerte darunter sieht. */}
|
||||
{collapsed
|
||||
? columns.map((col) =>
|
||||
col.kind === "phase" ? (
|
||||
<td
|
||||
key={`sum-${cat}-${col.phase.id}`}
|
||||
className="border-b border-r border-border bg-surface-2 px-2 py-1.5 text-center text-xs font-semibold text-muted"
|
||||
>
|
||||
{(() => {
|
||||
const rows = els
|
||||
.map((e) => computedElement(col.phase.id, e.id))
|
||||
.filter((ce): ce is NonNullable<typeof ce> => !!ce && ce.status === "ACTIVE");
|
||||
if (rows.length === 0) return "";
|
||||
const start = rows.reduce((sum, ce) => sum + ce.startValue, 0);
|
||||
const end = rows.reduce((sum, ce) => sum + ce.endValue, 0);
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{valStr(start, col.phase.cumulativeInflationStart, valueMode)}{" "}
|
||||
<span className="text-faint">→</span>{" "}
|
||||
{valStr(end, col.phase.cumulativeInflationEnd, valueMode)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
) : (
|
||||
<td key={`sum-t-${cat}-${col.fromPhase.id}`} className="border-b border-r border-border bg-surface-2" />
|
||||
)
|
||||
)
|
||||
: <td colSpan={columns.length} className="border-b border-border" />}
|
||||
</tr>
|
||||
{!collapsed &&
|
||||
els.map((el) => (
|
||||
@@ -645,6 +711,33 @@ export function PlanView({
|
||||
{diff?.elementRow.get(el.id) === "added" && (
|
||||
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
|
||||
)}
|
||||
{/* Aktionen zum Element selbst (phasenunabhängig): umbenennen,
|
||||
löschen, Detailansicht -- bewusst IMMER sichtbar, damit sie
|
||||
auch ohne Maus (Touch) erreichbar sind. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${el.name} umbenennen`}
|
||||
title="Umbenennen und Zuordnung ändern"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPanel({ kind: "elementMeta", elementId: el.id });
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${el.name} löschen`}
|
||||
title="Element löschen"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void deleteElement(el.id, el.name);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Detailansicht ${el.name}`}
|
||||
@@ -653,7 +746,7 @@ export function PlanView({
|
||||
e.stopPropagation();
|
||||
setDetailFor({ kind: "element", id: el.id });
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover/row:opacity-100"
|
||||
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -822,6 +915,7 @@ export function PlanView({
|
||||
<PhaseDetailDialog
|
||||
key={`detph-${detailFor.id}`}
|
||||
phase={ph}
|
||||
computed={computed}
|
||||
isFirst={ph.sequenceNumber === 1}
|
||||
onClose={() => setDetailFor(null)}
|
||||
onOpenSpec={onOpenSpec}
|
||||
@@ -862,6 +956,43 @@ export function PlanView({
|
||||
);
|
||||
|
||||
// Nächste Phasen-Kappung (für das Phase-Popup).
|
||||
// Element loeschen -- neu auch direkt aus der Zeile heraus.
|
||||
async function deleteElement(elementId: string, elementName: string) {
|
||||
const ok = await confirmDialog({
|
||||
title: "Element löschen?",
|
||||
message: `«${elementName}» wird aus ALLEN Lebensphasen entfernt, samt Werten und Übergangs-Entscheiden.`,
|
||||
confirmLabel: "Endgültig löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`/api/elements/${elementId}`);
|
||||
setPanel(null);
|
||||
onChanged();
|
||||
toast("success", "Element gelöscht.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePhase(phaseId: string, phaseName: string) {
|
||||
const ok = await confirmDialog({
|
||||
title: "Lebensphase löschen?",
|
||||
message: `«${phaseName}» wird mit allen erfassten Werten und Übergangs-Entscheiden entfernt.`,
|
||||
confirmLabel: "Endgültig löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`/api/phases/${phaseId}`);
|
||||
setPanel(null);
|
||||
onChanged();
|
||||
toast("success", "Lebensphase gelöscht.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
function nextPhaseCap(): number | null {
|
||||
// Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern).
|
||||
const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||
@@ -881,6 +1012,21 @@ export function PlanView({
|
||||
};
|
||||
|
||||
switch (panel.kind) {
|
||||
case "elementMeta": {
|
||||
const el = plan.elements.find((e) => e.id === panel.elementId);
|
||||
if (!el) return null;
|
||||
return (
|
||||
<InspectorShell key={`meta-${panel.elementId}`} title={`Element: ${el.name}`} onClose={close}>
|
||||
<ElementMetaPanel
|
||||
element={el}
|
||||
plan={plan}
|
||||
personLabel={personLabel}
|
||||
onSaved={closeAndRefresh}
|
||||
onDelete={() => deleteElement(el.id, el.name)}
|
||||
/>
|
||||
</InspectorShell>
|
||||
);
|
||||
}
|
||||
case "phase": {
|
||||
const phase = computed.phases.find((p) => p.id === panel.phaseId);
|
||||
const phaseInput = plan.phases.find((p) => p.id === panel.phaseId);
|
||||
@@ -889,6 +1035,7 @@ export function PlanView({
|
||||
<InspectorShell key={`ph-${panel.phaseId}`} title={`Lebensphase: ${phase.name}`} onClose={close}>
|
||||
<PhaseDetail
|
||||
phase={phaseInput}
|
||||
allPhases={plan.phases}
|
||||
maxDurationYears={phase.maxDurationYears}
|
||||
isLast={phase.sequenceNumber === computed.phases.length}
|
||||
laterPhaseCount={laterPhaseCount(phase)}
|
||||
@@ -921,7 +1068,7 @@ export function PlanView({
|
||||
onSaved={closeAndRefresh}
|
||||
onDeleteElement={() => {
|
||||
setPanel(null);
|
||||
deleteElement(element.id);
|
||||
deleteElement(element.id, element.name);
|
||||
}}
|
||||
/>
|
||||
</InspectorShell>
|
||||
@@ -966,19 +1113,6 @@ export function PlanView({
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteElement(id: string) {
|
||||
const ok = await confirmDialog({
|
||||
title: "Element löschen?",
|
||||
message: "Das Element wird aus ALLEN Lebensphasen entfernt – samt aller Werte und Übergangs-Entscheide.",
|
||||
confirmLabel: "Endgültig löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
await api.delete(`/api/elements/${id}`);
|
||||
toast("success", "Element gelöscht.");
|
||||
setPanel(null);
|
||||
onChanged();
|
||||
}
|
||||
|
||||
function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string {
|
||||
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||
@@ -1138,6 +1272,7 @@ function PhaseHeader({
|
||||
onExpand,
|
||||
onDistributeCapital,
|
||||
onDistributeRates,
|
||||
onDelete,
|
||||
active,
|
||||
}: {
|
||||
phase: PhaseComputed;
|
||||
@@ -1150,6 +1285,8 @@ function PhaseHeader({
|
||||
// wird in der ersten Phase von der Berechnung ignoriert, dort zählt der Startwert).
|
||||
onDistributeCapital: (() => void) | null;
|
||||
onDistributeRates: () => void;
|
||||
// null = nicht die letzte Phase (nur die letzte lässt sich löschen).
|
||||
onDelete: (() => void) | null;
|
||||
active: boolean;
|
||||
}) {
|
||||
const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote";
|
||||
@@ -1160,7 +1297,7 @@ function PhaseHeader({
|
||||
return (
|
||||
<th
|
||||
onClick={onClick}
|
||||
className={`group/ph sticky top-0 z-30 min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
||||
className={`group/ph sticky top-0 z-30 w-52 min-w-52 max-w-52 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
||||
active
|
||||
? "bg-accent-soft"
|
||||
: diffKind === "added"
|
||||
@@ -1180,8 +1317,35 @@ function PhaseHeader({
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||||
)}
|
||||
{/* Der Kopf öffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event
|
||||
deshalb stoppen, sonst gingen beide Dialoge gleichzeitig auf. */}
|
||||
{/* Aktionen zur Phase. Der Kopf selbst öffnet ebenfalls das Bearbeiten-Panel; die
|
||||
Knöpfe müssen das Event deshalb stoppen, sonst gingen zwei Dinge gleichzeitig auf.
|
||||
Bewusst IMMER sichtbar (Touch hat kein Hover). */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${phase.name} bearbeiten`}
|
||||
title="Bezeichnung und Dauer ändern"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${phase.name} löschen`}
|
||||
title="Lebensphase löschen"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Detailansicht ${phase.name}`}
|
||||
@@ -1190,7 +1354,7 @@ function PhaseHeader({
|
||||
e.stopPropagation();
|
||||
onExpand();
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-accent-soft hover:text-accent group-hover/ph:opacity-100"
|
||||
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -1290,7 +1454,7 @@ function TransitionHeader({ openCount, onClick }: { openCount: number; onClick:
|
||||
<th
|
||||
onClick={onClick}
|
||||
data-tour="transition"
|
||||
className={`sticky top-0 z-30 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${
|
||||
className={`sticky top-0 z-30 w-24 min-w-24 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${
|
||||
done ? "bg-surface-2 text-success" : "bg-attention text-attention-fg"
|
||||
}`}
|
||||
>
|
||||
@@ -1491,6 +1655,8 @@ function AddElementDialog({
|
||||
// Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere.
|
||||
inheritedValues: {},
|
||||
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
|
||||
investTargets: [],
|
||||
capitalFromTransfer: 0,
|
||||
};
|
||||
|
||||
async function create() {
|
||||
@@ -1763,9 +1929,6 @@ function TransitionReviewDialog({
|
||||
ct={ct}
|
||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
|
||||
capitalInflow={toPhase?.capitalInflow ?? 0}
|
||||
investTargets={investTargetsOf(toPhase)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1857,9 +2020,6 @@ function CashTransitionPanel({
|
||||
ct={ct}
|
||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
|
||||
capitalInflow={toPhase?.capitalInflow ?? 0}
|
||||
investTargets={investTargetsOf(toPhase)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
|
||||
@@ -2024,3 +2184,98 @@ function PanelActions({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Panel: Element umbenennen und zuordnen ----------------------------------------------
|
||||
// Beides sind Eigenschaften des ELEMENTS, nicht einer Phase -- deshalb ein eigenes Panel,
|
||||
// erreichbar über das Stift-Symbol in der Element-Zeile.
|
||||
function ElementMetaPanel({
|
||||
element,
|
||||
plan,
|
||||
personLabel,
|
||||
onSaved,
|
||||
onDelete,
|
||||
}: {
|
||||
element: ElementInput;
|
||||
plan: PlanInput;
|
||||
personLabel: (role: string) => string;
|
||||
onSaved: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(element.name);
|
||||
const [ownerRole, setOwnerRole] = useState<string>(element.ownerRole ?? "HOUSEHOLD");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const needsPerson = PERSON_ONLY_CATEGORIES.includes(element.category);
|
||||
const isCouple = plan.householdType === "COUPLE";
|
||||
const options = needsPerson
|
||||
? isCouple
|
||||
? [
|
||||
{ value: "PERSON_A", label: personLabel("PERSON_A") },
|
||||
{ value: "PERSON_B", label: personLabel("PERSON_B") },
|
||||
]
|
||||
: [{ value: "PERSON_A", label: personLabel("PERSON_A") }]
|
||||
: isCouple
|
||||
? [
|
||||
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||||
{ value: "PERSON_A", label: personLabel("PERSON_A") },
|
||||
{ value: "PERSON_B", label: personLabel("PERSON_B") },
|
||||
]
|
||||
: [
|
||||
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||||
{ value: "PERSON_A", label: personLabel("PERSON_A") },
|
||||
];
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.patch(`/api/elements/${element.id}`, { name: name.trim() || element.name, ownerRole });
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
|
||||
{CATEGORY_LABELS[element.category]}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
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>
|
||||
|
||||
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
||||
<SelectField
|
||||
label="Zuordnung"
|
||||
help={
|
||||
needsPerson
|
||||
? "AHV, Pensionskasse und Säule 3a hängen am individuellen Pensionierungszeitpunkt und gehören deshalb immer genau einer Person."
|
||||
: "Gemeinsame Elemente zählen zum Haushalt. Bei Paaren wird ein gemeinsames Einkommen NICHT für die AHV-Rente angerechnet."
|
||||
}
|
||||
value={ownerRole}
|
||||
onChange={setOwnerRole}
|
||||
options={options}
|
||||
/>
|
||||
<p className="text-xs text-faint">
|
||||
Die Werte je Lebensphase bearbeitest du direkt in den Zellen der Matrix.
|
||||
</p>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={saving} onClick={save}>
|
||||
{saving ? "Speichern…" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+84
-5
@@ -230,7 +230,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
|
||||
retirementAge: 65,
|
||||
initialCash: 50000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5, cashTransition: { capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } },
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
@@ -240,7 +240,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} },
|
||||
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } }
|
||||
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } }
|
||||
),
|
||||
el(
|
||||
"REAL_ESTATE",
|
||||
@@ -310,10 +310,17 @@ describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", ()
|
||||
|
||||
it("leitet den Zufluss gemaess Quote in Hypothek und Anlage statt ins Cash", () => {
|
||||
const mit = computePlan(withQuoten.build());
|
||||
// Vergleichsplan OHNE Verwendungs-Quoten: Die Quoten haengen seit 0.33 am PK-Element,
|
||||
// nicht mehr am Cash-Uebergang.
|
||||
const raw = withQuoten.build();
|
||||
const ohne = computePlan({
|
||||
...withQuoten.build(),
|
||||
phases: withQuoten.build().phases.map((p) => ({ ...p, cashTransition: {} })),
|
||||
});
|
||||
...raw,
|
||||
elements: raw.elements.map((e) =>
|
||||
e.category === "PENSION_FUND"
|
||||
? { ...e, transitionValues: { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } } }
|
||||
: e
|
||||
),
|
||||
} as PlanInput);
|
||||
const p2Mit = mit.phases[1];
|
||||
const p2Ohne = ohne.phases[1];
|
||||
|
||||
@@ -345,3 +352,75 @@ describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", ()
|
||||
expect(b.mortgageStart).toBeLessThan(a.mortgageStart);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => {
|
||||
// Genau der gemeldete Fehler: 100 % des PK-Kapitals auf den ETF -- danach zeigte das
|
||||
// Element als Zusatzinvestition eine 0, obwohl der Betrag eingeflossen war.
|
||||
function p(): PlanInput {
|
||||
return plan({
|
||||
age: 62,
|
||||
retirementAge: 65,
|
||||
initialCash: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 3 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} },
|
||||
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } }
|
||||
),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0 }, p2: {} }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("weist den umgeleiteten Betrag am Ziel-Element aus", () => {
|
||||
const c = computePlan(p());
|
||||
const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
// 400'000 brutto, 0 % Steuer -> 400'000 netto, davon 100 % in den ETF.
|
||||
expect(etf.capitalFromTransfer).toBe(400000);
|
||||
expect(etf.startValue).toBe(450000); // 50'000 fortgeschrieben + 400'000
|
||||
});
|
||||
|
||||
it("zaehlt ihn als Investition in der Cash-Bruecke (nicht als Tilgung)", () => {
|
||||
const c = computePlan(p());
|
||||
expect(c.phases[1].cashBridge.investments).toBe(400000);
|
||||
expect(c.phases[1].cashBridge.residual).toBe(0);
|
||||
});
|
||||
|
||||
it("fliesst NUR einmal -- nicht in jeder Folgephase erneut", () => {
|
||||
const base = p();
|
||||
const drei: PlanInput = {
|
||||
...base,
|
||||
phases: [...base.phases, { id: "p3", sequenceNumber: 3, name: "p3", durationYears: 5, cashTransition: {} }],
|
||||
} as PlanInput;
|
||||
const c = computePlan(drei);
|
||||
const etfP3 = c.phases[2].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
expect(etfP3.capitalFromTransfer ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("verteilt zwei Guthaben getrennt (PK und 3a je eigene Quote)", () => {
|
||||
const base = p();
|
||||
const mit3a: PlanInput = {
|
||||
...base,
|
||||
elements: [
|
||||
...base.elements,
|
||||
el(
|
||||
"PILLAR_3A",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 100000, expectedReturn: 0 }, p2: {} },
|
||||
{ p1: { capitalTaxRate: 0, capitalUseInvestPct: 50 } }
|
||||
),
|
||||
],
|
||||
} as PlanInput;
|
||||
const c = computePlan(mit3a);
|
||||
const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
// PK: 100 % von 400'000 = 400'000; 3a: 50 % von 100'000 = 50'000.
|
||||
expect(etf.capitalFromTransfer).toBe(450000);
|
||||
});
|
||||
});
|
||||
|
||||
+62
-40
@@ -13,7 +13,7 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { num } from "@/lib/elements";
|
||||
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
import type { ElementCategory, TransitionData } from "@/lib/elements";
|
||||
import type { PersonRole, PlanInput } from "@/lib/types";
|
||||
|
||||
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
|
||||
@@ -89,6 +89,9 @@ export interface ElementPhaseComputed {
|
||||
endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
|
||||
summary: string;
|
||||
note: string | null;
|
||||
// Betrag, der aus einem Kapitalbezug (PK/3a) des vorigen Übergangs in dieses Element
|
||||
// umgeleitet wurde (Punkt C). Nur bei OTHER_ASSET und nur ab Phase 2 > 0.
|
||||
capitalFromTransfer?: number;
|
||||
yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase
|
||||
trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain)
|
||||
transitionTrace?: Trace; // Rechenweg des Übergangs NACH dieser Phase (nur mit explain)
|
||||
@@ -293,6 +296,11 @@ interface Carry {
|
||||
owed: number; // Schulden: Restschuld (positiv)
|
||||
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
|
||||
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase
|
||||
// Betrag, der am letzten Übergang aus einem Kapitalbezug (PK/3a) in dieses Element
|
||||
// umgeleitet wurde (Roadmap Nr. 44, Punkt C). Wird in der Folgephase wie eine
|
||||
// Zusatzeinlage behandelt -- dadurch erscheint er im Element, in der Cash-Brücke und im
|
||||
// Verteil-Dialog, statt unsichtbar im Bestand zu verschwinden.
|
||||
capitalIn: number;
|
||||
// Punkt A (Roadmap Nr. 44): zuletzt verwendete Wiederkehr-Parameter (Raten, Beiträge,
|
||||
// Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase.
|
||||
rates: Record<string, number>;
|
||||
@@ -309,6 +317,7 @@ function emptyCarry(): Carry {
|
||||
owed: 0,
|
||||
pkPensionAnnual: 0,
|
||||
flowBasis: 0,
|
||||
capitalIn: 0,
|
||||
rates: {},
|
||||
hasCarry: false,
|
||||
};
|
||||
@@ -697,7 +706,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
}
|
||||
case "OTHER_ASSET": {
|
||||
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
|
||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||
// Zusatzeinlage = manuell erfasst PLUS das am Übergang umgeleitete Alterskapital.
|
||||
// Beides ist mechanisch dasselbe (Cash -> Vermögen), deshalb derselbe Weg.
|
||||
const fromTransfer = carry.hasCarry ? carry.capitalIn : 0;
|
||||
const topUp = (carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0) + fromTransfer;
|
||||
ec.capitalFromTransfer = fromTransfer;
|
||||
carry.capitalIn = 0; // verbraucht -- sonst flösse er in jeder Folgephase erneut
|
||||
const start = base + topUp;
|
||||
const rate = Math.round(inherited("annualContribution"));
|
||||
const withdrawal = Math.round(inherited("annualWithdrawal"));
|
||||
@@ -1411,6 +1425,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
// --- Übergang: Carry aktualisieren, Cash der Folgephase bilden ---
|
||||
let txInflow = 0;
|
||||
let txImmediateRepay = 0;
|
||||
// Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND
|
||||
// der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt.
|
||||
const capitalUses: { net: number; td: TransitionData }[] = [];
|
||||
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
|
||||
// Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind
|
||||
// nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz
|
||||
@@ -1490,6 +1507,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
txInflow += net;
|
||||
txTax += value - net;
|
||||
capitalUses.push({ net, td });
|
||||
carry.value = 0;
|
||||
carry.pkPensionAnnual = 0;
|
||||
} else if (mode === "PENSION") {
|
||||
@@ -1501,6 +1519,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
txInflow += net;
|
||||
txTax += capital - net;
|
||||
capitalUses.push({ net, td });
|
||||
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
|
||||
txPensionConversion += value - capital;
|
||||
carry.value = 0;
|
||||
@@ -1521,6 +1540,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
txInflow += net;
|
||||
txTax += ec.endValue - net;
|
||||
capitalUses.push({ net, td });
|
||||
carry.value = 0;
|
||||
} else {
|
||||
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||
@@ -1657,48 +1677,50 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
carry.hasCarry = true;
|
||||
}
|
||||
|
||||
// --- Punkt C (Roadmap Nr. 44): Verwendung des Kapitalzuflusses -----------------------
|
||||
// --- Punkt C (Roadmap Nr. 44): Verwendung des bezogenen Alterskapitals ---------------
|
||||
//
|
||||
// Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, 3a,
|
||||
// Immobilienverkauf). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht.
|
||||
// Die Verwendung wird deshalb als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert
|
||||
// sich der Betrag -- die Aufteilung skaliert mit, statt still falsch zu werden.
|
||||
// Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, Säule 3a).
|
||||
// Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Die Verwendung wird
|
||||
// als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert sich der Betrag -- die
|
||||
// Aufteilung skaliert mit, statt still falsch zu werden.
|
||||
//
|
||||
// Seit 0.33 hängt die Quote am jeweiligen VORSORGE-ELEMENT (PK bzw. 3a) statt am
|
||||
// Cash-Übergang: Nur so lassen sich zwei Guthaben getrennt verwenden, und die Frage steht
|
||||
// dort, wo der Bezugs-Entscheid fällt.
|
||||
//
|
||||
// Mechanisch nichts Neues: Die Amortisations-Quote wirkt wie eine Sonderamortisation, die
|
||||
// Anlage-Quote wie eine Zusatzinvestition. Beide sind schon heute Cash-Abflüsse an der
|
||||
// Grenze und laufen damit korrekt durch beide Brücken.
|
||||
if (txInflow > 0) {
|
||||
const ct = phase.cashTransition ?? {};
|
||||
const amortPct = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct)));
|
||||
const investPct = Math.max(0, Math.min(100 - amortPct, num(ct.capitalUseInvestPct)));
|
||||
if (amortPct > 0 || investPct > 0) {
|
||||
let amortBudget = Math.round((txInflow * amortPct) / 100);
|
||||
for (const e of orderedElements) {
|
||||
if (amortBudget <= 0) break;
|
||||
if (e.category !== "REAL_ESTATE") continue;
|
||||
const c = carries.get(e.id)!;
|
||||
if (c.status !== "ACTIVE" || c.mortgage <= 0) continue;
|
||||
const pay = Math.min(amortBudget, c.mortgage);
|
||||
c.mortgage -= pay;
|
||||
amortBudget -= pay;
|
||||
txImmediateRepay += pay;
|
||||
}
|
||||
// Anlage-Quote wie eine Zusatzeinlage in der Folgephase. Beide sind Cash-Abflüsse und
|
||||
// laufen damit korrekt durch beide Brücken.
|
||||
for (const use of capitalUses) {
|
||||
const amortPct = Math.max(0, Math.min(100, num(use.td.capitalUseAmortizationPct)));
|
||||
const investPct = Math.max(0, Math.min(100 - amortPct, num(use.td.capitalUseInvestPct)));
|
||||
if (use.net <= 0 || (amortPct === 0 && investPct === 0)) continue;
|
||||
|
||||
const investBudget = Math.round((txInflow * investPct) / 100);
|
||||
if (investBudget > 0) {
|
||||
const target =
|
||||
orderedElements.find(
|
||||
(e) =>
|
||||
e.id === ct.capitalUseTargetElementId &&
|
||||
e.category === "OTHER_ASSET" &&
|
||||
carries.get(e.id)!.status === "ACTIVE"
|
||||
) ??
|
||||
orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE");
|
||||
if (target) {
|
||||
carries.get(target.id)!.value += investBudget;
|
||||
txImmediateRepay += investBudget;
|
||||
}
|
||||
}
|
||||
let amortBudget = Math.round((use.net * amortPct) / 100);
|
||||
for (const e of orderedElements) {
|
||||
if (amortBudget <= 0) break;
|
||||
if (e.category !== "REAL_ESTATE") continue;
|
||||
const c = carries.get(e.id)!;
|
||||
if (c.status !== "ACTIVE" || c.mortgage <= 0) continue;
|
||||
const pay = Math.min(amortBudget, c.mortgage);
|
||||
c.mortgage -= pay;
|
||||
amortBudget -= pay;
|
||||
txImmediateRepay += pay;
|
||||
}
|
||||
|
||||
const investBudget = Math.round((use.net * investPct) / 100);
|
||||
if (investBudget > 0) {
|
||||
const target =
|
||||
orderedElements.find(
|
||||
(e) =>
|
||||
e.id === use.td.capitalUseTargetElementId &&
|
||||
e.category === "OTHER_ASSET" &&
|
||||
carries.get(e.id)!.status === "ACTIVE"
|
||||
) ??
|
||||
orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE");
|
||||
// Der Betrag wandert NICHT direkt in den Bestand, sondern über `capitalIn` in die
|
||||
// Zusatzeinlage der Folgephase -- dadurch wird er im UI überall sichtbar.
|
||||
if (target) carries.get(target.id)!.capitalIn += investBudget;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-11
@@ -122,6 +122,20 @@ export interface TransitionData {
|
||||
extraAmortization?: number;
|
||||
// OTHER_DEBT
|
||||
immediateRepayment?: number;
|
||||
// Verwendung des BEZOGENEN KAPITALS (Roadmap Nr. 44, Punkt C).
|
||||
//
|
||||
// Gilt für PENSION_FUND (nur bei Kapitalbezug) und PILLAR_3A am Pensions-Übergang: Wohin
|
||||
// fliesst das ausbezahlte Alterskapital? Bewusst in PROZENT und nicht in Franken -- wird
|
||||
// das Pensionsalter verschoben, ändert sich der Betrag, und eine Quote skaliert mit,
|
||||
// während eine Frankenzahl still falsch würde. Der nicht zugeteilte Rest bleibt Cash.
|
||||
//
|
||||
// Bis 0.32 lagen diese Felder am CASH-Übergang. Das war der falsche Ort: Die Frage gehört
|
||||
// zum Bezugs-Entscheid des jeweiligen Vorsorgeguthabens, nicht zum Cash-Konto -- und mit
|
||||
// zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten.
|
||||
capitalUseAmortizationPct?: number;
|
||||
capitalUseInvestPct?: number;
|
||||
// Ziel der Anlage-Quote; ohne Angabe das erste aktive «Sonstiges Vermögen».
|
||||
capitalUseTargetElementId?: string;
|
||||
}
|
||||
|
||||
// --- Cash-Übergang: einmalige Sonderein-/ausgaben ---
|
||||
@@ -140,14 +154,6 @@ export interface CashTransitionData {
|
||||
// Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft).
|
||||
outflowLabel?: string;
|
||||
outflowAmount?: number;
|
||||
// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C).
|
||||
// Bewusst in PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich
|
||||
// das bezogene Kapital -- eine Franken-Angabe müsste man dann von Hand nachziehen, eine
|
||||
// Quote skaliert mit. Der nicht zugeteilte Rest bleibt Cash.
|
||||
capitalUseAmortizationPct?: number;
|
||||
capitalUseInvestPct?: number;
|
||||
// Ziel der Anlage-Quote; ohne Angabe das erste aktive «Sonstiges Vermögen».
|
||||
capitalUseTargetElementId?: string;
|
||||
}
|
||||
|
||||
// --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) ---
|
||||
@@ -162,9 +168,6 @@ export const cashTransitionSchema = z
|
||||
inflowTaxRate: z.number().min(0).max(100).optional(),
|
||||
outflowLabel: z.string().max(120).optional(),
|
||||
outflowAmount: nonNeg.optional(),
|
||||
capitalUseAmortizationPct: z.number().min(0).max(100).optional(),
|
||||
capitalUseInvestPct: z.number().min(0).max(100).optional(),
|
||||
capitalUseTargetElementId: z.string().max(60).optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
@@ -210,6 +213,9 @@ export const transitionDataSchema = z
|
||||
partialSaleAmount: nonNeg.optional(),
|
||||
extraAmortization: nonNeg.optional(),
|
||||
immediateRepayment: nonNeg.optional(),
|
||||
capitalUseAmortizationPct: z.number().min(0).max(100).optional(),
|
||||
capitalUseInvestPct: z.number().min(0).max(100).optional(),
|
||||
capitalUseTargetElementId: z.string().max(60).optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { planSegments, defaultOpenDuration } from "@/lib/phaseplan";
|
||||
import { planSegments, defaultOpenDuration, planDurationChange } from "@/lib/phaseplan";
|
||||
|
||||
describe("planSegments", () => {
|
||||
it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => {
|
||||
@@ -78,3 +78,52 @@ describe("planSegments", () => {
|
||||
expect(defaultOpenDuration([{ age: 50 }, { age: 60 }], 5)).toBe(25); // ältester zählt: 90-(60+5)
|
||||
});
|
||||
});
|
||||
|
||||
describe("planDurationChange", () => {
|
||||
const phases = [
|
||||
{ id: "p1", name: "Erwerb", sequenceNumber: 1, durationYears: 10 },
|
||||
{ id: "p2", name: "Erwerb 2", sequenceNumber: 2, durationYears: 10 },
|
||||
{ id: "p3", name: "Pension", sequenceNumber: 3, durationYears: 25 },
|
||||
];
|
||||
|
||||
it("laesst die Folgephase den Ausgleich tragen", () => {
|
||||
// Genau der gemeldete Fall: Phase 1 von 10 auf 12 -- ohne Ausgleich wuerde Phase 2 die
|
||||
// Pensionierung ueberspannen.
|
||||
const r = planDurationChange(phases, "p1", 12);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.delta).toBe(2);
|
||||
expect(r.neighbourId).toBe("p2");
|
||||
expect(r.neighbourNewDuration).toBe(8);
|
||||
});
|
||||
|
||||
it("verlaengert die Folgephase beim Verkuerzen", () => {
|
||||
const r = planDurationChange(phases, "p1", 7);
|
||||
expect(r.neighbourNewDuration).toBe(13);
|
||||
});
|
||||
|
||||
it("blockt, wenn die Folgephase unter ein Jahr fiele", () => {
|
||||
const r = planDurationChange(phases, "p1", 20); // p2 haette dann 0
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toContain("Erwerb 2");
|
||||
});
|
||||
|
||||
it("laesst die LETZTE Phase frei -- sie verlaengert den Plan", () => {
|
||||
const r = planDurationChange(phases, "p3", 30);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.neighbourId).toBeNull();
|
||||
});
|
||||
|
||||
it("meldet keine Aenderung bei gleicher Dauer", () => {
|
||||
const r = planDurationChange(phases, "p2", 10);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.delta).toBe(0);
|
||||
expect(r.neighbourId).toBeNull();
|
||||
});
|
||||
|
||||
it("haelt die Gesamtdauer konstant", () => {
|
||||
const before = phases.reduce((s, p) => s + p.durationYears, 0);
|
||||
const r = planDurationChange(phases, "p1", 15);
|
||||
const after = 15 + r.neighbourNewDuration! + phases[2].durationYears;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,3 +48,66 @@ export function defaultOpenDuration(persons: { age: number }[], fromYear: number
|
||||
const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65;
|
||||
return Math.max(5, 90 - (oldestNow + fromYear));
|
||||
}
|
||||
|
||||
// --- Phasendauer ändern: die Folgephase gleicht aus --------------------------------------
|
||||
//
|
||||
// Wird eine Phase verlängert, muss die NÄCHSTE um denselben Betrag kürzer werden -- sonst
|
||||
// verschieben sich alle folgenden Phasengrenzen, und eine davon überspannt am Ende eine
|
||||
// Pensionierung. Genau das passierte bis 0.32: Die Kappung prüfte nur die BEARBEITETE Phase
|
||||
// («Phase 1 darf höchstens bis zur Pensionierung laufen»), nicht die Folgen für Phase 2.
|
||||
//
|
||||
// Dieselbe Mechanik wie beim Verschieben des Pensionsalters (lib/retirement.ts): Die
|
||||
// Gesamtdauer des Plans bleibt gleich, es wird nur Zeit umverteilt. Nur die LETZTE Phase hat
|
||||
// keine Nachfolgerin -- sie verlängert oder verkürzt den Plan tatsächlich.
|
||||
|
||||
export interface DurationChange {
|
||||
ok: boolean;
|
||||
// Die Folgephase, die den Ausgleich trägt (null bei der letzten Phase).
|
||||
neighbourId: string | null;
|
||||
neighbourName: string | null;
|
||||
neighbourNewDuration: number | null;
|
||||
delta: number; // Änderung der bearbeiteten Phase in Jahren
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function planDurationChange(
|
||||
phases: { id: string; name: string; sequenceNumber: number; durationYears: number }[],
|
||||
phaseId: string,
|
||||
newDuration: number
|
||||
): DurationChange {
|
||||
const sorted = [...phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
const i = sorted.findIndex((p) => p.id === phaseId);
|
||||
const none: DurationChange = {
|
||||
ok: false, neighbourId: null, neighbourName: null, neighbourNewDuration: null, delta: 0, error: null,
|
||||
};
|
||||
if (i < 0) return { ...none, error: "Diese Lebensphase gibt es nicht." };
|
||||
|
||||
const delta = Math.round(newDuration) - sorted[i].durationYears;
|
||||
if (newDuration < 1) return { ...none, delta, error: "Eine Lebensphase muss mindestens ein Jahr dauern." };
|
||||
if (delta === 0) return { ...none, ok: true };
|
||||
|
||||
const next = sorted[i + 1];
|
||||
// Letzte Phase: kein Ausgleich nötig, der Plan wird einfach länger oder kürzer.
|
||||
if (!next) return { ...none, ok: true, delta };
|
||||
|
||||
const neighbourNewDuration = next.durationYears - delta;
|
||||
if (neighbourNewDuration < 1) {
|
||||
return {
|
||||
...none,
|
||||
delta,
|
||||
error:
|
||||
`«${next.name}» dauert nur ${next.durationYears} Jahr${next.durationYears === 1 ? "" : "e"} und müsste den ` +
|
||||
`Ausgleich tragen. Verlängere diese Phase um höchstens ${next.durationYears - 1} Jahr` +
|
||||
`${next.durationYears - 1 === 1 ? "" : "e"} – oder passe zuerst die Folgephase an.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
neighbourId: next.id,
|
||||
neighbourName: next.name,
|
||||
neighbourNewDuration,
|
||||
delta,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
+12
-2
@@ -6,6 +6,7 @@
|
||||
// the client.» Deshalb liegen sie hier, wo beide Seiten sie benutzen dürfen.
|
||||
|
||||
import { num } from "@/lib/elements";
|
||||
import { DEFAULT_CAPITAL_TAX_RATE } from "@/lib/constants";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import type { CashTransitionData, ElementCategory, TransitionData } from "@/lib/elements";
|
||||
|
||||
@@ -29,7 +30,14 @@ export function withTransitionDefaults(
|
||||
out.withdrawalMode = "NONE";
|
||||
}
|
||||
} else if (category === "PILLAR_3A") {
|
||||
if (!isRetirement && out.withdrawalMode === undefined) out.withdrawalMode = "NONE";
|
||||
if (isRetirement) {
|
||||
// Der Bezug selbst steht fest (die 3a wird bei Pensionierung immer ausbezahlt) -- zu
|
||||
// entscheiden sind Steuersatz und Verwendung. Ein Speichern setzt den Satz und
|
||||
// markiert den Entscheid damit als getroffen.
|
||||
if (out.capitalTaxRate === undefined) out.capitalTaxRate = DEFAULT_CAPITAL_TAX_RATE;
|
||||
} else if (out.withdrawalMode === undefined) {
|
||||
out.withdrawalMode = "NONE";
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -50,7 +58,9 @@ export function isTransitionAnswered(
|
||||
case "PENSION_FUND":
|
||||
return isRetirement ? td.payoutMode !== undefined : td.withdrawalMode !== undefined;
|
||||
case "PILLAR_3A":
|
||||
return isRetirement ? true : td.withdrawalMode !== undefined;
|
||||
// Bei der Pensionierung gilt der Entscheid als getroffen, sobald der Steuersatz
|
||||
// erfasst ist -- vorher steht die Verwendung des Kapitals offen (Punkt C).
|
||||
return isRetirement ? td.capitalTaxRate !== undefined : td.withdrawalMode !== undefined;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user