Menueleiste als eine Komponente in zwei Breiten, Kartenansicht wieder erreichbar
Deploy App / deploy (push) Successful in 1m15s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 21:47:28 +02:00
parent f41c3614b5
commit ff862ed030
4 changed files with 441 additions and 343 deletions
+46 -10
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.42 | | **Version** | 0.42.1 |
| **Datum** | 2026-07-25 | | **Datum** | 2026-07-25 |
| **Status** | Lebendes Dokument | | **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `113616c` inkl. Kartenansicht (Branch `main`) | | **Codestand** | Arbeitsstand nach `f41c361` inkl. Memo 2026081702 (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 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). |
| 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). |
@@ -2558,9 +2559,19 @@ sich eine Grösse über alle Phasen entwickelt.
Der Umschalter steht oben rechts; die Wahl gehört zum **Benutzer** und überlebt die Sitzung Der Umschalter steht oben rechts; die Wahl gehört zum **Benutzer** und überlebt die Sitzung
(`localStorage`) sie ist eine Arbeitsgewohnheit, keine Eigenschaft des Plans. (`localStorage`) sie ist eine Arbeitsgewohnheit, keine Eigenschaft des Plans.
> **Ein Diff oder eine Ist-Ansicht erzwingt die Matrix.** Dort ist sie die richtige **Was die Matrix erzwingt und was nicht** (korrigiert in 0.42.1):
> Darstellung; stillschweigend die falsche zu zeigen wäre schlechter als der Wechsel. Ohne
> Lebensphasen gilt dasselbe eine Karte ohne Phase gibt es nicht. | Fall | Verhalten | Warum |
|---|---|---|
| **Ist-Ansicht** aktiv | Matrix erzwungen | Plan gegen Ist ist eine Frage über alle Phasen und es ist ein Modus, in den man bewusst wechselt |
| **Keine Lebensphase** | Matrix erzwungen | Eine Karte ohne Phase gibt es nicht |
| **Abweichungen zur Vorlage** | Karten bleiben, mit Hinweis | Die Diff-Markierungen sind eine Verzierung der Matrix, kein Modus |
> **Der erste Anlauf sperrte die Karten vollständig.** Die Bedingung lautete `!!diff`, und
> `computeScenarioDiff` liefert immer ein Objekt bei Gleichheit ein leeres. Der Umschalter
> war damit dauerhaft ausgegraut. Aber auch `diff.total > 0` wäre falsch gewesen: Ein
> Kind-Szenario weicht nach der ersten Änderung **immer** ab, und Karten wären dort für immer
> gesperrt. Eine Verzierung darf keinen Modus erzwingen.
### 3.15.5 Drei Spalten ### 3.15.5 Drei Spalten
@@ -2569,15 +2580,35 @@ Der Umschalter steht oben rechts; die Wahl gehört zum **Benutzer** und überleb
^ Pin haelt sie breit ^ einklappbar ^ Pin haelt sie breit ^ einklappbar
``` ```
**Links** die Menüleiste, eingeklappt eine Icon-Schiene mit Haus, Plan-Wähler und den vier **Links** die Menüleiste: Haus, Plan-Wähler und die vier Bereiche des aktiven Plans, darunter
Bereichen des aktiven Plans. Beim Überfahren fährt sie als **Overlay** aus sie kostet dann das Wissen. Beim Überfahren fährt sie als **Overlay** aus sie kostet dann keinen Platz , ein
keinen Platz , ein **Pin** hält sie breit und schiebt den Inhalt. Der Szenario-Baum steht **Pin** hält sie breit und schiebt den Inhalt.
bewusst nicht in der Schiene: Er braucht Text, und Text braucht Breite.
> **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
> Platz; rechts daneben kommt nur die Beschriftung dazu. Der erste Anlauf zeigte eingeklappt
> Icons und ausgefahren einen Plan-Baum das war kein Übergang, sondern ein Sprung, und man
> verlor jedes Mal die Orientierung.
Damit entfällt der **Baum aller Pläne**. Er wird nicht vermisst: Der Plan wird über den Wähler
gewechselt, und die vier Bereiche beziehen sich immer auf den **aktiven** Plan. Statt einer
Liste von Listen bleiben vier Knöpfe. Was daran hing, hat einen neuen Ort:
| Vorher im Baum | Neu |
|---|---|
| Plan wechseln | Plan-Wähler (Klapp-Menü) |
| Plan anlegen, Plan löschen | ebenfalls im Plan-Wähler |
| Szenario-Baum mit Kopieren/Löschen | aufklappbar unter «Szenarien» (nur ausgefahren er braucht Text) |
Der Plan-Wähler zeigt eingeklappt die **Initialen** des aktiven Plans. Ohne diesen Bezug wüsste 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 man nach dem Umschalten nicht mehr, worin man ist und die vier Knöpfe darunter wären
gegenstandslos. gegenstandslos.
**Die Matrix ist ein eigener Stapel-Kontext** (`relative z-0`). Ihre sticky Köpfe tragen `z-40`
und konkurrierten sonst global mit der ausgefahrenen Leiste die Ecke oben links schob sich
vor das Menü. Innerhalb der Matrix stapeln sich die Köpfe unverändert; nach aussen tritt sie
als eine Ebene auf.
**Rechts** die vier bisherigen Kacheln als aufklappbare Abschnitte. Das ist der entscheidende **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 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** Breitbildschirm ist Höhe die knappe Grösse. Eingeklappt bleibt die **Zahl der offenen Punkte**
@@ -2597,6 +2628,11 @@ Aus dem Umbau sind drei Regeln geworden, die für neue Oberfläche gelten:
Bildschirm, in jeder Sitzung, für jeden Benutzer. Bildschirm, in jeder Sitzung, für jeden Benutzer.
3. **Nichts legt sich über das, was man bearbeitet.** Overlays sind für Dinge, die den 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. Arbeitsfluss unterbrechen sollen nicht für die Arbeit selbst.
4. **Ein Übergang verschiebt, er ersetzt nicht.** Wenn ein Element beim Aus- oder Aufklappen
seinen Platz behält und nur Beiwerk dazukommt, folgt das Auge mit. Wechselt stattdessen die
ganze Darstellung, ist es ein Sprung und man sucht sich neu zurecht.
5. **Wer `z-index` vergibt, baut einen Stapel-Kontext.** Sonst konkurrieren Zahlen aus
verschiedenen Bauteilen miteinander, und wer gewinnt, ist Zufall der Reihenfolge im DOM.
Referenz: `src/components/PhaseCards.tsx`, `src/components/Timeline.tsx`, Referenz: `src/components/PhaseCards.tsx`, `src/components/Timeline.tsx`,
`IconRail` in `src/components/AppShell.tsx`, `RailSection` und `ValueModeSwitch` in `IconRail` in `src/components/AppShell.tsx`, `RailSection` und `ValueModeSwitch` in
+306 -275
View File
@@ -104,8 +104,6 @@ function AppShellInner({ username }: { username: string }) {
const [showActuals, setShowActuals] = useState(false); const [showActuals, setShowActuals] = useState(false);
// Plan-Ebene: Dashboard / Szenarien-Liste / Analysen. Null = kein Plan-View aktiv. // Plan-Ebene: Dashboard / Szenarien-Liste / Analysen. Null = kein Plan-View aktiv.
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports" } | null>(null); const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports" } | null>(null);
// Welche Szenario-Bäume in der Seitenleiste aufgeklappt sind. Standard: eingeklappt.
const [expandedTrees, setExpandedTrees] = useState<Record<string, boolean>>({});
const [savedAnalysisId, setSavedAnalysisId] = useState<string | null>(null); const [savedAnalysisId, setSavedAnalysisId] = useState<string | null>(null);
const [showSystemParams, setShowSystemParams] = useState(false); const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false); const [showPlanTraces, setShowPlanTraces] = useState(false);
@@ -342,184 +340,6 @@ function AppShellInner({ username }: { username: string }) {
return base; return base;
}, [detail, selectedScenarioId]); }, [detail, selectedScenarioId]);
const sidebar = (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-4 py-4">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent">
<PiggyBank className="h-5 w-5 text-accent-fg" />
</div>
<span className="text-sm font-semibold text-fg">FPT</span>
<button
type="button"
onClick={() => setShowPalette(true)}
title="Suchen und springen (Ctrl+K)"
className="ml-auto flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[10px] text-faint transition-colors hover:bg-surface-2 hover:text-fg"
>
<Search className="h-3 w-3" />
Ctrl K
</button>
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
<button
type="button"
onClick={() => {
setSelectedScenarioId(null);
setPlanNav(null);
setShowSpec(false);
setShowSystemParams(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
selectedScenarioId === null && !planNav && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<LayoutDashboard className="h-4 w-4" />
Übersicht
</button>
<div className="mt-4 flex items-center justify-between px-3">
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Meine Pläne</span>
<button
type="button"
onClick={() => setShowEmptyPlanDialog(true)}
aria-label="Neuen Plan erstellen"
className="rounded-md p-1 text-accent transition-colors hover:bg-accent-soft"
>
<Plus className="h-4 w-4" />
</button>
</div>
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Pläne.</p>}
{plans.map((p) => {
const navHere = planNav?.planId === p.id;
// Tour-Ziel «menu»: die Menü-Gruppe des gerade offenen Plans.
const isActivePlan = p.scenarios.some((s) => s.id === selectedScenarioId);
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports", label: string, Icon: typeof FolderKanban) => (
<button
type="button"
onClick={() => openPlanTab(p.id, tab)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-[1.625rem] pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === tab ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
{label}
</button>
);
return (
<div key={p.id} className="mb-1" data-tour={isActivePlan ? "menu" : undefined}>
{/* Plan-Name: Klick öffnet das Plan-Dashboard. */}
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
<button
type="button"
onClick={() => openPlanTab(p.id, "dashboard")}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
title={p.name}
>
<FolderKanban className={`h-3.5 w-3.5 shrink-0 ${navHere && planNav?.tab === "dashboard" ? "text-accent" : "text-faint"}`} />
<span className="min-w-0 flex-1 truncate">{p.name}</span>
</button>
<button
type="button"
aria-label="Plan löschen"
onClick={() => handleDeletePlan(p.id)}
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{/* «Szenarien»: Chevron klappt den Baum auf/zu (Standard: zu), das Label führt
in die Szenario-Liste. */}
<div
className={`group flex w-full items-center gap-1 rounded-lg py-1.5 pr-3 pl-1 text-xs font-medium transition-colors ${
navHere && planNav?.tab === "scenarios" ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<button
type="button"
aria-label={expandedTrees[p.id] ? "Szenarien einklappen" : "Szenarien aufklappen"}
onClick={() => setExpandedTrees((prev) => ({ ...prev, [p.id]: !prev[p.id] }))}
className="rounded p-0.5 text-faint hover:text-fg"
>
{expandedTrees[p.id] ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => openPlanTab(p.id, "scenarios")}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<Layers className="h-3.5 w-3.5 shrink-0" />
Szenarien
</button>
</div>
{/* Ebene 3: nur unter "Szenarien", verschachtelt nach Herkunft. */}
{expandedTrees[p.id] && (
<ScenarioTree
scenarios={p.scenarios}
selectedId={selectedScenarioId}
onSelect={openScenario}
onCopy={setCopyFrom}
onDelete={handleDeleteScenario}
/>
)}
<button
type="button"
onClick={() => void openActualsTab(p.id)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-[1.625rem] pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === "actuals" ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<CalendarClock className="h-3.5 w-3.5 shrink-0" />
Effektive Werte
</button>
{subItem("analyses", "Analysen", BarChart3)}
{subItem("reports", "Berichte", FileSpreadsheet)}
</div>
);
})}
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
<span className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">Wissen</span>
<button
type="button"
onClick={() => {
setShowSpec(true);
setSpecAnchor(null);
setShowSystemParams(false);
setSelectedScenarioId(null);
setPlanNav(null);
setSidebarOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<FileText className="h-4 w-4 shrink-0" />
So rechnet FPT
</button>
<button
type="button"
onClick={() => {
setShowSystemParams(true);
setShowSpec(false);
setSelectedScenarioId(null);
setPlanNav(null);
setSidebarOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<SlidersHorizontal className="h-4 w-4 shrink-0" />
Systemparameter
</button>
</div>
</nav>
</div>
);
const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null; const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null;
return ( return (
@@ -528,39 +348,23 @@ function AppShellInner({ username }: { username: string }) {
aus (kostet keinen Platz), angepinnt bleibt sie stehen und schiebt den Inhalt. 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 Icon-only braucht zwingend Tooltip UND aria-label -- sonst ist die Schiene fuer
Screenreader und fuer Neulinge gleichermassen stumm. */} Screenreader und fuer Neulinge gleichermassen stumm. */}
{/* Linke Leiste. EINE Komponente in zwei Breiten: Die Symbole bleiben an Ort und
Stelle, rechts daneben faehrt die Beschriftung aus. Ein Wechsel der Darstellung
(Icons hier, Baum dort) waere kein Uebergang, sondern ein Sprung -- und genau das
hat sich falsch angefuehlt. */}
<div <div
className="relative hidden shrink-0 lg:block" className="relative hidden shrink-0 lg:block"
style={{ width: railPinned ? "16rem" : "3.5rem" }} style={{ width: railPinned ? "15rem" : "3.5rem" }}
onMouseEnter={() => setRailHover(true)} onMouseEnter={() => setRailHover(true)}
onMouseLeave={() => { onMouseLeave={() => {
setRailHover(false); setRailHover(false);
setPlanMenuOpen(false); setPlanMenuOpen(false);
}} }}
> >
<aside <Rail
className={`absolute inset-y-0 left-0 z-30 flex flex-col border-r border-border bg-surface transition-[width] duration-200 ${ wide={railWide}
railWide ? "w-64 shadow-xl" : "w-14" pinned={railPinned}
} ${railPinned ? "shadow-none" : ""}`} onTogglePin={toggleRailPin}
>
{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} plans={plans}
activeScenarioId={selectedScenarioId} activeScenarioId={selectedScenarioId}
planNav={planNav} planNav={planNav}
@@ -569,6 +373,11 @@ function AppShellInner({ username }: { username: string }) {
showSystemParams={showSystemParams} showSystemParams={showSystemParams}
planMenuOpen={planMenuOpen} planMenuOpen={planMenuOpen}
onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)} onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)}
onNewPlan={() => {
setPlanMenuOpen(false);
setShowEmptyPlanDialog(true);
}}
onDeletePlan={handleDeletePlan}
onHome={() => { onHome={() => {
setSelectedScenarioId(null); setSelectedScenarioId(null);
setPlanNav(null); setPlanNav(null);
@@ -580,6 +389,9 @@ function AppShellInner({ username }: { username: string }) {
if (tab === "actuals") void openActualsTab(planId); if (tab === "actuals") void openActualsTab(planId);
else openPlanTab(planId, tab); else openPlanTab(planId, tab);
}} }}
onOpenScenario={openScenario}
onCopyScenario={setCopyFrom}
onDeleteScenario={handleDeleteScenario}
onSpec={() => { onSpec={() => {
setShowSpec(true); setShowSpec(true);
setSpecAnchor(null); setSpecAnchor(null);
@@ -594,10 +406,7 @@ function AppShellInner({ username }: { username: string }) {
setPlanNav(null); setPlanNav(null);
}} }}
onSearch={() => setShowPalette(true)} onSearch={() => setShowPalette(true)}
onPin={toggleRailPin}
/> />
)}
</aside>
</div> </div>
{sidebarOpen && ( {sidebarOpen && (
@@ -612,7 +421,60 @@ function AppShellInner({ username }: { username: string }) {
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
{sidebar} <Rail
wide
pinned
mobile
onTogglePin={() => setSidebarOpen(false)}
plans={plans}
activeScenarioId={selectedScenarioId}
planNav={planNav}
atHome={selectedScenarioId === null && !planNav && !showSpec && !showSystemParams}
showSpec={showSpec}
showSystemParams={showSystemParams}
planMenuOpen={planMenuOpen}
onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)}
onNewPlan={() => {
setPlanMenuOpen(false);
setShowEmptyPlanDialog(true);
}}
onDeletePlan={handleDeletePlan}
onHome={() => {
setSelectedScenarioId(null);
setPlanNav(null);
setShowSpec(false);
setShowSystemParams(false);
setSidebarOpen(false);
}}
onOpenTab={(planId, tab) => {
setPlanMenuOpen(false);
setSidebarOpen(false);
if (tab === "actuals") void openActualsTab(planId);
else openPlanTab(planId, tab);
}}
onOpenScenario={(id) => {
openScenario(id);
setSidebarOpen(false);
}}
onCopyScenario={setCopyFrom}
onDeleteScenario={handleDeleteScenario}
onSpec={() => {
setShowSpec(true);
setSpecAnchor(null);
setShowSystemParams(false);
setSelectedScenarioId(null);
setPlanNav(null);
setSidebarOpen(false);
}}
onParams={() => {
setShowSystemParams(true);
setShowSpec(false);
setSelectedScenarioId(null);
setPlanNav(null);
setSidebarOpen(false);
}}
onSearch={() => setShowPalette(true)}
/>
</aside> </aside>
</div> </div>
)} )}
@@ -1197,12 +1059,21 @@ function CopyScenarioDialog({
); );
} }
// --- Die eingeklappte Icon-Schiene ----------------------------------------------------------- // --- Die linke Leiste ------------------------------------------------------------------------
// //
// Sie zeigt genau das, was man im Alltag braucht: nach Hause, den aktiven Plan, seine vier // EINE Komponente in zwei Breiten. Die Symbole stehen immer an derselben Stelle -- in einer
// Bereiche. Der Szenario-BAUM steht bewusst nicht hier -- er braucht Text, und Text braucht // festen 3.5-rem-Spalte --, rechts daneben faehrt die Beschriftung aus. Bis 0.42 wechselte die
// Breite. Wer ihn will, faehrt die Leiste aus oder pinnt sie an. // Leiste beim Ueberfahren die Darstellung: eingeklappt Icons, ausgefahren ein Plan-Baum. Das
function IconRail({ // war kein Uebergang, sondern ein Sprung, und man verlor jedes Mal die Orientierung.
//
// Der Preis ist der Verlust des Baums ALLER Plaene. Er wird nicht vermisst: Der Plan wird ueber
// den Waehler oben gewechselt, und die vier Bereiche darunter beziehen sich immer auf den
// AKTIVEN Plan. Statt einer Liste von Listen bleiben vier Knoepfe.
function Rail({
wide,
pinned,
mobile,
onTogglePin,
plans, plans,
activeScenarioId, activeScenarioId,
planNav, planNav,
@@ -1211,13 +1082,21 @@ function IconRail({
showSystemParams, showSystemParams,
planMenuOpen, planMenuOpen,
onTogglePlanMenu, onTogglePlanMenu,
onNewPlan,
onDeletePlan,
onHome, onHome,
onOpenTab, onOpenTab,
onOpenScenario,
onCopyScenario,
onDeleteScenario,
onSpec, onSpec,
onParams, onParams,
onSearch, onSearch,
onPin,
}: { }: {
wide: boolean;
pinned: boolean;
mobile?: boolean;
onTogglePin: () => void;
plans: PlanListItem[]; plans: PlanListItem[];
activeScenarioId: string | null; activeScenarioId: string | null;
planNav: { planId: string; tab: string } | null; planNav: { planId: string; tab: string } | null;
@@ -1226,107 +1105,259 @@ function IconRail({
showSystemParams: boolean; showSystemParams: boolean;
planMenuOpen: boolean; planMenuOpen: boolean;
onTogglePlanMenu: () => void; onTogglePlanMenu: () => void;
onNewPlan: () => void;
onDeletePlan: (planId: string) => void;
onHome: () => void; onHome: () => void;
onOpenTab: (planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") => void; onOpenTab: (planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") => void;
onOpenScenario: (scenarioId: string) => void;
onCopyScenario: (meta: ScenarioMeta) => void;
onDeleteScenario: (s: ScenarioMeta) => void;
onSpec: () => void; onSpec: () => void;
onParams: () => void; onParams: () => void;
onSearch: () => void; onSearch: () => void;
onPin: () => void;
}) { }) {
// Welcher Plan ist gerade im Zugriff? Erst der offene, sonst der zuletzt gewaehlte Bereich, // Der Szenario-Baum haengt unter «Szenarien» und braucht Text -- eingeklappt gibt es ihn
// sonst der einzige. Ohne diesen Bezug waeren die vier Icons darunter gegenstandslos. // nicht. Standard: zu, sonst waere die Leiste beim Ueberfahren sofort lang.
const [treeOpen, setTreeOpen] = useState(false);
// Welcher Plan ist im Zugriff? Erst der offene, sonst der zuletzt gewaehlte Bereich, sonst
// der einzige. Ohne diesen Bezug waeren die vier Knoepfe darunter gegenstandslos.
const active = const active =
plans.find((p) => p.scenarios.some((sc) => sc.id === activeScenarioId)) ?? plans.find((p) => p.scenarios.some((sc) => sc.id === activeScenarioId)) ??
plans.find((p) => p.id === planNav?.planId) ?? plans.find((p) => p.id === planNav?.planId) ??
(plans.length === 1 ? plans[0] : undefined); (plans.length === 1 ? plans[0] : undefined);
const initials = active const initials = active
? active.name ? active.name.split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase()
.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 ( return (
<div className="flex h-full flex-col items-center gap-1 py-3"> <aside
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent" title="FPT"> className={`${
<PiggyBank className="h-5 w-5 text-accent-fg" /> mobile ? "relative h-full w-full" : "absolute inset-y-0 left-0 z-40"
</div> } flex flex-col overflow-hidden border-r border-border bg-surface transition-[width] duration-200 ${
<div className="my-1 h-px w-6 bg-border" /> mobile ? "" : wide ? "w-60 shadow-xl" : "w-14"
} ${pinned && !mobile ? "shadow-none" : ""}`}
>
<div className="flex min-h-0 flex-1 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" />
{item("Übersicht", LayoutDashboard, atHome, onHome)} <div className="my-1 h-px w-6 self-center bg-border" />
{/* Der Plan-Wähler. Eingeklappt zeigt er die Initialen -- sonst wüsste man nach dem <RailRow wide={wide} active={atHome} label="Übersicht" onClick={onHome} icon={<LayoutDashboard className="h-[18px] w-[18px]" />} />
{/* Plan-Wähler. Eingeklappt die Initialen -- ohne diesen Bezug wüsste man nach dem
Umschalten nicht mehr, worin man gerade ist. */} Umschalten nicht mehr, worin man gerade ist. */}
<div className="relative"> <div className="relative">
<button <RailRow
type="button" wide={wide}
label={active?.name ?? "Plan wählen"}
onClick={onTogglePlanMenu} onClick={onTogglePlanMenu}
aria-label={active ? `Plan: ${active.name}` : "Plan wählen"} trailing={<ChevronDown className="h-3.5 w-3.5 text-faint" />}
title={active ? `Plan: ${active.name}` : "Plan wählen"} icon={
className={`flex h-9 w-9 items-center justify-center rounded-lg border text-[11px] font-semibold transition-colors ${ <span
active ? "border-border text-fg hover:bg-surface-2" : "border-dashed border-border text-faint" className={`flex h-[26px] w-[26px] items-center justify-center rounded-md border text-[10px] font-semibold ${
active ? "border-border text-fg" : "border-dashed border-border text-faint"
}`} }`}
> >
{initials} {initials}
</button> </span>
}
/>
{planMenuOpen && ( {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"> <div
className={`ui-fade absolute z-50 w-56 rounded-xl border border-border bg-surface p-1 shadow-xl ${
wide ? "left-3 top-full mt-1" : "left-12 top-0"
}`}
>
{plans.length === 0 && <p className="px-2 py-1.5 text-xs text-faint">Noch keine Pläne.</p>} {plans.length === 0 && <p className="px-2 py-1.5 text-xs text-faint">Noch keine Pläne.</p>}
{plans.map((p) => ( {plans.map((p) => (
<div key={p.id} className="group flex items-center gap-1">
<button <button
key={p.id}
type="button" type="button"
onClick={() => onOpenTab(p.id, "dashboard")} 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 ${ className={`flex min-w-0 flex-1 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" 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" /> <FolderKanban className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate">{p.name}</span> <span className="min-w-0 truncate">{p.name}</span>
</button> </button>
<button
type="button"
aria-label={`${p.name} löschen`}
onClick={() => onDeletePlan(p.id)}
className="rounded p-1 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))} ))}
<button
type="button"
onClick={onNewPlan}
className="mt-1 flex w-full items-center gap-2 rounded-lg border-t border-border px-2 py-1.5 pt-2 text-left text-xs font-medium text-accent transition-colors hover:bg-accent-soft"
>
<Plus className="h-3.5 w-3.5 shrink-0" /> Neuer Plan
</button>
</div> </div>
)} )}
</div> </div>
{item("Szenarien", Layers, planNav?.tab === "scenarios" || !!activeScenarioId, () => active && onOpenTab(active.id, "scenarios"), !active)} {/* Die vier Bereiche des aktiven Plans. Mehr braucht es nicht. */}
{item("Effektive Werte", CalendarClock, planNav?.tab === "actuals", () => active && onOpenTab(active.id, "actuals"), !active)} <RailRow
{item("Analysen", BarChart3, planNav?.tab === "analyses", () => active && onOpenTab(active.id, "analyses"), !active)} wide={wide}
{item("Berichte", FileSpreadsheet, planNav?.tab === "reports", () => active && onOpenTab(active.id, "reports"), !active)} disabled={!active}
active={planNav?.tab === "scenarios" || !!activeScenarioId}
<div className="my-1 h-px w-6 bg-border" /> label="Szenarien"
{item("So rechnet FPT", FileText, showSpec, onSpec)} onClick={() => active && onOpenTab(active.id, "scenarios")}
{item("Systemparameter", SlidersHorizontal, showSystemParams, onParams)} icon={<Layers className="h-[18px] w-[18px]" />}
trailing={
<div className="mt-auto flex flex-col items-center gap-1"> wide && active ? (
{item("Suchen und springen (Ctrl+K)", Search, false, onSearch)} <span
{item("Menü anpinnen", Pin, false, onPin)} role="button"
tabIndex={0}
aria-label={treeOpen ? "Szenarien einklappen" : "Szenarien aufklappen"}
onClick={(e) => {
e.stopPropagation();
setTreeOpen((o) => !o);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
setTreeOpen((o) => !o);
}
}}
className="rounded p-0.5 text-faint hover:text-fg"
>
{treeOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</span>
) : undefined
}
/>
{wide && treeOpen && active && (
<div className="pl-3 pr-2">
<ScenarioTree
scenarios={active.scenarios}
selectedId={activeScenarioId}
onSelect={onOpenScenario}
onCopy={onCopyScenario}
onDelete={onDeleteScenario}
/>
</div> </div>
)}
<RailRow
wide={wide}
disabled={!active}
active={planNav?.tab === "actuals"}
label="Effektive Werte"
onClick={() => active && onOpenTab(active.id, "actuals")}
icon={<CalendarClock className="h-[18px] w-[18px]" />}
/>
<RailRow
wide={wide}
disabled={!active}
active={planNav?.tab === "analyses"}
label="Analysen"
onClick={() => active && onOpenTab(active.id, "analyses")}
icon={<BarChart3 className="h-[18px] w-[18px]" />}
/>
<RailRow
wide={wide}
disabled={!active}
active={planNav?.tab === "reports"}
label="Berichte"
onClick={() => active && onOpenTab(active.id, "reports")}
icon={<FileSpreadsheet className="h-[18px] w-[18px]" />}
/>
<div className="my-1 h-px w-6 self-center bg-border" />
<RailRow wide={wide} active={showSpec} label="So rechnet FPT" onClick={onSpec} icon={<FileText className="h-[18px] w-[18px]" />} />
<RailRow
wide={wide}
active={showSystemParams}
label="Systemparameter"
onClick={onParams}
icon={<SlidersHorizontal className="h-[18px] w-[18px]" />}
/>
</div> </div>
<div className="border-t border-border py-1.5">
<RailRow
wide={wide}
label="Suchen und springen (Ctrl+K)"
shortLabel="Suchen"
onClick={onSearch}
icon={<Search className="h-[18px] w-[18px]" />}
/>
{!mobile && (
<RailRow
wide={wide}
active={pinned}
label={pinned ? "Menü loslösen" : "Menü anpinnen"}
onClick={onTogglePin}
icon={pinned ? <PinOff className="h-[18px] w-[18px]" /> : <Pin className="h-[18px] w-[18px]" />}
/>
)}
</div>
</aside>
);
}
// Eine Zeile der Leiste. Das Symbol sitzt in einer FESTEN Spalte von 3.5 rem -- dadurch bleibt
// es beim Ausfahren exakt an seinem Platz, und es kommt nur die Beschriftung dazu.
function RailRow({
wide,
icon,
iconBox,
label,
shortLabel,
onClick,
active,
disabled,
trailing,
}: {
wide: boolean;
icon: React.ReactNode;
iconBox?: string;
label: string;
shortLabel?: string;
onClick?: () => void;
active?: boolean;
disabled?: boolean;
trailing?: React.ReactNode;
}) {
const cls = `flex w-full items-center rounded-lg pr-2 text-left transition-colors ${
disabled ? "opacity-30" : ""
} ${active ? "bg-accent-soft text-accent-soft-fg" : onClick ? "text-muted hover:bg-surface-2 hover:text-fg" : "text-fg"}`;
const inner = (
<>
<span className="flex h-9 w-14 shrink-0 items-center justify-center">
<span className={iconBox ? `flex h-7 w-7 items-center justify-center rounded-lg ${iconBox}` : ""}>{icon}</span>
</span>
{wide && (
<>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{shortLabel ?? label}</span>
{trailing}
</>
)}
</>
);
if (!onClick) return <div className={cls}>{inner}</div>;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={wide ? undefined : label}
className={cls}
>
{inner}
</button>
); );
} }
+34 -6
View File
@@ -9,6 +9,7 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
CreditCard, CreditCard,
GitCompare,
Home, Home,
Landmark, Landmark,
Pencil, Pencil,
@@ -166,6 +167,7 @@ export function PlanView({
// dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je // dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je
// Zelle (siehe SPEZIFIKATION 9.29). // Zelle (siehe SPEZIFIKATION 9.29).
const [dataSource, setDataSource] = useState<DataSource>("PLAN"); const [dataSource, setDataSource] = useState<DataSource>("PLAN");
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
const hasActuals = actualComputed !== null; const hasActuals = actualComputed !== null;
const computed = dataSource === "ACTUAL" && actualComputed ? actualComputed : planComputed; const computed = dataSource === "ACTUAL" && actualComputed ? actualComputed : planComputed;
@@ -202,7 +204,6 @@ export function PlanView({
// Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup, // Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup,
// weil beide mehrere Elemente auf einmal bearbeiten. // weil beide mehrere Elemente auf einmal bearbeiten.
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 [showInventory, setShowInventory] = useState(false); const [showInventory, setShowInventory] = useState(false);
// Karten oder Matrix. Die Wahl gilt fuer den Benutzer, nicht fuer das Szenario -- sie ist // Karten oder Matrix. Die Wahl gilt fuer den Benutzer, nicht fuer das Szenario -- sie ist
// eine Arbeitsgewohnheit, keine Eigenschaft des Plans. // eine Arbeitsgewohnheit, keine Eigenschaft des Plans.
@@ -442,10 +443,17 @@ 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 // Die IST-Ansicht erzwingt die Matrix: Plan gegen Ist ist eine Frage ueber alle Phasen, das
// eine einzelne Karte nicht zeigen. In diesen Faellen erzwingt FPT die Matrix, statt still // kann eine einzelne Karte nicht zeigen.
// die falsche Darstellung anzubieten. //
const forceMatrix = !!diff || !!actualComputed; // Der Szenario-Diff erzwingt sie NICHT -- und das war der Fehler in 0.42. Die Bedingung
// lautete `!!diff`, aber `computeScenarioDiff` liefert immer ein Objekt (bei Gleichheit ein
// leeres). Damit war `forceMatrix` dauerhaft wahr und die Kartenansicht schlicht nicht
// erreichbar. Auch `diff.total > 0` waere zu scharf: Ein Kind-Szenario weicht nach der
// ersten Aenderung IMMER von seiner Vorlage ab -- Karten waeren dort fuer immer gesperrt.
// Die Diff-Markierungen sind eine Verzierung der Matrix, kein Modus. In der Kartenansicht
// steht stattdessen ein Hinweis.
const forceMatrix = showDeviation;
const effectiveView: "cards" | "matrix" = forceMatrix || !hasPhases ? "matrix" : view; const effectiveView: "cards" | "matrix" = forceMatrix || !hasPhases ? "matrix" : view;
const currentPhaseId = const currentPhaseId =
computed.phases.find((p) => p.id === activePhaseId)?.id ?? computed.phases[0]?.id ?? null; computed.phases.find((p) => p.id === activePhaseId)?.id ?? computed.phases[0]?.id ?? null;
@@ -568,6 +576,23 @@ 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. */}
{/* Abweichungen zur Vorlage sind eine Frage über die ganze Fläche -- die Karte kann sie
nicht zeigen. Sie zu verschweigen wäre aber schlechter, als den Weg dorthin zu
nennen. */}
{effectiveView === "cards" && (diff?.total ?? 0) > 0 && (
<button
type="button"
onClick={() => setView("matrix")}
className="flex items-center gap-2 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-left text-xs text-diff transition-colors hover:brightness-95"
>
<GitCompare className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1">
{diff!.total} {diff!.total === 1 ? "Abweichung" : "Abweichungen"} zur Vorlage sichtbar in der Matrix
</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
</button>
)}
{effectiveView === "cards" && hasPhases && ( {effectiveView === "cards" && hasPhases && (
<PhaseCards <PhaseCards
plan={plan} plan={plan}
@@ -584,7 +609,10 @@ export function PlanView({
)} )}
{effectiveView === "matrix" && (hasPhases || plan.elements.length > 0) && ( {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"> // `relative z-0` macht die Matrix zu einem eigenen Stapel-Kontext: Ihre sticky Koepfe
// (z-40) stapeln sich nur noch UNTEREINANDER. Vorher konkurrierten sie global und die
// Ecke oben links schob sich vor die ausgefahrene Menueleiste.
<div data-tour="matrix" className="relative z-0 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.
Ein w-full haette die Spalten je nach Anzahl unterschiedlich gedehnt. */} Ein w-full haette die Spalten je nach Anzahl unterschiedlich gedehnt. */}
+4 -1
View File
@@ -30,7 +30,10 @@ export function ReviewTile({
return ( return (
<div <div
data-tour="assistant" data-tour="assistant"
className={`flex h-full flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${ // KEIN h-full: In der rechten Leiste ist die Kachel das einzige Element mit Inhalt --
// mit h-full zog sie sich ueber den ganzen Bildschirm, obwohl ein einziger Eintrag
// darin stand.
className={`flex flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
done ? "border-success bg-success-soft" : "border-attention bg-attention-soft" done ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
}`} }`}
> >