Plan-Assistent: abschnittsbasierte Phasenplanung + Vermoegen pro Person
Deploy App / deploy (push) Successful in 58s
Deploy App / deploy (push) Successful in 58s
Rein an der Oberflaeche, keine Aenderung an Berechnung, Datenmodell oder API. Schritt 2 (Lebensphasen) an den fixen Pensionierungszeitpunkten ausgerichtet: - neues reines Modul phaseplan.ts (planSegments) leitet die Abschnitte ab: Erwerb (alle arbeiten) / Misch (eine pensioniert, eine arbeitet) / Pension (alle pensioniert), je mit kurzer Definition - feste Abschnitte (durch Pensionierung begrenzt): beliebig viele Phasen mit +/Papierkorb und eigenem Namen; Live-Summe erzwingt exaktes Aufgehen, "Weiter" bis dahin gesperrt - offener Pensions-Abschnitt: Dauer frei - Anzahl Abschnitte wird abgeleitet (Einzel: 2, Paar unterschiedl. Ret: 3, bereits pensioniert: Misch/Pension zuerst) - neue Zeitachse mit Pensionierungs-Flaggen; Phasen nummeriert und UNTER dem Balken beschriftet (kurze Phasen bleiben lesbar) - behebt den Fehler, dass die Erwerbsphase beliebig ueber die Pensionierung hinaus gesetzt werden konnte Schritt 4 (Vorsorge & Vermoegen) bei Paaren aufgeteilt in Gemeinsam / Person A / Person B; PK und 3a je Person, Wertschriften/ Wohneigentum/Schulden je Bereich. 8 Tests (111 -> 119). SPEZIFIKATION auf 0.15 (Kap. 3.2.8 neu gefasst). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+440
-183
@@ -5,21 +5,25 @@
|
||||
// die BESTEHENDEN API-Endpunkte -- reine Orchestrierung, keine Änderung an Berechnung
|
||||
// oder Datenmodell.
|
||||
//
|
||||
// Reihenfolge beim Anlegen (wichtig): erst Phase 1, dann Elemente samt Phase-1-Werten,
|
||||
// dann die Folgephasen -- so belegt die Phasen-Route die Folgephasen korrekt vor.
|
||||
// Schritt 2 (Lebensphasen) ist an den fixen Pensionierungszeitpunkten ausgerichtet: Die
|
||||
// Lebenslinie zerfällt in Abschnitte (Erwerb / Misch / Pension, siehe lib/phaseplan). In den
|
||||
// FESTEN Abschnitten (durch eine Pensionierung begrenzt) verteilt der Nutzer beliebig viele
|
||||
// Phasen, deren Dauer exakt aufgehen muss; der letzte Pensions-Abschnitt ist offen.
|
||||
//
|
||||
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan.
|
||||
// Der ist normal weiterbearbeitbar; ein Rollback bräuchte Backend-Unterstützung und ist
|
||||
// bewusst nicht gebaut (siehe SPEZIFIKATION 9.23).
|
||||
// Reihenfolge beim Anlegen (wichtig): erst Phase 1, dann Elemente samt Phase-1-Werten, dann
|
||||
// die Folgephasen -- so belegt die Phasen-Route die Folgephasen korrekt vor.
|
||||
//
|
||||
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan
|
||||
// (siehe SPEZIFIKATION 9.23).
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Flag, Plus, Sparkles, Trash2 } from "lucide-react";
|
||||
import { Modal, Button } from "@/components/ui";
|
||||
import { MoneyField, NumberField, TextField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { maxPhaseDuration } from "@/lib/calculations";
|
||||
import { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan";
|
||||
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
|
||||
import type { PhaseData } from "@/lib/elements";
|
||||
|
||||
@@ -28,38 +32,68 @@ interface PhaseDraft {
|
||||
durationYears: number;
|
||||
}
|
||||
|
||||
interface AssetToggles {
|
||||
pk: boolean;
|
||||
p3a: boolean;
|
||||
etf: boolean;
|
||||
realEstate: boolean;
|
||||
debt: boolean;
|
||||
}
|
||||
|
||||
const STEP_TITLES = ["Über dich", "Deine Lebensphasen", "Einkommen & Ausgaben", "Vorsorge & Vermögen", "Fertig"];
|
||||
|
||||
// Vorschlag für die Phasenkette: Erwerbsphase bis zum nächsten Pensionsereignis
|
||||
// (dieselbe Kappungslogik wie beim manuellen Anlegen), danach eine Pensionsphase bis ~90.
|
||||
function suggestPhases(draft: ProfileDraft): PhaseDraft[] {
|
||||
const persons = draft.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge }));
|
||||
const cap = maxPhaseDuration(persons, 0);
|
||||
const phases: PhaseDraft[] = [];
|
||||
let elapsed = 0;
|
||||
if (cap != null && cap > 0) {
|
||||
phases.push({ name: "Erwerbsphase", durationYears: cap });
|
||||
elapsed = cap;
|
||||
// Bei Paaren mit unterschiedlichem Pensionsalter folgt eine Mischphase bis zur
|
||||
// zweiten Pensionierung.
|
||||
const cap2 = maxPhaseDuration(persons, elapsed);
|
||||
if (cap2 != null && cap2 > 0) {
|
||||
phases.push({ name: "Übergangsphase", durationYears: cap2 });
|
||||
elapsed += cap2;
|
||||
}
|
||||
}
|
||||
const oldest = Math.max(...draft.persons.map((p) => p.age));
|
||||
const pensionYears = Math.max(5, 90 - (oldest + elapsed));
|
||||
phases.push({ name: "Pensionsphase", durationYears: pensionYears });
|
||||
return phases;
|
||||
const SEGMENT_META: Record<SegmentType, { label: string; phase: string; def: string; color: string; soft: string }> = {
|
||||
ERWERB: {
|
||||
label: "Erwerb",
|
||||
phase: "Erwerbsphase",
|
||||
def: "Alle arbeiten noch – Einkommen fliesst, Vorsorge wächst.",
|
||||
color: "bg-accent/70 text-accent-fg",
|
||||
soft: "border-accent",
|
||||
},
|
||||
MIXED: {
|
||||
label: "Misch",
|
||||
phase: "Übergangsphase",
|
||||
def: "Eine Person ist pensioniert, die andere arbeitet noch.",
|
||||
color: "bg-accent/40 text-accent-fg",
|
||||
soft: "border-accent",
|
||||
},
|
||||
PENSION: {
|
||||
label: "Pension",
|
||||
phase: "Pensionsphase",
|
||||
def: "Alle sind pensioniert – gelebt wird aus Renten und Vermögen.",
|
||||
color: "bg-accent-soft text-accent-soft-fg",
|
||||
soft: "border-accent",
|
||||
},
|
||||
};
|
||||
|
||||
function personsSignature(draft: ProfileDraft): string {
|
||||
return draft.householdType + "|" + draft.persons.map((p) => `${p.age}/${p.retirementAge}`).join(",");
|
||||
}
|
||||
|
||||
// Eine Phase je Abschnitt als Startpunkt: feste Länge in den begrenzten Abschnitten, eine
|
||||
// sinnvolle Vorgabe im offenen Pensions-Abschnitt.
|
||||
function defaultSegPhases(segments: PlanSegment[], persons: { age: number }[]): PhaseDraft[][] {
|
||||
const typeCount: Record<SegmentType, number> = { ERWERB: 0, MIXED: 0, PENSION: 0 };
|
||||
return segments.map((seg) => {
|
||||
typeCount[seg.type] += 1;
|
||||
const dur = seg.fixedYears ?? defaultOpenDuration(persons, seg.fromYear);
|
||||
return [{ name: SEGMENT_META[seg.type].phase, durationYears: Math.max(1, dur) }];
|
||||
});
|
||||
}
|
||||
|
||||
// --- Vermögens-/Vorsorge-Erfassung je Eigentümer-Bereich ---------------------------------
|
||||
|
||||
type Scope = "SELF" | "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
|
||||
type Category = "pk" | "p3a" | "etf" | "re" | "debt";
|
||||
|
||||
interface ScopeAssets {
|
||||
pk: { on: boolean; value: number; contribution: number };
|
||||
p3a: { on: boolean; value: number; contribution: number };
|
||||
etf: { on: boolean; value: number; contribution: number };
|
||||
re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number };
|
||||
debt: { on: boolean; value: number; repayment: number };
|
||||
}
|
||||
|
||||
function emptyScope(): ScopeAssets {
|
||||
return {
|
||||
pk: { on: false, value: 0, contribution: 0 },
|
||||
p3a: { on: false, value: 0, contribution: 0 },
|
||||
etf: { on: false, value: 0, contribution: 0 },
|
||||
re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5 },
|
||||
debt: { on: false, value: 0, repayment: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function PlanWizard({
|
||||
@@ -72,30 +106,47 @@ export function PlanWizard({
|
||||
const [step, setStep] = useState(0);
|
||||
const [planName, setPlanName] = useState("Meine Planung");
|
||||
const [profile, setProfile] = useState<ProfileDraft>(emptyProfileDraft);
|
||||
const [phases, setPhases] = useState<PhaseDraft[] | null>(null);
|
||||
// Einkommen bewusst PRO PERSON: In Paar-Plänen zählt "Gemeinsam" nicht für die
|
||||
// AHV-Rente (SPEZIFIKATION 9.9) -- der Assistent räumt diese Falle von Anfang an aus.
|
||||
|
||||
// Abschnitte der Lebenslinie und die je Abschnitt geplanten Phasen (parallel indexiert).
|
||||
const segments = useMemo(() => planSegments(profile.persons), [profile.persons]);
|
||||
const [segPhases, setSegPhases] = useState<PhaseDraft[][]>([]);
|
||||
const segSig = useRef<string>("");
|
||||
|
||||
// Einkommen bewusst PRO PERSON: In Paar-Plänen zählt "Gemeinsam" nicht für die AHV-Rente
|
||||
// (SPEZIFIKATION 9.9) -- der Assistent räumt diese Falle von Anfang an aus.
|
||||
const [incomes, setIncomes] = useState<Record<string, number>>({ PERSON_A: 0 });
|
||||
const [expenses, setExpenses] = useState(0);
|
||||
const [initialCash, setInitialCash] = useState(0);
|
||||
const [toggles, setToggles] = useState<AssetToggles>({ pk: false, p3a: false, etf: false, realEstate: false, debt: false });
|
||||
const [pk, setPk] = useState({ value: 0, contribution: 0 });
|
||||
const [p3a, setP3a] = useState({ value: 0, contribution: 0 });
|
||||
const [etf, setEtf] = useState({ value: 0, contribution: 0 });
|
||||
const [re, setRe] = useState({ price: 0, mortgage: 0, amortization: 0, interest: 1.5 });
|
||||
const [debt, setDebt] = useState({ value: 0, repayment: 0 });
|
||||
|
||||
const [assets, setAssets] = useState<Record<Scope, ScopeAssets>>({
|
||||
SELF: emptyScope(),
|
||||
HOUSEHOLD: emptyScope(),
|
||||
PERSON_A: emptyScope(),
|
||||
PERSON_B: emptyScope(),
|
||||
});
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isCouple = profile.householdType === "COUPLE";
|
||||
const personLabel = (role: string) => {
|
||||
const p = profile.persons.find((x) => x.role === role);
|
||||
return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B");
|
||||
};
|
||||
|
||||
function setScope(scope: Scope, cat: Category, patch: Record<string, number | boolean>) {
|
||||
setAssets((prev) => ({ ...prev, [scope]: { ...prev[scope], [cat]: { ...prev[scope][cat], ...patch } } }));
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (step === 0) {
|
||||
// Phasenvorschlag aus dem (evtl. geänderten) Profil neu ableiten.
|
||||
setPhases(suggestPhases(profile));
|
||||
// Abschnitte neu ableiten; Phasen nur zurücksetzen, wenn sich das Profil strukturell
|
||||
// geändert hat (sonst blieben Bearbeitungen des Nutzers erhalten).
|
||||
const sig = personsSignature(profile);
|
||||
if (sig !== segSig.current || segPhases.length === 0) {
|
||||
setSegPhases(defaultSegPhases(segments, profile.persons));
|
||||
segSig.current = sig;
|
||||
}
|
||||
const inc: Record<string, number> = {};
|
||||
for (const p of profile.persons) inc[p.role] = incomes[p.role] ?? 0;
|
||||
setIncomes(inc);
|
||||
@@ -103,6 +154,40 @@ export function PlanWizard({
|
||||
setStep((s) => Math.min(s + 1, STEP_TITLES.length - 1));
|
||||
}
|
||||
|
||||
// --- Validierung Schritt 1 ---
|
||||
const segSums = segPhases.map((phs) => phs.reduce((s, p) => s + Math.max(0, p.durationYears), 0));
|
||||
const step1Valid =
|
||||
segPhases.length === segments.length &&
|
||||
segments.every((seg, i) => {
|
||||
const phs = segPhases[i] ?? [];
|
||||
if (phs.length === 0) return false;
|
||||
if (phs.some((p) => p.durationYears < 1)) return false;
|
||||
return seg.fixedYears === null ? segSums[i] >= 1 : segSums[i] === seg.fixedYears;
|
||||
});
|
||||
|
||||
// Flache, geordnete Phasenliste (für Vorschau, Timeline und Anlegen).
|
||||
const flatPhases: PhaseDraft[] = segPhases.flat();
|
||||
|
||||
function updatePhase(segIdx: number, phaseIdx: number, patch: Partial<PhaseDraft>) {
|
||||
setSegPhases((prev) =>
|
||||
prev.map((phs, i) => (i === segIdx ? phs.map((p, j) => (j === phaseIdx ? { ...p, ...patch } : p)) : phs))
|
||||
);
|
||||
}
|
||||
function addPhase(segIdx: number) {
|
||||
const seg = segments[segIdx];
|
||||
const rest = seg.fixedYears === null ? 5 : Math.max(0, seg.fixedYears - segSums[segIdx]);
|
||||
setSegPhases((prev) =>
|
||||
prev.map((phs, i) =>
|
||||
i === segIdx
|
||||
? [...phs, { name: `${SEGMENT_META[seg.type].phase} ${phs.length + 1}`, durationYears: Math.max(1, rest) }]
|
||||
: phs
|
||||
)
|
||||
);
|
||||
}
|
||||
function removePhase(segIdx: number, phaseIdx: number) {
|
||||
setSegPhases((prev) => prev.map((phs, i) => (i === segIdx ? phs.filter((_, j) => j !== phaseIdx) : phs)));
|
||||
}
|
||||
|
||||
async function create() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -117,10 +202,9 @@ export function PlanWizard({
|
||||
const sid = scenario.id;
|
||||
if (initialCash > 0) await api.patch(`/api/scenarios/${sid}`, { initialCash });
|
||||
|
||||
const phaseList = phases ?? suggestPhases(profile);
|
||||
const first = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
|
||||
name: phaseList[0].name,
|
||||
durationYears: phaseList[0].durationYears,
|
||||
name: flatPhases[0].name,
|
||||
durationYears: flatPhases[0].durationYears,
|
||||
});
|
||||
const firstPhaseId = first.phase.id;
|
||||
|
||||
@@ -139,42 +223,53 @@ export function PlanWizard({
|
||||
}
|
||||
if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 });
|
||||
for (const p of profile.persons) {
|
||||
await addEl("AHV", profile.persons.length > 1 ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 });
|
||||
}
|
||||
if (toggles.pk) {
|
||||
await addEl("PENSION_FUND", "Pensionskasse", "PERSON_A", {
|
||||
currentValue: pk.value,
|
||||
annualContribution: pk.contribution,
|
||||
expectedReturn: 1.5,
|
||||
});
|
||||
}
|
||||
if (toggles.p3a) {
|
||||
await addEl("PILLAR_3A", "Säule 3a", "PERSON_A", {
|
||||
currentValue: p3a.value,
|
||||
annualContribution: Math.min(p3a.contribution, PILLAR_3A_MAX_ANNUAL),
|
||||
expectedReturn: 2,
|
||||
});
|
||||
}
|
||||
if (toggles.etf) {
|
||||
await addEl("OTHER_ASSET", "Wertschriften / ETF", "HOUSEHOLD", {
|
||||
startValue: etf.value,
|
||||
annualContribution: etf.contribution,
|
||||
expectedReturn: 5,
|
||||
});
|
||||
}
|
||||
if (toggles.realEstate) {
|
||||
await addEl("REAL_ESTATE", "Eigenheim", "HOUSEHOLD", {
|
||||
purchasePrice: re.price,
|
||||
mortgage: re.mortgage,
|
||||
amortization: re.amortization,
|
||||
interestRate: re.interest,
|
||||
});
|
||||
}
|
||||
if (toggles.debt) {
|
||||
await addEl("OTHER_DEBT", "Kredit", "HOUSEHOLD", { startValue: debt.value, annualRepayment: debt.repayment });
|
||||
await addEl("AHV", isCouple ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 });
|
||||
}
|
||||
|
||||
for (const ph of phaseList.slice(1)) {
|
||||
// Vermögen/Vorsorge je Eigentümer-Bereich anlegen.
|
||||
for (const scope of renderedScopes) {
|
||||
const a = assets[scope.id];
|
||||
const suffix = scope.nameSuffix; // z. B. " Anna" oder " (gemeinsam)"
|
||||
if (scope.showVorsorge) {
|
||||
if (a.pk.on) {
|
||||
await addEl("PENSION_FUND", `Pensionskasse${suffix}`, scope.vorsorgeRole!, {
|
||||
currentValue: a.pk.value,
|
||||
annualContribution: a.pk.contribution,
|
||||
expectedReturn: 1.5,
|
||||
});
|
||||
}
|
||||
if (a.p3a.on) {
|
||||
await addEl("PILLAR_3A", `Säule 3a${suffix}`, scope.vorsorgeRole!, {
|
||||
currentValue: a.p3a.value,
|
||||
annualContribution: Math.min(a.p3a.contribution, PILLAR_3A_MAX_ANNUAL),
|
||||
expectedReturn: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (a.etf.on) {
|
||||
await addEl("OTHER_ASSET", `Wertschriften${suffix}`, scope.otherRole, {
|
||||
startValue: a.etf.value,
|
||||
annualContribution: a.etf.contribution,
|
||||
expectedReturn: 5,
|
||||
});
|
||||
}
|
||||
if (a.re.on) {
|
||||
await addEl("REAL_ESTATE", `Wohneigentum${suffix}`, scope.otherRole, {
|
||||
purchasePrice: a.re.price,
|
||||
mortgage: a.re.mortgage,
|
||||
amortization: a.re.amortization,
|
||||
interestRate: a.re.interest,
|
||||
});
|
||||
}
|
||||
if (a.debt.on) {
|
||||
await addEl("OTHER_DEBT", `Schulden${suffix}`, scope.otherRole, {
|
||||
startValue: a.debt.value,
|
||||
annualRepayment: a.debt.repayment,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const ph of flatPhases.slice(1)) {
|
||||
await api.post(`/api/scenarios/${sid}/phases`, { name: ph.name, durationYears: ph.durationYears });
|
||||
}
|
||||
|
||||
@@ -188,7 +283,29 @@ export function PlanWizard({
|
||||
}
|
||||
}
|
||||
|
||||
const workingPersons = profile.persons.filter((p) => p.age < p.retirementAge);
|
||||
// Welche Eigentümer-Bereiche in Schritt 4 gezeigt werden.
|
||||
const renderedScopes: {
|
||||
id: Scope;
|
||||
label: string;
|
||||
nameSuffix: string;
|
||||
showVorsorge: boolean;
|
||||
vorsorgeRole: string | null;
|
||||
otherRole: string;
|
||||
cats: Category[];
|
||||
}[] = isCouple
|
||||
? [
|
||||
{ id: "HOUSEHOLD", label: "Gemeinsam", nameSuffix: " (gemeinsam)", showVorsorge: false, vorsorgeRole: null, otherRole: "HOUSEHOLD", cats: ["etf", "re", "debt"] },
|
||||
{ id: "PERSON_A", label: personLabel("PERSON_A"), nameSuffix: ` ${personLabel("PERSON_A")}`, showVorsorge: true, vorsorgeRole: "PERSON_A", otherRole: "PERSON_A", cats: ["pk", "p3a", "etf", "re", "debt"] },
|
||||
{ id: "PERSON_B", label: personLabel("PERSON_B"), nameSuffix: ` ${personLabel("PERSON_B")}`, showVorsorge: true, vorsorgeRole: "PERSON_B", otherRole: "PERSON_B", cats: ["pk", "p3a", "etf", "re", "debt"] },
|
||||
]
|
||||
: [
|
||||
{ id: "SELF", label: "Deine Angaben", nameSuffix: "", showVorsorge: true, vorsorgeRole: "PERSON_A", otherRole: "HOUSEHOLD", cats: ["pk", "p3a", "etf", "re", "debt"] },
|
||||
];
|
||||
|
||||
const enabledAssetCount = renderedScopes.reduce((n, sc) => {
|
||||
const a = assets[sc.id];
|
||||
return n + sc.cats.filter((c) => a[c].on).length;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -197,7 +314,6 @@ export function PlanWizard({
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
{/* Fortschritt */}
|
||||
<div className="flex gap-1">
|
||||
{STEP_TITLES.map((_, i) => (
|
||||
<div key={i} className={`h-1 flex-1 rounded-full ${i <= step ? "bg-accent" : "bg-surface-2"}`} />
|
||||
@@ -214,44 +330,87 @@ export function PlanWizard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && phases && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{step === 1 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Deine Planung besteht aus <strong className="text-fg">Lebensphasen</strong> – Abschnitten, in denen deine
|
||||
Finanzen ähnlich laufen. Das Tool schlägt sie dir anhand deines Pensionsalters vor; die Dauer kannst du
|
||||
anpassen.
|
||||
Deine Planung besteht aus <strong className="text-fg">Lebensphasen</strong>. Das Pensionsalter setzt feste
|
||||
Grenzen auf der Lebenslinie – dazwischen kannst du beliebig viele Phasen einplanen.
|
||||
</p>
|
||||
{/* Mini-Vorschau der Kette */}
|
||||
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
||||
{phases.map((ph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ width: `${(ph.durationYears / phases.reduce((s, x) => s + x.durationYears, 0)) * 100}%` }}
|
||||
className={`px-2 py-1.5 text-[10px] font-semibold ${i === 0 && workingPersons.length > 0 ? "bg-accent/70 text-accent-fg" : "bg-accent-soft text-accent-soft-fg"} ${i > 0 ? "border-l border-border" : ""}`}
|
||||
>
|
||||
<span className="block truncate">{ph.name}</span>
|
||||
<span className="opacity-80">{ph.durationYears} J.</span>
|
||||
|
||||
<WizardTimeline segments={segments} segPhases={segPhases} profile={profile} />
|
||||
|
||||
{segments.map((seg, segIdx) => {
|
||||
const meta = SEGMENT_META[seg.type];
|
||||
const sum = segSums[segIdx] ?? 0;
|
||||
const fixed = seg.fixedYears;
|
||||
const ok = fixed === null ? sum >= 1 : sum === fixed;
|
||||
return (
|
||||
<div key={segIdx} className={`rounded-xl border-l-4 border border-border ${meta.soft} bg-surface-2 p-3`}>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-fg">
|
||||
Teil {segIdx + 1}: {meta.label}
|
||||
</span>
|
||||
{fixed === null ? (
|
||||
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-muted">
|
||||
Dauer frei · aktuell {sum} J.
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${
|
||||
ok ? "bg-success/15 text-success" : "bg-danger-soft text-danger"
|
||||
}`}
|
||||
>
|
||||
Summe {sum} / {fixed} Jahre{ok ? " ✓" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-muted">{meta.def}</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{(segPhases[segIdx] ?? []).map((ph, phaseIdx) => (
|
||||
<div key={phaseIdx} className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_9rem_auto] sm:items-end">
|
||||
<TextField
|
||||
label={phaseIdx === 0 ? "Bezeichnung" : ""}
|
||||
value={ph.name}
|
||||
onChange={(v) => updatePhase(segIdx, phaseIdx, { name: v })}
|
||||
/>
|
||||
<NumberField
|
||||
label={phaseIdx === 0 ? "Dauer (Jahre)" : ""}
|
||||
value={ph.durationYears}
|
||||
min={1}
|
||||
max={80}
|
||||
onChange={(v) => updatePhase(segIdx, phaseIdx, { durationYears: Math.max(1, Math.round(v)) })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Phase entfernen"
|
||||
disabled={(segPhases[segIdx]?.length ?? 0) <= 1}
|
||||
onClick={() => removePhase(segIdx, phaseIdx)}
|
||||
className="mb-0.5 flex h-8 w-8 items-center justify-center rounded-lg border border-border text-faint transition-colors hover:border-danger hover:bg-danger-soft hover:text-danger disabled:opacity-30"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addPhase(segIdx)}
|
||||
className="mt-2 inline-flex items-center gap-1 rounded-lg border border-dashed border-accent px-2.5 py-1 text-xs font-medium text-accent transition-colors hover:bg-accent-soft"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Phase hinzufügen
|
||||
</button>
|
||||
|
||||
{fixed !== null && !ok && (
|
||||
<p className="mt-2 text-[11px] text-danger">
|
||||
Dieser Teil ist durch die Pensionierung auf genau {fixed} Jahre festgelegt. Die Phasendauern müssen
|
||||
zusammen {fixed} ergeben (aktuell {sum}).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{phases.map((ph, i) => (
|
||||
<NumberField
|
||||
key={i}
|
||||
label={`${ph.name} – Dauer (Jahre)`}
|
||||
value={ph.durationYears}
|
||||
min={1}
|
||||
max={80}
|
||||
onChange={(v) =>
|
||||
setPhases((prev) => prev!.map((x, j) => (j === i ? { ...x, durationYears: Math.max(1, Math.round(v)) } : x)))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-faint">
|
||||
Eine Phase darf keine Pensionierung überspannen – die Dauer der Erwerbsphase ist deshalb ans Pensionsalter
|
||||
gekoppelt und wird beim Anlegen automatisch begrenzt.
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -283,61 +442,80 @@ export function PlanWizard({
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Was besitzt du? Hake an, was auf dich zutrifft – nur das Angehakte fragt nach Details.
|
||||
{profile.persons.length > 1 && " Vorsorge (PK/3a) wird Person A zugeordnet – weitere Konten kannst du später ergänzen."}
|
||||
Was besitzt ihr? Hake an, was zutrifft – nur das Angehakte fragt nach Details.
|
||||
{isCouple && " Pensionskasse und Säule 3a sind immer persönlich; Wertschriften, Wohneigentum und Schulden kannst du gemeinsam oder je Person erfassen."}
|
||||
</p>
|
||||
<WizardToggle
|
||||
label="Pensionskasse"
|
||||
help="Dein PK-Guthaben laut Vorsorgeausweis und die jährlichen Einzahlungen (Arbeitnehmer + Arbeitgeber)."
|
||||
checked={toggles.pk}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, pk: v }))}
|
||||
>
|
||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={pk.value} onChange={(v) => setPk((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={pk.contribution} onChange={(v) => setPk((s) => ({ ...s, contribution: v }))} />
|
||||
</WizardToggle>
|
||||
<WizardToggle
|
||||
label="Säule 3a"
|
||||
help={`Dein 3a-Guthaben und die jährliche Einzahlung (max. ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF).`}
|
||||
checked={toggles.p3a}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, p3a: v }))}
|
||||
>
|
||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={p3a.value} onChange={(v) => setP3a((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={p3a.contribution} max={PILLAR_3A_MAX_ANNUAL} onChange={(v) => setP3a((s) => ({ ...s, contribution: v }))} />
|
||||
</WizardToggle>
|
||||
<WizardToggle
|
||||
label="Wertschriften / ETF / Sparen"
|
||||
help="Depots, Fonds, Sparkonten – alles, was Rendite bringt und nicht Vorsorge ist."
|
||||
checked={toggles.etf}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, etf: v }))}
|
||||
>
|
||||
<MoneyField label="Aktueller Wert (CHF)" value={etf.value} onChange={(v) => setEtf((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Sparbeitrag pro Jahr (CHF)" value={etf.contribution} onChange={(v) => setEtf((s) => ({ ...s, contribution: v }))} />
|
||||
</WizardToggle>
|
||||
<WizardToggle
|
||||
label="Wohneigentum"
|
||||
help="Kaufpreis, Hypothek und jährliche Amortisation gemäss Hypothekarvertrag."
|
||||
checked={toggles.realEstate}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, realEstate: v }))}
|
||||
>
|
||||
<MoneyField label="Kaufpreis (CHF)" value={re.price} onChange={(v) => setRe((s) => ({ ...s, price: v }))} />
|
||||
<MoneyField label="Hypothek (CHF)" value={re.mortgage} onChange={(v) => setRe((s) => ({ ...s, mortgage: v }))} />
|
||||
<MoneyField label="Amortisation pro Jahr (CHF)" value={re.amortization} onChange={(v) => setRe((s) => ({ ...s, amortization: v }))} />
|
||||
<NumberField label="Hypothekarzins (%)" step={0.1} value={re.interest} onChange={(v) => setRe((s) => ({ ...s, interest: v }))} />
|
||||
</WizardToggle>
|
||||
<WizardToggle
|
||||
label="Schulden / Kredite"
|
||||
help="Privatkredite, Autoleasing-Restwert und Ähnliches – ohne die Hypothek (die gehört zum Wohneigentum)."
|
||||
checked={toggles.debt}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, debt: v }))}
|
||||
>
|
||||
<MoneyField label="Restschuld (CHF)" value={debt.value} onChange={(v) => setDebt((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Rückzahlung pro Jahr (CHF)" value={debt.repayment} onChange={(v) => setDebt((s) => ({ ...s, repayment: v }))} />
|
||||
</WizardToggle>
|
||||
<p className="mt-1 text-[11px] text-faint">
|
||||
Die AHV wird automatisch für {profile.persons.length > 1 ? "beide Personen" : "dich"} angelegt – die Rente
|
||||
berechnet das Tool aus deinem Einkommen.
|
||||
{renderedScopes.map((sc) => (
|
||||
<div key={sc.id} className="rounded-xl border border-border p-3">
|
||||
{isCouple && (
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">{sc.label}</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{sc.cats.includes("pk") && (
|
||||
<WizardToggle
|
||||
label="Pensionskasse"
|
||||
help="PK-Guthaben laut Vorsorgeausweis und die jährlichen Einzahlungen (Arbeitnehmer + Arbeitgeber)."
|
||||
checked={assets[sc.id].pk.on}
|
||||
onChange={(v) => setScope(sc.id, "pk", { on: v })}
|
||||
>
|
||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].pk.value} onChange={(v) => setScope(sc.id, "pk", { value: v })} />
|
||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={assets[sc.id].pk.contribution} onChange={(v) => setScope(sc.id, "pk", { contribution: v })} />
|
||||
</WizardToggle>
|
||||
)}
|
||||
{sc.cats.includes("p3a") && (
|
||||
<WizardToggle
|
||||
label="Säule 3a"
|
||||
help={`3a-Guthaben und die jährliche Einzahlung (max. ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF).`}
|
||||
checked={assets[sc.id].p3a.on}
|
||||
onChange={(v) => setScope(sc.id, "p3a", { on: v })}
|
||||
>
|
||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].p3a.value} onChange={(v) => setScope(sc.id, "p3a", { value: v })} />
|
||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={assets[sc.id].p3a.contribution} max={PILLAR_3A_MAX_ANNUAL} onChange={(v) => setScope(sc.id, "p3a", { contribution: v })} />
|
||||
</WizardToggle>
|
||||
)}
|
||||
{sc.cats.includes("etf") && (
|
||||
<WizardToggle
|
||||
label="Wertschriften / ETF / Sparen"
|
||||
help="Depots, Fonds, Sparkonten – alles, was Rendite bringt und nicht Vorsorge ist."
|
||||
checked={assets[sc.id].etf.on}
|
||||
onChange={(v) => setScope(sc.id, "etf", { on: v })}
|
||||
>
|
||||
<MoneyField label="Aktueller Wert (CHF)" value={assets[sc.id].etf.value} onChange={(v) => setScope(sc.id, "etf", { value: v })} />
|
||||
<MoneyField label="Sparbeitrag pro Jahr (CHF)" value={assets[sc.id].etf.contribution} onChange={(v) => setScope(sc.id, "etf", { contribution: v })} />
|
||||
</WizardToggle>
|
||||
)}
|
||||
{sc.cats.includes("re") && (
|
||||
<WizardToggle
|
||||
label="Wohneigentum"
|
||||
help="Kaufpreis, Hypothek und jährliche Amortisation gemäss Hypothekarvertrag."
|
||||
checked={assets[sc.id].re.on}
|
||||
onChange={(v) => setScope(sc.id, "re", { on: v })}
|
||||
>
|
||||
<MoneyField label="Kaufpreis (CHF)" value={assets[sc.id].re.price} onChange={(v) => setScope(sc.id, "re", { price: v })} />
|
||||
<MoneyField label="Hypothek (CHF)" value={assets[sc.id].re.mortgage} onChange={(v) => setScope(sc.id, "re", { mortgage: v })} />
|
||||
<MoneyField label="Amortisation pro Jahr (CHF)" value={assets[sc.id].re.amortization} onChange={(v) => setScope(sc.id, "re", { amortization: v })} />
|
||||
<NumberField label="Hypothekarzins (%)" step={0.1} value={assets[sc.id].re.interest} onChange={(v) => setScope(sc.id, "re", { interest: v })} />
|
||||
</WizardToggle>
|
||||
)}
|
||||
{sc.cats.includes("debt") && (
|
||||
<WizardToggle
|
||||
label="Schulden / Kredite"
|
||||
help="Privatkredite, Autoleasing-Restwert und Ähnliches – ohne die Hypothek (die gehört zum Wohneigentum)."
|
||||
checked={assets[sc.id].debt.on}
|
||||
onChange={(v) => setScope(sc.id, "debt", { on: v })}
|
||||
>
|
||||
<MoneyField label="Restschuld (CHF)" value={assets[sc.id].debt.value} onChange={(v) => setScope(sc.id, "debt", { value: v })} />
|
||||
<MoneyField label="Rückzahlung pro Jahr (CHF)" value={assets[sc.id].debt.repayment} onChange={(v) => setScope(sc.id, "debt", { repayment: v })} />
|
||||
</WizardToggle>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[11px] text-faint">
|
||||
Die AHV wird automatisch für {isCouple ? "beide Personen" : "dich"} angelegt – die Rente berechnet das Tool
|
||||
aus dem Einkommen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -347,15 +525,11 @@ export function PlanWizard({
|
||||
<p className="text-sm text-muted">Das wird angelegt:</p>
|
||||
<ul className="flex flex-col gap-1.5 rounded-xl border border-border bg-surface-2 p-4 text-sm text-fg">
|
||||
<li>📋 Plan «{planName.trim() || "Meine Planung"}» mit Basisszenario</li>
|
||||
<li>📅 {(phases ?? []).map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}</li>
|
||||
<li>📅 {flatPhases.map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}</li>
|
||||
<li>
|
||||
💰 {profile.persons.filter((p) => (incomes[p.role] ?? 0) > 0).length > 0 ? "Einkommen, " : ""}
|
||||
{expenses > 0 ? "Ausgaben, " : ""}AHV
|
||||
{toggles.pk ? ", Pensionskasse" : ""}
|
||||
{toggles.p3a ? ", Säule 3a" : ""}
|
||||
{toggles.etf ? ", Wertschriften" : ""}
|
||||
{toggles.realEstate ? ", Wohneigentum" : ""}
|
||||
{toggles.debt ? ", Schulden" : ""}
|
||||
{enabledAssetCount > 0 ? ` + ${enabledAssetCount} Vermögens-/Vorsorgeposten` : ""}
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs text-muted">
|
||||
@@ -379,7 +553,7 @@ export function PlanWizard({
|
||||
</Button>
|
||||
)}
|
||||
{step < STEP_TITLES.length - 1 ? (
|
||||
<Button onClick={goNext}>
|
||||
<Button onClick={goNext} disabled={step === 1 && !step1Valid}>
|
||||
Weiter <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
@@ -393,6 +567,89 @@ export function PlanWizard({
|
||||
);
|
||||
}
|
||||
|
||||
// Zeitachse: proportionaler Balken mit nummerierten Phasen; Pensionierungs-Fixpunkte als
|
||||
// Flaggen darüber; die Beschriftung steht als nummerierte Liste UNTER dem Balken, damit auch
|
||||
// kurze Phasen sauber lesbar sind.
|
||||
function WizardTimeline({
|
||||
segments,
|
||||
segPhases,
|
||||
profile,
|
||||
}: {
|
||||
segments: PlanSegment[];
|
||||
segPhases: PhaseDraft[][];
|
||||
profile: ProfileDraft;
|
||||
}) {
|
||||
const flat = segments.flatMap((seg, segIdx) =>
|
||||
(segPhases[segIdx] ?? []).map((ph) => ({ ...ph, type: seg.type }))
|
||||
);
|
||||
const total = flat.reduce((s, p) => s + Math.max(1, p.durationYears), 0);
|
||||
if (total <= 0 || flat.length === 0) return null;
|
||||
|
||||
const minAge = Math.min(...profile.persons.map((p) => p.age));
|
||||
const markers = profile.persons
|
||||
.map((p) => ({ label: p.name?.trim() || (p.role === "PERSON_A" ? "A" : "B"), year: Math.max(0, p.retirementAge - p.age), age: p.retirementAge, role: p.role }))
|
||||
.filter((m) => m.year > 0 && m.year < total);
|
||||
|
||||
const durs = flat.map((ph) => Math.max(1, ph.durationYears));
|
||||
const numbered = flat.map((ph, i) => {
|
||||
const from = durs.slice(0, i).reduce((s, d) => s + d, 0);
|
||||
return { ...ph, index: i + 1, from, to: from + durs[i] };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-3">
|
||||
<div className="relative pt-5">
|
||||
{/* Pensionierungs-Fixpunkte */}
|
||||
{markers.map((m) => (
|
||||
<div
|
||||
key={m.role}
|
||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
||||
style={{ left: `${(m.year / total) * 100}%` }}
|
||||
title={`${m.label}: Pensionierung mit ${m.age}`}
|
||||
>
|
||||
<span className="flex items-center gap-0.5 whitespace-nowrap text-[10px] font-medium text-accent">
|
||||
<Flag className="h-3 w-3" fill="var(--accent)" /> {m.label} · {m.age}
|
||||
</span>
|
||||
<div className="h-2 w-px bg-accent" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Balken mit nummerierten Segmenten */}
|
||||
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
||||
{numbered.map((ph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ width: `${(Math.max(1, ph.durationYears) / total) * 100}%` }}
|
||||
title={`${ph.name} · ${ph.durationYears} J.`}
|
||||
className={`flex items-center justify-center py-1.5 text-[10px] font-bold ${SEGMENT_META[ph.type].color} ${i > 0 ? "border-l border-border/50" : ""}`}
|
||||
>
|
||||
{ph.index}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Alters-Achse */}
|
||||
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
|
||||
<span>{minAge} J.</span>
|
||||
<span>{minAge + total} J.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Beschriftung unter dem Balken */}
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px]">
|
||||
{numbered.map((ph, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 whitespace-nowrap text-muted">
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-surface-2 text-[9px] font-bold text-fg">
|
||||
{ph.index}
|
||||
</span>
|
||||
{ph.name} · {ph.durationYears} J.
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardToggle({
|
||||
label,
|
||||
help,
|
||||
|
||||
Reference in New Issue
Block a user