Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
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:
2026-07-25 23:09:03 +02:00
parent 0761f6b3e2
commit 0953880a80
17 changed files with 1040 additions and 267 deletions
+24 -8
View File
@@ -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%">
+7
View File
@@ -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">
+7 -25
View File
@@ -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>
</>
+59 -26
View File
@@ -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>
)}
+106 -44
View File
@@ -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);
+76 -7
View File
@@ -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>
);
}
+55 -14
View File
@@ -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
View File
@@ -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>
);
}