From f41c3614b5a9315967836fc0c6ca2db8946aecce Mon Sep 17 00:00:00 2001 From: kelle Date: Mon, 17 Aug 2026 21:29:42 +0200 Subject: [PATCH] Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts Co-Authored-By: Claude Opus 5 --- SPEZIFIKATION.md | 124 +++++- src/components/AppShell.tsx | 245 +++++++++++- src/components/ElementDetail.tsx | 6 + src/components/PhaseCards.tsx | 642 +++++++++++++++++++++++++++++++ src/components/PlanView.tsx | 300 +++++++++++++-- src/components/Timeline.tsx | 23 +- 6 files changed, 1294 insertions(+), 46 deletions(-) create mode 100644 src/components/PhaseCards.tsx 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. */} +
setRailHover(true)} + onMouseLeave={() => { + setRailHover(false); + setPlanMenuOpen(false); + }} + > + +
{sidebarOpen && (
@@ -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. */} +
+ + {planMenuOpen && ( +
+ {plans.length === 0 &&

Noch keine Pläne.

} + {plans.map((p) => ( + + ))} +
+ )} +
+ + {item("Szenarien", Layers, planNav?.tab === "scenarios" || !!activeScenarioId, () => active && onOpenTab(active.id, "scenarios"), !active)} + {item("Effektive Werte", CalendarClock, planNav?.tab === "actuals", () => active && onOpenTab(active.id, "actuals"), !active)} + {item("Analysen", BarChart3, planNav?.tab === "analyses", () => active && onOpenTab(active.id, "analyses"), !active)} + {item("Berichte", FileSpreadsheet, planNav?.tab === "reports", () => active && onOpenTab(active.id, "reports"), !active)} + +
+ {item("So rechnet FPT", FileText, showSpec, onSpec)} + {item("Systemparameter", SlidersHorizontal, showSystemParams, onParams)} + +
+ {item("Suchen und springen (Ctrl+K)", Search, false, onSearch)} + {item("Menü anpinnen", Pin, false, onPin)} +
+
+ ); +} 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 ( +
+ + +
+ + + Phase {index + 1} von {phases.length} + + {index === phases.length - 1 ? ( + + ) : ( + + )} +
+
+ ); +} + +// --- Die Karte ----------------------------------------------------------------------------- + +function PhaseCard({ + plan, + computed, + phase, + previous, + mode, + phaseContext, + transitionContext, + transitionElements, + cashTransitionFor, + actions, +}: { + plan: PlanInput; + computed: PlanComputed; + phase: PhaseComputed; + previous: PhaseComputed | undefined; + mode: ValueMode; + phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext; + transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext; + transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[]; + cashTransitionFor: (phaseId: string) => CashTransitionData; + actions: CardActions; +}) { + // Welche Zeilen sind aufgeklappt? Beim Wechsel der Karte faengt es wieder bei zu an -- + // die Karte wird ueber `key` neu montiert. + const [open, setOpen] = useState>({}); + const rows = phase.elements.filter((e) => e.status !== "SOLD" || e.startValue !== 0); + const openCount = unconfirmedCells(plan, computed, phase.id).length; + const ratesOpen = plan.phases.find((p) => p.id === phase.id)?.ratesConfirmed !== true; + const pot = capitalPot(phase); + const capitalOpen = pot.total > 0 && pot.allocatedInvestments + pot.allocatedRepayments === 0; + + const transEls = previous ? transitionElements(previous, phase) : []; + const ct = previous ? cashTransitionFor(previous.id) : null; + const cashOpen = ct ? !isCashTransitionAnswered(ct) : false; + + // Ein Knopf fuer alle Zeilen der Karte. Bei acht Elementen ist das der Unterschied zwischen + // «einmal durchsehen» und acht Klicks. + const allKeys = [...transEls.map((e) => `t:${e.id}`), ...rows.map((e) => `p:${e.elementId}`)]; + const anyOpen = allKeys.some((k) => open[k]); + const toggleAll = () => + setOpen(anyOpen ? {} : Object.fromEntries(allKeys.map((k) => [k, true]))); + + const dS = phase.cumulativeInflationStart; + const dE = phase.cumulativeInflationEnd; + + const years = + plan.startYear != null + ? (() => { + const before = computed.phases + .slice(0, computed.phases.findIndex((p) => p.id === phase.id)) + .reduce((s, p) => s + p.durationYears, 0); + return `${plan.startYear + before}–${plan.startYear + before + phase.durationYears - 1}`; + })() + : null; + + return ( +
+ {/* Kopf: ruhig und gross. Er beantwortet «wo bin ich» in einem Blick. */} +
+
+

{phase.name}

+

+ {years ? `${years} · ` : ""} + {phase.durationYears} {phase.durationYears === 1 ? "Jahr" : "Jahre"} + {phase.persons.length > 0 ? " · " : ""} + {phase.persons + .map((p) => `${ownerLabel(plan.persons, p.role)} ${p.startAge}–${p.endAge}`) + .join(", ")} +

+
+ +
+ {openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0) > 0 && ( + + + {openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0)} offen + + )} + {allKeys.length > 0 && ( + + )} + actions.onEditPhase(phase.id)}> + + + actions.onOpenPhaseDetail(phase.id)}> + + + {phase.sequenceNumber === computed.phases.length && ( + actions.onDeletePhase(phase.id, phase.name)}> + + + )} +
+
+ + {/* 1 · EINTRITT */} +
+ {!previous ? ( +

+ Diese Phase beginnt mit dem heutigen Stand. Startwerte änderst du in der + Bestandsaufnahme oder über die Zeile selbst. +

+ ) : ( + <> + {ct && ( + } + name="Cash" + meta="einmalige Sonderein-/ausgaben" + open={cashOpen} + right={{cashTransitionSummary(ct)}} + onClick={() => actions.onCashTransition(previous.id)} + /> + )} + {transEls.length === 0 && !cashOpen && ( +

An diesem Übergang gibt es nichts zu entscheiden.

+ )} + {transEls.map((el) => { + const key = `t:${el.id}`; + const ctx = transitionContext(previous, phase, el); + return ( + setOpen((p) => ({ ...p, [key]: !p[key] }))} + icon={} + name={el.name} + meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`} + right={null} + > + actions.onDeleteElement(el.id, el.name)} + /> + + ); + })} +
+
+ + )} +
+ + {/* 2 · LAUFEND */} +
+ Element + + } + > + {rows.length === 0 && ( +

Noch keine Elemente erfasst.

+ )} + {rows.map((ce) => { + const el = plan.elements.find((x) => x.id === ce.elementId); + if (!el) return null; + const key = `p:${el.id}`; + const isOpen = unconfirmedCells(plan, computed, phase.id).includes(el.id); + return ( + setOpen((p) => ({ ...p, [key]: !p[key] }))} + icon={} + name={el.name} + meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`} + attention={isOpen} + right={} + > + ((p) => ({ id: p.id, name: p.name, sequenceNumber: p.sequenceNumber }))} + phaseDataByPhase={Object.fromEntries( + plan.phases.map((p) => [p.id, (el.phaseValues[p.id] ?? {}) as PhaseData]) + )} + onSaved={actions.onChanged} + onDeleteElement={() => actions.onDeleteElement(el.id, el.name)} + /> + + ); + })} +
+ actions.onDistributeRates(phase.id)} + /> +
+
+ + {/* 3 · ERGEBNIS */} +
+
+
+
+
+ {phase.cashNegative && ( +

+ + Das Cash-Konto fällt in dieser Phase unter 0 – eine Liquiditätslücke. +

+ )} +
+
+ ); +} + +// --- Bausteine ----------------------------------------------------------------------------- + +function IconBtn({ + label, + onClick, + danger, + children, +}: { + label: string; + onClick: () => void; + danger?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} + +function Section({ + title, + hint, + action, + tinted, + children, +}: { + title: string; + hint: string; + action?: React.ReactNode; + tinted?: boolean; + children: React.ReactNode; +}) { + return ( +
+
+ {title} + {hint} + {action && {action}} +
+ {children} +
+ ); +} + +// Eine aufklappbare Zeile. Zugeklappt ist sie eine Zusammenfassung, aufgeklappt der ganze +// Editor -- und zwar IN der Karte. Kein Panel legt sich mehr ueber das, was man bearbeitet. +function Row({ + open, + onToggle, + icon, + name, + meta, + right, + attention, + children, +}: { + open: boolean; + onToggle: () => void; + icon: React.ReactNode; + name: string; + meta: string; + right: React.ReactNode; + attention?: boolean; + children: React.ReactNode; +}) { + return ( +
+ + {open &&
{children}
} +
+ ); +} + +// Zeile ohne Editor -- sie fuehrt in einen eigenen Dialog (Cash-Uebergang). +function RowShell({ + icon, + name, + meta, + right, + open, + onClick, +}: { + icon: React.ReactNode; + name: string; + meta: string; + right: React.ReactNode; + open?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function CellValue({ + ce, + dS, + dE, + mode, +}: { + ce: ElementPhaseComputed; + dS: number; + dE: number; + mode: ValueMode; +}) { + if (ce.summary) return {ce.summary}; + if (ce.note) return {ce.note}; + return ( + + {val(ce.startValue, dS, mode)} {val(ce.endValue, dE, mode)} + + ); +} + +function Figure({ + label, + from, + to, + strong, + danger, +}: { + label: string; + from: string; + to: string; + strong?: boolean; + danger?: boolean; +}) { + return ( +
+ {label} + + {from} {to} + +
+ ); +} + +// 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. */} -
-
- setPanel({ kind: "profile" })} - /> - - setShowInventory(true)} - onHistory={onOpenHistory} - onTour={onStartTour} - onNewScenario={onCopyScenario} - onTraces={onOpenTraces} - exportHref={exportHref} - /> - setShowInventory(true)} - onAddPhase={() => setShowAddPhase(true)} - onJump={(g) => - g.kind === "phase" - ? setPanel({ kind: "phase", phaseId: g.id }) - : setReviewFromPhaseId(g.id) - } - /> +
+ {/* 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. */} +
+ {(["cards", "matrix"] as const).map((v) => ( + + ))} +
+ + {!railOpen && ( + + )} +
-
+ {/* 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. */} +
-
{plan.elements.length === 0 && ( 0) && ( + {effectiveView === "cards" && hasPhases && ( + + )} + + {effectiveView === "matrix" && (hasPhases || plan.elements.length > 0) && (
{/* 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 ( +
+ {action &&
{action}
} + {children} +
+ ); + } return (
@@ -2688,16 +2853,74 @@ function Tile({ ); } +// Ein aufklappbarer Abschnitt der rechten Leiste. Zugeklappt kostet er eine Zeile. +function RailSection({ + title, + defaultOpen, + children, +}: { + title: string; + defaultOpen?: boolean; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen ?? false); + return ( +
+ + {open &&
{children}
} +
+ ); +} + +// 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 ( +
+ {(["nominal", "both", "real"] as ValueMode[]).map((m) => ( + + ))} +
+ ); +} + function SettingsTile({ plan, computed, diffChanged, onEdit, + bare, }: { plan: PlanInput; computed: PlanComputed; diffChanged: boolean; onEdit: () => void; + bare?: boolean; }) { // Der Planungshorizont wird ABGELEITET -- er ist die Summe der Lebensphasen. Bis 0.37 war // er eine eigene Zahl, die man setzte und der die letzte Phase folgte. Das war eine zweite @@ -2708,6 +2931,7 @@ function SettingsTile({ return ( +
@@ -2816,6 +3040,7 @@ function QuickActionsTile({ onNewScenario, onTraces, exportHref, + bare, }: { onInventory?: () => void; onHistory?: () => void; @@ -2823,11 +3048,12 @@ function QuickActionsTile({ onNewScenario?: () => void; onTraces?: () => void; exportHref?: string; + bare?: boolean; }) { const cls = "flex w-full items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"; return ( - + {onInventory && ( ); })}