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
+48 -10
View File
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.27 |
| **Version** | 0.28 |
| **Datum** | 2026-07-24 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `d6855ef` inkl. Sicherheits-Nachbesserungen Auth (Branch `main`) |
| **Codestand** | Arbeitsstand nach `2bb243b` inkl. Modul-Review 2 (Onboarding) (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 0.28 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 2 (Onboarding & Ansicht): Layout-Umbau, Tour und Assistenten-Redesign.** (1) **Szenario-Ansicht neu geordnet** (Kap. 3.7.7): oben eine schlanke Funktions-Leiste (Änderungshistorie · Tour · Neues Szenario · Rechenwege · CSV-Export · Löschen · Abweichungs-Badge), darunter Nächste Schritte → Grundprofil → Zeitachse → Anzeige-Umschalter → Matrix. Grafiken, Effektive Werte, Live-Simulation, Monte-Carlo und Einflussfaktoren sind aus der Leiste **entfernt** sie laufen über die eigenen Menüpunkte (Analysen / Effektive Werte). (2) **Tour** liegt neu in `AppShell` (statt `PlanView`) und startet nach **jeder** Plan-Erstellung Assistent, Beispielplan **und** leerer Plan unabhängig davon, ob sie schon einmal beendet wurde (Kap. 3.7.8); die Erfolgsmeldung hält damit ihr Versprechen. (3) **Assistent durchgehend in Du-Form** im Einzelmodus (Paarmodus weiter «ihr» / je Person). (4) **Schritt-Redesign** (Kap. 3.2.8): Schritt «Vorsorge & Vermögen» erfasst nur noch **Bestandswerte**; ein **neuer Schritt «Sparen & Verteilen»** zeigt die Sparquote (Nettoeinkommen Ausgaben) und lässt sie auf 3a, Wertschriften, Amortisation und Schuldtilgung verteilen der Rest bleibt sichtbar auf dem Cash-Konto. Die **PK-Einzahlung** steht dort bewusst separat, mit dem Hinweis, dass sie vom Bruttolohn kommt und die Sparquote **nicht** schmälert (deckt sich mit dem Rechenkern). Immobilien fragen im Assistenten neu **Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab; der Lohn bekommt 1 % Default-Erhöhung. (5) **«Grosse Säule 3a» für Selbstständige** (Roadmap-Feedback): ein Schalter am 3a-Element (und im Assistenten) hebt die Beitrags-Obergrenze von 7258 auf ca. 36288 CHF an (neue Konstante `PILLAR_3A_MAX_SELF_EMPLOYED`, **2026-Wert zu verifizieren**; neues Feld `selfEmployed3a`). (6) Nebenbei: `POST /plans` liefert bei Validierungsfehlern eine lesbare Meldung statt eines rohen zod-Objekts. Keine Änderung am Rechenkern; 267 Tests unverändert grün. |
| 0.27 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 1 (Zugang & App-Rahmen): fünf Nachbesserungen an der Authentifizierung.** Ergebnis der ersten gemeinsamen Test- und Review-Runde. (1) **Timing-Ausgleich beim Login:** Ein unbekannter Benutzer wird neu gegen einen Dummy-bcrypt-Hash geprüft, damit die Antwortzeit dieselbe ist wie bei einem bekannten -- vorher liess sich aus der Dauer ablesen, ob ein Benutzername existiert (Kap. 3.1.2). (2) **Zurück-Knopf nach Logout:** Die aus dem Browser-Cache (bfcache) zurückgeholte, eingefrorene Ansicht prüft neu beim `pageshow` die Session und leitet ohne Anmeldung sofort auf `/login` -- vorher blieb die alte Ansicht sichtbar (Kap. 3.1.3). (3) **Rate-Limiting:** Login (10/15 min je IP), Registrierung (5/h je IP) und Passwortänderung (10/15 min je Benutzer) sind gegen Durchprobieren gebremst; neues In-Memory-Modul `rate-limit.ts`, 429 mit `Retry-After` (Kap. 3.1.6). Der Login-Zähler läuft je IP **und Benutzername** -- ein Konto sperrt nicht die anderen Konten derselben IP. (4) **Passwort-Dialog** läuft neu über die zentrale `Modal`-Komponente und schliesst damit auf Esc (Fokus-Falle, aria-modal inklusive) -- war zuvor von Hand gebaut (Kap. 3.1.4). (5) **Registrierungs-Fehler** werden sauber getrennt: nur der belegte Benutzername ist ein 409 mit freundlicher Meldung, jeder andere Fehler ein 500 statt einer rohen Prisma-Meldung als Konflikt. 6 Tests ergänzt (261 → 267). Offen für Roadmap #34: kein Passwort-Längen-Maximum (bcrypt-72-Byte-Grenze), keine ARIA-Labels auf Login/Palette. |
| 0.26 | 2026-07-21 | Claude (Opus 4.8) | **Pensionsalter anpassen** (Roadmap Nr. 44, neue Kapitel 3.12, 4.4.7 und 4.16). Bisher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Lebensphase endet ein frei geändertes Alter hätte die Phasengrenze zerrissen. Neu wird nicht das Alter geändert, sondern **die Grenze verschoben**: Die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt gleich. Der Spielraum endet dort, wo eine angrenzende Phase unter ein Jahr fiele; ein Schritt weiter **entfällt sie ganz**, was vorher bestätigt wird, weil dabei zwei Übergänge **zusammengelegt** werden (bereits getroffene Entscheide bleiben, leere Felder werden aus dem entfallenden Übergang ergänzt, einmalige Cash-Beträge werden addiert der Steuersatz betragsgewichtet). Das Pensionsalter ist damit auch **Treiber im Tornado** und **Regler in der Live-Simulation**. **AHV-Referenzalter (4.4.7):** Die Rente beginnt neu **immer mit 65**, unabhängig vom Pensionsalter wer länger arbeitet, erhält sie zusätzlich zum Lohn; wer früher aufhört, zahlt bis 65 einen **Beitrag als Nichterwerbstätige(r)**, der als laufende Ausgabe auf die Verzehrquote schlägt und mit 65 wegfällt (neues Feld `ahvContribution`, ohne Default, Hilfetext nennt die reale Bandbreite von rund 530 bis 26'500 CHF pro Jahr). Beide Wechsel können **innerhalb** einer Phase liegen, die AHV wird deshalb **jahresweise** statt phasenweise gerechnet. **Punkt B:** Ein Einkommen, das einer **Person** zugeordnet ist, fällt bei deren Pensionierung auf 0 bisher lief der Lohn stillschweigend in die Pension weiter. Gemeinsame Einkommen (Mieterträge o. Ä.) bleiben; ein ausdrücklich erfasster Betrag gewinnt, damit ein Teilzeitpensum modellierbar bleibt. **Punkt A:** Wiederkehr-Parameter (Raten, Beiträge, Amortisation, Wertsteigerung, Zinssatz) werden neu **live aus der Vorphase geerbt** statt beim Anlegen der Phase kopiert sichtbar als angehaktes **«Aus Vorphase übernehmen»** je Feld. Vorher blieb die Kopie stehen, wenn man die Vorphase später änderte. **Punkt C:** Der Kapitalzufluss am Pensions-Übergang (PK, 3a, Verkaufserlös) lässt sich in **Prozent** auf Amortisation, Anlage und Cash aufteilen bewusst nicht in Franken, weil sich der Betrag mit dem Pensionsalter ändert und eine Quote mitskaliert. **Nebenbei ein echter Fehler behoben:** Der fortgeschriebene Basiswert für Einkommen und Ausgaben wurde **vor** der Jahresschleife berechnet effektive Werte kamen dadurch nie in der Folgephase an. Neues Modul `retirement.ts`, neuer Endpunkt `POST /api/scenarios/<id>/retirement`; 40 Tests ergänzt (221 → 261). |
| 0.25 | 2026-07-21 | Claude (Opus 4.8) | **PDF-Berichte** (Roadmap Nr. 11, neues Kapitel 3.11). Neuer Unterpunkt **«Berichte»** auf Plan-Ebene: Liste der erzeugten Berichte plus Assistent zum Anlegen (Titel, Notiz, nominal **oder** real, Plan- oder effektive Daten, bis zu **drei** Szenarien, beliebige gespeicherte Analysen). Das **Layout ist immer gleich**; die Auswahl bestimmt nur, welche Bausteine erscheinen: Deckblatt mit Zusammenfassung und drei Kernaussagen, dann je Szenario Kennzahlen, Vermögensverlauf, Lebensphasen und Annahmen, danach Vergleich, Plan/Ist, Analysen und die Hinweise. **Die PDF-Datei wird als Datei abgelegt** (BYTEA in Postgres, nicht im Container-Dateisystem, das jeder Deploy neu baut): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein eine Neuerzeugung könnte das nach Änderungen an Plan, Rechenkern oder Layout nicht garantieren. **Kennzahlen je Szenario:** Endvermögen, Kapitalreichweite, Vermögen und Vorsorgekapital bei Pensionierung, AHV- und PK-Rente sowie die **offenen Entscheide** die einzige unmittelbar handlungsleitende Zahl. Damit Bericht und Matrix nie verschiedene Zahlen nennen, liegt deren Zählung neu als reine Funktion in `decisions.ts`, die beide benutzen. **Zu jeder Kennzahl steht ihre Grundlage** als kurzer Verweis; die vollständigen Annahmen (Startwerte, Renditen, Raten je Element) stehen **einmal** je Szenario, statt bei jeder Kennzahl wiederholt zu werden. Ein **Haftungsausschluss** ist verpflichtend und durch einen Test gesichert ein formal gesetztes PDF wird sonst als Beratung gelesen. **Technik:** `pdfkit` in der Node-Runtime statt Headless-Browser (kein Chromium im Image); `@react-pdf/renderer` schied aus, weil es mit React 19 / Next 16 bricht. Diagramme entstehen als **echte Vektoren** aus den gespeicherten Zahlen genau dafür wurden die Analysen in 0.24 als Zahlen und nicht als Bilder abgelegt. `pdfkit` ist als externes Paket deklariert, weil es Font-Metriken über Dateipfade lädt und gebündelt erst in der Produktion bräche. Neue Tabelle `Report`, Endpunkte unter `/api/plans/<id>/reports`, neue Module `report.ts`, `report-pdf.ts`, `decisions.ts`; 9 Tests ergänzt (212 → 221). |
@@ -411,8 +412,10 @@ gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr 1)`.
### 3.2.8 Geführter Assistent und Beispielplan
**Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in
fünf Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und Ausgaben
plus Kontostand, (4) Vorsorge und Vermögen, (5) Zusammenfassung.
**sechs** Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und
Ausgaben plus Kontostand, (4) Vorsorge und Vermögen (**nur Bestandswerte**), (5) Sparen und
Verteilen, (6) Zusammenfassung. Im **Einzelmodus** durchgehend in **Du-Form** («Was verdienst
du?»); im Paarmodus je Person bzw. «ihr».
**Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder
Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem
@@ -444,7 +447,22 @@ Phasen lesbar bleiben.
**Schritt 4 (Vorsorge & Vermögen)** ist bei Paaren in **Gemeinsam / Person A / Person B**
aufgeteilt. Pensionskasse und Säule 3a sind immer persönlich (personengebundene Kategorien);
Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen.
Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen. Hier
werden nur die **heutigen Bestandswerte** erfasst (Guthaben, Kaufpreis, Hypothek, Restschuld)
die laufenden Jahresbeträge folgen in Schritt 5. Zusätzlich fragt das Wohneigentum die
**Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab (damit Hypothekarzinsen nicht
doppelt zählen), und die Säule 3a einen Schalter **«Selbstständig ohne PK (grosse Säule 3a)»**,
der die Beitrags-Obergrenze anhebt (siehe 4.11 / Feld `selfEmployed3a`).
**Schritt 5 (Sparen & Verteilen)** bringt das Kernmodell des Tools zum Anfassen: Aus
`Nettoeinkommen Ausgaben` entsteht die **Sparquote**; der Nutzer verteilt sie auf Säule 3a,
Wertschriften, Amortisation und Schuldtilgung, und der **Rest bleibt sichtbar auf dem
Cash-Konto** (live gerechnet, negativer Rest wird gewarnt). Die **PK-Einzahlung** steht bewusst
in einem **eigenen** Block mit dem Hinweis, dass sie vom **Bruttolohn** bezahlt wird also
**vor** dem Nettoeinkommen und die Sparquote deshalb **nicht** schmälert. Das deckt sich exakt
mit dem Rechenkern, wo der PK-Beitrag nicht zur Quote zählt ([4.6.3](#463-pension_fund)). Der
Schritt entfällt fachlich nie, aber wenn keine Spar-/Vorsorgeposten angehakt sind, weist er nur
darauf hin, dass der ganze Betrag auf dem Cash-Konto wächst.
Zwei bewusste Entscheide bleiben:
- **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» in Paar-Plänen zählt
@@ -1273,13 +1291,31 @@ Neuaufbau des Formulars wie zuvor bei den Dialogen.
Der frühere Dialog «Plan-Einstellungen» heisst im Panel korrekt **«Szenario-Profil»** er
bearbeitet seit V6 das Szenario, nicht den Plan.
**Aufbau der Szenario-Ansicht (seit 0.28):** von oben nach unten eine schlanke
**Funktions-Leiste** (Änderungshistorie · Tour · Neues Szenario aus diesem · Rechenwege ·
CSV-Export · Szenario löschen · Abweichungs-Badge), dann **Nächste Schritte**, das
**Grundprofil**, die **Zeitachse**, der **Anzeige-Umschalter** (Nominal/Beide/Real, Plan/Ist)
und zuunterst die **Matrix** mit ihren «Element»-/«Lebensphase»-Knöpfen. Die Analyse-Werkzeuge
(Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) und die Effektiven Werte sind hier
bewusst **nicht** mehr verlinkt sie laufen ausschliesslich über ihre eigenen Menüpunkte
([3.10](#310-navigation-auf-plan-ebene-und-gespeicherte-analysen)), damit es je Funktion genau
einen Ort gibt.
### 3.7.8 Tour und «Nächste Schritte»
Die **Tour** (`Tour.tsx`) startet einmalig beim ersten Öffnen eines Plans mit Phasen
(localStorage `fpt-tour-done`) und führt in bis zu sechs Schritten über Profil, Zeitachse,
Matrix, Übergänge, Cash und Analysen als Karte am unteren Rand plus pulsierender Rahmen um
das Ziel (`data-tour`-Attribute). Schritte ohne vorhandenes Ziel werden übersprungen; der
«Tour»-Knopf in der Werkzeugleiste startet sie jederzeit neu. Grenze: 9.24.
Die **Tour** (`Tour.tsx`) führt in bis zu sechs Schritten über Profil, Zeitachse, Matrix,
Übergänge, Cash und Analysen als Karte am unteren Rand plus pulsierender Rahmen um das Ziel
(`data-tour`-Attribute). Schritte ohne vorhandenes Ziel werden übersprungen; der «Tour»-Knopf
in der oberen Funktions-Leiste startet sie jederzeit neu. Grenze: 9.24.
Sie liegt seit 0.28 in `AppShell` (nicht mehr in `PlanView`), weil dort die Plan-Erstellung
zusammenläuft, und hat **zwei Auslöser**: Nach **jeder** Plan-Erstellung (Assistent,
Beispielplan, leerer Plan) startet sie **einmal** unabhängig davon, ob sie schon beendet
wurde (die Erfolgsmeldung «die Tour zeigt dir gleich …» hält damit ihr Versprechen). Zusätzlich
startet sie beim ersten Öffnen eines bestehenden Plans mit Phasen, solange sie noch nie beendet
wurde (localStorage `fpt-tour-done`). Ein aus dem DOM gelesenes Ziel setzt voraus, dass die
Szenario-Ansicht bereits gerendert ist; der Auslöser feuert deshalb erst, wenn das Szenario
geladen ist.
Die Karte **«Nächste Schritte»** über der Matrix leitet aus den vorhandenen Daten ab, was
sinnvollerweise als Nächstes ansteht offene Übergangs-Entscheide (mit Direktsprung in den
@@ -2455,6 +2491,7 @@ Zentral in `src/lib/constants.ts` geführt, weil sie sich periodisch durch Bunde
| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) |
| `AHV_REFERENCE_AGE` | 65 | Referenzalter ab hier fliesst die Rente, **unabhängig vom Pensionsalter** ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)) |
| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
| `PILLAR_3A_MAX_SELF_EMPLOYED` | 36'288 | Max. 3a-Beitrag/Jahr «grosse Säule 3a» (Selbstständige ohne PK). **2026-Wert zu verifizieren** (2025: 36'288) |
| `DEFAULT_PK_CONVERSION_RATE` | 6 % | Umwandlungssatz |
| `DEFAULT_CAPITAL_TAX_RATE` | 8 % | Kapitalbezugssteuer |
| `DEFAULT_PROPERTY_GAINS_TAX_RATE` | 20 % | Grundstückgewinnsteuer |
@@ -3338,6 +3375,7 @@ Referenz: `prisma/schema.prisma` Zeilen 46, `src/lib/elements.ts` Zeilen 48
| `startValue` | OTHER_ASSET, OTHER_DEBT | ≥ 0 |
| `expectedReturn` | PK, 3a, OTHER_ASSET | 50 bis 100 |
| `annualContribution` | PK, 3a, OTHER_ASSET | ≥ 0 |
| `selfEmployed3a` | PILLAR_3A «grosse Säule 3a» (Selbstständige ohne PK) → höhere Obergrenze | Boolean |
| `annualWithdrawal` | OTHER_ASSET | ≥ 0 |
| `additionalInvestment` | PK, 3a, OTHER_ASSET (ab Phase 2) | ≥ 0 |
| `purchasePrice` | REAL_ESTATE | ≥ 0 |
+8 -1
View File
@@ -65,7 +65,14 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = createPlanSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
if (!parsed.success) {
// Lesbare Meldung statt des rohen zod-Objekts (sonst sieht der Nutzer einen JSON-Blob).
const first = parsed.error.issues[0];
return NextResponse.json(
{ error: first ? `${first.path.join(".") || "Eingabe"}: ${first.message}` : "Ungültige Eingabe." },
{ status: 400 }
);
}
const error = validatePersonsForType(parsed.data);
if (error) return NextResponse.json({ error }, { status: 400 });
+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>
);
}
+17 -3
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 ? (
@@ -684,14 +688,23 @@ export function ElementPhaseFields({
) : (
<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">
+19 -1
View File
@@ -53,6 +53,13 @@ export const AHV_FULL_CONTRIBUTION_YEARS = 44;
// Maximaler jährlicher Säule-3a-Beitrag für PK-Versicherte (2026).
export const PILLAR_3A_MAX_ANNUAL = 7258;
// Maximaler jährlicher Säule-3a-Beitrag für Selbstständigerwerbende OHNE Pensionskasse
// («grosse Säule 3a»): 20 % des Nettoerwerbseinkommens, gedeckelt auf diesen Betrag.
// TODO(Verifikation): Wert gegen die aktuellen BSV-Angaben für 2026 prüfen -- 2025 galt
// CHF 36'288. Das Tool klammert das Eingabefeld hart auf diesen Deckel; die 20-%-Regel
// selbst wird (noch) nicht geprüft.
export const PILLAR_3A_MAX_SELF_EMPLOYED = 36288;
// Default-Annahmen für Übergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als
// UI-Vorschlag als auch in der Berechnung als Fallback verwendet -- damit ein nicht
// angetupfter Wert nicht fälschlich als 0 gerechnet wird.
@@ -175,7 +182,7 @@ export const SYSTEM_PARAMETERS: SystemParameter[] = [
},
{
key: "PILLAR_3A_MAX_ANNUAL",
label: "Maximaler Säule-3a-Beitrag pro Jahr",
label: "Maximaler Säule-3a-Beitrag pro Jahr (mit PK)",
value: PILLAR_3A_MAX_ANNUAL,
unit: "CHF",
meaning: "Höchstbetrag für Personen mit Pensionskasse. Im Eingabefeld hart geklammert.",
@@ -183,6 +190,17 @@ export const SYSTEM_PARAMETERS: SystemParameter[] = [
validFrom: "1.1.2026",
group: "Vorsorge",
},
{
key: "PILLAR_3A_MAX_SELF_EMPLOYED",
label: "Maximaler Säule-3a-Beitrag pro Jahr (Selbstständige ohne PK)",
value: PILLAR_3A_MAX_SELF_EMPLOYED,
unit: "CHF",
meaning:
"«Grosse Säule 3a»: 20 % des Nettoerwerbseinkommens, gedeckelt auf diesen Betrag. Gilt für Selbstständige ohne Pensionskasse. Wert für 2026 noch zu verifizieren (2025: 36'288).",
source: 'BSV «Beträge gültig ab 1.1.2026» (zu verifizieren)',
validFrom: "1.1.2026",
group: "Vorsorge",
},
{
key: "DEFAULT_PK_CONVERSION_RATE",
label: "Umwandlungssatz Pensionskasse (Vorschlag)",
+3
View File
@@ -71,6 +71,8 @@ export interface PhaseData {
startValue?: number;
expectedReturn?: number;
annualContribution?: number;
// PILLAR_3A: «grosse Säule 3a» für Selbstständige ohne PK -> höhere Beitrags-Obergrenze.
selfEmployed3a?: boolean;
// OTHER_ASSET: jährliche Bezugsrate (Entnahme). Mindert das Vermögen und fliesst ins Cash.
annualWithdrawal?: number;
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusätzliche Einlage aus dem
@@ -178,6 +180,7 @@ export const phaseDataSchema = z
startValue: nonNeg.optional(),
expectedReturn: z.number().min(-50).max(100).optional(),
annualContribution: nonNeg.optional(),
selfEmployed3a: z.boolean().optional(),
annualWithdrawal: nonNeg.optional(),
additionalInvestment: nonNeg.optional(),
purchasePrice: nonNeg.optional(),