Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts
Deploy App / deploy (push) Successful in 1m10s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 21:29:42 +02:00
parent 113616cc1f
commit f41c3614b5
6 changed files with 1294 additions and 46 deletions
+122 -2
View File
@@ -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` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 0.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)) |
+242 -3
View File
@@ -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 (
<div className="flex min-h-screen w-full">
<aside className="hidden w-64 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
{/* 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. */}
<div
className="relative hidden shrink-0 lg:block"
style={{ width: railPinned ? "16rem" : "3.5rem" }}
onMouseEnter={() => setRailHover(true)}
onMouseLeave={() => {
setRailHover(false);
setPlanMenuOpen(false);
}}
>
<aside
className={`absolute inset-y-0 left-0 z-30 flex flex-col border-r border-border bg-surface transition-[width] duration-200 ${
railWide ? "w-64 shadow-xl" : "w-14"
} ${railPinned ? "shadow-none" : ""}`}
>
{railWide ? (
<>
<div className="flex items-center gap-1 px-3 pt-3">
<button
type="button"
onClick={toggleRailPin}
aria-label={railPinned ? "Menü loslösen" : "Menü anpinnen"}
title={railPinned ? "Menü loslösen" : "Menü anpinnen"}
className={`ml-auto rounded-lg p-1.5 transition-colors ${
railPinned ? "bg-accent-soft text-accent-soft-fg" : "text-faint hover:bg-surface-2 hover:text-fg"
}`}
>
{railPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
</button>
</div>
<div className="min-h-0 flex-1">{sidebar}</div>
</>
) : (
<IconRail
plans={plans}
activeScenarioId={selectedScenarioId}
planNav={planNav}
atHome={selectedScenarioId === null && !planNav && !showSpec && !showSystemParams}
showSpec={showSpec}
showSystemParams={showSystemParams}
planMenuOpen={planMenuOpen}
onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)}
onHome={() => {
setSelectedScenarioId(null);
setPlanNav(null);
setShowSpec(false);
setShowSystemParams(false);
}}
onOpenTab={(planId, tab) => {
setPlanMenuOpen(false);
if (tab === "actuals") void openActualsTab(planId);
else openPlanTab(planId, tab);
}}
onSpec={() => {
setShowSpec(true);
setSpecAnchor(null);
setShowSystemParams(false);
setSelectedScenarioId(null);
setPlanNav(null);
}}
onParams={() => {
setShowSystemParams(true);
setShowSpec(false);
setSelectedScenarioId(null);
setPlanNav(null);
}}
onSearch={() => setShowPalette(true)}
onPin={toggleRailPin}
/>
)}
</aside>
</div>
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
@@ -1091,3 +1196,137 @@ function CopyScenarioDialog({
</Modal>
);
}
// --- 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
) => (
<button
key={label}
type="button"
disabled={disabled}
onClick={onClick}
aria-label={label}
title={label}
className={`flex h-9 w-9 items-center justify-center rounded-lg transition-colors disabled:opacity-30 ${
on ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2 hover:text-fg"
}`}
>
<Icon className="h-[18px] w-[18px]" />
</button>
);
return (
<div className="flex h-full flex-col items-center gap-1 py-3">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent" title="FPT">
<PiggyBank className="h-5 w-5 text-accent-fg" />
</div>
<div className="my-1 h-px w-6 bg-border" />
{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. */}
<div className="relative">
<button
type="button"
onClick={onTogglePlanMenu}
aria-label={active ? `Plan: ${active.name}` : "Plan wählen"}
title={active ? `Plan: ${active.name}` : "Plan wählen"}
className={`flex h-9 w-9 items-center justify-center rounded-lg border text-[11px] font-semibold transition-colors ${
active ? "border-border text-fg hover:bg-surface-2" : "border-dashed border-border text-faint"
}`}
>
{initials}
</button>
{planMenuOpen && (
<div className="ui-fade absolute left-11 top-0 z-50 w-56 rounded-xl border border-border bg-surface p-1 shadow-xl">
{plans.length === 0 && <p className="px-2 py-1.5 text-xs text-faint">Noch keine Pläne.</p>}
{plans.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onOpenTab(p.id, "dashboard")}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs font-medium transition-colors ${
p.id === active?.id ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<FolderKanban className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate">{p.name}</span>
</button>
))}
</div>
)}
</div>
{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)}
<div className="my-1 h-px w-6 bg-border" />
{item("So rechnet FPT", FileText, showSpec, onSpec)}
{item("Systemparameter", SlidersHorizontal, showSystemParams, onParams)}
<div className="mt-auto flex flex-col items-center gap-1">
{item("Suchen und springen (Ctrl+K)", Search, false, onSearch)}
{item("Menü anpinnen", Pin, false, onPin)}
</div>
</div>
);
}
+6
View File
@@ -104,6 +104,9 @@ interface Props {
phaseDataByPhase?: Record<string, PhaseData>;
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>({ ...phaseData });
const [td, setTd] = useState<TransitionData>({ ...transitionData });
@@ -1252,6 +1256,7 @@ export function ElementDetail({
return (
<div className="flex flex-col gap-4">
{!compact && (
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
@@ -1268,6 +1273,7 @@ export function ElementDetail({
<Trash2 className="h-3.5 w-3.5" /> Element löschen
</button>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */}
+642
View File
@@ -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 (
<div className="flex flex-col gap-3">
<PhaseCard
key={phase.id}
plan={plan}
computed={computed}
phase={phase}
previous={phases[index - 1]}
mode={mode}
phaseContext={phaseContext}
transitionContext={transitionContext}
transitionElements={transitionElements}
cashTransitionFor={cashTransitionFor}
actions={actions}
/>
<div className="flex items-center justify-center gap-2 text-xs text-muted">
<button
type="button"
disabled={index === 0}
onClick={() => onSelectPhase(phases[index - 1].id)}
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2 disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
{phases[index - 1]?.name ?? "Anfang"}
</button>
<span className="px-2 tabular-nums">
Phase {index + 1} von {phases.length}
</span>
{index === phases.length - 1 ? (
<button
type="button"
onClick={actions.onAddPhase}
className="flex items-center gap-1 rounded-lg border border-dashed border-accent px-2.5 py-1.5 font-medium text-accent transition-colors hover:bg-accent hover:text-accent-fg"
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
) : (
<button
type="button"
onClick={() => onSelectPhase(phases[index + 1].id)}
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2"
>
{phases[index + 1].name}
<ChevronRight className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
// --- 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<Record<string, boolean>>({});
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 (
<article className="ui-fade overflow-hidden rounded-2xl border border-border bg-surface shadow-sm">
{/* Kopf: ruhig und gross. Er beantwortet «wo bin ich» in einem Blick. */}
<header className="flex flex-wrap items-start gap-3 px-5 py-4">
<div className="min-w-0">
<h2 className="truncate text-lg font-semibold text-fg">{phase.name}</h2>
<p className="mt-0.5 text-xs text-muted">
{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(", ")}
</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-1.5">
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0) > 0 && (
<span className="flex items-center gap-1 rounded-full bg-attention-soft px-2 py-0.5 text-[11px] font-semibold text-attention-soft-fg">
<AlertTriangle className="h-3 w-3" />
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0)} offen
</span>
)}
{allKeys.length > 0 && (
<button
type="button"
onClick={toggleAll}
className="rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{anyOpen ? "Alle zuklappen" : "Alle aufklappen"}
</button>
)}
<IconBtn label="Bezeichnung und Dauer ändern" onClick={() => actions.onEditPhase(phase.id)}>
<Pencil className="h-4 w-4" />
</IconBtn>
<IconBtn label="Detailansicht" onClick={() => actions.onOpenPhaseDetail(phase.id)}>
<Maximize2 className="h-4 w-4" />
</IconBtn>
{phase.sequenceNumber === computed.phases.length && (
<IconBtn label="Lebensphase löschen" danger onClick={() => actions.onDeletePhase(phase.id, phase.name)}>
<Trash2 className="h-4 w-4" />
</IconBtn>
)}
</div>
</header>
{/* 1 · EINTRITT */}
<Section
title={previous ? "Eintritt" : "Start"}
hint={
previous
? `Was beim Wechsel aus «${previous.name}» geschah`
: "Der Bestand bei Planbeginn erfasst in der Bestandsaufnahme"
}
>
{!previous ? (
<p className="px-1 py-2 text-xs text-muted">
Diese Phase beginnt mit dem heutigen Stand. Startwerte änderst du in der
Bestandsaufnahme oder über die Zeile selbst.
</p>
) : (
<>
{ct && (
<RowShell
icon={<Wallet className="h-4 w-4" />}
name="Cash"
meta="einmalige Sonderein-/ausgaben"
open={cashOpen}
right={<span className="tabular-nums">{cashTransitionSummary(ct)}</span>}
onClick={() => actions.onCashTransition(previous.id)}
/>
)}
{transEls.length === 0 && !cashOpen && (
<p className="px-1 py-2 text-xs text-muted">An diesem Übergang gibt es nichts zu entscheiden.</p>
)}
{transEls.map((el) => {
const key = `t:${el.id}`;
const ctx = transitionContext(previous, phase, el);
return (
<Row
key={key}
open={!!open[key]}
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
icon={<Coins className="h-4 w-4" />}
name={el.name}
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
right={null}
>
<ElementDetail
compact
element={el}
context={ctx}
phaseData={el.phaseValues[phase.id] ?? {}}
transitionData={el.transitionValues[previous.id] ?? {}}
onSaved={actions.onChanged}
onDeleteElement={() => actions.onDeleteElement(el.id, el.name)}
/>
</Row>
);
})}
<div className="pt-1">
<QuotaButton
label="Kapital verteilen"
open={capitalOpen}
hidden={!pot.total && !pot.allocatedInvestments && !pot.allocatedRepayments}
sub={`${formatChf(pot.total)} verfügbar · ${formatChf(pot.rest)} bleibt Cash`}
onClick={() => actions.onDistributeCapital(phase.id)}
/>
</div>
</>
)}
</Section>
{/* 2 · LAUFEND */}
<Section
title="Laufend"
hint={`Was in diesen ${phase.durationYears} Jahren gilt`}
action={
<button
type="button"
onClick={actions.onAddElement}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
<Plus className="h-3.5 w-3.5" /> Element
</button>
}
>
{rows.length === 0 && (
<p className="px-1 py-2 text-xs text-muted">Noch keine Elemente erfasst.</p>
)}
{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 (
<Row
key={key}
open={!!open[key]}
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
icon={<PiggyBank className="h-4 w-4" />}
name={el.name}
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
attention={isOpen}
right={<CellValue ce={ce} dS={dS} dE={dE} mode={mode} />}
>
<ElementDetail
compact
element={el}
context={phaseContext(phase, el)}
phaseData={el.phaseValues[phase.id] ?? {}}
transitionData={{}}
allPhases={plan.phases.map<PhaseRef>((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)}
/>
</Row>
);
})}
<div className="pt-1">
<QuotaButton
label={phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
open={ratesOpen}
sub={`${phase.isConsumption ? "Verzehrquote" : "Sparquote"} ${val(phase.quotaStart, dS, mode)}${val(
phase.quotaEnd,
phase.flowDeflatorEnd,
mode
)}`}
onClick={() => actions.onDistributeRates(phase.id)}
/>
</div>
</Section>
{/* 3 · ERGEBNIS */}
<Section title="Ergebnis" hint="Stand am Ende dieser Phase" tinted>
<div className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
<Figure label="Vermögen" from={val(phase.startWealthNominal, dS, mode)} to={val(phase.endWealthNominal, dE, mode)} strong />
<Figure
label="Cash"
from={val(phase.cashStart, dS, mode)}
to={val(phase.cashEnd, dE, mode)}
danger={phase.cashNegative}
/>
</div>
{phase.cashNegative && (
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Das Cash-Konto fällt in dieser Phase unter 0 eine Liquiditätslücke.
</p>
)}
</Section>
</article>
);
}
// --- Bausteine -----------------------------------------------------------------------------
function IconBtn({
label,
onClick,
danger,
children,
}: {
label: string;
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`rounded-lg p-1.5 text-faint transition-colors ${
danger ? "hover:bg-danger-soft hover:text-danger" : "hover:bg-surface-2 hover:text-fg"
}`}
>
{children}
</button>
);
}
function Section({
title,
hint,
action,
tinted,
children,
}: {
title: string;
hint: string;
action?: React.ReactNode;
tinted?: boolean;
children: React.ReactNode;
}) {
return (
<section className={`border-t border-border px-5 py-3 ${tinted ? "bg-surface-2/50" : ""}`}>
<div className="mb-1.5 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-fg">{title}</span>
<span className="min-w-0 truncate text-[11px] text-faint">{hint}</span>
{action && <span className="ml-auto">{action}</span>}
</div>
{children}
</section>
);
}
// 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 (
<div className={`border-b border-border last:border-b-0 ${open ? "bg-surface-2/40" : ""}`}>
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center gap-2.5 py-2 text-left transition-colors hover:bg-surface-2/60"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${attention ? "bg-attention" : "bg-success"}`} />
<span className={attention ? "text-attention" : "text-faint"}>{icon}</span>
<span className="min-w-0">
<span className="block truncate text-sm text-fg">{name}</span>
<span className="block truncate text-[11px] text-faint">{meta}</span>
</span>
<span className="ml-auto shrink-0 text-xs text-muted">{right}</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-faint transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && <div className="pb-4 pl-6 pr-1">{children}</div>}
</div>
);
}
// 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 (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-2.5 border-b border-border py-2 text-left transition-colors last:border-b-0 hover:bg-surface-2/60"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${open ? "bg-attention" : "bg-success"}`} />
<span className={open ? "text-attention" : "text-faint"}>{icon}</span>
<span className="min-w-0">
<span className="block truncate text-sm text-fg">{name}</span>
<span className="block truncate text-[11px] text-faint">{meta}</span>
</span>
<span className="ml-auto shrink-0 text-xs text-muted">{right}</span>
<ChevronRight className="h-4 w-4 shrink-0 text-faint" />
</button>
);
}
function CellValue({
ce,
dS,
dE,
mode,
}: {
ce: ElementPhaseComputed;
dS: number;
dE: number;
mode: ValueMode;
}) {
if (ce.summary) return <span className="tabular-nums">{ce.summary}</span>;
if (ce.note) return <span>{ce.note}</span>;
return (
<span className="whitespace-nowrap tabular-nums">
{val(ce.startValue, dS, mode)} <span className="text-faint"></span> {val(ce.endValue, dE, mode)}
</span>
);
}
function Figure({
label,
from,
to,
strong,
danger,
}: {
label: string;
from: string;
to: string;
strong?: boolean;
danger?: boolean;
}) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-border/60 py-1 last:border-b-0">
<span className="text-xs text-muted">{label}</span>
<span
className={`whitespace-nowrap tabular-nums ${strong ? "text-sm font-semibold" : "text-xs"} ${
danger ? "text-danger" : "text-fg"
}`}
>
{from} <span className="text-faint"></span> {to}
</span>
</div>
);
}
// 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 (
<Button variant={open ? "primary" : "ghost"} onClick={onClick} className="w-full justify-between">
<span className="flex flex-col items-start">
<span>
{label}
{open ? "" : " ✓"}
</span>
<span className="text-[11px] font-normal opacity-80">{sub}</span>
</span>
<ChevronRight className="h-4 w-4" />
</Button>
);
}
+262 -36
View File
@@ -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<ValueMode>("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<string | null>(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 (
<div className="flex flex-col gap-5">
{/* 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. */}
<div className="rounded-2xl border border-border bg-surface-2/60 p-4 shadow-sm">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<SettingsTile
plan={plan}
computed={computed}
diffChanged={!!diff?.profileChanged}
onEdit={() => setPanel({ kind: "profile" })}
/>
<FiguresTile plan={plan} computed={computed} />
<QuickActionsTile
onInventory={() => setShowInventory(true)}
onHistory={onOpenHistory}
onTour={onStartTour}
onNewScenario={onCopyScenario}
onTraces={onOpenTraces}
exportHref={exportHref}
/>
<ReviewTile
review={review}
onOpenInventory={() => setShowInventory(true)}
onAddPhase={() => setShowAddPhase(true)}
onJump={(g) =>
g.kind === "phase"
? setPanel({ kind: "phase", phaseId: g.id })
: setReviewFromPhaseId(g.id)
<div className="flex min-w-0 gap-4">
{/* 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). */}
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<h2 className="min-w-0 truncate text-lg font-semibold text-fg">{plan.name}</h2>
<div className="ml-auto flex flex-wrap items-center gap-2">
{/* 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. */}
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{(["cards", "matrix"] as const).map((v) => (
<button
key={v}
type="button"
disabled={forceMatrix || (v === "cards" && !hasPhases)}
onClick={() => setView(v)}
title={
forceMatrix
? "Vergleiche werden in der Matrix gezeigt"
: v === "cards" && !hasPhases
? "Erst mit einer Lebensphase"
: undefined
}
/>
className={`rounded-md px-2.5 py-1 font-medium transition-colors disabled:opacity-40 ${
effectiveView === v ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{v === "cards" ? "Karten" : "Matrix"}
</button>
))}
</div>
<ValueModeSwitch mode={valueMode} onChange={setValueMode} />
{!railOpen && (
<button
type="button"
onClick={() => setRailOpen(true)}
title="Steuerung einblenden"
className="relative rounded-lg border border-border p-1.5 text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
<PanelRight className="h-4 w-4" />
{review.total > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-attention px-1 text-[10px] font-semibold text-attention-fg">
{review.total}
</span>
)}
</button>
)}
</div>
</div>
<div data-tour="timeline" className="mt-3">
{/* 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. */}
<div data-tour="timeline">
<Timeline
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
actualYears={actualYears}
startYear={plan.startYear}
activePhaseId={effectiveView === "cards" ? currentPhaseId : null}
onSelectPhase={effectiveView === "cards" ? setActivePhaseId : undefined}
/>
</div>
</div>
{plan.elements.length === 0 && (
<EmptyState
@@ -497,7 +568,22 @@ export function PlanView({
Sie erscheint, sobald es ELEMENTE gibt -- Lebensphasen sind dafür nicht nötig. Bis
0.36 hing sie an den Phasen, wodurch die Bestandsaufnahme aus Schritt 1 im
Nichts verschwand: erfasst, gespeichert, unsichtbar. */}
{(hasPhases || plan.elements.length > 0) && (
{effectiveView === "cards" && hasPhases && (
<PhaseCards
plan={plan}
computed={computed}
mode={valueMode}
activePhaseId={currentPhaseId}
onSelectPhase={setActivePhaseId}
phaseContext={buildPhaseContext}
transitionContext={buildTransitionContext}
transitionElements={transitionElements}
cashTransitionFor={cashTransitionFor}
actions={cardActions}
/>
)}
{effectiveView === "matrix" && (hasPhases || plan.elements.length > 0) && (
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
{/* 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
</table>
</div>
)}
</div>
{/* 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 && (
<aside className="hidden w-[19rem] shrink-0 flex-col gap-2 xl:flex">
<div className="flex items-center gap-2 px-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Steuerung</span>
<button
type="button"
onClick={() => setRailOpen(false)}
aria-label="Steuerung ausblenden"
title="Steuerung ausblenden"
className="ml-auto rounded p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
<ReviewTile
review={review}
onOpenInventory={() => setShowInventory(true)}
onAddPhase={() => setShowAddPhase(true)}
onJump={(g) => {
if (g.kind === "transition") {
// Ein Übergang gehört zur FOLGEphase -- dorthin springt die Karte.
const i = computed.phases.findIndex((p) => p.id === g.id);
const next = computed.phases[i + 1];
if (effectiveView === "cards" && next) return setActivePhaseId(next.id);
return setReviewFromPhaseId(g.id);
}
if (effectiveView === "cards") return setActivePhaseId(g.id);
setPanel({ kind: "phase", phaseId: g.id });
}}
/>
<RailSection title="Kennzahlen" defaultOpen>
<FiguresTile plan={plan} computed={computed} bare />
</RailSection>
<RailSection title="Grundeinstellungen">
<SettingsTile
plan={plan}
computed={computed}
diffChanged={!!diff?.profileChanged}
onEdit={() => setPanel({ kind: "profile" })}
bare
/>
</RailSection>
<RailSection title="Schnellaktionen">
<QuickActionsTile
onInventory={() => setShowInventory(true)}
onHistory={onOpenHistory}
onTour={onStartTour}
onNewScenario={onCopyScenario}
onTraces={onOpenTraces}
exportHref={exportHref}
bare
/>
</RailSection>
</aside>
)}
{/* 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 (
<div data-tour={tour} className="flex flex-col gap-1">
{action && <div className="mb-1 flex justify-end">{action}</div>}
{children}
</div>
);
}
return (
<div data-tour={tour} className="flex h-full flex-col rounded-xl border border-border bg-surface px-3 py-2.5 shadow-sm">
<div className="mb-2 flex items-start justify-between gap-2">
@@ -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 (
<div className="rounded-xl border border-border bg-surface shadow-sm">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex w-full items-center gap-2 px-3 py-2 text-left"
>
<span className="text-xs font-semibold uppercase tracking-wide text-faint">{title}</span>
<ChevronDown className={`ml-auto h-4 w-4 shrink-0 text-faint transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && <div className="border-t border-border px-3 py-2.5">{children}</div>}
</div>
);
}
// 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 (
<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={() => onChange(m)}
title={
m === "real"
? "Kaufkraftbereinigt auf den Planbeginn"
: m === "both"
? "Nominal und real"
: "Nominale Beträge"
}
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
mode === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{m === "nominal" ? "Nominal" : m === "both" ? "Beide" : "Real"}
</button>
))}
</div>
);
}
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 (
<Tile
bare={bare}
tour="profil"
title={`Grundeinstellungen${diffChanged ? " · abweichend" : ""}`}
action={
@@ -2762,7 +2986,7 @@ function SettingsTile({
);
}
function FiguresTile({ plan, computed }: { plan: PlanInput; computed: PlanComputed }) {
function FiguresTile({ plan, computed, bare }: { plan: PlanInput; computed: PlanComputed; bare?: boolean }) {
const last = computed.phases[computed.phases.length - 1] ?? null;
// «Vermögen heute» ist die Summe der erfassten Bestände. Sie steht hier, weil sie die
// einzige Kennzahl ist, die schon VOR jeder Zeitplanung etwas aussagt -- und damit die
@@ -2777,7 +3001,7 @@ function FiguresTile({ plan, computed }: { plan: PlanInput; computed: PlanComput
}, plan.initialCash || 0);
return (
<Tile tour="endwealth" title="Kennzahlen">
<Tile tour="endwealth" title="Kennzahlen" bare={bare}>
<Figure label="Vermögen heute" value={today} />
<Figure label="Endvermögen nominal" value={last ? Math.round(last.endWealthNominal) : null} />
<Figure label="Endvermögen real" value={last ? Math.round(last.endWealthReal) : null} />
@@ -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 (
<Tile tour="toolbar" title="Schnellaktionen">
<Tile tour="toolbar" title="Schnellaktionen" bare={bare}>
{onInventory && (
<button type="button" onClick={onInventory} className={cls}>
<ClipboardList className="h-3.5 w-3.5" /> Bestandsaufnahme
+19 -4
View File
@@ -20,6 +20,8 @@ export function Timeline({
ruinAge,
startYear,
actualYears = [],
activePhaseId,
onSelectPhase,
}: {
phases: PhaseComputed[];
persons: PersonAxis[];
@@ -28,6 +30,11 @@ export function Timeline({
// Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz
// wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos.
actualYears?: number[];
// Seit 0.42 ist die Zeitachse nicht mehr nur Anzeige, sondern der NAVIGATOR der
// Kartenansicht: Das Segment der offenen Karte ist hervorgehoben, ein Klick springt hin.
// Zeit läuft von links nach rechts -- das versteht man ohne Erklärung.
activePhaseId?: string | null;
onSelectPhase?: (phaseId: string) => void;
}) {
if (phases.length === 0 || persons.length === 0) return null;
@@ -135,14 +142,22 @@ export function Timeline({
<div className="flex w-full overflow-hidden rounded-lg border border-border">
{segments.map((s, i) => {
const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
const active = activePhaseId === s.phase.id;
const clickable = !!onSelectPhase;
return (
<div
<button
key={s.phase.id}
type="button"
disabled={!clickable}
onClick={() => onSelectPhase?.(s.phase.id)}
aria-current={active ? "true" : undefined}
style={{ width: `${(s.phase.durationYears / span) * 100}%` }}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`flex min-w-0 flex-col justify-center px-2 py-1.5 ${
className={`flex min-w-0 flex-col justify-center px-2 py-1.5 text-left transition-all ${
i > 0 ? "border-l border-border" : ""
} ${s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"}`}
} ${clickable ? "cursor-pointer hover:brightness-105" : ""} ${
s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"
} ${active ? "ring-2 ring-inset ring-fg/70" : clickable ? "opacity-70" : ""}`}
>
<span className={`truncate text-[10px] font-semibold ${soft ? "text-accent-soft-fg" : "text-accent-fg"}`}>
{s.phase.name}
@@ -151,7 +166,7 @@ export function Timeline({
{s.phase.durationYears} J.
{yearOf(s.from) ? ` · ${yearOf(s.from)}${yearOf(s.to)}` : ""}
</span>
</div>
</button>
);
})}
</div>