96fdd00d5c
Deploy App / deploy (push) Successful in 1m1s
Tour: - Spotlight neu aus vier fixed-Abdunkelflaechen um die Bounding-Box statt box-shadow -- funktioniert jetzt unabhaengig von z-index (sticky Matrix-Koepfe blieben hell) und overflow (im Matrix-Scrollbereich blieb fast alles hell); folgt dem Ziel per requestAnimationFrame - Schritte ueberarbeitet: Zeitachse + Endvermoegen neu erklaert; der vormalige "Analysen"-Schritt beschreibt jetzt die obere Funktions-Leiste; neuer Schritt fuer das linke Menue (Analysen/Berichte/Effektive Werte); unsichtbare Ziele werden uebersprungen Wizard: - 3a-Schalter "Selbststaendig ohne PK" von Schritt 4 nach Schritt 5 (er betrifft die Einzahlung / Sparraten-Verteilung) SPEZIFIKATION 0.30 (9.24 neu gefasst). 267 Tests unveraendert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1206 lines
47 KiB
TypeScript
1206 lines
47 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
BarChart3,
|
||
BookOpen,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Copy,
|
||
FileText,
|
||
FileSpreadsheet,
|
||
FolderKanban,
|
||
CalendarClock,
|
||
GitBranch,
|
||
HelpCircle,
|
||
History,
|
||
Download,
|
||
Layers,
|
||
LayoutDashboard,
|
||
Menu,
|
||
PiggyBank,
|
||
Plus,
|
||
Search,
|
||
SlidersHorizontal,
|
||
Sparkles,
|
||
Trash2,
|
||
X,
|
||
} from "lucide-react";
|
||
import { PlanView } from "@/components/PlanView";
|
||
import { Dashboard } from "@/components/Dashboard";
|
||
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
||
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
||
import { LiveSimDialog } from "@/components/LiveSimDialog";
|
||
import { ActualsDialog } from "@/components/ActualsDialog";
|
||
import { PlanDashboardView, ScenarioListView, AnalysesView } from "@/components/PlanViews";
|
||
import { SavedAnalysisView } from "@/components/SavedAnalysisView";
|
||
import { ReportsView } from "@/components/ReportsView";
|
||
import { buildViews } from "@/lib/dataview";
|
||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
|
||
import { SpecView } from "@/components/SpecView";
|
||
import { SystemParametersView } from "@/components/SystemParametersView";
|
||
import { PlanTraceDialog } from "@/components/DetailView";
|
||
import { computePlan } from "@/lib/calculations";
|
||
import { ProfileMenu } from "@/components/ProfileMenu";
|
||
import { PlanWizard } from "@/components/PlanWizard";
|
||
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
|
||
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
|
||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||
import {
|
||
Button,
|
||
ConfirmProvider,
|
||
EmptyState,
|
||
Modal,
|
||
PlanSkeleton,
|
||
ToastProvider,
|
||
useConfirm,
|
||
useToast,
|
||
} from "@/components/ui";
|
||
import { api } from "@/lib/api-client";
|
||
import { createDemoPlan } from "@/lib/demoplan";
|
||
import { computeScenarioDiff } from "@/lib/diff";
|
||
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
|
||
import type { PlanComputed } from "@/lib/calculations";
|
||
|
||
interface ScenarioDetail {
|
||
plan: PlanInput; // das Szenario selbst (berechenbare Einheit)
|
||
computed: PlanComputed;
|
||
base: PlanInput | null; // Eltern-Szenario als Vergleichsbasis
|
||
meta: ScenarioMeta & { planName: string };
|
||
// Effektive Werte des PLANS plus die Element-Herkunft aller Szenarien -- daraus entsteht
|
||
// im Browser der zweite Rechenlauf (siehe lib/dataview.ts).
|
||
actuals: ActualsSetInput[];
|
||
elementOrigins: ElementOrigin[];
|
||
}
|
||
|
||
// Die Provider (Toast, Bestätigung) müssen UM die Shell liegen, damit deren Hooks
|
||
// innerhalb funktionieren.
|
||
export function AppShell({ username }: { username: string }) {
|
||
return (
|
||
<ToastProvider>
|
||
<ConfirmProvider>
|
||
<AppShellInner username={username} />
|
||
</ConfirmProvider>
|
||
</ToastProvider>
|
||
);
|
||
}
|
||
|
||
function AppShellInner({ username }: { username: string }) {
|
||
const toast = useToast();
|
||
const confirm = useConfirm();
|
||
|
||
const [plans, setPlans] = useState<PlanListItem[]>([]);
|
||
const [selectedScenarioId, setSelectedScenarioId] = useState<string | null>(null);
|
||
const [detail, setDetail] = useState<ScenarioDetail | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||
const [showNewPlanChooser, setShowNewPlanChooser] = useState(false);
|
||
const [showWizard, setShowWizard] = useState(false);
|
||
const [showEmptyPlanDialog, setShowEmptyPlanDialog] = useState(false);
|
||
const [creatingDemo, setCreatingDemo] = useState(false);
|
||
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
|
||
const [showSpec, setShowSpec] = useState(false);
|
||
const [showCharts, setShowCharts] = useState(false);
|
||
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
|
||
const [showSensitivity, setShowSensitivity] = useState(false);
|
||
const [showLiveSim, setShowLiveSim] = useState(false);
|
||
const [showHistory, setShowHistory] = useState(false);
|
||
const [showActuals, setShowActuals] = useState(false);
|
||
// 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);
|
||
// 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 [showSystemParams, setShowSystemParams] = useState(false);
|
||
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
||
const [showPalette, setShowPalette] = useState(false);
|
||
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
|
||
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
|
||
|
||
// Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im
|
||
// DOM der Szenario-Ansicht). Zwei Auslöser:
|
||
// * pendingTourRef: nach JEDER Plan-Erstellung genau einmal erzwingen (Roadmap-Feedback F14).
|
||
// * Erst-Öffnen eines Plans mit Phasen, solange die Tour noch nie beendet wurde.
|
||
const [showTour, setShowTour] = useState(false);
|
||
const pendingTourRef = useRef(false);
|
||
const autoTourCheckedRef = useRef<Set<string>>(new Set());
|
||
|
||
useEffect(() => {
|
||
if (!detail || detail.plan.phases.length === 0) return;
|
||
const sid = detail.meta.id;
|
||
if (pendingTourRef.current) {
|
||
pendingTourRef.current = false;
|
||
autoTourCheckedRef.current.add(sid);
|
||
setShowTour(true);
|
||
return;
|
||
}
|
||
if (autoTourCheckedRef.current.has(sid)) return; // pro Szenario nur einmal, nicht bei Refresh
|
||
autoTourCheckedRef.current.add(sid);
|
||
try {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung aus localStorage
|
||
if (!localStorage.getItem(TOUR_DONE_KEY)) setShowTour(true);
|
||
} catch {
|
||
/* localStorage nicht verfügbar */
|
||
}
|
||
}, [detail]);
|
||
|
||
function openSpecAt(anchor: string) {
|
||
setSpecAnchor(anchor);
|
||
setShowSpec(true);
|
||
setShowSystemParams(false);
|
||
setSelectedScenarioId(null);
|
||
setPlanNav(null);
|
||
}
|
||
|
||
const loadPlans = useCallback(async () => {
|
||
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
|
||
setPlans(data.plans);
|
||
return data.plans;
|
||
}, []);
|
||
|
||
// silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert,
|
||
// damit die Scrollposition (z. B. nach dem Schliessen eines Panels) erhalten bleibt.
|
||
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
|
||
if (!silent) setLoading(true);
|
||
try {
|
||
const d = await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`);
|
||
setDetail(d);
|
||
return d;
|
||
} finally {
|
||
if (!silent) setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf beim Mount
|
||
loadPlans().finally(() => setLoading(false));
|
||
}, [loadPlans]);
|
||
|
||
useEffect(() => {
|
||
if (selectedScenarioId) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf
|
||
loadDetail(selectedScenarioId);
|
||
}
|
||
// Kein `else setDetail(null)`: In der Plan-Ebene (planNav) wird `detail` fürs Basisszenario
|
||
// geladen, damit die Werkzeuge starten können -- das darf hier nicht weggeräumt werden.
|
||
}, [selectedScenarioId, loadDetail]);
|
||
|
||
// Cmd/Ctrl+K öffnet die Befehls-Palette.
|
||
useEffect(() => {
|
||
function onKey(e: KeyboardEvent) {
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||
e.preventDefault();
|
||
setShowPalette((v) => !v);
|
||
}
|
||
}
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, []);
|
||
|
||
function refreshCurrent() {
|
||
if (selectedScenarioId) loadDetail(selectedScenarioId, true);
|
||
}
|
||
|
||
function openScenario(id: string) {
|
||
setSelectedScenarioId(id);
|
||
setPlanNav(null);
|
||
setShowSpec(false);
|
||
setShowSystemParams(false);
|
||
setSidebarOpen(false);
|
||
}
|
||
|
||
// Plan-Ebene öffnen (Dashboard / Szenarien / Analysen). Räumt die Szenario- und Wissens-
|
||
// Ansichten weg -- es kann immer nur eine Hauptansicht aktiv sein.
|
||
function openPlanTab(planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") {
|
||
setPlanNav({ planId, tab });
|
||
setSelectedScenarioId(null);
|
||
setShowSpec(false);
|
||
setShowSystemParams(false);
|
||
setSidebarOpen(false);
|
||
}
|
||
|
||
// Ein Analysewerkzeug aus der Analysen-Ansicht heraus starten. Die Werkzeuge arbeiten auf
|
||
// einem Szenario -- ohne ausgewähltes nehmen wir das Basisszenario; im Werkzeug selbst
|
||
// lässt sich Szenario/Version weiterhin umstellen.
|
||
async function launchTool(planId: string, kind: "CHART" | "MONTE_CARLO" | "SENSITIVITY" | "LIVESIM") {
|
||
const plan = plans.find((p) => p.id === planId);
|
||
const baseId = plan?.scenarios.find((s) => s.isBase)?.id ?? plan?.scenarios[0]?.id;
|
||
if (!baseId) return;
|
||
if (detail?.meta.id !== baseId) await loadDetail(baseId, true);
|
||
if (kind === "CHART") setShowCharts(true);
|
||
else if (kind === "MONTE_CARLO") setShowMonteCarlo(true);
|
||
else if (kind === "SENSITIVITY") setShowSensitivity(true);
|
||
else setShowLiveSim(true);
|
||
}
|
||
|
||
// Effektive-Werte-Ansicht (Plan-Ebene, eingebettet). Lädt das Basisszenario, das der
|
||
// eingebettete Dialog als Ausgangspunkt braucht, und öffnet dann den Tab.
|
||
async function openActualsTab(planId: string) {
|
||
const plan = plans.find((p) => p.id === planId);
|
||
const baseId = plan?.scenarios.find((s) => s.isBase)?.id ?? plan?.scenarios[0]?.id;
|
||
if (baseId && detail?.meta.id !== baseId) await loadDetail(baseId, true);
|
||
openPlanTab(planId, "actuals");
|
||
}
|
||
|
||
async function handleDeletePlan(id: string) {
|
||
const plan = plans.find((p) => p.id === id);
|
||
const ok = await confirm({
|
||
title: "Plan löschen?",
|
||
message: `«${plan?.name ?? "Plan"}» wird mit allen Szenarien unwiderruflich gelöscht.`,
|
||
confirmLabel: "Endgültig löschen",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
await api.delete(`/api/plans/${id}`);
|
||
toast("success", "Plan gelöscht.");
|
||
const rest = await loadPlans();
|
||
if (!rest.some((p) => p.scenarios.some((s) => s.id === selectedScenarioId))) {
|
||
setSelectedScenarioId(null);
|
||
}
|
||
}
|
||
|
||
async function handleDeleteScenario(s: ScenarioMeta) {
|
||
const ok = await confirm({
|
||
title: "Szenario löschen?",
|
||
message: `«${s.name}» wird unwiderruflich gelöscht. Untergeordnete Szenarien bleiben bestehen.`,
|
||
confirmLabel: "Endgültig löschen",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api.delete(`/api/scenarios/${s.id}`);
|
||
toast("success", "Szenario gelöscht.");
|
||
} catch (e) {
|
||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||
return;
|
||
}
|
||
await loadPlans();
|
||
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
|
||
}
|
||
|
||
async function handleCreateDemo() {
|
||
setCreatingDemo(true);
|
||
try {
|
||
const sid = await createDemoPlan();
|
||
await loadPlans();
|
||
setShowNewPlanChooser(false);
|
||
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
|
||
openScenario(sid);
|
||
toast("success", "Beispielplan angelegt – die Tour zeigt dir gleich die wichtigsten Stellen.");
|
||
} catch (e) {
|
||
toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden.");
|
||
} finally {
|
||
setCreatingDemo(false);
|
||
}
|
||
}
|
||
|
||
const activePlan =
|
||
plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ??
|
||
(planNav ? plans.find((p) => p.id === planNav.planId) ?? null : null);
|
||
|
||
// Plan-Sicht und Ist-Sicht in einem Zug. Ohne erfasste Ist-Werte bleibt `actual` null und
|
||
// die Oberflaeche verhaelt sich exakt wie bisher.
|
||
const views = useMemo(
|
||
() =>
|
||
detail ? buildViews(detail.plan, detail.actuals ?? [], detail.elementOrigins ?? []) : null,
|
||
[detail]
|
||
);
|
||
|
||
// Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario).
|
||
const paletteActions = useMemo<PaletteAction[]>(() => {
|
||
const base: PaletteAction[] = [
|
||
{ id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowNewPlanChooser(true) },
|
||
{ id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setPlanNav(null); setShowSpec(false); setShowSystemParams(false); } },
|
||
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); setPlanNav(null); } },
|
||
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); setPlanNav(null); } },
|
||
];
|
||
if (detail && selectedScenarioId) {
|
||
base.unshift(
|
||
{ id: "a-charts", label: "Grafiken öffnen", hint: "Aktion", run: () => setShowCharts(true) },
|
||
{ id: "a-mc", label: "Monte-Carlo-Simulation", hint: "Aktion", run: () => setShowMonteCarlo(true) },
|
||
{ id: "a-sens", label: "Einflussfaktoren berechnen", hint: "Aktion", run: () => setShowSensitivity(true) },
|
||
{ id: "a-traces", label: "Rechenwege anzeigen", hint: "Aktion", run: () => setShowPlanTraces(true) },
|
||
{ id: "a-copy", label: "Neues Szenario aus diesem", hint: "Aktion", run: () => setCopyFrom(detail.meta) }
|
||
);
|
||
}
|
||
return base;
|
||
}, [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={() => setShowNewPlanChooser(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-8 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>
|
||
{/* Der Baum erscheint nur aufgeklappt -- alle Szenarien gleich eingerückt. */}
|
||
{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-8 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;
|
||
|
||
return (
|
||
<div className="flex min-h-screen w-full">
|
||
<aside className="hidden w-64 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
|
||
|
||
{sidebarOpen && (
|
||
<div className="fixed inset-0 z-40 lg:hidden">
|
||
<div className="ui-fade absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
|
||
<aside className="ui-slide-in absolute left-0 top-0 h-full w-72 border-r border-border bg-surface shadow-xl">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSidebarOpen(false)}
|
||
aria-label="Menü schliessen"
|
||
className="absolute right-2 top-3 rounded-md p-1.5 text-faint hover:bg-surface-2"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
{sidebar}
|
||
</aside>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex min-w-0 flex-1 flex-col">
|
||
<header className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSidebarOpen(true)}
|
||
aria-label="Menü öffnen"
|
||
className="rounded-lg border border-border p-2 text-muted lg:hidden"
|
||
>
|
||
<Menu className="h-4 w-4" />
|
||
</button>
|
||
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
|
||
{showSystemParams
|
||
? "Systemparameter"
|
||
: showSpec
|
||
? "So rechnet FPT"
|
||
: planNav
|
||
? `${activePlan?.name ?? "Plan"} · ${
|
||
{ dashboard: "Dashboard", scenarios: "Szenarien", actuals: "Effektive Werte", analyses: "Analysen", reports: "Berichte" }[
|
||
planNav.tab
|
||
]
|
||
}`
|
||
: selectedScenarioId && detail
|
||
? `${detail.meta.planName} · ${detail.meta.name}`
|
||
: "Übersicht"}
|
||
</h1>
|
||
<ProfileMenu username={username} />
|
||
</header>
|
||
|
||
<main className="flex-1 px-4 py-6 lg:px-8">
|
||
{showSystemParams && <SystemParametersView />}
|
||
|
||
{showSpec && <SpecView anchor={specAnchor} />}
|
||
|
||
{!showSpec && !showSystemParams && loading && !planNav && <PlanSkeleton />}
|
||
|
||
{/* Plan-Ebene: Dashboard / Szenarien / Analysen */}
|
||
{!showSpec && !showSystemParams && planNav?.tab === "dashboard" && (
|
||
<PlanDashboardView planId={planNav.planId} />
|
||
)}
|
||
{!showSpec && !showSystemParams && planNav?.tab === "scenarios" && (
|
||
<ScenarioListView
|
||
planId={planNav.planId}
|
||
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);
|
||
}}
|
||
/>
|
||
)}
|
||
{!showSpec && !showSystemParams && planNav?.tab === "actuals" && activePlan && (
|
||
detail && detail.meta.planId === planNav.planId ? (
|
||
<ActualsDialog
|
||
key={planNav.planId}
|
||
embedded
|
||
planId={planNav.planId}
|
||
planName={activePlan.name}
|
||
scenarioMetas={activePlan.scenarios}
|
||
initial={{ id: detail.meta.id, name: detail.meta.name, isBase: detail.meta.isBase, plan: detail.plan }}
|
||
onClose={() => {}}
|
||
onChanged={refreshCurrent}
|
||
/>
|
||
) : (
|
||
<p className="text-sm text-muted">Wird geladen…</p>
|
||
)
|
||
)}
|
||
|
||
{!showSpec && !showSystemParams && planNav?.tab === "analyses" && (
|
||
<AnalysesView
|
||
planId={planNav.planId}
|
||
onLaunch={(kind) => void launchTool(planNav.planId, kind)}
|
||
onOpenSaved={(id) => setSavedAnalysisId(id)}
|
||
/>
|
||
)}
|
||
|
||
{!showSpec && !showSystemParams && planNav?.tab === "reports" && activePlan && (
|
||
<ReportsView
|
||
planId={planNav.planId}
|
||
scenarios={activePlan.scenarios.map((s) => ({ id: s.id, name: s.name, isBase: s.isBase }))}
|
||
hasActuals={(activePlan._count?.actuals ?? 0) > 0}
|
||
/>
|
||
)}
|
||
|
||
{!showSpec && !showSystemParams && !loading && !planNav && selectedScenarioId === null && (
|
||
<DashboardHome
|
||
username={username}
|
||
plans={plans}
|
||
creatingDemo={creatingDemo}
|
||
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
|
||
onCreateGuided={() => setShowWizard(true)}
|
||
onCreateChooser={() => setShowNewPlanChooser(true)}
|
||
onCreateDemo={handleCreateDemo}
|
||
onDelete={handleDeletePlan}
|
||
/>
|
||
)}
|
||
|
||
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
||
<div className="flex flex-col gap-6">
|
||
{/* Funktions-Buttons der Szenario-Ansicht. Grafiken/Effektive Werte/Live-Sim/
|
||
Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über
|
||
die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen
|
||
Szenario-Aktionen plus Tour und CSV-Export. */}
|
||
<div className="flex flex-wrap items-center gap-2" data-tour="toolbar">
|
||
{detail.plan.phases.length > 0 && (
|
||
<Button variant="secondary" onClick={() => setShowHistory(true)}>
|
||
<History className="h-4 w-4" />
|
||
Änderungshistorie
|
||
</Button>
|
||
)}
|
||
{detail.plan.phases.length > 0 && (
|
||
<Button variant="secondary" onClick={() => setShowTour(true)} title="Kurze Tour durch die Ansicht">
|
||
<HelpCircle className="h-4 w-4" />
|
||
Tour
|
||
</Button>
|
||
)}
|
||
<Button variant="secondary" onClick={() => setCopyFrom(detail.meta)}>
|
||
<Copy className="h-4 w-4" />
|
||
Neues Szenario aus diesem
|
||
</Button>
|
||
{detail.plan.phases.length > 0 && (
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => setShowPlanTraces(true)}
|
||
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
|
||
>
|
||
<BookOpen className="h-4 w-4" />
|
||
Rechenwege
|
||
</Button>
|
||
)}
|
||
{detail.plan.phases.length > 0 && (
|
||
<a
|
||
href={`/api/scenarios/${detail.meta.id}/export`}
|
||
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium text-muted transition-all duration-150 hover:bg-surface-2 hover:text-fg"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
CSV-Export
|
||
</a>
|
||
)}
|
||
{!detail.meta.isBase && (
|
||
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
|
||
<Trash2 className="h-4 w-4" />
|
||
Szenario löschen
|
||
</Button>
|
||
)}
|
||
{diff && detail.base && (
|
||
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
|
||
<GitBranch className="h-3.5 w-3.5" />
|
||
{diff.total === 0
|
||
? "Unverändert gegenüber der Vorlage"
|
||
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<PlanView
|
||
plan={detail.plan}
|
||
computed={detail.computed}
|
||
actualComputed={views?.actual ?? null}
|
||
actualYears={views?.actualYears ?? []}
|
||
diff={diff}
|
||
onChanged={refreshCurrent}
|
||
onOpenSpec={openSpecAt}
|
||
onOpenSensitivity={() => setShowSensitivity(true)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
|
||
{showNewPlanChooser && (
|
||
<NewPlanChooser
|
||
creatingDemo={creatingDemo}
|
||
onGuided={() => {
|
||
setShowNewPlanChooser(false);
|
||
setShowWizard(true);
|
||
}}
|
||
onEmpty={() => {
|
||
setShowNewPlanChooser(false);
|
||
setShowEmptyPlanDialog(true);
|
||
}}
|
||
onDemo={handleCreateDemo}
|
||
onClose={() => setShowNewPlanChooser(false)}
|
||
/>
|
||
)}
|
||
|
||
{showWizard && (
|
||
<PlanWizard
|
||
onCreated={async (sid) => {
|
||
setShowWizard(false);
|
||
await loadPlans();
|
||
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
|
||
openScenario(sid);
|
||
toast("success", "Plan erstellt – die Tour zeigt dir gleich die wichtigsten Stellen.");
|
||
}}
|
||
onClose={() => setShowWizard(false)}
|
||
/>
|
||
)}
|
||
|
||
{showEmptyPlanDialog && (
|
||
<PlanDialog
|
||
onCreate={async (name, profile) => {
|
||
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
|
||
"/api/plans",
|
||
{ name, ...profile }
|
||
);
|
||
setShowEmptyPlanDialog(false);
|
||
await loadPlans();
|
||
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
|
||
openScenario(scenario.id);
|
||
toast("success", "Plan erstellt – die Tour zeigt dir gleich die wichtigsten Stellen.");
|
||
}}
|
||
onClose={() => setShowEmptyPlanDialog(false)}
|
||
/>
|
||
)}
|
||
|
||
{showCharts && detail && (
|
||
<ChartsDialog onClose={() => setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}>
|
||
<Dashboard
|
||
plan={detail.plan}
|
||
planId={detail.meta.planId}
|
||
computed={detail.computed}
|
||
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
|
||
actuals={detail.actuals ?? []}
|
||
origins={detail.elementOrigins ?? []}
|
||
/>
|
||
</ChartsDialog>
|
||
)}
|
||
|
||
{showMonteCarlo && detail && (
|
||
<MonteCarloDialog
|
||
plan={detail.plan}
|
||
computed={detail.computed}
|
||
meta={detail.meta}
|
||
scenarios={activePlan?.scenarios ?? [detail.meta]}
|
||
actuals={detail.actuals ?? []}
|
||
origins={detail.elementOrigins ?? []}
|
||
onClose={() => setShowMonteCarlo(false)}
|
||
/>
|
||
)}
|
||
|
||
{showSensitivity && detail && (
|
||
<SensitivityDialog
|
||
plan={detail.plan}
|
||
planId={detail.meta.planId}
|
||
actuals={detail.actuals ?? []}
|
||
origins={detail.elementOrigins ?? []}
|
||
onClose={() => setShowSensitivity(false)}
|
||
/>
|
||
)}
|
||
|
||
{showLiveSim && detail && (
|
||
<LiveSimDialog
|
||
plan={detail.plan}
|
||
actuals={detail.actuals ?? []}
|
||
origins={detail.elementOrigins ?? []}
|
||
onClose={() => setShowLiveSim(false)}
|
||
/>
|
||
)}
|
||
|
||
{savedAnalysisId && planNav && (
|
||
<SavedAnalysisView
|
||
planId={planNav.planId}
|
||
analysisId={savedAnalysisId}
|
||
onClose={() => setSavedAnalysisId(null)}
|
||
/>
|
||
)}
|
||
|
||
{showActuals && detail && activePlan && (
|
||
<ActualsDialog
|
||
planId={detail.meta.planId}
|
||
planName={detail.meta.planName}
|
||
scenarioMetas={activePlan.scenarios}
|
||
initial={{ id: detail.meta.id, name: detail.meta.name, isBase: detail.meta.isBase, plan: detail.plan }}
|
||
onClose={() => setShowActuals(false)}
|
||
onChanged={refreshCurrent}
|
||
/>
|
||
)}
|
||
|
||
{showHistory && detail && (
|
||
<VersionHistoryDialog
|
||
scenarioId={detail.meta.id}
|
||
scenarioName={detail.meta.name}
|
||
onClose={() => setShowHistory(false)}
|
||
onRestored={refreshCurrent}
|
||
/>
|
||
)}
|
||
|
||
{showPlanTraces && detail && (
|
||
<PlanTraceDialog
|
||
computed={computePlan(detail.plan, undefined, { explain: true })}
|
||
onClose={() => setShowPlanTraces(false)}
|
||
onOpenSpec={openSpecAt}
|
||
/>
|
||
)}
|
||
|
||
{copyFrom && (
|
||
<CopyScenarioDialog
|
||
source={copyFrom}
|
||
onClose={() => setCopyFrom(null)}
|
||
onCreate={async (name) => {
|
||
const { scenarioId } = await api.post<{ scenarioId: string }>(
|
||
`/api/scenarios/${copyFrom.id}/copy`,
|
||
{ name }
|
||
);
|
||
setCopyFrom(null);
|
||
await loadPlans();
|
||
openScenario(scenarioId);
|
||
toast("success", "Szenario erstellt.");
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<CommandPalette
|
||
open={showPalette}
|
||
onClose={() => setShowPalette(false)}
|
||
plans={plans}
|
||
actions={paletteActions}
|
||
onOpenScenario={openScenario}
|
||
/>
|
||
|
||
{showTour && <Tour onClose={() => setShowTour(false)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Auswahl beim Anlegen eines Plans: geführt (empfohlen), leer oder Beispielplan.
|
||
function NewPlanChooser({
|
||
creatingDemo,
|
||
onGuided,
|
||
onEmpty,
|
||
onDemo,
|
||
onClose,
|
||
}: {
|
||
creatingDemo: boolean;
|
||
onGuided: () => void;
|
||
onEmpty: () => void;
|
||
onDemo: () => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const options = [
|
||
{
|
||
icon: <Sparkles className="h-5 w-5" />,
|
||
title: "Geführt erstellen",
|
||
badge: "Empfohlen",
|
||
text: "Der Assistent fragt Schritt für Schritt – in fünf Minuten steht dein Grundgerüst.",
|
||
run: onGuided,
|
||
},
|
||
{
|
||
icon: <Plus className="h-5 w-5" />,
|
||
title: "Leer starten",
|
||
badge: null,
|
||
text: "Nur das Grundprofil erfassen, alles Weitere selbst aufbauen – für geübte Nutzer.",
|
||
run: onEmpty,
|
||
},
|
||
{
|
||
icon: <PiggyBank className="h-5 w-5" />,
|
||
title: creatingDemo ? "Beispielplan wird angelegt…" : "Beispielplan ansehen",
|
||
badge: null,
|
||
text: "Ein fertig ausgefüllter, fiktiver Plan zum Erkunden – so siehst du das Tool zuerst in Aktion.",
|
||
run: onDemo,
|
||
},
|
||
];
|
||
return (
|
||
<Modal title="Neuer Plan" onClose={onClose}>
|
||
<div className="flex flex-col gap-2">
|
||
{options.map((o) => (
|
||
<button
|
||
key={o.title}
|
||
type="button"
|
||
disabled={creatingDemo}
|
||
onClick={o.run}
|
||
className="flex items-start gap-3 rounded-xl border border-border p-3.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/30 disabled:opacity-60"
|
||
>
|
||
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
|
||
{o.icon}
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span className="flex items-center gap-2 text-sm font-semibold text-fg">
|
||
{o.title}
|
||
{o.badge && (
|
||
<span className="rounded bg-accent px-1.5 py-0.5 text-[9px] font-semibold uppercase text-accent-fg">{o.badge}</span>
|
||
)}
|
||
</span>
|
||
<span className="mt-0.5 block text-xs text-muted">{o.text}</span>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// Breiter Dialog für den Analyse-Bereich (Grafiken).
|
||
function ChartsDialog({
|
||
title,
|
||
children,
|
||
onClose,
|
||
}: {
|
||
title: string;
|
||
children: React.ReactNode;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||
<div
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="ui-pop flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||
<BarChart3 className="h-5 w-5 text-accent" /> {title}
|
||
</h2>
|
||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Rekursiver Szenario-Baum: Kinder werden eingerückt, damit Sub-Szenarien sichtbar sind.
|
||
// Die Aktions-Icons sind immer leicht sichtbar (nicht nur bei Hover) -- auf Touch-Geräten
|
||
// gibt es kein Hover.
|
||
function ScenarioTree({
|
||
scenarios,
|
||
selectedId,
|
||
onSelect,
|
||
onCopy,
|
||
onDelete,
|
||
}: {
|
||
scenarios: ScenarioMeta[];
|
||
selectedId: string | null;
|
||
onSelect: (id: string) => void;
|
||
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));
|
||
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 ${
|
||
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||
}`}
|
||
>
|
||
<button type="button" onClick={() => onSelect(s.id)} className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
|
||
{s.isBase ? <PiggyBank className="h-3.5 w-3.5 shrink-0" /> : <GitBranch className="h-3.5 w-3.5 shrink-0" />}
|
||
<span className="min-w-0 flex-1 truncate" title={s.name}>{s.name}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Kopie erstellen"
|
||
title="Neues Szenario aus diesem"
|
||
onClick={() => onCopy(s)}
|
||
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-accent-soft hover:text-accent group-hover:opacity-100"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</button>
|
||
{!s.isBase && (
|
||
<button
|
||
type="button"
|
||
aria-label="Szenario löschen"
|
||
onClick={() => onDelete(s)}
|
||
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>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Startansicht: Begrüssung + Plan-Kacheln (Klick öffnet das Basisszenario).
|
||
function DashboardHome({
|
||
username,
|
||
plans,
|
||
creatingDemo,
|
||
onOpenPlan,
|
||
onCreateGuided,
|
||
onCreateChooser,
|
||
onCreateDemo,
|
||
onDelete,
|
||
}: {
|
||
username: string;
|
||
plans: PlanListItem[];
|
||
creatingDemo: boolean;
|
||
onOpenPlan: (planId: string) => void;
|
||
onCreateGuided: () => void;
|
||
onCreateChooser: () => void;
|
||
onCreateDemo: () => void;
|
||
onDelete: (id: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
<div>
|
||
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
|
||
<p className="mt-1 text-sm text-muted">
|
||
Wähle einen Plan oder erstelle einen neuen. Jeder Plan enthält ein Basisszenario und beliebig
|
||
viele Varianten davon.
|
||
</p>
|
||
</div>
|
||
|
||
{plans.length === 0 ? (
|
||
<EmptyState
|
||
icon={<Sparkles className="h-6 w-6" />}
|
||
title="Deine erste Finanzplanung"
|
||
text="Am schnellsten geht es geführt: Der Assistent fragt dich Schritt für Schritt und baut dein Grundgerüst – in rund fünf Minuten. Oder schau dir zuerst den Beispielplan an."
|
||
>
|
||
<Button onClick={onCreateGuided}>
|
||
<Sparkles className="h-4 w-4" /> Geführt starten
|
||
</Button>
|
||
<Button variant="secondary" onClick={onCreateDemo} disabled={creatingDemo}>
|
||
<PiggyBank className="h-4 w-4" /> {creatingDemo ? "Wird angelegt…" : "Beispielplan ansehen"}
|
||
</Button>
|
||
</EmptyState>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||
{plans.map((p) => {
|
||
const others = p.scenarios.length - 1;
|
||
const persons = (p.persons ?? [])
|
||
.map((pe) => pe.name || (pe.role === "PERSON_A" ? "Person A" : "Person B"))
|
||
.join(" · ");
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
|
||
onClick={() => onOpenPlan(p.id)}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
|
||
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate font-medium text-fg">{p.name}</div>
|
||
<div className="text-xs text-muted">
|
||
{persons || "Basisszenario"}
|
||
{others > 0 ? ` · +${others} Variante${others === 1 ? "" : "n"}` : ""}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="Plan löschen"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDelete(p.id);
|
||
}}
|
||
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 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>
|
||
{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.analyses > 0 && <span>{p._count.analyses} Analyse{p._count.analyses === 1 ? "" : "n"}</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onCreateChooser}
|
||
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-accent bg-accent-soft text-sm font-medium text-accent-soft-fg transition-colors hover:bg-accent-soft"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
Neuer Plan
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlanDialog({
|
||
onCreate,
|
||
onClose,
|
||
}: {
|
||
onCreate: (name: string, profile: ProfileDraft) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const [name, setName] = useState("Meine Planung");
|
||
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
return (
|
||
<Modal title="Neuen Plan erstellen" onClose={onClose}>
|
||
<p className="text-xs text-muted">
|
||
Es wird automatisch ein <strong className="text-fg">Basisszenario</strong> angelegt. Weitere
|
||
Szenarien entstehen später als Kopien davon.
|
||
</p>
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
|
||
<input
|
||
autoFocus
|
||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="Name des Plans"
|
||
/>
|
||
</div>
|
||
<PlanProfileFields draft={draft} onChange={setDraft} />
|
||
<div className="flex gap-2">
|
||
<Button
|
||
disabled={saving}
|
||
onClick={() => {
|
||
setSaving(true);
|
||
onCreate(name.trim() || "Plan", draft);
|
||
}}
|
||
>
|
||
{saving ? "…" : "Erstellen"}
|
||
</Button>
|
||
<Button variant="secondary" onClick={onClose}>
|
||
Abbrechen
|
||
</Button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function CopyScenarioDialog({
|
||
source,
|
||
onCreate,
|
||
onClose,
|
||
}: {
|
||
source: ScenarioMeta;
|
||
onCreate: (name: string) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const [name, setName] = useState(`${source.name} – Variante`);
|
||
const [saving, setSaving] = useState(false);
|
||
return (
|
||
<Modal title="Neues Szenario" onClose={onClose}>
|
||
<p className="text-xs text-muted">
|
||
Vollständige Kopie von <strong className="text-fg">{source.name}</strong>. Änderungen darin
|
||
werden anschliessend farblich hervorgehoben.
|
||
</p>
|
||
<input
|
||
autoFocus
|
||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="Name des Szenarios"
|
||
/>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
disabled={saving}
|
||
onClick={() => {
|
||
setSaving(true);
|
||
onCreate(name.trim() || "Szenario");
|
||
}}
|
||
>
|
||
{saving ? "…" : "Erstellen"}
|
||
</Button>
|
||
<Button variant="secondary" onClick={onClose}>
|
||
Abbrechen
|
||
</Button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|