79fb2b9ca6
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1090 lines
43 KiB
TypeScript
1090 lines
43 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
BarChart3,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Copy,
|
||
FileText,
|
||
FileSpreadsheet,
|
||
FolderKanban,
|
||
CalendarClock,
|
||
GitBranch,
|
||
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 { 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 { 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 [showEmptyPlanDialog, setShowEmptyPlanDialog] = 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);
|
||
// 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:
|
||
// * 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();
|
||
// 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);
|
||
}
|
||
|
||
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: () => setShowEmptyPlanDialog(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={() => 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;
|
||
|
||
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}
|
||
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));
|
||
}}
|
||
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);
|
||
}}
|
||
/>
|
||
)}
|
||
{!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}
|
||
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
|
||
onCreatePlan={() => setShowEmptyPlanDialog(true)}
|
||
onDelete={handleDeletePlan}
|
||
/>
|
||
)}
|
||
|
||
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
||
<div className="flex flex-col gap-6">
|
||
{/* Nur noch, was NICHT zum Szenario-Inhalt gehört: das Löschen des Szenarios
|
||
und der Hinweis auf Abweichungen zur Vorlage. Die inhaltlichen Aktionen
|
||
(Historie, Tour, Kopie, Rechenwege, Export) sitzen seit 0.36 als Kachel im
|
||
Szenario-Screen -- dort, wo man sie braucht. */}
|
||
{(!detail.meta.isBase || (diff && detail.base)) && (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{!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)}
|
||
onOpenHistory={() => setShowHistory(true)}
|
||
onStartTour={() => setShowTour(true)}
|
||
onCopyScenario={() => setCopyFrom(detail.meta)}
|
||
onOpenTraces={() => setShowPlanTraces(true)}
|
||
exportHref={`/api/scenarios/${detail.meta.id}/export`}
|
||
/>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
|
||
|
||
|
||
{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>
|
||
);
|
||
}
|
||
|
||
// 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;
|
||
}) {
|
||
// 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}
|
||
style={{ paddingLeft: `${2.5 + depthOf(s) * 0.85}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"
|
||
}`}
|
||
>
|
||
<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,
|
||
onOpenPlan,
|
||
onCreatePlan,
|
||
onDelete,
|
||
}: {
|
||
username: string;
|
||
plans: PlanListItem[];
|
||
onOpenPlan: (planId: string) => void;
|
||
onCreatePlan: () => 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 ? (
|
||
/* Genau EIN Weg hinein. Bis 0.35 standen hier drei Knöpfe (geführt / Beispielplan /
|
||
leer) -- eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt. Der
|
||
Assistent führt jetzt IM Plan statt davor; die Tour zeigt vorab den Aufbau. */
|
||
<EmptyState
|
||
icon={<Sparkles className="h-6 w-6" />}
|
||
title="Deine erste Finanzplanung"
|
||
text="Ein paar Angaben zu dir – danach zeigt dir eine kurze Tour, wie FPT aufgebaut ist, und der Assistent führt dich Schritt für Schritt durch die Planung."
|
||
>
|
||
<Button onClick={onCreatePlan}>
|
||
<Plus className="h-4 w-4" /> Meinen ersten Finanzplan anlegen
|
||
</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 ? "" : "en"}</span>
|
||
{p.startYear && <span>ab {p.startYear}</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>
|
||
);
|
||
})}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onCreatePlan}
|
||
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>
|
||
);
|
||
}
|