Modul-Review 3: Struktur, Dashboard, CSV-Export und Grafiken
Deploy App / deploy (push) Successful in 1m59s

Versionierung:
- startet neu bei 0.1; erst eine gesetzte Hauptversion macht daraus 1.0
- Szenario-Liste zeigt die echte Version (z.B. 0.17) statt "1.x", plus
  neue Spalte "Phasen"
- Migration 20260724120000_version_zero_start (nur der Default)

Plan-Dashboard:
- Kacheln anklickbar (fuehren in ihren Bereich), neue Kachel "Berichte"
- Plan umbenennen ueber Stift-Symbol
- Ist-Abweichung nennt das Jahr des juengsten Ist-Datensatzes und ist bei
  positiver Abweichung gruen statt rot

Szenario-Liste:
- Klick auf die Zeile oeffnet die Matrix (Matrix-Knopf entfaellt)
- je Zeile Kopie (Vorlage frei waehlbar) und Loeschen
- laedt nach einer Loeschung neu (zeigte vorher den alten Stand)

Seitenleiste:
- Szenarien wieder verschachtelt nach Herkunft
- Effektive Werte / Analysen / Berichte buendig zum Knoten "Szenarien"

CSV-Export (neues Modul lib/csv.ts):
- vier Bloecke: Kopf, Lebensphasen, ganze Matrix (Elemente x Phasen inkl.
  Uebergangs-Entscheide im Klartext), Jahreswerte
- mit BOM (Excel-Umlaute), Dateiname transliteriert Umlaute
- vorher enthielt die Datei kein einziges finanzielles Element

Grafiken:
- Szenario-Waehler gilt fuer alle drei Grafiken
- BUGFIX Szenario-Vergleich: WealthChart nutzte den Namen als Datenschluessel
  -> gleichnamige Szenarien ueberschrieben sich (Legende zeigte beide, Chart
  nur eine). Neu die ID; stille Deckelung auf 4 Serien entfaellt
- eigene Legende mit freier Farbwahl je Serie + Erklaerung des Linienstils
- Vermoegensaufteilung neu: gestapelte Flaeche ueber die Planjahre + Ring
  fuer die relative Aufteilung zu einem waehlbaren Zeitpunkt
- alle Diagrammfarben aus neuen Theme-Tokens (--chart-1..6, --chart-grid)

Doku-Drift bereinigt: Kapitel 2.1, 3.2.2-3.2.7 und 3.10 beschrieben noch den
Stand vor V7 (Grundprofil am Szenario, parentPlanId, Scenario.startYear,
window.confirm, drei Sidebar-Unterpunkte).

Nebenbei: verstuemmelte Hex-Farbe --danger-soft (warm) repariert, deutsche
Plural-/Umlautfehler in den Uebersichts-Kacheln.

SPEZIFIKATION 0.31. 267 -> 275 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 12:16:07 +02:00
parent 96fdd00d5c
commit b42f9b385f
22 changed files with 1199 additions and 289 deletions
+53 -13
View File
@@ -117,6 +117,8 @@ function AppShellInner({ username }: { username: string }) {
const [showPalette, setShowPalette] = useState(false);
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
// Erhoehen erzwingt ein Neuladen der Szenario-Liste (bleibt bei einer Loeschung montiert).
const [scenarioListKey, setScenarioListKey] = useState(0);
// Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im
// DOM der Szenario-Ansicht). Zwei Auslöser:
@@ -276,6 +278,9 @@ function AppShellInner({ username }: { username: string }) {
return;
}
await loadPlans();
// Die Szenario-Liste bleibt beim Löschen aus der Seitenleiste montiert -- ohne dieses
// Signal zeigte sie den gelöschten Eintrag weiter.
setScenarioListKey((k) => k + 1);
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
}
@@ -384,7 +389,7 @@ function AppShellInner({ username }: { username: string }) {
<button
type="button"
onClick={() => openPlanTab(p.id, tab)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-3 text-left text-xs font-medium transition-colors ${
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-3 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"
}`}
>
@@ -452,7 +457,7 @@ function AppShellInner({ username }: { username: string }) {
<button
type="button"
onClick={() => void openActualsTab(p.id)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-3 text-left text-xs font-medium transition-colors ${
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-3 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"
}`}
>
@@ -565,18 +570,27 @@ function AppShellInner({ username }: { username: string }) {
{/* Plan-Ebene: Dashboard / Szenarien / Analysen */}
{!showSpec && !showSystemParams && planNav?.tab === "dashboard" && (
<PlanDashboardView planId={planNav.planId} />
<PlanDashboardView
planId={planNav.planId}
onNavigate={(tab) => (tab === "actuals" ? void openActualsTab(planNav.planId) : openPlanTab(planNav.planId, tab))}
onRenamed={() => void loadPlans()}
/>
)}
{!showSpec && !showSystemParams && planNav?.tab === "scenarios" && (
<ScenarioListView
planId={planNav.planId}
reloadKey={scenarioListKey}
onOpenMatrix={openScenario}
onOpenHistory={(sid) => {
void loadDetail(sid, true).then(() => setShowHistory(true));
}}
onNew={() => {
const base = activePlan?.scenarios.find((s) => s.isBase) ?? activePlan?.scenarios[0];
if (base) setCopyFrom({ id: base.id, planId: base.planId, planName: activePlan?.name ?? "", name: base.name, isBase: base.isBase, parentScenarioId: base.parentScenarioId } as ScenarioMeta);
onCopyFrom={(sid) => {
const src = activePlan?.scenarios.find((s) => s.id === sid);
if (src) setCopyFrom(src);
}}
onDelete={(sid) => {
const s = activePlan?.scenarios.find((x) => x.id === sid);
if (s) void handleDeleteScenario(s);
}}
/>
)}
@@ -968,17 +982,43 @@ function ScenarioTree({
onCopy: (s: ScenarioMeta) => void;
onDelete: (s: ScenarioMeta) => void;
}) {
// Flache, gleichmässig eingerückte Liste: Basisszenario zuoberst, danach die Varianten.
// Die Herkunfts-Verschachtelung wird in der Szenario-Liste (Spalte «aus …») gezeigt, nicht
// mehr durch die Einrückung in der Seitenleiste.
const ordered = [...scenarios].sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
// Echte Verschachtelung: Ein Szenario wird unter seiner Vorlage eingerückt (Tiefe = Länge
// der Herkunftskette). So ist in der Seitenleiste sofort sichtbar, woraus ein Szenario
// entstanden ist -- die Szenario-Liste zeigt dasselbe zusätzlich als Spalte «aus …».
const byId = new Map(scenarios.map((s) => [s.id, s]));
const depthOf = (s: ScenarioMeta): number => {
let d = 0;
let cur = s.parentScenarioId ? byId.get(s.parentScenarioId) : undefined;
// Deckel gegen eine (theoretisch) zyklische Kette.
while (cur && d < 8) {
d += 1;
cur = cur.parentScenarioId ? byId.get(cur.parentScenarioId) : undefined;
}
return d;
};
// Vorordnung: Basis zuoberst, Kinder direkt unter ihrer Vorlage.
const ordered: ScenarioMeta[] = [];
const visit = (parentId: string | null) => {
for (const s of scenarios) {
const pid = s.isBase ? null : s.parentScenarioId;
if ((pid ?? null) !== parentId) continue;
ordered.push(s);
visit(s.id);
}
};
visit(null);
// Szenarien, deren Vorlage gelöscht wurde, hängen sonst nirgends -- ans Ende.
for (const s of scenarios) if (!ordered.includes(s)) ordered.push(s);
if (ordered.length === 0) return null;
return (
<>
{ordered.map((s) => (
<div
key={s.id}
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 pl-9 text-sm transition-colors ${
style={{ paddingLeft: `${2.25 + depthOf(s) * 0.75}rem` }}
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm transition-colors ${
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
@@ -1093,9 +1133,9 @@ function DashboardHome({
{/* Ein paar Kennzahlen pro Plan direkt in der Übersicht. */}
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted">
<span>{p.scenarios.length} Szenario{p.scenarios.length === 1 ? "" : "s"}</span>
<span>{p.scenarios.length} Szenario{p.scenarios.length === 1 ? "" : "en"}</span>
{p.startYear && <span>ab {p.startYear}</span>}
{p._count && p._count.actuals > 0 && <span>{p._count.actuals} Ist-Datensatz{p._count.actuals === 1 ? "" : "e"}</span>}
{p._count && p._count.actuals > 0 && <span>{p._count.actuals} {p._count.actuals === 1 ? "Ist-Datensatz" : "Ist-Datensätze"}</span>}
{p._count && p._count.analyses > 0 && <span>{p._count.analyses} Analyse{p._count.analyses === 1 ? "" : "n"}</span>}
</div>
</div>