Menue schiebt statt zu ueberlappen, Karte schmaler mit Seitenpfeilen, Zeitachse neu, Cash inline
Deploy App / deploy (push) Successful in 1m9s
Deploy App / deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+38
-4
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.42.1 |
|
| **Version** | 0.42.2 |
|
||||||
| **Datum** | 2026-07-25 |
|
| **Datum** | 2026-07-25 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `f41c361` inkl. Memo 2026081702 (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `ff862ed` inkl. Memo 2026081703 (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.2 | 2026-08-17 | Claude (Opus 5) | **Memo 2026081703** – Feinschliff der neuen Oberfläche, sechs Punkte. (1) **Die Menüleiste schiebt, statt zu überlappen.** Beim Ausfahren legte sie sich über den Inhalt; ein Streifen verschwand jedes Mal, und das las sich wie ein Fehler. Neu ist der ausgefahrene Zustand optisch identisch mit dem angepinnten – das Anpinnen entscheidet nur noch, ob es so BLEIBT. (2) **Suchen und Anpinnen stehen direkt unter den Systemparametern** statt am Fuss der Leiste. Die Leiste ist so hoch wie die Seite, und mit einer aufgeklappten Karte wird die sehr lang – am Fuss waren die beiden ausserhalb des Blickfelds. Zusätzlich klebt der Inhalt beider Leisten neu am oberen Bildschirmrand (`sticky`), sonst scrollt die Steuerung beim Arbeiten weg. (3) **Die Karte nutzt nicht mehr die volle Breite**, sondern höchstens 46 rem. Eine Zeile aus 1300 px liest sich schlecht: Das Auge verliert beim Zeilenwechsel den Anschluss, und Beschriftung und Wert driften so weit auseinander, dass man sie nicht mehr zusammenbringt. Die frei werdende Luft links und rechts trägt zwei **grosse Pfeile** zur Nachbarphase – ein Bedienelement, das keinen zusätzlichen Platz kostet. (4) **Die drei Akte sind sichtbar getrennt.** Vorher schied sie nur eine Haarlinie, und man las die Karte als eine lange Liste statt als drei Schritte. Neu trägt jeder Akt einen getönten Kopfstreifen über die volle Breite mit einer **Nummer im Kreis**; «Ergebnis» ist durchgehend getönt und trägt die Nummer im Akzent – es ist der Schlusspunkt, nicht ein weiterer Abschnitt. (5) **Die Zeitachse neu gezeichnet**: kein Kasten mit Überschrift, keine Legende, keine Fusszeile. Stattdessen abgesetzte Kacheln, deren Abstand die Phasengrenze zeigt, dazwischen ein Pfeil für den Übergang, und die Marker (Pensionierung, Ruinalter, erfasste Ist-Jahre) sitzen als Pillen direkt an der Leiste statt darüber zu schweben. (6) **Cash verhält sich wie jede andere Zeile.** Es war der einzige Eintritts-Punkt, der das rechte Panel öffnete statt aufzuklappen – auf einer Karte, die genau davon wegwollte, ein Fremdkörper. Neu klappt es inline auf, mit denselben Feldern (`CashTransitionFields`) und eigenem Speichern. |
|
||||||
| 0.42.1 | 2026-08-17 | Claude (Opus 5) | **Memo 2026081702** – vier Befunde aus der ersten Testrunde der neuen Oberfläche. (1) **Die Kartenansicht war überhaupt nicht erreichbar** – der Umschalter dauerhaft ausgegraut. Ursache: Die Bedingung lautete `forceMatrix = !!diff`, aber `computeScenarioDiff` liefert **immer** ein Objekt (bei Gleichheit ein leeres). Auch `diff.total > 0` wäre falsch gewesen: Ein Kind-Szenario weicht nach der ersten Änderung immer von seiner Vorlage ab, Karten wären dort für immer gesperrt. Die Diff-Markierungen sind eine **Verzierung** der Matrix, kein Modus. Neu erzwingt nur die **Ist-Ansicht** die Matrix (das ist ein Modus, in den man bewusst wechselt); bei Abweichungen zur Vorlage steht in der Kartenansicht ein Hinweis mit dem Weg dorthin. (2) **Die Menüleiste wechselte beim Überfahren die Darstellung** – eingeklappt Icons, ausgefahren ein Plan-Baum. Das war kein Übergang, sondern ein Sprung. Neu ist es **eine Komponente in zwei Breiten**: Die Symbole sitzen in einer festen 3.5-rem-Spalte und bleiben exakt an ihrem Platz, rechts daneben fährt nur die Beschriftung aus. Damit entfällt der Baum aller Pläne; er wird nicht vermisst, weil der Plan über den Wähler oben gewechselt wird und die vier Bereiche sich immer auf den **aktiven** Plan beziehen. Plan anlegen und löschen sind in den Wähler gewandert, der Szenario-Baum hängt aufklappbar unter «Szenarien». (3) **Die Matrix schob sich vor die ausgefahrene Leiste.** Ihre sticky Köpfe tragen `z-40` und konkurrierten damit global; die Ecke oben links gewann. Die Matrix ist neu ein eigener **Stapel-Kontext** (`relative z-0`) – ihre Köpfe stapeln sich nur noch untereinander. (4) **Die Kachel «Offene Punkte» zog sich über den ganzen Bildschirm**, auch mit einem einzigen Eintrag: ein `h-full` aus der Zeit, als die vier Kacheln in einem Raster gleich hoch sein mussten. In der rechten Leiste ist das falsch – sie ist jetzt so hoch wie ihr Inhalt. |
|
| 0.42.1 | 2026-08-17 | Claude (Opus 5) | **Memo 2026081702** – vier Befunde aus der ersten Testrunde der neuen Oberfläche. (1) **Die Kartenansicht war überhaupt nicht erreichbar** – der Umschalter dauerhaft ausgegraut. Ursache: Die Bedingung lautete `forceMatrix = !!diff`, aber `computeScenarioDiff` liefert **immer** ein Objekt (bei Gleichheit ein leeres). Auch `diff.total > 0` wäre falsch gewesen: Ein Kind-Szenario weicht nach der ersten Änderung immer von seiner Vorlage ab, Karten wären dort für immer gesperrt. Die Diff-Markierungen sind eine **Verzierung** der Matrix, kein Modus. Neu erzwingt nur die **Ist-Ansicht** die Matrix (das ist ein Modus, in den man bewusst wechselt); bei Abweichungen zur Vorlage steht in der Kartenansicht ein Hinweis mit dem Weg dorthin. (2) **Die Menüleiste wechselte beim Überfahren die Darstellung** – eingeklappt Icons, ausgefahren ein Plan-Baum. Das war kein Übergang, sondern ein Sprung. Neu ist es **eine Komponente in zwei Breiten**: Die Symbole sitzen in einer festen 3.5-rem-Spalte und bleiben exakt an ihrem Platz, rechts daneben fährt nur die Beschriftung aus. Damit entfällt der Baum aller Pläne; er wird nicht vermisst, weil der Plan über den Wähler oben gewechselt wird und die vier Bereiche sich immer auf den **aktiven** Plan beziehen. Plan anlegen und löschen sind in den Wähler gewandert, der Szenario-Baum hängt aufklappbar unter «Szenarien». (3) **Die Matrix schob sich vor die ausgefahrene Leiste.** Ihre sticky Köpfe tragen `z-40` und konkurrierten damit global; die Ecke oben links gewann. Die Matrix ist neu ein eigener **Stapel-Kontext** (`relative z-0`) – ihre Köpfe stapeln sich nur noch untereinander. (4) **Die Kachel «Offene Punkte» zog sich über den ganzen Bildschirm**, auch mit einem einzigen Eintrag: ein `h-full` aus der Zeit, als die vier Kacheln in einem Raster gleich hoch sein mussten. In der rechten Leiste ist das falsch – sie ist jetzt so hoch wie ihr Inhalt. |
|
||||||
| 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.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). |
|
||||||
@@ -2529,18 +2530,40 @@ Sonderamortisation und Sofort-Tilgung eingeführt hat ([3.12.5](#3125-punkt-c-ve
|
|||||||
Der Preis ist bekannt: Wer den Hausverkauf gedanklich am Ende von Phase N trifft, findet ihn
|
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.
|
auf Karte N+1. Bewusst in Kauf genommen, weil dort die **Folgen** stehen.
|
||||||
|
|
||||||
|
**Die Karte ist auf 46 rem begrenzt** und steht zentriert. Die volle Breite auszunutzen war
|
||||||
|
verlockend und falsch: Eine Zeile aus 1300 px liest sich schlecht, weil das Auge beim
|
||||||
|
Zeilenwechsel den Anschluss verliert und Beschriftung und Wert so weit auseinanderdriften, dass
|
||||||
|
man sie nicht mehr zusammenbringt. Die Luft links und rechts ist kein Verlust – sie trägt die
|
||||||
|
beiden **grossen Pfeile** zur Nachbarphase, ein Bedienelement ohne eigenen Platzbedarf.
|
||||||
|
|
||||||
|
**Die drei Akte sind sichtbar getrennt.** Jeder trägt einen getönten Kopfstreifen über die
|
||||||
|
volle Breite mit einer Nummer im Kreis; «Ergebnis» ist durchgehend getönt und trägt die Nummer
|
||||||
|
im Akzent – es ist der Schlusspunkt, nicht ein weiterer Abschnitt. Der erste Anlauf trennte sie
|
||||||
|
nur mit einer Haarlinie, und man las die Karte als eine lange Liste statt als drei Schritte.
|
||||||
|
|
||||||
**Bearbeitet wird in der Karte.** Ein Klick klappt die Zeile auf, die Felder stehen darunter –
|
**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.
|
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.
|
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
|
Ein Knopf im Kartenkopf klappt **alle** Zeilen auf oder zu: Bei acht Elementen ist das der
|
||||||
Unterschied zwischen einmal durchsehen und acht Klicks.
|
Unterschied zwischen einmal durchsehen und acht Klicks.
|
||||||
|
|
||||||
|
Das gilt **ausnahmslos**, auch für den Cash-Übergang. Er öffnete bis 0.42.1 als einziger das
|
||||||
|
rechte Panel – auf einer Karte, die genau davon wegwollte, ein Fremdkörper. Die Felder sind
|
||||||
|
dieselben wie im Panel (`CashTransitionFields`); zwei Implementierungen wären garantiert
|
||||||
|
auseinandergelaufen.
|
||||||
|
|
||||||
### 3.15.3 Die Zeitachse ist der Navigator
|
### 3.15.3 Die Zeitachse ist der Navigator
|
||||||
|
|
||||||
Sie war bis 0.41 Dekoration. Neu ist das Segment der offenen Karte hervorgehoben, und ein
|
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
|
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.
|
läuft von links nach rechts, das braucht keine Erklärung.
|
||||||
|
|
||||||
|
**Gezeichnet ist sie als Reihe abgesetzter Kacheln** (0.42.2). Der Abstand dazwischen zeigt die
|
||||||
|
Phasengrenze, ohne dass es dafür eine Trennlinie braucht; im Zwischenraum steht ein Pfeil für
|
||||||
|
den Übergang. Die Marker – Pensionierung je Person, Ruinalter, erfasste Ist-Jahre – sitzen als
|
||||||
|
Pillen direkt an der Leiste, statt als Fähnchen darüber zu schweben. Entfallen sind die
|
||||||
|
Überschrift (eine Zeitachse erkennt man), der Rahmen und die Legende.
|
||||||
|
|
||||||
Drei Wege durch die Phasen, weil verschiedene Leute verschieden arbeiten:
|
Drei Wege durch die Phasen, weil verschiedene Leute verschieden arbeiten:
|
||||||
|
|
||||||
* **Klick auf die Zeitachse** – gezielt zu einer bestimmten Phase.
|
* **Klick auf die Zeitachse** – gezielt zu einer bestimmten Phase.
|
||||||
@@ -2581,8 +2604,19 @@ Der Umschalter steht oben rechts; die Wahl gehört zum **Benutzer** und überleb
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Links** die Menüleiste: Haus, Plan-Wähler und die vier Bereiche des aktiven Plans, darunter
|
**Links** die Menüleiste: Haus, Plan-Wähler und die vier Bereiche des aktiven Plans, darunter
|
||||||
das Wissen. Beim Überfahren fährt sie als **Overlay** aus – sie kostet dann keinen Platz –, ein
|
das Wissen sowie Suchen und Anpinnen. Beim Überfahren fährt sie aus und **schiebt** den Inhalt;
|
||||||
**Pin** hält sie breit und schiebt den Inhalt.
|
der **Pin** entscheidet, ob sie so bleibt.
|
||||||
|
|
||||||
|
> **Ausfahren heisst schieben, nicht überlagern** (korrigiert in 0.42.2). Der erste Anlauf
|
||||||
|
> liess sie als Overlay ausfahren, damit sie im Ruhezustand keinen Platz kostet. Dabei
|
||||||
|
> verschwand aber jedes Mal ein Streifen des Inhalts, und das las sich wie ein Fehler.
|
||||||
|
> Aufgeklappt sieht es jetzt genau so aus wie angepinnt – der einzige Unterschied ist, ob es
|
||||||
|
> so bleibt.
|
||||||
|
|
||||||
|
**Suchen und Anpinnen stehen direkt unter den Systemparametern**, nicht am Fuss der Leiste. Die
|
||||||
|
Leiste ist so hoch wie die Seite, und mit einer aufgeklappten Karte wird die sehr lang – am
|
||||||
|
Fuss wären die beiden ausserhalb des Blickfelds. Der Inhalt beider Leisten klebt zudem am
|
||||||
|
oberen Bildschirmrand (`sticky`): Steuerung, die beim Arbeiten wegscrollt, ist keine.
|
||||||
|
|
||||||
> **Eine Komponente in zwei Breiten, nicht zwei Darstellungen** (korrigiert in 0.42.1). Die
|
> **Eine Komponente in zwei Breiten, nicht zwei Darstellungen** (korrigiert in 0.42.1). Die
|
||||||
> Symbole sitzen in einer **festen 3.5-rem-Spalte** und bleiben beim Ausfahren exakt an ihrem
|
> Symbole sitzen in einer **festen 3.5-rem-Spalte** und bleiben beim Ausfahren exakt an ihrem
|
||||||
|
|||||||
+21
-10
@@ -113,10 +113,14 @@ 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
|
// Menueleiste links: eingeklappt auf eine Icon-Schiene (56 px) statt 240 px.
|
||||||
// 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.
|
// Beim Ueberfahren faehrt sie aus und SCHIEBT den Inhalt -- sie legt sich nicht darueber.
|
||||||
// Die Wahl gehoert zum Benutzer, nicht zum Plan, und ueberlebt deshalb die Sitzung.
|
// Der erste Anlauf (0.42) liess sie als Overlay ausfahren, um im Ruhezustand keinen Platz
|
||||||
|
// zu kosten; dabei verschwand aber jedes Mal ein Streifen des Inhalts, und das las sich wie
|
||||||
|
// ein Fehler. Aufgeklappt sieht es damit genau so aus wie angepinnt, und das Anpinnen
|
||||||
|
// entscheidet nur noch, ob es so BLEIBT. Die Wahl gehoert zum Benutzer und ueberlebt die
|
||||||
|
// Sitzung.
|
||||||
const [railPinned, setRailPinned] = useState(false);
|
const [railPinned, setRailPinned] = useState(false);
|
||||||
const [railHover, setRailHover] = useState(false);
|
const [railHover, setRailHover] = useState(false);
|
||||||
const [planMenuOpen, setPlanMenuOpen] = useState(false);
|
const [planMenuOpen, setPlanMenuOpen] = useState(false);
|
||||||
@@ -354,7 +358,7 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
hat sich falsch angefuehlt. */}
|
hat sich falsch angefuehlt. */}
|
||||||
<div
|
<div
|
||||||
className="relative hidden shrink-0 lg:block"
|
className="relative hidden shrink-0 lg:block"
|
||||||
style={{ width: railPinned ? "15rem" : "3.5rem" }}
|
style={{ width: railWide ? "15rem" : "3.5rem", transition: "width 200ms" }}
|
||||||
onMouseEnter={() => setRailHover(true)}
|
onMouseEnter={() => setRailHover(true)}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
setRailHover(false);
|
setRailHover(false);
|
||||||
@@ -1136,10 +1140,13 @@ function Rail({
|
|||||||
className={`${
|
className={`${
|
||||||
mobile ? "relative h-full w-full" : "absolute inset-y-0 left-0 z-40"
|
mobile ? "relative h-full w-full" : "absolute inset-y-0 left-0 z-40"
|
||||||
} flex flex-col overflow-hidden border-r border-border bg-surface transition-[width] duration-200 ${
|
} flex flex-col overflow-hidden border-r border-border bg-surface transition-[width] duration-200 ${
|
||||||
mobile ? "" : wide ? "w-60 shadow-xl" : "w-14"
|
mobile ? "" : wide ? "w-60" : "w-14"
|
||||||
} ${pinned && !mobile ? "shadow-none" : ""}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto py-3">
|
{/* Klebt am oberen Bildschirmrand: Die Leiste ist so hoch wie die SEITE, und mit einer
|
||||||
|
aufgeklappten Karte wird die sehr lang -- ohne sticky waeren die Knoepfe nach ein
|
||||||
|
wenig Scrollen ausserhalb des Blickfelds. */}
|
||||||
|
<div className="sticky top-0 flex max-h-screen flex-col gap-0.5 overflow-y-auto py-3">
|
||||||
<RailRow wide={wide} icon={<PiggyBank className="h-[18px] w-[18px] text-accent-fg" />} iconBox="bg-accent" label="FPT" />
|
<RailRow wide={wide} icon={<PiggyBank className="h-[18px] w-[18px] text-accent-fg" />} iconBox="bg-accent" label="FPT" />
|
||||||
|
|
||||||
<div className="my-1 h-px w-6 self-center bg-border" />
|
<div className="my-1 h-px w-6 self-center bg-border" />
|
||||||
@@ -1282,9 +1289,13 @@ function Rail({
|
|||||||
onClick={onParams}
|
onClick={onParams}
|
||||||
icon={<SlidersHorizontal className="h-[18px] w-[18px]" />}
|
icon={<SlidersHorizontal className="h-[18px] w-[18px]" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border py-1.5">
|
{/* Suchen und Anpinnen stehen DIREKT hier, nicht am unteren Rand. Die Leiste ist so
|
||||||
|
hoch wie die Seite -- und mit einer aufgeklappten Karte wird die sehr lang. Am Fuss
|
||||||
|
waeren die beiden dann ausserhalb des Blickfelds, obwohl sie zu den am haeufigsten
|
||||||
|
gebrauchten Knoepfen gehoeren. */}
|
||||||
|
<div className="my-1 h-px w-6 self-center bg-border" />
|
||||||
|
|
||||||
<RailRow
|
<RailRow
|
||||||
wide={wide}
|
wide={wide}
|
||||||
label="Suchen und springen (Ctrl+K)"
|
label="Suchen und springen (Ctrl+K)"
|
||||||
|
|||||||
+129
-39
@@ -38,11 +38,14 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import {
|
import {
|
||||||
|
CashTransitionFields,
|
||||||
ElementDetail,
|
ElementDetail,
|
||||||
cashTransitionSummary,
|
cashTransitionSummary,
|
||||||
isCashTransitionAnswered,
|
isCashTransitionAnswered,
|
||||||
|
withCashTransitionDefaults,
|
||||||
type CellContext,
|
type CellContext,
|
||||||
} from "@/components/ElementDetail";
|
} from "@/components/ElementDetail";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
import { CATEGORY_LABELS, ownerLabel, type CashTransitionData, type PhaseData } from "@/lib/elements";
|
import { CATEGORY_LABELS, ownerLabel, type CashTransitionData, type PhaseData } from "@/lib/elements";
|
||||||
import { capitalPot } from "@/lib/distribution";
|
import { capitalPot } from "@/lib/distribution";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
@@ -128,6 +131,18 @@ export function PhaseCards({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Die Karte laeuft NICHT ueber die volle Breite. Eine Zeile aus 1300 px liest sich
|
||||||
|
schlecht -- das Auge verliert beim Zeilenwechsel den Anschluss, und Beschriftung und
|
||||||
|
Wert driften so weit auseinander, dass man sie nicht mehr zusammenbringt. Links und
|
||||||
|
rechts bleibt Luft, und die traegt die beiden grossen Pfeile. */}
|
||||||
|
<div className="flex items-stretch justify-center gap-3">
|
||||||
|
<SideArrow
|
||||||
|
dir="left"
|
||||||
|
label={phases[index - 1]?.name}
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => index > 0 && onSelectPhase(phases[index - 1].id)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1" style={{ maxWidth: "46rem" }}>
|
||||||
<PhaseCard
|
<PhaseCard
|
||||||
key={phase.id}
|
key={phase.id}
|
||||||
plan={plan}
|
plan={plan}
|
||||||
@@ -141,6 +156,14 @@ export function PhaseCards({
|
|||||||
cashTransitionFor={cashTransitionFor}
|
cashTransitionFor={cashTransitionFor}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<SideArrow
|
||||||
|
dir="right"
|
||||||
|
label={phases[index + 1]?.name}
|
||||||
|
disabled={index === phases.length - 1}
|
||||||
|
onClick={() => index < phases.length - 1 && onSelectPhase(phases[index + 1].id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-2 text-xs text-muted">
|
<div className="flex items-center justify-center gap-2 text-xs text-muted">
|
||||||
<button
|
<button
|
||||||
@@ -284,6 +307,7 @@ function PhaseCard({
|
|||||||
|
|
||||||
{/* 1 · EINTRITT */}
|
{/* 1 · EINTRITT */}
|
||||||
<Section
|
<Section
|
||||||
|
step={1}
|
||||||
title={previous ? "Eintritt" : "Start"}
|
title={previous ? "Eintritt" : "Start"}
|
||||||
hint={
|
hint={
|
||||||
previous
|
previous
|
||||||
@@ -299,14 +323,25 @@ function PhaseCard({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{ct && (
|
{ct && (
|
||||||
<RowShell
|
// Cash verhielt sich bis 0.42.1 als einziger Eintritts-Punkt anders: Ein Klick
|
||||||
|
// oeffnete das rechte Panel statt aufzuklappen. Auf einer Karte, die genau davon
|
||||||
|
// wegwollte, war das ein Fremdkoerper.
|
||||||
|
<Row
|
||||||
|
open={!!open["cash"]}
|
||||||
|
onToggle={() => setOpen((pr) => ({ ...pr, cash: !pr.cash }))}
|
||||||
icon={<Wallet className="h-4 w-4" />}
|
icon={<Wallet className="h-4 w-4" />}
|
||||||
name="Cash"
|
name="Cash"
|
||||||
meta="einmalige Sonderein-/ausgaben"
|
meta="einmalige Sonderein-/ausgaben"
|
||||||
open={cashOpen}
|
attention={cashOpen}
|
||||||
right={<span className="tabular-nums">{cashTransitionSummary(ct)}</span>}
|
right={<span className="tabular-nums">{cashTransitionSummary(ct)}</span>}
|
||||||
onClick={() => actions.onCashTransition(previous.id)}
|
>
|
||||||
/>
|
<CashTransitionEditor
|
||||||
|
fromPhaseId={previous.id}
|
||||||
|
initial={ct}
|
||||||
|
deflatorEnd={previous.cumulativeInflationEnd}
|
||||||
|
onSaved={actions.onChanged}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
)}
|
)}
|
||||||
{transEls.length === 0 && !cashOpen && (
|
{transEls.length === 0 && !cashOpen && (
|
||||||
<p className="px-1 py-2 text-xs text-muted">An diesem Übergang gibt es nichts zu entscheiden.</p>
|
<p className="px-1 py-2 text-xs text-muted">An diesem Übergang gibt es nichts zu entscheiden.</p>
|
||||||
@@ -351,6 +386,7 @@ function PhaseCard({
|
|||||||
|
|
||||||
{/* 2 · LAUFEND */}
|
{/* 2 · LAUFEND */}
|
||||||
<Section
|
<Section
|
||||||
|
step={2}
|
||||||
title="Laufend"
|
title="Laufend"
|
||||||
hint={`Was in diesen ${phase.durationYears} Jahren gilt`}
|
hint={`Was in diesen ${phase.durationYears} Jahren gilt`}
|
||||||
action={
|
action={
|
||||||
@@ -413,7 +449,7 @@ function PhaseCard({
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 3 · ERGEBNIS */}
|
{/* 3 · ERGEBNIS */}
|
||||||
<Section title="Ergebnis" hint="Stand am Ende dieser Phase" tinted>
|
<Section step={3} title="Ergebnis" hint="Stand am Ende dieser Phase" tone="result">
|
||||||
<div className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
|
<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="Vermögen" from={val(phase.startWealthNominal, dS, mode)} to={val(phase.endWealthNominal, dE, mode)} strong />
|
||||||
<Figure
|
<Figure
|
||||||
@@ -462,31 +498,72 @@ function IconBtn({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein Akt der Karte. Die drei sind bewusst deutlich voneinander abgesetzt: ein getoenter
|
||||||
|
// Kopfstreifen ueber die volle Breite, eine Nummer im Kreis, darunter der Inhalt. Beim ersten
|
||||||
|
// Anlauf trennte sie nur eine Haarlinie -- man las die Karte als eine lange Liste statt als
|
||||||
|
// drei Schritte.
|
||||||
function Section({
|
function Section({
|
||||||
|
step,
|
||||||
title,
|
title,
|
||||||
hint,
|
hint,
|
||||||
action,
|
action,
|
||||||
tinted,
|
tone = "plain",
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
step: number;
|
||||||
title: string;
|
title: string;
|
||||||
hint: string;
|
hint: string;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
tinted?: boolean;
|
// "plain" = weisser Inhalt, "result" = durchgehend getoent (der Schlusspunkt).
|
||||||
|
tone?: "plain" | "result";
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className={`border-t border-border px-5 py-3 ${tinted ? "bg-surface-2/50" : ""}`}>
|
<section className={`border-t-2 border-border ${tone === "result" ? "bg-surface-2" : ""}`}>
|
||||||
<div className="mb-1.5 flex items-center gap-2">
|
<div className={`flex items-center gap-2 px-5 py-2 ${tone === "result" ? "" : "bg-surface-2/70"}`}>
|
||||||
|
<span
|
||||||
|
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold ${
|
||||||
|
tone === "result" ? "bg-accent text-accent-fg" : "bg-surface text-muted ring-1 ring-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-fg">{title}</span>
|
<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>
|
<span className="min-w-0 truncate text-[11px] text-faint">{hint}</span>
|
||||||
{action && <span className="ml-auto">{action}</span>}
|
{action && <span className="ml-auto shrink-0">{action}</span>}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
<div className="px-5 py-2.5">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Der grosse Pfeil neben der Karte. Er sitzt in der Luft, die durch die begrenzte Kartenbreite
|
||||||
|
// ohnehin entsteht -- ein Bedienelement, das keinen zusaetzlichen Platz kostet.
|
||||||
|
function SideArrow({
|
||||||
|
dir,
|
||||||
|
label,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
dir: "left" | "right";
|
||||||
|
label?: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={dir === "left" ? `Zurück zu ${label ?? "vorheriger Phase"}` : `Weiter zu ${label ?? "nächster Phase"}`}
|
||||||
|
title={label}
|
||||||
|
className="hidden w-12 shrink-0 items-center justify-center rounded-2xl border border-transparent text-faint transition-colors hover:border-border hover:bg-surface hover:text-accent disabled:pointer-events-none disabled:opacity-0 lg:flex"
|
||||||
|
>
|
||||||
|
{dir === "left" ? <ChevronLeft className="h-7 w-7" /> : <ChevronRight className="h-7 w-7" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Eine aufklappbare Zeile. Zugeklappt ist sie eine Zusammenfassung, aufgeklappt der ganze
|
// 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.
|
// Editor -- und zwar IN der Karte. Kein Panel legt sich mehr ueber das, was man bearbeitet.
|
||||||
function Row({
|
function Row({
|
||||||
@@ -530,37 +607,50 @@ function Row({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zeile ohne Editor -- sie fuehrt in einen eigenen Dialog (Cash-Uebergang).
|
// Der Cash-Uebergang in der Karte. Dieselben Felder wie im Panel (`CashTransitionFields`),
|
||||||
function RowShell({
|
// nur mit eigenem Speichern -- eine zweite Implementierung der Felder waere garantiert
|
||||||
icon,
|
// auseinandergelaufen.
|
||||||
name,
|
function CashTransitionEditor({
|
||||||
meta,
|
fromPhaseId,
|
||||||
right,
|
initial,
|
||||||
open,
|
deflatorEnd,
|
||||||
onClick,
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
fromPhaseId: string;
|
||||||
name: string;
|
initial: CashTransitionData;
|
||||||
meta: string;
|
deflatorEnd: number;
|
||||||
right: React.ReactNode;
|
onSaved: () => void;
|
||||||
open?: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
|
// Vorbelegen, damit ein blosses Speichern den sichtbaren Default (1:1) festhaelt.
|
||||||
|
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initial));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.put(`/api/phases/${fromPhaseId}/cash-transition`, ct);
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="flex flex-col gap-3">
|
||||||
type="button"
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
onClick={onClick}
|
<CashTransitionFields ct={ct} setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={deflatorEnd} />
|
||||||
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"
|
</div>
|
||||||
>
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${open ? "bg-attention" : "bg-success"}`} />
|
<div>
|
||||||
<span className={open ? "text-attention" : "text-faint"}>{icon}</span>
|
<Button disabled={saving} onClick={save}>
|
||||||
<span className="min-w-0">
|
{saving ? "Speichern…" : "Speichern"}
|
||||||
<span className="block truncate text-sm text-fg">{name}</span>
|
</Button>
|
||||||
<span className="block truncate text-[11px] text-faint">{meta}</span>
|
</div>
|
||||||
</span>
|
</div>
|
||||||
<span className="ml-auto shrink-0 text-xs text-muted">{right}</span>
|
|
||||||
<ChevronRight className="h-4 w-4 shrink-0 text-faint" />
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1075,7 +1075,8 @@ Erste Lebensphase anlegen
|
|||||||
Eingeklappt bleibt die Zahl am Knopf sichtbar -- sonst klappt man die Leiste zu und
|
Eingeklappt bleibt die Zahl am Knopf sichtbar -- sonst klappt man die Leiste zu und
|
||||||
vergisst den halben Plan. */}
|
vergisst den halben Plan. */}
|
||||||
{railOpen && (
|
{railOpen && (
|
||||||
<aside className="hidden w-[19rem] shrink-0 flex-col gap-2 xl:flex">
|
<aside className="hidden w-[19rem] shrink-0 xl:block">
|
||||||
|
<div className="sticky top-4 flex max-h-[calc(100vh-2rem)] flex-col gap-2 overflow-y-auto">
|
||||||
<div className="flex items-center gap-2 px-1">
|
<div className="flex items-center gap-2 px-1">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Steuerung</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Steuerung</span>
|
||||||
<button
|
<button
|
||||||
@@ -1129,6 +1130,7 @@ Erste Lebensphase anlegen
|
|||||||
bare
|
bare
|
||||||
/>
|
/>
|
||||||
</RailSection>
|
</RailSection>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+74
-99
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CalendarCheck, Flag } from "lucide-react";
|
import { CalendarCheck, ChevronRight, Flag } from "lucide-react";
|
||||||
import type { PhaseComputed } from "@/lib/calculations";
|
import type { PhaseComputed } from "@/lib/calculations";
|
||||||
|
|
||||||
interface PersonAxis {
|
interface PersonAxis {
|
||||||
@@ -11,9 +11,17 @@ interface PersonAxis {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer),
|
// Die Zeitachse: die Lebensphasen als Segmente, Breite nach Dauer.
|
||||||
// darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter
|
//
|
||||||
// je Person und ein allfälliges Ruinalter sind als Marker eingezeichnet.
|
// Seit 0.42 ist sie zugleich der NAVIGATOR der Kartenansicht -- das aktive Segment ist
|
||||||
|
// hervorgehoben, ein Klick springt hin. Zeit laeuft von links nach rechts; das versteht man
|
||||||
|
// ohne Erklaerung.
|
||||||
|
//
|
||||||
|
// 0.42.2 hat sie neu gezeichnet. Vorher war sie ein Kasten mit Ueberschrift, Legende, drei
|
||||||
|
// Sorten schwebender Marker und einer Fusszeile -- viel Beiwerk fuer wenig Aussage. Jetzt ist
|
||||||
|
// sie eine durchgehende Leiste aus abgesetzten Kacheln: keine Ueberschrift (eine Zeitachse
|
||||||
|
// erkennt man), keine Rahmen um Rahmen, die Marker sitzen AN der Leiste statt darueber zu
|
||||||
|
// schweben, und zwischen den Phasen steht ein Pfeil fuer den Uebergang.
|
||||||
export function Timeline({
|
export function Timeline({
|
||||||
phases,
|
phases,
|
||||||
persons,
|
persons,
|
||||||
@@ -30,9 +38,6 @@ 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;
|
activePhaseId?: string | null;
|
||||||
onSelectPhase?: (phaseId: string) => void;
|
onSelectPhase?: (phaseId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -46,140 +51,110 @@ export function Timeline({
|
|||||||
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
|
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
|
||||||
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
|
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
|
||||||
|
|
||||||
// Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation während
|
|
||||||
// des Renderns, deshalb die kumulierte Summe der vorangehenden Dauern.
|
|
||||||
const segments = phases.map((p, i) => {
|
const segments = phases.map((p, i) => {
|
||||||
const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
|
const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
|
||||||
return { phase: p, from, to: from + p.durationYears };
|
return { phase: p, from, to: from + p.durationYears };
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const clickable = !!onSelectPhase;
|
||||||
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
|
|
||||||
<div className="flex gap-3 text-xs text-muted">
|
|
||||||
{persons.map((p) => (
|
|
||||||
<span key={p.role} className="flex items-center gap-1">
|
|
||||||
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
|
|
||||||
{p.label} (heute {p.currentAge})
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative pt-6">
|
return (
|
||||||
{/* Pensionsmarker je Person */}
|
<div className="flex flex-col gap-1.5">
|
||||||
{persons.map((p, i) =>
|
{/* Marker-Band. Es traegt Pensionierungen, das Ruinalter und erfasste Ist-Jahre --
|
||||||
|
dieselbe Prozent-Position wie die Segmente darunter, damit sie sich decken. */}
|
||||||
|
<div className="relative h-5">
|
||||||
|
{persons.map((p) =>
|
||||||
p.retirementAge > minAge && p.retirementAge < maxAge ? (
|
p.retirementAge > minAge && p.retirementAge < maxAge ? (
|
||||||
<div
|
<span
|
||||||
key={p.role}
|
key={p.role}
|
||||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
className="absolute bottom-0 flex -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-full px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
style={{ left: pct(p.retirementAge) }}
|
style={{ left: pct(p.retirementAge), color: p.color, backgroundColor: `${p.color}1f` }}
|
||||||
title={`${p.label}: Pensionierung mit ${p.retirementAge}${
|
title={`${p.label}: Pensionierung mit ${p.retirementAge}${
|
||||||
yearOf(p.retirementAge - minAge) ? ` (${yearOf(p.retirementAge - minAge)})` : ""
|
yearOf(p.retirementAge - minAge) ? ` (${yearOf(p.retirementAge - minAge)})` : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} />
|
<Flag className="h-2.5 w-2.5" style={{ color: p.color }} />
|
||||||
<span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}>
|
{p.label} {p.retirementAge}
|
||||||
{p.retirementAge}
|
</span>
|
||||||
</span>
|
|
||||||
<div className="mt-0.5 h-3 w-px" style={{ backgroundColor: p.color, marginTop: i * 2 }} />
|
|
||||||
</div>
|
|
||||||
) : null
|
) : null
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ruin-Marker (Person A) */}
|
|
||||||
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
|
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
|
||||||
<div
|
<span
|
||||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
className="absolute bottom-0 -translate-x-1/2 whitespace-nowrap rounded-full bg-danger-soft px-1.5 py-0.5 text-[10px] font-semibold text-danger"
|
||||||
style={{ left: pct(ruinAge) }}
|
style={{ left: pct(ruinAge) }}
|
||||||
title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
|
title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] font-semibold" style={{ color: "var(--danger)" }}>
|
Ruin {ruinAge}
|
||||||
Ruin {ruinAge}
|
</span>
|
||||||
</span>
|
|
||||||
<div className="mt-0.5 h-4 w-px" style={{ backgroundColor: "var(--danger)" }} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Marker für erfasste effektive Werte. Nur mit bekanntem Planstartjahr platzierbar --
|
|
||||||
ohne Kalenderbezug gäbe es keine Position auf der Achse. */}
|
|
||||||
{startYear &&
|
{startYear &&
|
||||||
actualYears.map((y) => {
|
actualYears.map((y) => {
|
||||||
const age = minAge + (y - startYear);
|
const age = minAge + (y - startYear);
|
||||||
if (age < minAge || age > maxAge) return null;
|
if (age < minAge || age > maxAge) return null;
|
||||||
const isLatest = y === actualYears[actualYears.length - 1];
|
const isLatest = y === actualYears[actualYears.length - 1];
|
||||||
return (
|
return (
|
||||||
<div
|
<span
|
||||||
key={y}
|
key={y}
|
||||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
className="absolute bottom-0 flex -translate-x-1/2 items-center gap-0.5 whitespace-nowrap text-[10px] font-medium"
|
||||||
style={{ left: pct(age), opacity: isLatest ? 1 : 0.35 }}
|
style={{ left: pct(age), color: isLatest ? "var(--attention)" : "var(--faint)" }}
|
||||||
title={
|
title={isLatest ? `Effektive Werte für ${y} (aktuell)` : `Effektive Werte für ${y} (überholt)`}
|
||||||
isLatest
|
|
||||||
? `Effektive Werte erfasst für ${y} (aktuellster Stand)`
|
|
||||||
: `Effektive Werte erfasst für ${y} (überholt)`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<CalendarCheck
|
<CalendarCheck className="h-2.5 w-2.5" />
|
||||||
className="h-3.5 w-3.5"
|
{y}
|
||||||
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
|
</span>
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="whitespace-nowrap text-[10px] font-medium"
|
|
||||||
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
|
|
||||||
>
|
|
||||||
{y}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="mt-0.5 h-3 w-px"
|
|
||||||
style={{ backgroundColor: isLatest ? "var(--attention)" : "var(--muted)" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
|
{/* Die Leiste. Abgesetzte Kacheln statt eines geteilten Balkens: Der Abstand macht die
|
||||||
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
Phasengrenze sichtbar, ohne dass es dafür eine Trennlinie braucht. */}
|
||||||
{segments.map((s, i) => {
|
<div className="flex items-stretch gap-1">
|
||||||
const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
|
{segments.map((s, i) => {
|
||||||
const active = activePhaseId === s.phase.id;
|
const active = activePhaseId === s.phase.id;
|
||||||
const clickable = !!onSelectPhase;
|
const from = yearOf(s.from);
|
||||||
return (
|
const to = yearOf(s.to);
|
||||||
|
return (
|
||||||
|
<div key={s.phase.id} className="flex min-w-0 items-stretch" style={{ width: `${(s.phase.durationYears / span) * 100}%` }}>
|
||||||
|
{i > 0 && (
|
||||||
|
<span className="flex w-4 shrink-0 items-center justify-center text-faint" title="Übergang">
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
key={s.phase.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!clickable}
|
disabled={!clickable}
|
||||||
onClick={() => onSelectPhase?.(s.phase.id)}
|
onClick={() => onSelectPhase?.(s.phase.id)}
|
||||||
aria-current={active ? "true" : undefined}
|
aria-current={active ? "true" : undefined}
|
||||||
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 text-left transition-all ${
|
className={`flex min-w-0 flex-1 flex-col justify-center rounded-xl px-2.5 py-2 text-left transition-all ${
|
||||||
i > 0 ? "border-l border-border" : ""
|
clickable ? "cursor-pointer" : ""
|
||||||
} ${clickable ? "cursor-pointer hover:brightness-105" : ""} ${
|
} ${
|
||||||
s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"
|
active
|
||||||
} ${active ? "ring-2 ring-inset ring-fg/70" : clickable ? "opacity-70" : ""}`}
|
? "bg-accent text-accent-fg shadow-sm"
|
||||||
|
: s.phase.type === "PENSION"
|
||||||
|
? "bg-accent-soft text-accent-soft-fg hover:brightness-95"
|
||||||
|
: "bg-surface-2 text-muted hover:bg-accent-soft hover:text-accent-soft-fg"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`truncate text-[10px] font-semibold ${soft ? "text-accent-soft-fg" : "text-accent-fg"}`}>
|
<span className="truncate text-[11px] font-semibold">{s.phase.name}</span>
|
||||||
{s.phase.name}
|
<span className={`truncate text-[10px] ${active ? "opacity-80" : "opacity-70"}`}>
|
||||||
</span>
|
{from ? `${from}–${to! - 1}` : `${s.phase.durationYears} J.`}
|
||||||
<span className={`truncate text-[9px] ${soft ? "text-accent-soft-fg" : "text-accent-fg"} opacity-80`}>
|
|
||||||
{s.phase.durationYears} J.
|
|
||||||
{yearOf(s.from) ? ` · ${yearOf(s.from)}–${yearOf(s.to)}` : ""}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Alters- und Jahres-Beschriftung */}
|
<div className="flex justify-between px-0.5 text-[10px] text-faint">
|
||||||
<div className="mt-1 flex justify-between text-[11px] text-muted">
|
<span>
|
||||||
<span>
|
{minAge} J.{yearOf(0) ? ` · ${yearOf(0)}` : ""}
|
||||||
{minAge} J.{yearOf(0) ? ` · ${yearOf(0)}` : ""}
|
</span>
|
||||||
</span>
|
<span>
|
||||||
<span>
|
{maxAge} J.{startYear ? ` · ${startYear + totalYears - 1}` : ""}
|
||||||
{maxAge} J.{yearOf(totalYears) ? ` · ${yearOf(totalYears)}` : ""}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user