Modul-Review 2: Onboarding-Umbau, Tour, Assistenten-Redesign
Deploy App / deploy (push) Successful in 1m10s
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:
+48
-10
@@ -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` (v1–v5) 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 7’258 auf ca. 36’288 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 4–6, `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 |
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user