Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts
Deploy App / deploy (push) Successful in 1m10s
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+122
-2
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.41 |
|
| **Version** | 0.42 |
|
||||||
| **Datum** | 2026-07-25 |
|
| **Datum** | 2026-07-25 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **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 |
|
| **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` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| 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.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.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). |
|
| 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
|
### 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,
|
Alle **Einzel-Bearbeitungen** – Phasenzelle, Übergangszelle, Cash-Übergang, Cash-Anfangswert,
|
||||||
Phase (Name/Dauer), Szenario-Profil – öffnen sich als rechtes Seitenpanel (`InspectorShell`,
|
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
|
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
|
# 4. Berechnungsmodell
|
||||||
|
|
||||||
> Dies ist der fachliche Kern des Tools. Der gesamte Abschnitt beschreibt
|
> 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 |
|
| `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)) |
|
| `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)) |
|
| `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)) |
|
| `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)) |
|
| `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)) |
|
| `DistributionDialogs` | ~460 | Verteil-Werkzeuge für Kapital und Spar-/Verzehrquote ([3.6.10](#3610-verteil-werkzeuge)) |
|
||||||
|
|||||||
+242
-3
@@ -14,6 +14,8 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Menu,
|
Menu,
|
||||||
|
Pin,
|
||||||
|
PinOff,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
@@ -39,8 +41,7 @@ import { SystemParametersView } from "@/components/SystemParametersView";
|
|||||||
import { PlanTraceDialog } from "@/components/DetailView";
|
import { PlanTraceDialog } from "@/components/DetailView";
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { computePlan } from "@/lib/calculations";
|
||||||
import { ProfileMenu } from "@/components/ProfileMenu";
|
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||||
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
|
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
|
||||||
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
|
|
||||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -52,6 +53,9 @@ import {
|
|||||||
useConfirm,
|
useConfirm,
|
||||||
useToast,
|
useToast,
|
||||||
} from "@/components/ui";
|
} 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 { api } from "@/lib/api-client";
|
||||||
import { computeScenarioDiff } from "@/lib/diff";
|
import { computeScenarioDiff } from "@/lib/diff";
|
||||||
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
|
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).
|
// Erhoehen erzwingt ein Neuladen der Szenario-Liste (bleibt bei einer Loeschung montiert).
|
||||||
const [scenarioListKey, setScenarioListKey] = useState(0);
|
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
|
// Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im
|
||||||
// DOM der Szenario-Ansicht). Zwei Auslöser:
|
// DOM der Szenario-Ansicht). Zwei Auslöser:
|
||||||
// * pendingTourRef: nach JEDER Plan-Erstellung genau einmal erzwingen (Roadmap-Feedback F14).
|
// * pendingTourRef: nach JEDER Plan-Erstellung genau einmal erzwingen (Roadmap-Feedback F14).
|
||||||
@@ -493,7 +524,81 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen w-full">
|
<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 && (
|
{sidebarOpen && (
|
||||||
<div className="fixed inset-0 z-40 lg:hidden">
|
<div className="fixed inset-0 z-40 lg:hidden">
|
||||||
@@ -1091,3 +1196,137 @@ function CopyScenarioDialog({
|
|||||||
</Modal>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ interface Props {
|
|||||||
phaseDataByPhase?: Record<string, PhaseData>;
|
phaseDataByPhase?: Record<string, PhaseData>;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onDeleteElement: () => 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
|
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
|
||||||
@@ -1182,6 +1185,7 @@ export function ElementDetail({
|
|||||||
phaseDataByPhase = {},
|
phaseDataByPhase = {},
|
||||||
onSaved,
|
onSaved,
|
||||||
onDeleteElement,
|
onDeleteElement,
|
||||||
|
compact,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [pd, setPd] = useState<PhaseData>({ ...phaseData });
|
const [pd, setPd] = useState<PhaseData>({ ...phaseData });
|
||||||
const [td, setTd] = useState<TransitionData>({ ...transitionData });
|
const [td, setTd] = useState<TransitionData>({ ...transitionData });
|
||||||
@@ -1252,6 +1256,7 @@ export function ElementDetail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{!compact && (
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
|
<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
|
<Trash2 className="h-3.5 w-3.5" /> Element löschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */}
|
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+263
-37
@@ -24,6 +24,8 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Tornado,
|
Tornado,
|
||||||
|
PanelRight,
|
||||||
|
PanelRightClose,
|
||||||
Trash2,
|
Trash2,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -47,6 +49,7 @@ import {
|
|||||||
} from "@/lib/retirement-decision";
|
} from "@/lib/retirement-decision";
|
||||||
import { RetirementFields } from "@/components/RetirementFields";
|
import { RetirementFields } from "@/components/RetirementFields";
|
||||||
import { ReviewTile } from "@/components/ReviewTile";
|
import { ReviewTile } from "@/components/ReviewTile";
|
||||||
|
import { PhaseCards, type CardActions } from "@/components/PhaseCards";
|
||||||
import { BaseFields, InventoryDialog } from "@/components/InventoryDialog";
|
import { BaseFields, InventoryDialog } from "@/components/InventoryDialog";
|
||||||
import { reviewPlan, unconfirmedCells } from "@/lib/review";
|
import { reviewPlan, unconfirmedCells } from "@/lib/review";
|
||||||
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
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 [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
|
||||||
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
||||||
const [showInventory, setShowInventory] = useState(false);
|
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
|
// 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.
|
// Attention-Farbe wie ein offener Uebergang -- "hier fehlt was" sieht ueberall gleich aus.
|
||||||
const cellOpen = (elementId: string, phaseId: string) =>
|
const cellOpen = (elementId: string, phaseId: string) =>
|
||||||
@@ -415,51 +442,95 @@ export function PlanView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasPhases = computed.phases.length > 0;
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex min-w-0 gap-4">
|
||||||
{/* OBERER BEREICH -- die Steuerung. Farblich abgesetzt vom unteren, damit sichtbar
|
{/* MITTE -- die Arbeitsfläche. Die Steuerung liegt seit 0.42 rechts statt oben: Sie
|
||||||
ist: hier stellst du ein, unten siehst du das Ergebnis. Vier Kacheln nebeneinander,
|
kostete dort VERTIKALEN Platz, und Höhe ist auf einem Breitbildschirm die knappe
|
||||||
darunter die Zeitachse über die volle Breite. */}
|
Grösse. Rechts kostet sie horizontalen, den man eher hat -- und eingeklappt gar
|
||||||
<div className="rounded-2xl border border-border bg-surface-2/60 p-4 shadow-sm">
|
keinen (SPEZIFIKATION 3.15). */}
|
||||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
|
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||||
<SettingsTile
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
plan={plan}
|
<h2 className="min-w-0 truncate text-lg font-semibold text-fg">{plan.name}</h2>
|
||||||
computed={computed}
|
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||||
diffChanged={!!diff?.profileChanged}
|
{/* Zwei Sichten auf dasselbe Material (Airtable-Prinzip): Karten zum ARBEITEN,
|
||||||
onEdit={() => setPanel({ kind: "profile" })}
|
Matrix zum VERGLEICHEN. Ein Diff oder eine Ist-Ansicht erzwingt die Matrix --
|
||||||
/>
|
über Phasen hinweg zu vergleichen kann eine Karte prinzipiell nicht. */}
|
||||||
<FiguresTile plan={plan} computed={computed} />
|
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||||||
<QuickActionsTile
|
{(["cards", "matrix"] as const).map((v) => (
|
||||||
onInventory={() => setShowInventory(true)}
|
<button
|
||||||
onHistory={onOpenHistory}
|
key={v}
|
||||||
onTour={onStartTour}
|
type="button"
|
||||||
onNewScenario={onCopyScenario}
|
disabled={forceMatrix || (v === "cards" && !hasPhases)}
|
||||||
onTraces={onOpenTraces}
|
onClick={() => setView(v)}
|
||||||
exportHref={exportHref}
|
title={
|
||||||
/>
|
forceMatrix
|
||||||
<ReviewTile
|
? "Vergleiche werden in der Matrix gezeigt"
|
||||||
review={review}
|
: v === "cards" && !hasPhases
|
||||||
onOpenInventory={() => setShowInventory(true)}
|
? "Erst mit einer Lebensphase"
|
||||||
onAddPhase={() => setShowAddPhase(true)}
|
: undefined
|
||||||
onJump={(g) =>
|
}
|
||||||
g.kind === "phase"
|
className={`rounded-md px-2.5 py-1 font-medium transition-colors disabled:opacity-40 ${
|
||||||
? setPanel({ kind: "phase", phaseId: g.id })
|
effectiveView === v ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||||
: setReviewFromPhaseId(g.id)
|
}`}
|
||||||
}
|
>
|
||||||
/>
|
{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>
|
||||||
|
|
||||||
<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
|
<Timeline
|
||||||
phases={computed.phases}
|
phases={computed.phases}
|
||||||
persons={personAxes}
|
persons={personAxes}
|
||||||
ruinAge={computed.ruinAge}
|
ruinAge={computed.ruinAge}
|
||||||
actualYears={actualYears}
|
actualYears={actualYears}
|
||||||
startYear={plan.startYear}
|
startYear={plan.startYear}
|
||||||
|
activePhaseId={effectiveView === "cards" ? currentPhaseId : null}
|
||||||
|
onSelectPhase={effectiveView === "cards" ? setActivePhaseId : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{plan.elements.length === 0 && (
|
{plan.elements.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -497,7 +568,22 @@ export function PlanView({
|
|||||||
Sie erscheint, sobald es ELEMENTE gibt -- Lebensphasen sind dafür nicht nötig. Bis
|
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
|
0.36 hing sie an den Phasen, wodurch die Bestandsaufnahme aus Schritt 1 im
|
||||||
Nichts verschwand: erfasst, gespeichert, unsichtbar. */}
|
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">
|
<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
|
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
|
||||||
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
|
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
|
||||||
@@ -954,6 +1040,69 @@ Erste Lebensphase anlegen
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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
|
{/* Inspector-Panel: alle Einzel-Bearbeitungen. Der key erzwingt beim Wechsel der
|
||||||
Auswahl einen Neuaufbau, damit kein Formular-Zustand der vorherigen Zelle
|
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"];
|
const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"];
|
||||||
|
|
||||||
type ValueMode = "nominal" | "both" | "real";
|
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";
|
const VALUE_MODE_KEY = "fpt-value-mode";
|
||||||
|
|
||||||
// Realwert = nominal / kumulierte Inflation (Kaufkraft zum Planbeginn).
|
// Realwert = nominal / kumulierte Inflation (Kaufkraft zum Planbeginn).
|
||||||
@@ -2671,12 +2824,24 @@ function Tile({
|
|||||||
children,
|
children,
|
||||||
action,
|
action,
|
||||||
tour,
|
tour,
|
||||||
|
bare,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
tour?: string;
|
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 (
|
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 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">
|
<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({
|
function SettingsTile({
|
||||||
plan,
|
plan,
|
||||||
computed,
|
computed,
|
||||||
diffChanged,
|
diffChanged,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
bare,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
diffChanged: boolean;
|
diffChanged: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
bare?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Der Planungshorizont wird ABGELEITET -- er ist die Summe der Lebensphasen. Bis 0.37 war
|
// 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
|
// er eine eigene Zahl, die man setzte und der die letzte Phase folgte. Das war eine zweite
|
||||||
@@ -2708,6 +2931,7 @@ function SettingsTile({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tile
|
<Tile
|
||||||
|
bare={bare}
|
||||||
tour="profil"
|
tour="profil"
|
||||||
title={`Grundeinstellungen${diffChanged ? " · abweichend" : ""}`}
|
title={`Grundeinstellungen${diffChanged ? " · abweichend" : ""}`}
|
||||||
action={
|
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;
|
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
|
// «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
|
// 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);
|
}, plan.initialCash || 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tile tour="endwealth" title="Kennzahlen">
|
<Tile tour="endwealth" title="Kennzahlen" bare={bare}>
|
||||||
<Figure label="Vermögen heute" value={today} />
|
<Figure label="Vermögen heute" value={today} />
|
||||||
<Figure label="Endvermögen nominal" value={last ? Math.round(last.endWealthNominal) : null} />
|
<Figure label="Endvermögen nominal" value={last ? Math.round(last.endWealthNominal) : null} />
|
||||||
<Figure label="Endvermögen real" value={last ? Math.round(last.endWealthReal) : null} />
|
<Figure label="Endvermögen real" value={last ? Math.round(last.endWealthReal) : null} />
|
||||||
@@ -2816,6 +3040,7 @@ function QuickActionsTile({
|
|||||||
onNewScenario,
|
onNewScenario,
|
||||||
onTraces,
|
onTraces,
|
||||||
exportHref,
|
exportHref,
|
||||||
|
bare,
|
||||||
}: {
|
}: {
|
||||||
onInventory?: () => void;
|
onInventory?: () => void;
|
||||||
onHistory?: () => void;
|
onHistory?: () => void;
|
||||||
@@ -2823,11 +3048,12 @@ function QuickActionsTile({
|
|||||||
onNewScenario?: () => void;
|
onNewScenario?: () => void;
|
||||||
onTraces?: () => void;
|
onTraces?: () => void;
|
||||||
exportHref?: string;
|
exportHref?: string;
|
||||||
|
bare?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cls =
|
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";
|
"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 (
|
return (
|
||||||
<Tile tour="toolbar" title="Schnellaktionen">
|
<Tile tour="toolbar" title="Schnellaktionen" bare={bare}>
|
||||||
{onInventory && (
|
{onInventory && (
|
||||||
<button type="button" onClick={onInventory} className={cls}>
|
<button type="button" onClick={onInventory} className={cls}>
|
||||||
<ClipboardList className="h-3.5 w-3.5" /> Bestandsaufnahme
|
<ClipboardList className="h-3.5 w-3.5" /> Bestandsaufnahme
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function Timeline({
|
|||||||
ruinAge,
|
ruinAge,
|
||||||
startYear,
|
startYear,
|
||||||
actualYears = [],
|
actualYears = [],
|
||||||
|
activePhaseId,
|
||||||
|
onSelectPhase,
|
||||||
}: {
|
}: {
|
||||||
phases: PhaseComputed[];
|
phases: PhaseComputed[];
|
||||||
persons: PersonAxis[];
|
persons: PersonAxis[];
|
||||||
@@ -28,6 +30,11 @@ export function Timeline({
|
|||||||
// Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz
|
// Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz
|
||||||
// wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos.
|
// wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos.
|
||||||
actualYears?: number[];
|
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;
|
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">
|
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
||||||
{segments.map((s, i) => {
|
{segments.map((s, i) => {
|
||||||
const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
|
const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
|
||||||
|
const active = activePhaseId === s.phase.id;
|
||||||
|
const clickable = !!onSelectPhase;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={s.phase.id}
|
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}%` }}
|
style={{ width: `${(s.phase.durationYears / span) * 100}%` }}
|
||||||
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
|
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" : ""
|
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"}`}>
|
<span className={`truncate text-[10px] font-semibold ${soft ? "text-accent-soft-fg" : "text-accent-fg"}`}>
|
||||||
{s.phase.name}
|
{s.phase.name}
|
||||||
@@ -151,7 +166,7 @@ export function Timeline({
|
|||||||
{s.phase.durationYears} J.
|
{s.phase.durationYears} J.
|
||||||
{yearOf(s.from) ? ` · ${yearOf(s.from)}–${yearOf(s.to)}` : ""}
|
{yearOf(s.from) ? ` · ${yearOf(s.from)}–${yearOf(s.to)}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user