Modul-Review 2: Onboarding-Umbau, Tour, Assistenten-Redesign
Deploy App / deploy (push) Successful in 1m10s

Ergebnis der zweiten Test- und Review-Runde (Modul "Onboarding & Plan-Erstellung"):

Layout:
- Szenario-Ansicht neu geordnet: schlanke Funktions-Leiste oben (Historie,
  Tour, Neues Szenario, Rechenwege, CSV-Export, Loeschen, Diff-Badge), dann
  Naechste Schritte -> Grundprofil -> Zeitachse -> Anzeige -> Matrix
- Grafiken/Effektive Werte/Live-Sim/MC/Einflussfaktoren aus der Leiste
  entfernt (laufen ueber die eigenen Menuepunkte)
- Tour nach AppShell gehoben; startet neu bei JEDER Plan-Erstellung (F14)

Assistent:
- durchgehend Du-Form im Einzelmodus
- Schritt "Vorsorge & Vermoegen" nur noch Bestandswerte
- neuer Schritt "Sparen & Verteilen": Sparquote (Netto - Ausgaben) auf
  3a/Wertschriften/Amortisation/Tilgung verteilen, Rest bleibt Cash; PK
  separat mit Hinweis "vor Netto, reduziert Sparquote nicht"
- Immobilie: Wertsteigerung + Zins-in-Ausgaben-Schalter; Lohn 1% Default

Grosse Saeule 3a (Selbststaendige ohne PK):
- Schalter am 3a-Element + im Assistenten hebt Obergrenze 7258 -> 36288
- neue Konstante PILLAR_3A_MAX_SELF_EMPLOYED (2026-Wert zu verifizieren)
- neues Feld selfEmployed3a

Nebenbei: POST /plans liefert lesbare Fehlermeldung statt zod-Objekt.

Kein Eingriff in den Rechenkern; 267 Tests unveraendert. SPEZIFIKATION 0.28.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 21:57:56 +02:00
parent 2bb243b896
commit 52c48a9956
8 changed files with 448 additions and 192 deletions
+73 -41
View File
@@ -1,19 +1,20 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
BookOpen,
ChevronDown,
ChevronRight,
Copy,
Dices,
FileText,
FileSpreadsheet,
FolderKanban,
CalendarClock,
GitBranch,
HelpCircle,
History,
Download,
Layers,
LayoutDashboard,
Menu,
@@ -22,7 +23,6 @@ import {
Search,
SlidersHorizontal,
Sparkles,
Tornado,
Trash2,
X,
} from "lucide-react";
@@ -44,6 +44,7 @@ import { PlanTraceDialog } from "@/components/DetailView";
import { computePlan } from "@/lib/calculations";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanWizard } from "@/components/PlanWizard";
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import {
@@ -117,6 +118,33 @@ function AppShellInner({ username }: { username: string }) {
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
// Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im
// DOM der Szenario-Ansicht). Zwei Auslöser:
// * pendingTourRef: nach JEDER Plan-Erstellung genau einmal erzwingen (Roadmap-Feedback F14).
// * Erst-Öffnen eines Plans mit Phasen, solange die Tour noch nie beendet wurde.
const [showTour, setShowTour] = useState(false);
const pendingTourRef = useRef(false);
const autoTourCheckedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!detail || detail.plan.phases.length === 0) return;
const sid = detail.meta.id;
if (pendingTourRef.current) {
pendingTourRef.current = false;
autoTourCheckedRef.current.add(sid);
setShowTour(true);
return;
}
if (autoTourCheckedRef.current.has(sid)) return; // pro Szenario nur einmal, nicht bei Refresh
autoTourCheckedRef.current.add(sid);
try {
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung aus localStorage
if (!localStorage.getItem(TOUR_DONE_KEY)) setShowTour(true);
} catch {
/* localStorage nicht verfügbar */
}
}, [detail]);
function openSpecAt(anchor: string) {
setSpecAnchor(anchor);
setShowSpec(true);
@@ -257,8 +285,9 @@ function AppShellInner({ username }: { username: string }) {
const sid = await createDemoPlan();
await loadPlans();
setShowNewPlanChooser(false);
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
openScenario(sid);
toast("success", "Beispielplan angelegt schau dich um!");
toast("success", "Beispielplan angelegt die Tour zeigt dir gleich die wichtigsten Stellen.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden.");
} finally {
@@ -597,53 +626,52 @@ function AppShellInner({ username }: { username: string }) {
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
<div className="flex flex-col gap-6">
{/* Funktions-Buttons der Szenario-Ansicht. Grafiken/Effektive Werte/Live-Sim/
Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über
die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen
Szenario-Aktionen plus Tour und CSV-Export. */}
<div className="flex flex-wrap items-center gap-2" data-tour="analysen">
{detail.plan.phases.length > 0 && (
<Button variant="secondary" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4" />
Änderungshistorie
</Button>
)}
{detail.plan.phases.length > 0 && (
<Button variant="secondary" onClick={() => setShowTour(true)} title="Kurze Tour durch die Ansicht">
<HelpCircle className="h-4 w-4" />
Tour
</Button>
)}
<Button variant="secondary" onClick={() => setCopyFrom(detail.meta)}>
<Copy className="h-4 w-4" />
Neues Szenario aus diesem
</Button>
{detail.plan.phases.length > 0 && (
<Button
variant="secondary"
onClick={() => setShowPlanTraces(true)}
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
>
<BookOpen className="h-4 w-4" />
Rechenwege
</Button>
)}
{detail.plan.phases.length > 0 && (
<a
href={`/api/scenarios/${detail.meta.id}/export`}
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium text-muted transition-all duration-150 hover:bg-surface-2 hover:text-fg"
>
<Download className="h-4 w-4" />
CSV-Export
</a>
)}
{!detail.meta.isBase && (
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
<Trash2 className="h-4 w-4" />
Szenario löschen
</Button>
)}
{detail.plan.phases.length > 0 && (
<>
<Button variant="secondary" onClick={() => setShowCharts(true)}>
<BarChart3 className="h-4 w-4" />
Grafiken
</Button>
<Button variant="secondary" onClick={() => setShowActuals(true)}>
<CalendarClock className="h-4 w-4" />
Effektive Werte
</Button>
<Button variant="secondary" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4" />
Änderungshistorie
</Button>
<Button variant="secondary" onClick={() => setShowLiveSim(true)}>
<SlidersHorizontal className="h-4 w-4" />
Live-Simulation
</Button>
<Button variant="secondary" onClick={() => setShowMonteCarlo(true)}>
<Dices className="h-4 w-4" />
Monte-Carlo-Simulation
</Button>
<Button variant="secondary" onClick={() => setShowSensitivity(true)}>
<Tornado className="h-4 w-4" />
Einflussfaktoren
</Button>
<Button
variant="secondary"
onClick={() => setShowPlanTraces(true)}
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
>
<BookOpen className="h-4 w-4" />
Rechenwege
</Button>
</>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
@@ -690,6 +718,7 @@ function AppShellInner({ username }: { username: string }) {
onCreated={async (sid) => {
setShowWizard(false);
await loadPlans();
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
openScenario(sid);
toast("success", "Plan erstellt die Tour zeigt dir gleich die wichtigsten Stellen.");
}}
@@ -706,8 +735,9 @@ function AppShellInner({ username }: { username: string }) {
);
setShowEmptyPlanDialog(false);
await loadPlans();
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
openScenario(scenario.id);
toast("success", "Plan erstellt.");
toast("success", "Plan erstellt die Tour zeigt dir gleich die wichtigsten Stellen.");
}}
onClose={() => setShowEmptyPlanDialog(false)}
/>
@@ -817,6 +847,8 @@ function AppShellInner({ username }: { username: string }) {
actions={paletteActions}
onOpenScenario={openScenario}
/>
{showTour && <Tour onClose={() => setShowTour(false)} />}
</div>
);
}
+18 -4
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { Trash2 } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client";
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
@@ -31,6 +32,7 @@ import {
DEFAULT_PK_CONVERSION_RATE,
DEFAULT_PROPERTY_GAINS_TAX_RATE,
PILLAR_3A_MAX_ANNUAL,
PILLAR_3A_MAX_SELF_EMPLOYED,
} from "@/lib/constants";
import type {
CashTransitionData,
@@ -664,10 +666,12 @@ export function ElementPhaseFields({
)}
</>
);
case "PILLAR_3A":
case "PILLAR_3A": {
if (!context.ownerWorking) {
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
}
// «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c).
const max3a = pd.selfEmployed3a ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL;
return (
<>
{carried ? (
@@ -677,21 +681,30 @@ export function ElementPhaseFields({
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfügbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)}
<label className="col-span-2 flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={!!pd.selfEmployed3a}
onChange={(e) => setP({ selfEmployed3a: e.target.checked })}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
</label>
{inh(
"annualContribution",
"Jährliche Einzahlung (CHF)",
(v, set) => (
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={PILLAR_3A_MAX_ANNUAL} onChange={set} />
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={max3a} onChange={set} />
),
asChf,
`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
`Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
)}
{inh(
"expectedReturn",
@@ -701,6 +714,7 @@ export function ElementPhaseFields({
)}
</>
);
}
case "REAL_ESTATE": {
// Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation,
// der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung).
+73 -101
View File
@@ -9,8 +9,6 @@ import {
ChevronDown,
ChevronRight,
CreditCard,
Download,
HelpCircle,
Home,
Landmark,
ListTodo,
@@ -33,7 +31,6 @@ import {
transitionInactive as inactiveAtTransition,
TRANSITION_CATEGORIES,
} from "@/lib/decisions";
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
import {
@@ -165,24 +162,12 @@ export function PlanView({
const [addCategory, setAddCategory] = useState<ElementCategory | null>(null);
const [showAddPhase, setShowAddPhase] = useState(false);
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
const [showTour, setShowTour] = useState(false);
// Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup,
// weil beide mehrere Elemente auf einmal bearbeiten.
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
// Tour beim ersten Besuch eines Plans mit Phasen automatisch starten.
useEffect(() => {
try {
if (computed.phases.length > 0 && !localStorage.getItem(TOUR_DONE_KEY)) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung aus localStorage
setShowTour(true);
}
} catch {
/* localStorage nicht verfügbar */
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
// Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht.
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null);
@@ -361,60 +346,19 @@ export function PlanView({
return (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Anzeige</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{(["nominal", "both", "real"] as ValueMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => changeValueMode(m)}
className={`rounded-md px-2.5 py-1 font-medium ${
valueMode === m ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{m === "nominal" ? "Nominal" : m === "both" ? "Beide" : "Real"}
</button>
))}
</div>
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
sonst wäre es ein Umschalter ohne Gegenstück. */}
{hasActuals && (
<>
<span className="ml-2 text-xs text-muted">Zahlen</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{DATA_SOURCE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => setDataSource(o.value)}
title={o.label}
className={`rounded-md px-2.5 py-1 font-medium ${
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{o.short}
</button>
))}
</div>
{dataSource === "ACTUAL" && (
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
)}
</>
)}
</div>
<div data-tour="timeline">
<Timeline
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
actualYears={actualYears}
startYear={plan.startYear}
/>
</div>
<NextSteps
plan={plan}
computed={computed}
columns={columns}
openCountFor={transitionOpenCount}
onReview={(fromPhaseId) => setReviewFromPhaseId(fromPhaseId)}
onAddElement={(cat) => {
setAddCategory(cat);
setShowAdd(true);
}}
onAddPhase={() => setShowAddPhase(true)}
onOpenSensitivity={onOpenSensitivity}
/>
{/* Szenario-Profil */}
<div
@@ -441,19 +385,64 @@ export function PlanView({
</button>
</div>
<NextSteps
plan={plan}
computed={computed}
columns={columns}
openCountFor={transitionOpenCount}
onReview={(fromPhaseId) => setReviewFromPhaseId(fromPhaseId)}
onAddElement={(cat) => {
setAddCategory(cat);
setShowAdd(true);
}}
onAddPhase={() => setShowAddPhase(true)}
onOpenSensitivity={onOpenSensitivity}
/>
<div data-tour="timeline">
<Timeline
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
actualYears={actualYears}
startYear={plan.startYear}
/>
</div>
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
deren Zahlen steuert. */}
{hasPhases && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Anzeige</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{(["nominal", "both", "real"] as ValueMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => changeValueMode(m)}
className={`rounded-md px-2.5 py-1 font-medium ${
valueMode === m ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{m === "nominal" ? "Nominal" : m === "both" ? "Beide" : "Real"}
</button>
))}
</div>
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
sonst wäre es ein Umschalter ohne Gegenstück. */}
{hasActuals && (
<>
<span className="ml-2 text-xs text-muted">Zahlen</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{DATA_SOURCE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => setDataSource(o.value)}
title={o.label}
className={`rounded-md px-2.5 py-1 font-medium ${
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{o.short}
</button>
))}
</div>
{dataSource === "ACTUAL" && (
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
)}
</>
)}
</div>
)}
{!hasPhases && (
<EmptyState
@@ -479,21 +468,6 @@ export function PlanView({
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
{/* CSV-Export der Matrix. Liest immer den aktuellen Stand aus der Datenbank. */}
<a
href={`/api/scenarios/${plan.id}/export`}
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"
>
<Download className="h-3.5 w-3.5" /> CSV-Export
</a>
<button
type="button"
onClick={() => setShowTour(true)}
title="Kurze Tour durch die Ansicht"
className="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"
>
<HelpCircle className="h-3.5 w-3.5" /> Tour
</button>
</div>
)}
@@ -852,8 +826,6 @@ export function PlanView({
}}
/>
)}
{showTour && <Tour onClose={() => setShowTour(false)} />}
</div>
);
+206 -34
View File
@@ -16,7 +16,7 @@
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan
// (siehe SPEZIFIKATION 9.23).
import { useMemo, useRef, useState } from "react";
import { Fragment, 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";
@@ -24,7 +24,7 @@ import { InfoBubble } from "@/components/InfoBubble";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client";
import { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan";
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants";
import type { PhaseData } from "@/lib/elements";
interface PhaseDraft {
@@ -32,7 +32,14 @@ interface PhaseDraft {
durationYears: number;
}
const STEP_TITLES = ["Über dich", "Deine Lebensphasen", "Einkommen & Ausgaben", "Vorsorge & Vermögen", "Fertig"];
const STEP_TITLES = [
"Über dich",
"Deine Lebensphasen",
"Einkommen & Ausgaben",
"Vorsorge & Vermögen",
"Sparen & Verteilen",
"Fertig",
];
const SEGMENT_META: Record<SegmentType, { label: string; phase: string; def: string; color: string; soft: string }> = {
ERWERB: {
@@ -80,18 +87,20 @@ type Category = "pk" | "p3a" | "etf" | "re" | "debt";
interface ScopeAssets {
pk: { on: boolean; value: number; contribution: number };
p3a: { on: boolean; value: number; contribution: number };
// selfEmployed: «grosse Säule 3a» ohne PK -> höhere Obergrenze (Roadmap-Feedback C7c).
p3a: { on: boolean; value: number; contribution: number; selfEmployed: boolean };
etf: { on: boolean; value: number; contribution: number };
re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number };
// growth = Wertsteigerung %/Jahr; interestIncluded = Zinsen bereits in den Ausgaben.
re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number; growth: number; interestIncluded: boolean };
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 },
p3a: { on: false, value: 0, contribution: 0, selfEmployed: false },
etf: { on: false, value: 0, contribution: 0 },
re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5 },
re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5, growth: 1, interestIncluded: true },
debt: { on: false, value: 0, repayment: 0 },
};
}
@@ -219,7 +228,8 @@ export function PlanWizard({
for (const p of profile.persons) {
const amount = incomes[p.role] ?? 0;
if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 0 });
// 1 % nominale Lohnerhöhung als dezenter Default -- 0 % liesse den Lohn real verlieren.
if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 1 });
}
if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 });
for (const p of profile.persons) {
@@ -241,8 +251,9 @@ export function PlanWizard({
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),
annualContribution: Math.min(a.p3a.contribution, max3aFor(scope.id)),
expectedReturn: 2,
selfEmployed3a: a.p3a.selfEmployed,
});
}
}
@@ -259,6 +270,8 @@ export function PlanWizard({
mortgage: a.re.mortgage,
amortization: a.re.amortization,
interestRate: a.re.interest,
valueGrowth: a.re.growth,
interestHandling: a.re.interestIncluded ? "INCLUDED" : "ADD",
});
}
if (a.debt.on) {
@@ -307,6 +320,30 @@ export function PlanWizard({
return n + sc.cats.filter((c) => a[c].on).length;
}, 0);
// --- Sparquote (Schritt "Sparen & Verteilen") ---
// Die Sparquote entsteht aus Nettoeinkommen minus Ausgaben. PK-Beiträge kommen VOM
// Bruttolohn (vor dem Netto) und reduzieren sie deshalb NICHT -- genau wie im Rechenkern.
const totalIncome = profile.persons.reduce((s, p) => s + (incomes[p.role] ?? 0), 0);
const sparBase = totalIncome - expenses;
const allocatedFromQuote = renderedScopes.reduce((sum, sc) => {
const a = assets[sc.id];
return (
sum +
(a.p3a.on ? a.p3a.contribution : 0) +
(a.etf.on ? a.etf.contribution : 0) +
(a.re.on ? a.re.amortization : 0) +
(a.debt.on ? a.debt.repayment : 0)
);
}, 0);
const restToCash = sparBase - allocatedFromQuote;
const anyPk = renderedScopes.some((sc) => assets[sc.id].pk.on);
const anyQuoteTarget = renderedScopes.some((sc) => {
const a = assets[sc.id];
return a.p3a.on || a.etf.on || a.re.on || a.debt.on;
});
const scopeTag = (sc: { label: string }) => (isCouple ? ` · ${sc.label}` : "");
const max3aFor = (sc: Scope) => (assets[sc].p3a.selfEmployed ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL);
return (
<Modal
title="Plan erstellen geführt"
@@ -416,25 +453,32 @@ export function PlanWizard({
{step === 2 && (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted">Die zwei wichtigsten Zahlen deiner Planung grob geschätzt reicht für den Anfang.</p>
{profile.persons.map((p) => (
<MoneyField
key={p.role}
label={`Was verdient ${personLabel(p.role)} netto pro Jahr? (CHF)`}
help="Der Betrag, der nach allen Lohnabzügen tatsächlich aufs Konto kommt nicht der Bruttolohn. Für die AHV-Rente rechnet das Tool intern hoch."
value={incomes[p.role] ?? 0}
onChange={(v) => setIncomes((prev) => ({ ...prev, [p.role]: v }))}
/>
))}
<p className="text-sm text-muted">Die wichtigsten Zahlen deiner Planung grob geschätzt reicht für den Anfang.</p>
{profile.persons.map((p) => {
const nm = profile.persons.find((x) => x.role === p.role)?.name?.trim();
return (
<MoneyField
key={p.role}
label={
isCouple
? `Was verdient ${personLabel(p.role)} netto pro Jahr? (CHF)`
: `Was verdienst du${nm ? ` (${nm})` : ""} netto pro Jahr? (CHF)`
}
help="Der Betrag, der nach allen Lohnabzügen tatsächlich aufs Konto kommt nicht der Bruttolohn. Für die AHV-Rente rechnet das Tool intern hoch."
value={incomes[p.role] ?? 0}
onChange={(v) => setIncomes((prev) => ({ ...prev, [p.role]: v }))}
/>
);
})}
<MoneyField
label="Was gebt ihr heute pro Jahr aus? (CHF)"
label={isCouple ? "Was gebt ihr heute pro Jahr aus? (CHF)" : "Was gibst du heute pro Jahr aus? (CHF)"}
help="Alle Lebenshaltungskosten in heutiger Kaufkraft Miete/Wohnen, Essen, Mobilität, Versicherungen, Steuern, Freizeit. Die Inflation rechnet das Tool automatisch dazu."
value={expenses}
onChange={setExpenses}
/>
<MoneyField
label="Wie viel liegt auf dem Konto? (CHF)"
help="Dein heutiger Kontostand (Cash). Er ist der Startwert des Ausgleichskontos."
label={isCouple ? "Wie viel liegt auf euren Konten? (CHF)" : "Wie viel liegt auf deinem Konto? (CHF)"}
help="Euer heutiger Kontostand (Cash). Er ist der Startwert des Ausgleichskontos."
value={initialCash}
onChange={setInitialCash}
/>
@@ -444,8 +488,10 @@ export function PlanWizard({
{step === 3 && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
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."}
{isCouple ? "Was besitzt ihr?" : "Was besitzt du?"} Hake an, was zutrifft nur das Angehakte fragt nach Details.
Hier geht es nur um die <strong className="text-fg">heutigen Werte</strong>; die jährlichen Einzahlungen und
Amortisationen verteilst du im nächsten Schritt aus deiner Sparquote.
{isCouple && " Pensionskasse und Säule 3a sind immer persönlich; Wertschriften, Wohneigentum und Schulden könnt ihr gemeinsam oder je Person erfassen."}
</p>
{renderedScopes.map((sc) => (
<div key={sc.id} className="rounded-xl border border-border p-3">
@@ -456,58 +502,72 @@ export function PlanWizard({
{sc.cats.includes("pk") && (
<WizardToggle
label="Pensionskasse"
help="PK-Guthaben laut Vorsorgeausweis und die jährlichen Einzahlungen (Arbeitnehmer + Arbeitgeber)."
help="PK-Guthaben laut Vorsorgeausweis. Die jährliche Einzahlung erfasst du im nächsten Schritt."
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).`}
help="3a-Guthaben laut Bescheinigung. Die jährliche Einzahlung erfasst du im nächsten Schritt."
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 })} />
<label className="col-span-full flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={assets[sc.id].p3a.selfEmployed}
onChange={(e) => setScope(sc.id, "p3a", { selfEmployed: e.target.checked })}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
</label>
</WizardToggle>
)}
{sc.cats.includes("etf") && (
<WizardToggle
label="Wertschriften / ETF / Sparen"
help="Depots, Fonds, Sparkonten alles, was Rendite bringt und nicht Vorsorge ist."
help="Depots, Fonds, Sparkonten alles, was Rendite bringt und nicht Vorsorge ist. Den jährlichen Sparbeitrag erfasst du im nächsten Schritt."
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."
help="Kaufpreis und Hypothek laut Vertrag. Die jährliche Amortisation erfasst du im nächsten Schritt."
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 })} />
<NumberField label="Wertsteigerung (%/Jahr)" step={0.1} value={assets[sc.id].re.growth} onChange={(v) => setScope(sc.id, "re", { growth: v })} />
<label className="col-span-full flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={assets[sc.id].re.interestIncluded}
onChange={(e) => setScope(sc.id, "re", { interestIncluded: e.target.checked })}
/>
Hypothekarzinsen sind bereits in den Ausgaben enthalten
<InfoBubble text="Damit die Zinsen nicht doppelt zählen. Angehakt (Standard): Du hast sie oben schon in den Jahresausgaben berücksichtigt. Nicht angehakt: Das Tool rechnet sie zusätzlich dazu." />
</label>
</WizardToggle>
)}
{sc.cats.includes("debt") && (
<WizardToggle
label="Schulden / Kredite"
help="Privatkredite, Autoleasing-Restwert und Ähnliches ohne die Hypothek (die gehört zum Wohneigentum)."
help="Privatkredite, Autoleasing-Restwert und Ähnliches ohne die Hypothek (die gehört zum Wohneigentum). Die jährliche Rückzahlung erfasst du im nächsten Schritt."
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>
@@ -521,6 +581,118 @@ export function PlanWizard({
)}
{step === 4 && (
<div className="flex flex-col gap-4">
{/* Sparquote-Karte: das Kernmodell des Tools zum Anfassen. */}
<div className={`rounded-xl border p-4 ${sparBase >= 0 ? "border-accent bg-accent-soft/30" : "border-danger bg-danger-soft"}`}>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">Deine jährliche Sparquote</div>
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="text-muted">{totalIncome.toLocaleString("de-CH")} CHF Einkommen</span>
<span className="text-faint"></span>
<span className="text-muted">{expenses.toLocaleString("de-CH")} CHF Ausgaben</span>
<span className="text-faint">=</span>
<span className={`text-lg font-bold ${sparBase >= 0 ? "text-fg" : "text-danger"}`}>
{sparBase.toLocaleString("de-CH")} CHF / Jahr
</span>
</div>
<p className="mt-1 text-xs text-muted">
{sparBase >= 0
? "Dieser Betrag bleibt dir jährlich zum Sparen, Amortisieren oder Anlegen. Verteile ihn unten was übrig bleibt, wächst auf dem Cash-Konto."
: "Du gibst mehr aus, als du einnimmst die Differenz wird laufend aus deinem Vermögen bzw. Cash gedeckt. Einzahlungen unten sind dann nur sinnvoll, wenn genügend Cash vorhanden ist."}
</p>
</div>
{/* PK-Einzahlung: bewusst SEPARAT, weil sie vom Bruttolohn kommt und die Sparquote
nicht schmälert -- exakt wie der Rechenkern es behandelt. */}
{anyPk && (
<div className="rounded-xl border border-border p-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
Pensionskassen-Einzahlung
</div>
<p className="mb-2 text-xs text-muted">
Wird direkt vom <strong className="text-fg">Bruttolohn</strong> bezahlt also <strong className="text-fg">bevor</strong> dein
Nettoeinkommen entsteht. Sie schmälert deine Sparquote deshalb <strong className="text-fg">nicht</strong>.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{renderedScopes.filter((sc) => assets[sc.id].pk.on).map((sc) => (
<MoneyField
key={sc.id}
label={`PK-Einzahlung pro Jahr (CHF)${scopeTag(sc)}`}
value={assets[sc.id].pk.contribution}
onChange={(v) => setScope(sc.id, "pk", { contribution: v })}
/>
))}
</div>
</div>
)}
{/* Verteilung der Sparquote auf 3a / Wertschriften / Amortisation / Schuldtilgung. */}
{anyQuoteTarget ? (
<div className="rounded-xl border border-border p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst deine Sparquote?
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{renderedScopes.map((sc) => (
<Fragment key={sc.id}>
{assets[sc.id].p3a.on && (
<MoneyField
label={`Säule 3a: Einzahlung / Jahr (CHF)${scopeTag(sc)}`}
help={`Maximal ${max3aFor(sc.id).toLocaleString("de-CH")} CHF${assets[sc.id].p3a.selfEmployed ? " (Selbstständig ohne PK)" : " (mit PK)"}.`}
value={assets[sc.id].p3a.contribution}
max={max3aFor(sc.id)}
onChange={(v) => setScope(sc.id, "p3a", { contribution: v })}
/>
)}
{assets[sc.id].etf.on && (
<MoneyField
label={`Wertschriften: Sparbeitrag / Jahr (CHF)${scopeTag(sc)}`}
value={assets[sc.id].etf.contribution}
onChange={(v) => setScope(sc.id, "etf", { contribution: v })}
/>
)}
{assets[sc.id].re.on && (
<MoneyField
label={`Wohneigentum: Amortisation / Jahr (CHF)${scopeTag(sc)}`}
value={assets[sc.id].re.amortization}
onChange={(v) => setScope(sc.id, "re", { amortization: v })}
/>
)}
{assets[sc.id].debt.on && (
<MoneyField
label={`Schulden: Rückzahlung / Jahr (CHF)${scopeTag(sc)}`}
value={assets[sc.id].debt.repayment}
onChange={(v) => setScope(sc.id, "debt", { repayment: v })}
/>
)}
</Fragment>
))}
</div>
{/* Live-Rest: was nach der Verteilung aufs Cash geht. */}
<div className="mt-3 flex items-center justify-between border-t border-border pt-2 text-sm">
<span className="text-muted">Rest bleibt auf dem Cash-Konto</span>
<span className={`font-semibold ${restToCash < 0 ? "text-danger" : "text-fg"}`}>
{restToCash.toLocaleString("de-CH")} CHF / Jahr
</span>
</div>
{restToCash < 0 && (
<p className="mt-1 text-[11px] text-danger">
Du verteilst mehr, als deine Sparquote hergibt die Differenz muss aus dem Cash-Bestand kommen und kann
ihn ins Minus ziehen.
</p>
)}
</div>
) : (
<p className="text-sm text-muted">
Du hast im vorigen Schritt keine Spar-/Vorsorgeposten angehakt, in die deine Sparquote fliessen könnte. Der
ganze Betrag wächst dann auf dem Cash-Konto. Zurück im vorigen Schritt kannst du z. B. Wertschriften oder die
Säule 3a ergänzen.
</p>
)}
</div>
)}
{step === 5 && (
<div className="flex flex-col gap-3">
<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">