diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md
index 766d0d5..7802ae3 100644
--- a/SPEZIFIKATION.md
+++ b/SPEZIFIKATION.md
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
-| **Version** | 0.41 |
+| **Version** | 0.42 |
| **Datum** | 2026-07-25 |
| **Status** | Lebendes Dokument |
-| **Codestand** | Arbeitsstand nach `f9d7ca0` inkl. Memo 2026081701 (Branch `main`) |
+| **Codestand** | Arbeitsstand nach `113616c` inkl. Kartenansicht (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.42 | 2026-08-17 | Claude (Opus 5) | **Kartenansicht und Drei-Spalten-Shell** (neues Kapitel 3.15). Der grösste Eingriff in die Oberfläche seit 0.36 – und er behebt eine Ursache statt Symptome. Ausgemessen war die Lage eindeutig: Der Phasenkopf trug 220 px an Kennzahlen, dauerhaft fixiert; von 675 px Matrixhöhe blieben 430 px zum Arbeiten. Fünf Phasen brauchten 1760 px Breite, sichtbar waren mit offenem Panel 1024 px. Und das Bearbeitungs-Panel (`fixed right-0`, 416 px) legte sich ausgerechnet über die Spalte, zu der man gerade gescrollt hatte. (1) **Eine Karte je Lebensphase** ersetzt die Matrix als Arbeitsansicht. Sie erzählt die Phase als Zeitablauf: **Eintritt** (der Übergang aus der Vorphase; bei Phase 1 der Bestand), **Laufend** (die Elemente mit ihren Annahmen und die Quote), **Ergebnis** (Vermögen und Cash am Phasenende). Horizontales Scrollen entfällt vollständig. Der Übergang gehört bewusst zur FOLGEphase – dort wird das frei werdende Kapital verteilt, konsistent mit 0.41. (2) **Bearbeitet wird IN der Karte.** Ein Klick klappt die Zeile auf, die Felder stehen darunter. Damit kann nichts mehr verdecken, woran man arbeitet – das Panel bleibt nur für die Matrix. Ein Knopf im Kartenkopf klappt **alle** Zeilen auf oder zu; bei acht Elementen ist das der Unterschied zwischen einmal durchsehen und acht Klicks. (3) **Die Zeitachse wird zum Navigator**: Das Segment der offenen Karte ist hervorgehoben, ein Klick springt hin. Dazu Pfeilknöpfe unten und die **Pfeiltasten** (ausser in Eingabefeldern – sonst springt die Karte beim Tippen weg). (4) **Die Matrix bleibt als Vergleichsansicht.** Umschalter oben rechts; die Wahl gehört zum Benutzer und überlebt die Sitzung. Ein Szenario-Diff oder eine Ist-Ansicht **erzwingt** die Matrix: Vergleich über alle Phasen kann eine einzelne Karte prinzipiell nicht leisten, und stillschweigend die falsche Darstellung anzubieten wäre schlechter als der Wechsel. (5) **Drei-Spalten-Shell.** Die Menüleiste links ist eingeklappt eine **Icon-Schiene** (56 statt 256 px); beim Überfahren fährt sie als Overlay aus, ein **Pin** hält sie breit. Die vier Kacheln wandern von oben in eine **rechte Leiste** mit aufklappbaren Abschnitten. Das ist der entscheidende Tausch: Sie kosteten oben **vertikalen** Platz, rechts kosten sie horizontalen – und Höhe ist auf einem Breitbildschirm die knappe Grösse. Eingeklappt bleibt die Zahl der offenen Punkte am Knopf sichtbar. Zusammen: statt 1184 × 574 rund **1328 × 804** für den Inhalt, also +12 % Breite und **+40 % Höhe**. (6) Der Anzeige-Umschalter nominal/beide/real steht neu auch in der Werkzeugleiste (`ValueModeSwitch`, eine Implementierung für beide Orte). Neue Komponente `PhaseCards`; `ElementDetail` bekommt eine `compact`-Variante, `Timeline` die Navigator-Rolle, `Tile` eine rahmenlose Variante. Kein Eingriff in den Rechenkern; 337 Tests unverändert grün. |
| 0.41 | 2026-08-17 | Claude (Opus 5) | **Memo 2026081701** – zehn Punkte aus einer Testrunde, mehrheitlich Feinschliff, zwei davon inhaltlich. (1) **Die Kapitalverwendung heisst neu Schuldentilgung · Investition · Cash, und die Tilgung hat ein wählbares Ziel** (`capitalUseDebtTargetElementId`): eine Immobilie **oder** eine sonstige Schuld. Bisher floss die Quote stur in die erstbeste Immobilie – wer neben einer Hypothek zu 1,5 % einen Konsumkredit zu 6 % trägt, konnte den Kredit gar nicht zuerst tilgen. Ohne Wahl bleibt es beim alten Verhalten, damit bestehende Pläne unverändert rechnen. **Cash ist neu eine gleichrangige Zeile** statt einer blassen Fussnote: «ich lasse es liegen» ist ein legitimer Entscheid. Rechnerisch bleibt es der Rest – alle drei frei eintippbar zu machen hiesse, dass beim Tippen still eine andere Zahl wandert. (2) **Die Maske gibt es neu auch bei der Säule 3a.** Dort stellt sich die Frage sogar zwingender als bei der PK: Ein 3a-Konto wird IMMER vollständig bezogen, es fliesst also in jedem Fall ein grosser Betrag – der bis 0.40 stumm auf dem Cash landete. (3) **Je Person entsteht beim Abschluss der Bestandsaufnahme automatisch ein AHV-Element**, idempotent (wer die Bestandsaufnahme später korrigiert, bekommt kein zweites). Die AHV ist die einzige Kategorie ohne Bestand – ein Plus-Knopf mit leerer Karte wäre sinnlos, das Element wegzulassen aber auch: Ohne es rechnet der Plan ab der Pensionierung mit einer AHV-Rente von 0. Die Karte in der Bestandsaufnahme sagt neu, warum dort nichts zu erfassen ist. (4) **Farben folgen der Bedeutung, nicht dem Zustand.** Ein einmaliger Cash-Zufluss am Übergang trug die Akzentfarbe – im warmen Schema ein Orangerot, das eine Erbschaft wie einen Fehler aussehen liess; neu ist er grün, ein Abfluss neutral. Die Knöpfe «Kapital verteilen» und «Sparquote verteilen» sind **grün mit Haken, sobald erledigt** – vorher sah man einer vollständig verteilten Phase nicht an, dass sie fertig war. (5) **Sechs Stellen mit unlesbarer Schrift behoben:** `text-attention-fg` ist **weiss** und für vollflächigen Attention-Grund gedacht; auf hellem oder 10-%-Grund war der Text im warmen und im hellen Schema praktisch unsichtbar (PK-Einkaufswarnung, 3a-Kollision, fehlendes Anlageziel, zwei Stellen in der Kachel «Offene Punkte»). (6) **Der Kapitalanteil-Regler hatte keine sichtbare Schiene**: `appearance-none` schaltet die native Darstellung ab, womit auch `accent-color` nicht mehr greift – übrig blieb der blosse Knopf. (7) **Der Umwandlungssatz verschwindet bei 100 % Kapital**, spiegelbildlich zur Kapitalbezugssteuer bei 0 %. Bei vollem Kapitalbezug wird nichts verrentet. (8) **Der Dialog «Neue Lebensphase» erklärt die Dauer-Kappung** – welches Ereignis sie setzt und warum (die Rechnung leitet Erwerbsstatus und Bezüge am Phasen**beginn** ab). Vorher stand dort nur «max. N». (9) **Zustand statt Regel nach der Pensionierung:** Die Zellen von PK und 3a beschrieben in der Pensionsphase die Regel im Futur («wird berechnet», «wird bezogen»), obwohl der Bezug längst geschehen war. Neu steht dort der gerechnete Stand – bei der PK die Rente in Franken oder «Vollständig bezogen», bei der 3a «Vollständig bezogen». (10) **Eine Vokabel für einen Gedanken:** Die Bestätigung der Pensionsentscheide heisst neu «angeschaut» wie die der Phasenzellen, und der Erklärtext sagt ausdrücklich, dass der Haken **keine Zahl ändert**. Zwei Wörter für dieselbe Sache hatten mehr verwirrt als geholfen. Die Fixpunkt-Kappung um AHV-, PK- und 3a-Bezugsalter zu erweitern wurde **bewusst zurückgestellt** – im Basisszenario ist das Pensionsalter fest 65, das Thema gehört zur Szenario-Art «Frühpensionierung» (Roadmap Nr. 48). 3 Tests ergänzt (334 → 337). |
| 0.40.1 | 2026-08-16 | Claude (Opus 5) | **Zwei Fehler aus der Testrunde.** (1) **«Ein Wert, ein Ort» gilt jetzt im Rechenkern, nicht nur in der Oberfläche.** Eine Immobilie zeigte in der ersten Lebensphase Startwert 0 statt Kaufpreis minus Hypothek. Ursache: Der Rechenkern legte die Phasenwerte über die Stammdaten (`{...baseData, ...phaseValues}`) – eine in 0.39 versehentlich gespeicherte 0 gewann damit gegen die Bestandsaufnahme. Seit 0.40 ist das Feld read-only, wodurch dieser Altwert **unerreichbar** war und den Kaufpreis dauerhaft verdeckt hätte. Neu gewinnen bei den fünf Bestandsfeldern (`amount`, `currentValue`, `startValue`, `purchasePrice`, `mortgage`) **immer die Stammdaten**, wo sie einen Wert tragen (`firstPhaseValues`); der Rückfall auf den Phasenwert bleibt nur, solange die Stammdaten leer sind – sonst fielen Pläne aus der Zeit vor 0.36 schlagartig auf 0. Die Annahmen (Rendite, Zins, Wertsteigerung, Teuerung) bleiben in Phase 1 überschreibbar: Sie gelten für einen Zeitraum, nicht für den Anfangsbestand. Damit heilen bestehende Daten von selbst. (2) **«Alle bestätigen» war zu grob.** Der Knopf in der Annahmen-Maske schrieb `confirmed: true` auf jede Zeile – man konnte ihn drücken, ohne je gescrollt zu haben, also genau das Durchwinken, das der Mechanismus verhindern soll. Neu trägt **jede Zeile ein Häkchen «Angeschaut»**, anfangs leer; wer ein Feld ändert, hakt es automatisch an (bearbeiten IST anschauen); oben markiert ein Klick alle auf einmal, aber als sichtbarer Akt. Gespeichert werden die Werte aller Zeilen, bestätigt nur die angehakten – der Knopf nennt die Zahl. 4 Tests ergänzt (330 → 334). |
| 0.40 | 2026-08-16 | Claude (Opus 5) | **Ein Wert, ein Ort** (neues Kap. 3.14.3, aus einer Testrunde des Nutzers). Der rote Faden aller acht Punkte: Für dieselbe Zahl gab es an mehreren Stellen ein Eingabefeld, und der Rechenkern entschied still, welches gewinnt. (1) **Startwerte sind in der ersten Lebensphase read-only.** Sie standen dort als Eingabefeld, das `phaseValues[phase1]` schrieb – der Rechenkern legt die Phase über die Stammdaten (`{...baseData, ...phaseValues}`), also überschattete jede Eingabe still die Bestandsaufnahme. Neu wird der Stammdatenwert angezeigt, mit Absprung in die Spalte «Start». Ab Phase 2 bleibt der Betrag bei Einkommen und Ausgaben **änderbar** – Teilzeit, Beförderung, Jobwechsel sind echte Entscheide dieser Phase; die Bestände sind dort ohnehin schon fortgeschrieben und read-only. (2) **Jährliche Raten entstehen nur noch im Verteil-Dialog**, einmalige Kapitalverwendungen nur noch im Kapital-Dialog. In der Zelle stehen sie weiterhin, aber read-only mit Absprung. Nur im Dialog sieht man, wie viel überhaupt zu verteilen ist und ob die Summe aufgeht. (3) **Der PK-Beitrag wandert in den Verteil-Dialog** – in einen **eigenen Block** «Aus dem Bruttolohn (ausserhalb der Quote)». Er ist bewusst nicht Teil der Quote ([4.6.3](#463-pension_fund)), aber der Dialog ist neu der einzige Ort für jährliche Beträge; ohne ihn wäre er unerreichbar und fiele still auf 0. (4) **Deckel und Schalter «grosse Säule 3a» stehen im Verteil-Dialog**, direkt über der Einzahlung, deren Obergrenze sie bestimmen – getrennt davon war der Schalter eine Einstellung ohne sichtbare Wirkung. (5) **Sonderamortisation und Sofort-Tilgung verlassen die Übergangszelle.** Sie zehren vom Kapital der FOLGEphase und werden dort entschieden; angezeigt werden sie in der Phasenzelle, wo auch die Zusatzeinlage steht. (6) **Der «Kapital verteilen»-Knopf ist immer sichtbar.** Er hing an `pot.total > 0` – wer alles verteilt hätte, käme an seine eigene Zuteilung nie mehr heran, sobald die Elementfelder read-only sind. (7) **Cash in der Bestandsaufnahme.** Es fehlte ganz: Die Summe «Vermögen heute» rechnete es bereits mit, erfassen konnte man es dort nirgends. Kein Plus-Knopf, sondern ein festes Feld – Cash ist kein Element, sondern `Scenario.initialCash`. (8) **Die Tour startet direkt nach dem Anlegen des Plans.** Sie hing an `phases.length > 0`, einem Überbleibsel der Spotlight-Tour, die echte DOM-Ziele brauchte; die Attrappe braucht nichts – und die Tour gehört genau dorthin, wo man noch nicht weiss, wie der Plan aufgebaut ist. (9) **Das Abzeichen im Phasenkopf öffnet eine Maske mit allen offenen Annahmen** (`PhaseReviewDialog`), analog zum Übergangs-Review. Bis 0.39 fragte es nur «N Annahmen bestätigen?» – eine Zustimmung zu etwas Ungesehenem, also genau die Bewegung, die der Mechanismus verhindern soll. (10) **Unbestätigte Zellen sind deutlicher markiert**: getönter Grund, kräftiger linker Balken, Warnzeichen. Der dünne Ring aus 0.39 ging in einer vollen Matrix unter. (11) **Cash-Zeile mit `ValuePair`**: der Realwert steht im Modus «Beide» darunter statt daneben, wie in jeder anderen Zeile. (12) Zwei Folgen daraus: Der Dialog **«Element anlegen» schreibt neu in die Stammdaten** statt in Phase 1 und **braucht keine Lebensphase mehr**; und `needsConfirmation` verlangt keine Bestätigung mehr für Zellen **ohne jede Annahme** – eine «Sonstige Schuld» trägt seit (2) nichts mehr in der Phasenzelle. 1 Test ergänzt (329 → 330). |
@@ -1427,6 +1428,10 @@ dem Schema heraus und sahen im Dunkelmodus falsch aus.
### 3.7.7 Inspector-Panel statt Modals
+> **Seit 0.42 gilt das nur noch für die Matrix.** In der Kartenansicht wird in der Karte selbst
+> bearbeitet ([3.15.2](#3152-eine-karte-je-lebensphase)) – das Panel legte sich als
+> `fixed right-0` ausgerechnet über die Spalte, zu der man gerade gescrollt hatte.
+
Alle **Einzel-Bearbeitungen** – Phasenzelle, Übergangszelle, Cash-Übergang, Cash-Anfangswert,
Phase (Name/Dauer), Szenario-Profil – öffnen sich als rechtes Seitenpanel (`InspectorShell`,
26 rem, mobil volle Breite) statt als Modal. Bewusst **ohne Backdrop**: Die Matrix bleibt
@@ -2485,6 +2490,120 @@ Referenz: `src/lib/review.ts`, `src/components/ReviewTile.tsx`,
---
+## 3.15 Der Bildschirm: drei Spalten, zwei Sichten
+
+### 3.15.1 Was gemessen wurde
+
+Die Matrix war ab der vierten Lebensphase nicht mehr zu bedienen. Der Befund in Zahlen, bevor
+irgendetwas entworfen wurde:
+
+| Grösse | Wert | Folge |
+|---|---|---|
+| Phasenkopf | 220 px, dauerhaft fixiert | von 675 px Matrixhöhe blieben **430 px** |
+| Fünf Phasen | 224 + 112 + 5×208 + 4×96 = **1760 px** | passt auf keinen Laptop |
+| Bearbeitungs-Panel | `fixed right-0`, 416 px | legte sich über die Spalte, zu der man gescrollt hatte |
+| Kacheln und Zeitachse | `230 px über der Matrix | ein zweiter Kopf über dem ersten |
+
+Drei davon haben dieselbe Wurzel: **Der Phasenkopf war ein Kennzahlen-Dashboard.** Die 208 px
+Spaltenbreite waren nicht die Breite, die eine Zelle braucht, sondern die, die der
+Kennzahlenblock brauchte.
+
+### 3.15.2 Eine Karte je Lebensphase
+
+Die Matrix stellt Phasen **nebeneinander** – gut zum Vergleichen, schlecht zum Arbeiten. Eine
+Karte zeigt genau eine Phase über die ganze Breite. Damit wird horizontales Scrollen, das eine
+Web-App am zuverlässigsten unbenutzbar macht, gegen vertikales getauscht, das jeder beherrscht.
+
+Die Karte erzählt die Phase als **Zeitablauf**:
+
+| Abschnitt | Inhalt |
+|---|---|
+| **Eintritt** | der Übergang aus der Vorphase: Verkauf oder Halten, Kapitalbezug, einmalige Cash-Ereignisse, die Kapitalverteilung. Bei Phase 1 stattdessen der **Start** aus der Bestandsaufnahme |
+| **Laufend** | die Elemente mit ihren Annahmen, dazu die Spar- bzw. Verzehrquote und ihre Verteilung |
+| **Ergebnis** | Vermögen und Cash am Phasenende |
+
+**Der Übergang gehört zur Folgephase, nicht ans Ende der Vorphase.** Das dabei frei werdende
+Kapital wird in der Folgephase verteilt – dieselbe Zuordnung, die 0.41 schon für
+Sonderamortisation und Sofort-Tilgung eingeführt hat ([3.12.5](#3125-punkt-c-verwendung-des-bezogenen-alterskapitals)).
+Der Preis ist bekannt: Wer den Hausverkauf gedanklich am Ende von Phase N trifft, findet ihn
+auf Karte N+1. Bewusst in Kauf genommen, weil dort die **Folgen** stehen.
+
+**Bearbeitet wird in der Karte.** Ein Klick klappt die Zeile auf, die Felder stehen darunter –
+dieselben wie im Panel (`ElementDetail` mit `compact`), also keine zweite Implementierung.
+Damit kann nichts mehr verdecken, woran man arbeitet; das Panel bleibt der Matrix vorbehalten.
+Ein Knopf im Kartenkopf klappt **alle** Zeilen auf oder zu: Bei acht Elementen ist das der
+Unterschied zwischen einmal durchsehen und acht Klicks.
+
+### 3.15.3 Die Zeitachse ist der Navigator
+
+Sie war bis 0.41 Dekoration. Neu ist das Segment der offenen Karte hervorgehoben, und ein
+Klick springt hin. Das ist der Punkt, an dem sich Aussehen und Verständlichkeit treffen: Zeit
+läuft von links nach rechts, das braucht keine Erklärung.
+
+Drei Wege durch die Phasen, weil verschiedene Leute verschieden arbeiten:
+
+* **Klick auf die Zeitachse** – gezielt zu einer bestimmten Phase.
+* **Pfeilknöpfe unter der Karte**, beschriftet mit dem Namen der Nachbarphase. Auf der
+ **letzten** Karte steht dort stattdessen «+ Lebensphase» – am Ende der Kette baut man weiter.
+* **Pfeiltasten links/rechts**, ausser der Fokus steht in einem Eingabefeld. Ohne diese
+ Ausnahme spränge die Karte beim Bearbeiten eines Betrags weg.
+
+### 3.15.4 Zwei Sichten auf dasselbe Material
+
+Die Matrix wird **nicht** ersetzt. Ihr Wert ist der Vergleich über Phasen hinweg, und den kann
+eine Karte prinzipiell nicht leisten. Konkret hängen drei Dinge daran: der **Szenario-Diff**
+(was ist anders als in der Vorlage), der **Plan/Ist-Vergleich** und die schlichte Frage, wie
+sich eine Grösse über alle Phasen entwickelt.
+
+Der Umschalter steht oben rechts; die Wahl gehört zum **Benutzer** und überlebt die Sitzung
+(`localStorage`) – sie ist eine Arbeitsgewohnheit, keine Eigenschaft des Plans.
+
+> **Ein Diff oder eine Ist-Ansicht erzwingt die Matrix.** Dort ist sie die richtige
+> Darstellung; stillschweigend die falsche zu zeigen wäre schlechter als der Wechsel. Ohne
+> Lebensphasen gilt dasselbe – eine Karte ohne Phase gibt es nicht.
+
+### 3.15.5 Drei Spalten
+
+```
+[Icon-Schiene 56px] [ Karten oder Matrix ] [ Steuerung 304px ]
+ ^ Pin haelt sie breit ^ einklappbar
+```
+
+**Links** die Menüleiste, eingeklappt eine Icon-Schiene mit Haus, Plan-Wähler und den vier
+Bereichen des aktiven Plans. Beim Überfahren fährt sie als **Overlay** aus – sie kostet dann
+keinen Platz –, ein **Pin** hält sie breit und schiebt den Inhalt. Der Szenario-Baum steht
+bewusst nicht in der Schiene: Er braucht Text, und Text braucht Breite.
+
+Der Plan-Wähler zeigt eingeklappt die **Initialen** des aktiven Plans. Ohne diesen Bezug wüsste
+man nach dem Umschalten nicht mehr, worin man ist – und die vier Icons darunter wären
+gegenstandslos.
+
+**Rechts** die vier bisherigen Kacheln als aufklappbare Abschnitte. Das ist der entscheidende
+Tausch: Oben kosteten sie **vertikalen** Platz, rechts kosten sie horizontalen – und auf einem
+Breitbildschirm ist Höhe die knappe Grösse. Eingeklappt bleibt die **Zahl der offenen Punkte**
+am Knopf sichtbar; sonst klappt man die Leiste zu und vergisst den halben Plan.
+
+Unter 1280 px verschwindet die rechte Leiste, unter 1024 px die linke Schiene (dort greift der
+bestehende Overlay-Drawer). Ergebnis auf 1440 × 900: statt 1184 × 574 rund **1328 × 804** für
+den Inhalt – 12 % mehr Breite und **40 % mehr Höhe**.
+
+### 3.15.6 Grundsätze, die daraus folgen
+
+Aus dem Umbau sind drei Regeln geworden, die für neue Oberfläche gelten:
+
+1. **Höhe ist knapper als Breite.** Was dauerhaft sichtbar sein muss, gehört an den Rand, nicht
+ nach oben.
+2. **Was fixiert ist, muss sich rechtfertigen.** Ein sticky Element kostet seine Höhe auf jedem
+ Bildschirm, in jeder Sitzung, für jeden Benutzer.
+3. **Nichts legt sich über das, was man bearbeitet.** Overlays sind für Dinge, die den
+ Arbeitsfluss unterbrechen sollen – nicht für die Arbeit selbst.
+
+Referenz: `src/components/PhaseCards.tsx`, `src/components/Timeline.tsx`,
+`IconRail` in `src/components/AppShell.tsx`, `RailSection` und `ValueModeSwitch` in
+`src/components/PlanView.tsx`.
+
+---
+
# 4. Berechnungsmodell
> Dies ist der fachliche Kern des Tools. Der gesamte Abschnitt beschreibt
@@ -4240,6 +4359,7 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
| `InfoBubble` | 28 | Hilfe-Tooltip |
| `ui` | ~370 | UI-Primitiven: Button, Modal, InspectorShell, Confirm, Toast, Skeleton, EmptyState ([3.7.6](#376-sprache-und-ui-primitiven)/[3.7.7](#377-inspector-panel-statt-modals)) |
| `ReviewTile` · `InventoryDialog` | ~900 | Übersicht der offenen Punkte und der Sammel-Dialog der Bestandsaufnahme ([3.14](#314-bestandsaufnahme-und-offene-punkte)) |
+| `PhaseCards` | ~560 | Kartenansicht: eine Karte je Lebensphase, Bearbeitung in der Karte ([3.15](#315-der-bildschirm-drei-spalten-zwei-sichten)) |
| `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) |
| `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) |
| `DistributionDialogs` | ~460 | Verteil-Werkzeuge für Kapital und Spar-/Verzehrquote ([3.6.10](#3610-verteil-werkzeuge)) |
diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx
index 9f0aff2..967635a 100644
--- a/src/components/AppShell.tsx
+++ b/src/components/AppShell.tsx
@@ -14,6 +14,8 @@ import {
Layers,
LayoutDashboard,
Menu,
+ Pin,
+ PinOff,
PiggyBank,
Plus,
Search,
@@ -39,8 +41,7 @@ import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
import { computePlan } from "@/lib/calculations";
import { ProfileMenu } from "@/components/ProfileMenu";
-import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
-import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
+import { Tour, TOUR_DONE_KEY } from "@/components/Tour";import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import {
Button,
@@ -52,6 +53,9 @@ import {
useConfirm,
useToast,
} from "@/components/ui";
+
+// Angepinnte Menueleiste: eine Arbeitsgewohnheit des Benutzers, keine Eigenschaft des Plans.
+const RAIL_PINNED_KEY = "fpt.rail.pinned";
import { api } from "@/lib/api-client";
import { computeScenarioDiff } from "@/lib/diff";
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
@@ -111,6 +115,33 @@ function AppShellInner({ username }: { username: string }) {
// Erhoehen erzwingt ein Neuladen der Szenario-Liste (bleibt bei einer Loeschung montiert).
const [scenarioListKey, setScenarioListKey] = useState(0);
+ // Menueleiste links: eingeklappt auf eine Icon-Schiene (56 px) statt 256 px. Angepinnt
+ // bleibt sie breit und schiebt den Inhalt; nicht angepinnt faehrt sie beim Ueberfahren als
+ // Overlay aus und legt sich UEBER den Inhalt -- so kostet sie im Ruhezustand nichts.
+ // Die Wahl gehoert zum Benutzer, nicht zum Plan, und ueberlebt deshalb die Sitzung.
+ const [railPinned, setRailPinned] = useState(false);
+ const [railHover, setRailHover] = useState(false);
+ const [planMenuOpen, setPlanMenuOpen] = useState(false);
+ useEffect(() => {
+ try {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung
+ if (localStorage.getItem(RAIL_PINNED_KEY) === "1") setRailPinned(true);
+ } catch {
+ /* localStorage nicht verfuegbar */
+ }
+ }, []);
+ function toggleRailPin() {
+ setRailPinned((v) => {
+ try {
+ localStorage.setItem(RAIL_PINNED_KEY, v ? "0" : "1");
+ } catch {
+ /* ignorieren */
+ }
+ return !v;
+ });
+ }
+ const railWide = railPinned || railHover;
+
// 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).
@@ -493,7 +524,81 @@ function AppShellInner({ username }: { username: string }) {
return (
-
+ {/* Linke Leiste. Eingeklappt eine Icon-Schiene; beim Ueberfahren faehrt sie als Overlay
+ aus (kostet keinen Platz), angepinnt bleibt sie stehen und schiebt den Inhalt.
+ Icon-only braucht zwingend Tooltip UND aria-label -- sonst ist die Schiene fuer
+ Screenreader und fuer Neulinge gleichermassen stumm. */}
+
@@ -1091,3 +1196,137 @@ function CopyScenarioDialog({
);
}
+
+// --- Die eingeklappte Icon-Schiene -----------------------------------------------------------
+//
+// Sie zeigt genau das, was man im Alltag braucht: nach Hause, den aktiven Plan, seine vier
+// Bereiche. Der Szenario-BAUM steht bewusst nicht hier -- er braucht Text, und Text braucht
+// Breite. Wer ihn will, faehrt die Leiste aus oder pinnt sie an.
+function IconRail({
+ plans,
+ activeScenarioId,
+ planNav,
+ atHome,
+ showSpec,
+ showSystemParams,
+ planMenuOpen,
+ onTogglePlanMenu,
+ onHome,
+ onOpenTab,
+ onSpec,
+ onParams,
+ onSearch,
+ onPin,
+}: {
+ plans: PlanListItem[];
+ activeScenarioId: string | null;
+ planNav: { planId: string; tab: string } | null;
+ atHome: boolean;
+ showSpec: boolean;
+ showSystemParams: boolean;
+ planMenuOpen: boolean;
+ onTogglePlanMenu: () => void;
+ onHome: () => void;
+ onOpenTab: (planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") => void;
+ onSpec: () => void;
+ onParams: () => void;
+ onSearch: () => void;
+ onPin: () => void;
+}) {
+ // Welcher Plan ist gerade im Zugriff? Erst der offene, sonst der zuletzt gewaehlte Bereich,
+ // sonst der einzige. Ohne diesen Bezug waeren die vier Icons darunter gegenstandslos.
+ const active =
+ plans.find((p) => p.scenarios.some((sc) => sc.id === activeScenarioId)) ??
+ plans.find((p) => p.id === planNav?.planId) ??
+ (plans.length === 1 ? plans[0] : undefined);
+
+ const initials = active
+ ? active.name
+ .split(/\s+/)
+ .slice(0, 2)
+ .map((w) => w[0])
+ .join("")
+ .toUpperCase()
+ : "–";
+
+ const item = (
+ label: string,
+ Icon: typeof LayoutDashboard,
+ on: boolean,
+ onClick: () => void,
+ disabled?: boolean
+ ) => (
+
+ );
+
+ return (
+
+
+
+
+
+
+ {item("Übersicht", LayoutDashboard, atHome, onHome)}
+
+ {/* Der Plan-Wähler. Eingeklappt zeigt er die Initialen -- sonst wüsste man nach dem
+ Umschalten nicht mehr, worin man gerade ist. */}
+
+ );
+}
diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx
index 9bb6592..2b6f416 100644
--- a/src/components/ElementDetail.tsx
+++ b/src/components/ElementDetail.tsx
@@ -104,6 +104,9 @@ interface Props {
phaseDataByPhase?: Record;
onSaved: () => void;
onDeleteElement: () => void;
+ // In der Kartenansicht steht Name und Kategorie schon im Zeilenkopf -- dann waere die
+ // eigene Ueberschrift eine Wiederholung, und der Loeschen-Knopf sitzt dort im Menue.
+ compact?: boolean;
}
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
@@ -1182,6 +1185,7 @@ export function ElementDetail({
phaseDataByPhase = {},
onSaved,
onDeleteElement,
+ compact,
}: Props) {
const [pd, setPd] = useState({ ...phaseData });
const [td, setTd] = useState({ ...transitionData });
@@ -1252,6 +1256,7 @@ export function ElementDetail({
return (
+ {!compact && (
@@ -1268,6 +1273,7 @@ export function ElementDetail({
Element löschen
+ )}
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */}
diff --git a/src/components/PhaseCards.tsx b/src/components/PhaseCards.tsx
new file mode 100644
index 0000000..856902c
--- /dev/null
+++ b/src/components/PhaseCards.tsx
@@ -0,0 +1,642 @@
+"use client";
+
+// Die Kartenansicht: eine Karte je Lebensphase (SPEZIFIKATION 3.15).
+//
+// Sie loest das Grundproblem der Matrix, nicht dessen Symptome. Die Matrix stellt Phasen
+// NEBENEINANDER -- gut zum Vergleichen, schlecht zum Arbeiten: Ab der vierten Phase scrollt
+// man horizontal, und das rechte Bearbeitungs-Panel legte sich ausgerechnet ueber die Spalte,
+// zu der man gerade gescrollt hatte. Eine Karte zeigt genau eine Phase, ueber die ganze
+// Breite, und getippt wird in der Karte selbst. Horizontales Scrollen gibt es nicht mehr.
+//
+// Die Karte erzaehlt die Phase als ZEITABLAUF in drei Abschnitten:
+// 1. Eintritt -- was beim Wechsel in diese Phase geschah (Uebergang aus der Vorphase).
+// Bei der ersten Phase stattdessen der Bestand aus der Bestandsaufnahme.
+// 2. Laufend -- was in diesen Jahren gilt: die Elemente mit ihren Annahmen.
+// 3. Ergebnis -- was am Ende dasteht.
+//
+// Der Uebergang gehoert bewusst zur FOLGEphase und nicht ans Ende der Vorphase: Das dabei
+// frei werdende Kapital wird in der Folgephase verteilt (seit 0.41 auch in der Anzeige, Kap.
+// 3.12.5). Der Preis ist, dass man den Hausverkauf gedanklich am Ende von Phase N trifft, ihn
+// aber auf Karte N+1 findet -- bewusst in Kauf genommen, weil dort die Folgen stehen.
+//
+// Die Matrix bleibt als VERGLEICHSANSICHT bestehen (Diff, Plan/Ist, Verlauf ueber alle
+// Phasen) -- dasselbe Datenmaterial, zwei Sichten.
+
+import { useEffect, useState } from "react";
+import {
+ AlertTriangle,
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ Coins,
+ Maximize2,
+ Pencil,
+ PiggyBank,
+ Plus,
+ Trash2,
+ Wallet,
+} from "lucide-react";
+import { Button } from "@/components/ui";
+import {
+ ElementDetail,
+ cashTransitionSummary,
+ isCashTransitionAnswered,
+ type CellContext,
+} from "@/components/ElementDetail";
+import { CATEGORY_LABELS, ownerLabel, type CashTransitionData, type PhaseData } from "@/lib/elements";
+import { capitalPot } from "@/lib/distribution";
+import { formatChf } from "@/lib/format";
+import { unconfirmedCells } from "@/lib/review";
+import type { PhaseComputed, PlanComputed, ElementPhaseComputed } from "@/lib/calculations";
+import type { ElementInput, PlanInput } from "@/lib/types";
+import type { PhaseRef } from "@/lib/ratefields";
+
+export type ValueMode = "nominal" | "both" | "real";
+
+// Alles, was die Karte an ihre Umgebung zurueckmeldet. Bewusst ein flaches Buendel statt
+// vieler Einzel-Props: Die Karte ist eine Ansicht, keine Datenhaltung.
+export interface CardActions {
+ onEditPhase: (phaseId: string) => void;
+ onDeletePhase: (phaseId: string, name: string) => void;
+ onAddPhase: () => void;
+ onAddElement: () => void;
+ onDeleteElement: (elementId: string, name: string) => void;
+ onDistributeRates: (phaseId: string) => void;
+ onDistributeCapital: (phaseId: string) => void;
+ onCashTransition: (fromPhaseId: string) => void;
+ onOpenPhaseDetail: (phaseId: string) => void;
+ onChanged: () => void;
+}
+
+interface Props {
+ plan: PlanInput;
+ computed: PlanComputed;
+ mode: ValueMode;
+ activePhaseId: string | null;
+ onSelectPhase: (phaseId: string) => void;
+ phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext;
+ transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext;
+ // Elemente, die am Uebergang NACH einer Phase ueberhaupt etwas zu entscheiden haben.
+ transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[];
+ cashTransitionFor: (phaseId: string) => CashTransitionData;
+ actions: CardActions;
+}
+
+const real = (v: number, d: number) => Math.round(v / (d || 1));
+
+function val(v: number, deflator: number, mode: ValueMode): string {
+ if (mode === "real") return formatChf(real(v, deflator));
+ if (mode === "both") return `${formatChf(v)} (${formatChf(real(v, deflator))})`;
+ return formatChf(v);
+}
+
+export function PhaseCards({
+ plan,
+ computed,
+ mode,
+ activePhaseId,
+ onSelectPhase,
+ phaseContext,
+ transitionContext,
+ transitionElements,
+ cashTransitionFor,
+ actions,
+}: Props) {
+ const phases = computed.phases;
+ const index = Math.max(0, phases.findIndex((p) => p.id === activePhaseId));
+ const phase = phases[index];
+
+ // Pfeiltasten. Der dritte Weg neben Zeitachse und den Knoepfen unten -- wer eine Phase nach
+ // der anderen durchgeht, will die Hand nicht an der Maus haben. Eingabefelder duerfen davon
+ // nicht betroffen sein, sonst springt die Karte beim Bearbeiten eines Betrags weg.
+ useEffect(() => {
+ function onKey(e: KeyboardEvent) {
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
+ const t = e.target as HTMLElement | null;
+ if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
+ const next = e.key === "ArrowLeft" ? index - 1 : index + 1;
+ if (next < 0 || next >= phases.length) return;
+ e.preventDefault();
+ onSelectPhase(phases[next].id);
+ }
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [index, phases, onSelectPhase]);
+
+ if (!phase) return null;
+
+ return (
+
+ );
+}
+
+// Der Weg in einen Verteil-Dialog. Gruen mit Haken, sobald erledigt -- dieselbe Sprache wie
+// im Phasenkopf der Matrix (SPEZIFIKATION 3.6.9).
+function QuotaButton({
+ label,
+ sub,
+ open,
+ hidden,
+ onClick,
+}: {
+ label: string;
+ sub: string;
+ open: boolean;
+ hidden?: boolean;
+ onClick: () => void;
+}) {
+ if (hidden) return null;
+ return (
+
+ );
+}
diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx
index 558bec0..800138a 100644
--- a/src/components/PlanView.tsx
+++ b/src/components/PlanView.tsx
@@ -24,6 +24,8 @@ import {
Plus,
ShoppingCart,
Tornado,
+ PanelRight,
+ PanelRightClose,
Trash2,
TrendingUp,
Wallet,
@@ -47,6 +49,7 @@ import {
} from "@/lib/retirement-decision";
import { RetirementFields } from "@/components/RetirementFields";
import { ReviewTile } from "@/components/ReviewTile";
+import { PhaseCards, type CardActions } from "@/components/PhaseCards";
import { BaseFields, InventoryDialog } from "@/components/InventoryDialog";
import { reviewPlan, unconfirmedCells } from "@/lib/review";
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
@@ -201,6 +204,30 @@ export function PlanView({
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
const [valueMode, setValueMode] = useState("nominal");
const [showInventory, setShowInventory] = useState(false);
+ // Karten oder Matrix. Die Wahl gilt fuer den Benutzer, nicht fuer das Szenario -- sie ist
+ // eine Arbeitsgewohnheit, keine Eigenschaft des Plans.
+ const [view, setView] = useState<"cards" | "matrix">("cards");
+ const [railOpen, setRailOpen] = useState(true);
+ const [activePhaseId, setActivePhaseId] = useState(null);
+
+ useEffect(() => {
+ try {
+ const v = localStorage.getItem(VIEW_KEY);
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung
+ if (v === "cards" || v === "matrix") setView(v);
+ if (localStorage.getItem(RAIL_KEY) === "0") setRailOpen(false);
+ } catch {
+ /* localStorage nicht verfuegbar */
+ }
+ }, []);
+ useEffect(() => {
+ try {
+ localStorage.setItem(VIEW_KEY, view);
+ localStorage.setItem(RAIL_KEY, railOpen ? "1" : "0");
+ } catch {
+ /* ignorieren */
+ }
+ }, [view, railOpen]);
// Eine Zelle, deren Annahmen noch nie jemand angeschaut hat. Sie traegt dieselbe
// Attention-Farbe wie ein offener Uebergang -- "hier fehlt was" sieht ueberall gleich aus.
const cellOpen = (elementId: string, phaseId: string) =>
@@ -415,51 +442,95 @@ export function PlanView({
}
const hasPhases = computed.phases.length > 0;
+ // Ein Vergleich (Szenario-Diff oder Plan/Ist) ist eine Frage ueber ALLE Phasen -- das kann
+ // eine einzelne Karte nicht zeigen. In diesen Faellen erzwingt FPT die Matrix, statt still
+ // die falsche Darstellung anzubieten.
+ const forceMatrix = !!diff || !!actualComputed;
+ const effectiveView: "cards" | "matrix" = forceMatrix || !hasPhases ? "matrix" : view;
+ const currentPhaseId =
+ computed.phases.find((p) => p.id === activePhaseId)?.id ?? computed.phases[0]?.id ?? null;
+
+ const cardActions: CardActions = {
+ onEditPhase: (phaseId) => setPanel({ kind: "phase", phaseId }),
+ onDeletePhase: (phaseId, name) => void deletePhase(phaseId, name),
+ onAddPhase: () => setShowAddPhase(true),
+ onAddElement: () => setShowAdd(true),
+ onDeleteElement: (elementId, name) => void deleteElement(elementId, name),
+ onDistributeRates: (phaseId) => setDistribute({ kind: "rates", phaseId }),
+ onDistributeCapital: (phaseId) => setDistribute({ kind: "capital", phaseId }),
+ onCashTransition: (fromPhaseId) => setPanel({ kind: "cashTransition", fromPhaseId }),
+ onOpenPhaseDetail: (phaseId) => setDetailFor({ kind: "phase", id: phaseId }),
+ onChanged,
+ };
return (
-
- {/* OBERER BEREICH -- die Steuerung. Farblich abgesetzt vom unteren, damit sichtbar
- ist: hier stellst du ein, unten siehst du das Ergebnis. Vier Kacheln nebeneinander,
- darunter die Zeitachse über die volle Breite. */}
-
+ {/* MITTE -- die Arbeitsfläche. Die Steuerung liegt seit 0.42 rechts statt oben: Sie
+ kostete dort VERTIKALEN Platz, und Höhe ist auf einem Breitbildschirm die knappe
+ Grösse. Rechts kostet sie horizontalen, den man eher hat -- und eingeklappt gar
+ keinen (SPEZIFIKATION 3.15). */}
+
+
+
{plan.name}
+
+ {/* Zwei Sichten auf dasselbe Material (Airtable-Prinzip): Karten zum ARBEITEN,
+ Matrix zum VERGLEICHEN. Ein Diff oder eine Ist-Ansicht erzwingt die Matrix --
+ über Phasen hinweg zu vergleichen kann eine Karte prinzipiell nicht. */}
+
+ {/* Die Zeitachse ist seit 0.42 der NAVIGATOR: Das Segment der offenen Karte ist
+ hervorgehoben, ein Klick springt hin. Zeit läuft von links nach rechts -- das
+ versteht man ohne Erklärung. */}
+
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
@@ -954,6 +1040,69 @@ Erste Lebensphase anlegen
)}
+
+
+ {/* RECHTE LEISTE -- die Steuerung, nach Dringlichkeit sortiert. «Offene Punkte» steht
+ oben, ist als einzige eingefärbt und standardmässig offen; der Rest ist zugeklappt.
+ Eingeklappt bleibt die Zahl am Knopf sichtbar -- sonst klappt man die Leiste zu und
+ vergisst den halben Plan. */}
+ {railOpen && (
+
+ )}
{/* Inspector-Panel: alle Einzel-Bearbeitungen. Der key erzwingt beim Wechsel der
Auswahl einen Neuaufbau, damit kein Formular-Zustand der vorherigen Zelle
@@ -1336,6 +1485,10 @@ Erste Lebensphase anlegen
const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"];
type ValueMode = "nominal" | "both" | "real";
+
+// Arbeitsgewohnheiten des Benutzers, nicht Eigenschaften des Plans -- deshalb lokal.
+const VIEW_KEY = "fpt.view";
+const RAIL_KEY = "fpt.rail";
const VALUE_MODE_KEY = "fpt-value-mode";
// Realwert = nominal / kumulierte Inflation (Kaufkraft zum Planbeginn).
@@ -2671,12 +2824,24 @@ function Tile({
children,
action,
tour,
+ bare,
}: {
title: string;
children: React.ReactNode;
action?: React.ReactNode;
tour?: string;
+ // In der rechten Leiste liefert die aufklappbare Sektion Rahmen und Titel -- eine zweite
+ // Kachel darin waere ein Kasten im Kasten.
+ bare?: boolean;
}) {
+ if (bare) {
+ return (
+
+ );
+}
+
+// Nominal / Beide / Real. Seit 0.42 an ZWEI Orten: in der Werkzeugleiste (sie gilt fuer beide
+// Ansichten) und weiterhin in der Ecke der Matrix. Eine Implementierung, damit sie nicht
+// auseinanderlaufen.
+function ValueModeSwitch({ mode, onChange }: { mode: ValueMode; onChange: (m: ValueMode) => void }) {
+ return (
+