4791dccf93
Deploy App / deploy (push) Successful in 57s
Roadmap Nr. 43 (Detailansichten) und Nr. 41 (Berechnungslogiken offenlegen).
Systemparameter-Ansicht:
- SYSTEM_PARAMETERS in constants.ts: Wert, Bedeutung, Herleitung, Quelle,
Stand -- aus derselben Datei, aus der gerechnet wird
Detailansichten (nur lesen, per Expand-Icon):
- Element: Verlauf ueber ALLE Planjahre (neu ElementPhaseComputed.yearly),
bei Immobilien Verkehrswert/Restschuld/Eigenkapital getrennt
- Phase: Vermoegensaufteilung + zwei Wasserfaelle
Zwei getrennte Wasserfaelle (WealthBridge / CashBridge):
- Sparraten, Amortisationen und Investitionen sind UMBUCHUNGEN und erscheinen
nur im Cash-Wasserfall -- als Vermoegensabgang gezeichnet wuerden sie einen
Verlust vortaeuschen, den es nicht gibt
- PK-Beitraege dagegen sind ein echter Vermoegenszugang (belasten kein Cash),
Verrentung ein echter Abgang (Kapital verlaesst die Bilanz)
- residual als Kontrollgroesse fuer die Vollstaendigkeit der Zerlegung
Rechenweg-Protokoll, vollstaendige Abdeckung:
- computePlan(plan, sample?, { explain }) protokolliert die Schritte, die es
ohnehin ausfuehrt -- die Erklaerung IST die Rechnung, statt einer zweiten
Formel-Implementierung im UI, die still abdriften koennte
- standardmaessig aus (Monte Carlo bleibt unberuehrt)
- Arithmetik nicht umgestellt: Zwischengroessen werden als Differenz
abgeleitet, damit die 43 Golden Tests bitgleich bleiben
- jeder Trace verlinkt in die SPEZIFIKATION; ein Test prueft gegen die echte
Datei, dass alle Verweise eine existierende Ueberschrift treffen
SPEZIFIKATION auf 0.11: neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21.
12 Tests ergaenzt (80 -> 92). Keine DB-Aenderung.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
729 lines
27 KiB
TypeScript
729 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import {
|
||
BarChart3,
|
||
BookOpen,
|
||
Copy,
|
||
Dices,
|
||
FileText,
|
||
FolderKanban,
|
||
GitBranch,
|
||
LayoutDashboard,
|
||
Menu,
|
||
PiggyBank,
|
||
Plus,
|
||
SlidersHorizontal,
|
||
Tornado,
|
||
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 { 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 { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||
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 };
|
||
}
|
||
|
||
export function AppShell({ username }: { username: string }) {
|
||
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 [showNewPlan, setShowNewPlan] = 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 [showSystemParams, setShowSystemParams] = useState(false);
|
||
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
||
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
|
||
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
|
||
|
||
function openSpecAt(anchor: string) {
|
||
setSpecAnchor(anchor);
|
||
setShowSpec(true);
|
||
setShowSystemParams(false);
|
||
setSelectedScenarioId(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 Popups) erhalten bleibt.
|
||
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
|
||
if (!silent) setLoading(true);
|
||
try {
|
||
setDetail(await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`));
|
||
} 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);
|
||
} else {
|
||
setDetail(null);
|
||
}
|
||
}, [selectedScenarioId, loadDetail]);
|
||
|
||
function refreshCurrent() {
|
||
if (selectedScenarioId) loadDetail(selectedScenarioId, true);
|
||
}
|
||
|
||
function openScenario(id: string) {
|
||
setSelectedScenarioId(id);
|
||
setShowSpec(false);
|
||
setShowSystemParams(false);
|
||
setSidebarOpen(false);
|
||
}
|
||
|
||
async function handleDeletePlan(id: string) {
|
||
if (!confirm("Diesen Plan mit ALLEN Szenarien wirklich loeschen?")) return;
|
||
await api.delete(`/api/plans/${id}`);
|
||
const rest = await loadPlans();
|
||
if (!rest.some((p) => p.scenarios.some((s) => s.id === selectedScenarioId))) {
|
||
setSelectedScenarioId(null);
|
||
}
|
||
}
|
||
|
||
async function handleDeleteScenario(s: ScenarioMeta) {
|
||
if (!confirm(`Szenario "${s.name}" wirklich loeschen?`)) return;
|
||
try {
|
||
await api.delete(`/api/scenarios/${s.id}`);
|
||
} catch (e) {
|
||
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
|
||
return;
|
||
}
|
||
await loadPlans();
|
||
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
|
||
}
|
||
|
||
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
|
||
|
||
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>
|
||
</div>
|
||
|
||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedScenarioId(null);
|
||
setShowSpec(false);
|
||
setShowSystemParams(false);
|
||
setSidebarOpen(false);
|
||
}}
|
||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
||
selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||
}`}
|
||
>
|
||
<LayoutDashboard className="h-4 w-4" />
|
||
Uebersicht
|
||
</button>
|
||
|
||
<div className="mt-4 flex items-center justify-between px-3">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Plaene</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowNewPlan(true)}
|
||
aria-label="Neuen Plan erstellen"
|
||
className="rounded-md p-1 text-accent 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 Plaene.</p>}
|
||
|
||
{plans.map((p) => (
|
||
<div key={p.id} className="mb-1">
|
||
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
|
||
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-faint" />
|
||
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
|
||
<button
|
||
type="button"
|
||
aria-label="Plan loeschen"
|
||
onClick={() => handleDeletePlan(p.id)}
|
||
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<ScenarioTree
|
||
scenarios={p.scenarios}
|
||
parentId={null}
|
||
depth={0}
|
||
selectedId={selectedScenarioId}
|
||
onSelect={openScenario}
|
||
onCopy={setCopyFrom}
|
||
onDelete={handleDeleteScenario}
|
||
/>
|
||
</div>
|
||
))}
|
||
|
||
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowSystemParams(true);
|
||
setShowSpec(false);
|
||
setSelectedScenarioId(null);
|
||
setSidebarOpen(false);
|
||
}}
|
||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
|
||
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||
}`}
|
||
>
|
||
<SlidersHorizontal className="h-4 w-4 shrink-0" />
|
||
Systemparameter
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowSpec(true);
|
||
setSpecAnchor(null);
|
||
setShowSystemParams(false);
|
||
setSelectedScenarioId(null);
|
||
setSidebarOpen(false);
|
||
}}
|
||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
|
||
showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||
}`}
|
||
>
|
||
<FileText className="h-4 w-4 shrink-0" />
|
||
SPEZIFIKATION
|
||
</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="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
|
||
<aside className="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="Menue 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="Menue oeffnen"
|
||
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
|
||
? "Spezifikation"
|
||
: detail
|
||
? `${detail.meta.planName} · ${detail.meta.name}`
|
||
: "Uebersicht"}
|
||
</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 && <p className="text-sm text-muted">Laedt…</p>}
|
||
|
||
{!showSpec && !showSystemParams && !loading && selectedScenarioId === null && (
|
||
<DashboardHome
|
||
username={username}
|
||
plans={plans}
|
||
onSelect={openScenario}
|
||
onCreate={() => setShowNewPlan(true)}
|
||
onDelete={handleDeletePlan}
|
||
/>
|
||
)}
|
||
|
||
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
||
<div className="flex flex-col gap-6">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setCopyFrom(detail.meta)}
|
||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||
>
|
||
<Copy className="h-4 w-4" />
|
||
Neues Szenario aus diesem
|
||
</button>
|
||
{!detail.meta.isBase && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDeleteScenario(detail.meta)}
|
||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
Szenario loeschen
|
||
</button>
|
||
)}
|
||
{detail.plan.phases.length > 0 && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCharts(true)}
|
||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||
>
|
||
<BarChart3 className="h-4 w-4" />
|
||
Grafiken
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowMonteCarlo(true)}
|
||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||
>
|
||
<Dices className="h-4 w-4" />
|
||
Monte-Carlo-Simulation
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowSensitivity(true)}
|
||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||
>
|
||
<Tornado className="h-4 w-4" />
|
||
Einflussfaktoren berechnen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowPlanTraces(true)}
|
||
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
|
||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||
>
|
||
<BookOpen className="h-4 w-4" />
|
||
Rechenwege
|
||
</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
|
||
? "Unveraendert gegenueber der Vorlage"
|
||
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenueber der Vorlage`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<PlanView
|
||
plan={detail.plan}
|
||
computed={detail.computed}
|
||
diff={diff}
|
||
onChanged={refreshCurrent}
|
||
onOpenSpec={openSpecAt}
|
||
/>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
|
||
{showNewPlan && (
|
||
<PlanDialog
|
||
onCreate={async (name, profile) => {
|
||
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
|
||
"/api/plans",
|
||
{ name, ...profile }
|
||
);
|
||
setShowNewPlan(false);
|
||
await loadPlans();
|
||
openScenario(scenario.id);
|
||
}}
|
||
onClose={() => setShowNewPlan(false)}
|
||
/>
|
||
)}
|
||
|
||
{showCharts && detail && (
|
||
<ChartsDialog onClose={() => setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}>
|
||
<Dashboard
|
||
plan={detail.plan}
|
||
computed={detail.computed}
|
||
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
|
||
/>
|
||
</ChartsDialog>
|
||
)}
|
||
|
||
{showMonteCarlo && detail && (
|
||
<MonteCarloDialog
|
||
plan={detail.plan}
|
||
computed={detail.computed}
|
||
meta={detail.meta}
|
||
scenarios={activePlan?.scenarios ?? [detail.meta]}
|
||
onClose={() => setShowMonteCarlo(false)}
|
||
/>
|
||
)}
|
||
|
||
{showSensitivity && detail && (
|
||
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
|
||
)}
|
||
|
||
{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);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Breiter Dialog fuer den Analyse-Bereich (Grafiken).
|
||
function ChartsDialog({
|
||
title,
|
||
children,
|
||
onClose,
|
||
}: {
|
||
title: string;
|
||
children: React.ReactNode;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<div className="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="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 eingerueckt, damit Sub-Szenarien sichtbar sind.
|
||
function ScenarioTree({
|
||
scenarios,
|
||
parentId,
|
||
depth,
|
||
selectedId,
|
||
onSelect,
|
||
onCopy,
|
||
onDelete,
|
||
}: {
|
||
scenarios: ScenarioMeta[];
|
||
parentId: string | null;
|
||
depth: number;
|
||
selectedId: string | null;
|
||
onSelect: (id: string) => void;
|
||
onCopy: (s: ScenarioMeta) => void;
|
||
onDelete: (s: ScenarioMeta) => void;
|
||
}) {
|
||
const level = scenarios.filter((s) => (s.parentScenarioId ?? null) === parentId);
|
||
if (level.length === 0) return null;
|
||
return (
|
||
<>
|
||
{level.map((s) => (
|
||
<div key={s.id}>
|
||
<div
|
||
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm ${
|
||
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||
}`}
|
||
style={{ paddingLeft: `${12 + depth * 14}px` }}
|
||
>
|
||
<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-0 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 loeschen"
|
||
onClick={() => onDelete(s)}
|
||
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<ScenarioTree
|
||
scenarios={scenarios}
|
||
parentId={s.id}
|
||
depth={depth + 1}
|
||
selectedId={selectedId}
|
||
onSelect={onSelect}
|
||
onCopy={onCopy}
|
||
onDelete={onDelete}
|
||
/>
|
||
</div>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Startansicht: Begruessung + Plan-Kacheln (Klick oeffnet das Basisszenario).
|
||
function DashboardHome({
|
||
username,
|
||
plans,
|
||
onSelect,
|
||
onCreate,
|
||
onDelete,
|
||
}: {
|
||
username: string;
|
||
plans: PlanListItem[];
|
||
onSelect: (scenarioId: string) => void;
|
||
onCreate: () => 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">
|
||
Waehlen Sie einen Plan oder erstellen Sie einen neuen. Jeder Plan enthaelt ein Basisszenario
|
||
und beliebig viele Varianten davon.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||
{plans.map((p) => {
|
||
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
|
||
const others = p.scenarios.length - 1;
|
||
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={() => base && onSelect(base.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">
|
||
Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="Plan loeschen"
|
||
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>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onCreate}
|
||
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 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 (
|
||
<div className="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="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||
>
|
||
<h2 className="text-base font-semibold text-fg">Neuen Plan erstellen</h2>
|
||
<p className="text-xs text-muted">
|
||
Es wird automatisch ein <strong className="text-fg">Basisszenario</strong> angelegt. Weitere
|
||
Szenarien entstehen spaeter 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
|
||
type="button"
|
||
disabled={saving}
|
||
onClick={() => {
|
||
setSaving(true);
|
||
onCreate(name.trim() || "Plan", draft);
|
||
}}
|
||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||
>
|
||
{saving ? "..." : "Erstellen"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||
<div
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||
>
|
||
<h2 className="text-base font-semibold text-fg">Neues Szenario</h2>
|
||
<p className="text-xs text-muted">
|
||
Vollstaendige Kopie von <strong className="text-fg">{source.name}</strong>. Aenderungen 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
|
||
type="button"
|
||
disabled={saving}
|
||
onClick={() => {
|
||
setSaving(true);
|
||
onCreate(name.trim() || "Szenario");
|
||
}}
|
||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||
>
|
||
{saving ? "..." : "Erstellen"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|