Menueleiste als eine Komponente in zwei Breiten, Kartenansicht wieder erreichbar
Deploy App / deploy (push) Successful in 1m15s
Deploy App / deploy (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+46
-10
@@ -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` (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.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
|
||||||
|
|||||||
+357
-326
@@ -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,76 +348,65 @@ 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}
|
||||||
>
|
plans={plans}
|
||||||
{railWide ? (
|
activeScenarioId={selectedScenarioId}
|
||||||
<>
|
planNav={planNav}
|
||||||
<div className="flex items-center gap-1 px-3 pt-3">
|
atHome={selectedScenarioId === null && !planNav && !showSpec && !showSystemParams}
|
||||||
<button
|
showSpec={showSpec}
|
||||||
type="button"
|
showSystemParams={showSystemParams}
|
||||||
onClick={toggleRailPin}
|
planMenuOpen={planMenuOpen}
|
||||||
aria-label={railPinned ? "Menü loslösen" : "Menü anpinnen"}
|
onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)}
|
||||||
title={railPinned ? "Menü loslösen" : "Menü anpinnen"}
|
onNewPlan={() => {
|
||||||
className={`ml-auto rounded-lg p-1.5 transition-colors ${
|
setPlanMenuOpen(false);
|
||||||
railPinned ? "bg-accent-soft text-accent-soft-fg" : "text-faint hover:bg-surface-2 hover:text-fg"
|
setShowEmptyPlanDialog(true);
|
||||||
}`}
|
}}
|
||||||
>
|
onDeletePlan={handleDeletePlan}
|
||||||
{railPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
|
onHome={() => {
|
||||||
</button>
|
setSelectedScenarioId(null);
|
||||||
</div>
|
setPlanNav(null);
|
||||||
<div className="min-h-0 flex-1">{sidebar}</div>
|
setShowSpec(false);
|
||||||
</>
|
setShowSystemParams(false);
|
||||||
) : (
|
}}
|
||||||
<IconRail
|
onOpenTab={(planId, tab) => {
|
||||||
plans={plans}
|
setPlanMenuOpen(false);
|
||||||
activeScenarioId={selectedScenarioId}
|
if (tab === "actuals") void openActualsTab(planId);
|
||||||
planNav={planNav}
|
else openPlanTab(planId, tab);
|
||||||
atHome={selectedScenarioId === null && !planNav && !showSpec && !showSystemParams}
|
}}
|
||||||
showSpec={showSpec}
|
onOpenScenario={openScenario}
|
||||||
showSystemParams={showSystemParams}
|
onCopyScenario={setCopyFrom}
|
||||||
planMenuOpen={planMenuOpen}
|
onDeleteScenario={handleDeleteScenario}
|
||||||
onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)}
|
onSpec={() => {
|
||||||
onHome={() => {
|
setShowSpec(true);
|
||||||
setSelectedScenarioId(null);
|
setSpecAnchor(null);
|
||||||
setPlanNav(null);
|
setShowSystemParams(false);
|
||||||
setShowSpec(false);
|
setSelectedScenarioId(null);
|
||||||
setShowSystemParams(false);
|
setPlanNav(null);
|
||||||
}}
|
}}
|
||||||
onOpenTab={(planId, tab) => {
|
onParams={() => {
|
||||||
setPlanMenuOpen(false);
|
setShowSystemParams(true);
|
||||||
if (tab === "actuals") void openActualsTab(planId);
|
setShowSpec(false);
|
||||||
else openPlanTab(planId, tab);
|
setSelectedScenarioId(null);
|
||||||
}}
|
setPlanNav(null);
|
||||||
onSpec={() => {
|
}}
|
||||||
setShowSpec(true);
|
onSearch={() => setShowPalette(true)}
|
||||||
setSpecAnchor(null);
|
/>
|
||||||
setShowSystemParams(false);
|
|
||||||
setSelectedScenarioId(null);
|
|
||||||
setPlanNav(null);
|
|
||||||
}}
|
|
||||||
onParams={() => {
|
|
||||||
setShowSystemParams(true);
|
|
||||||
setShowSpec(false);
|
|
||||||
setSelectedScenarioId(null);
|
|
||||||
setPlanNav(null);
|
|
||||||
}}
|
|
||||||
onSearch={() => setShowPalette(true)}
|
|
||||||
onPin={toggleRailPin}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
</div>
|
</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]" />} />
|
||||||
Umschalten nicht mehr, worin man gerade ist. */}
|
|
||||||
<div className="relative">
|
{/* Plan-Wähler. Eingeklappt die Initialen -- ohne diesen Bezug wüsste man nach dem
|
||||||
<button
|
Umschalten nicht mehr, worin man gerade ist. */}
|
||||||
type="button"
|
<div className="relative">
|
||||||
onClick={onTogglePlanMenu}
|
<RailRow
|
||||||
aria-label={active ? `Plan: ${active.name}` : "Plan wählen"}
|
wide={wide}
|
||||||
title={active ? `Plan: ${active.name}` : "Plan wählen"}
|
label={active?.name ?? "Plan wählen"}
|
||||||
className={`flex h-9 w-9 items-center justify-center rounded-lg border text-[11px] font-semibold transition-colors ${
|
onClick={onTogglePlanMenu}
|
||||||
active ? "border-border text-fg hover:bg-surface-2" : "border-dashed border-border text-faint"
|
trailing={<ChevronDown className="h-3.5 w-3.5 text-faint" />}
|
||||||
}`}
|
icon={
|
||||||
>
|
<span
|
||||||
{initials}
|
className={`flex h-[26px] w-[26px] items-center justify-center rounded-md border text-[10px] font-semibold ${
|
||||||
</button>
|
active ? "border-border text-fg" : "border-dashed border-border text-faint"
|
||||||
{planMenuOpen && (
|
|
||||||
<div className="ui-fade absolute left-11 top-0 z-50 w-56 rounded-xl border border-border bg-surface p-1 shadow-xl">
|
|
||||||
{plans.length === 0 && <p className="px-2 py-1.5 text-xs text-faint">Noch keine Pläne.</p>}
|
|
||||||
{plans.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenTab(p.id, "dashboard")}
|
|
||||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs font-medium transition-colors ${
|
|
||||||
p.id === active?.id ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FolderKanban className="h-3.5 w-3.5 shrink-0" />
|
{initials}
|
||||||
<span className="min-w-0 truncate">{p.name}</span>
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{planMenuOpen && (
|
||||||
|
<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.map((p) => (
|
||||||
|
<div key={p.id} className="group flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTab(p.id, "dashboard")}
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FolderKanban className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="min-w-0 truncate">{p.name}</span>
|
||||||
|
</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>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Die vier Bereiche des aktiven Plans. Mehr braucht es nicht. */}
|
||||||
|
<RailRow
|
||||||
|
wide={wide}
|
||||||
|
disabled={!active}
|
||||||
|
active={planNav?.tab === "scenarios" || !!activeScenarioId}
|
||||||
|
label="Szenarien"
|
||||||
|
onClick={() => active && onOpenTab(active.id, "scenarios")}
|
||||||
|
icon={<Layers className="h-[18px] w-[18px]" />}
|
||||||
|
trailing={
|
||||||
|
wide && active ? (
|
||||||
|
<span
|
||||||
|
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>
|
||||||
|
|
||||||
{item("Szenarien", Layers, planNav?.tab === "scenarios" || !!activeScenarioId, () => active && onOpenTab(active.id, "scenarios"), !active)}
|
<div className="border-t border-border py-1.5">
|
||||||
{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)}
|
label="Suchen und springen (Ctrl+K)"
|
||||||
|
shortLabel="Suchen"
|
||||||
<div className="my-1 h-px w-6 bg-border" />
|
onClick={onSearch}
|
||||||
{item("So rechnet FPT", FileText, showSpec, onSpec)}
|
icon={<Search className="h-[18px] w-[18px]" />}
|
||||||
{item("Systemparameter", SlidersHorizontal, showSystemParams, onParams)}
|
/>
|
||||||
|
{!mobile && (
|
||||||
<div className="mt-auto flex flex-col items-center gap-1">
|
<RailRow
|
||||||
{item("Suchen und springen (Ctrl+K)", Search, false, onSearch)}
|
wide={wide}
|
||||||
{item("Menü anpinnen", Pin, false, onPin)}
|
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>
|
</div>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user