UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
Deploy App / deploy (push) Successful in 1m1s

Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert
(103 Tests unveraendert gruen).

Paket A (Fundament):
- durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten,
  inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue)
- neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation,
  Bestaetigungs-Dialog statt window.confirm, Toasts statt alert,
  Skeleton-Loader, EmptyState
- eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent
- Micro-Interactions mit prefers-reduced-motion-Fallback

Paket B (Onboarding, Roadmap Nr. 10):
- gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person
  (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte
- Beispielplan mit einem Klick; Uebergaenge absichtlich offen
- interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar)
- abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente,
  fehlende Pensionsphase, Ruin -> Einflussfaktoren)

Paket C (Struktur):
- Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen;
  Matrix bleibt sichtbar, Zellklick wechselt den Inhalt
- Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht)
- Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert
- Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt
  SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen"
- Aktions-Icons ohne Hover sichtbar (Touch)

Paket D (Extras):
- Sparklines je Element-Zeile aus den 0.11-Verlaufswerten
- Befehls-Palette (Ctrl/Cmd+K)
- Ruin-Banner verlinkt auf die Einflussfaktoren

Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind
behoben -- npm run lint laeuft erstmals fehlerfrei.

SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24;
3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 07:40:42 +02:00
parent e1f74fca95
commit c2fb82b0de
60 changed files with 2915 additions and 1210 deletions
+411 -218
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
BarChart3,
BookOpen,
@@ -13,7 +13,9 @@ import {
Menu,
PiggyBank,
Plus,
Search,
SlidersHorizontal,
Sparkles,
Tornado,
Trash2,
X,
@@ -27,8 +29,21 @@ 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 { 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";
@@ -40,13 +55,31 @@ interface ScenarioDetail {
meta: ScenarioMeta & { planName: string };
}
// 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 [showNewPlan, setShowNewPlan] = 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);
@@ -54,6 +87,7 @@ export function AppShell({ username }: { username: string }) {
const [showSensitivity, setShowSensitivity] = useState(false);
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);
@@ -71,7 +105,7 @@ export function AppShell({ username }: { username: string }) {
}, []);
// silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert,
// damit die Scrollposition (z. B. nach dem Schliessen eines Popups) erhalten bleibt.
// 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 {
@@ -95,6 +129,18 @@ export function AppShell({ username }: { username: string }) {
}
}, [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);
}
@@ -107,8 +153,16 @@ export function AppShell({ username }: { username: string }) {
}
async function handleDeletePlan(id: string) {
if (!confirm("Diesen Plan mit ALLEN Szenarien wirklich loeschen?")) return;
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);
@@ -116,19 +170,61 @@ export function AppShell({ username }: { username: string }) {
}
async function handleDeleteScenario(s: ScenarioMeta) {
if (!confirm(`Szenario "${s.name}" wirklich loeschen?`)) return;
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) {
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
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);
openScenario(sid);
toast("success", "Beispielplan angelegt schau dich um!");
} 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)) ?? null;
// 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); setShowSpec(false); setShowSystemParams(false); } },
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); } },
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(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">
@@ -136,6 +232,15 @@ export function AppShell({ username }: { username: string }) {
<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">
@@ -147,26 +252,26 @@ export function AppShell({ username }: { username: string }) {
setShowSystemParams(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<LayoutDashboard className="h-4 w-4" />
Uebersicht
Übersicht
</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>
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Meine Pläne</span>
<button
type="button"
onClick={() => setShowNewPlan(true)}
onClick={() => setShowNewPlanChooser(true)}
aria-label="Neuen Plan erstellen"
className="rounded-md p-1 text-accent hover:bg-accent-soft"
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 Plaene.</p>}
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Pläne.</p>}
{plans.map((p) => (
<div key={p.id} className="mb-1">
@@ -175,9 +280,9 @@ export function AppShell({ username }: { username: string }) {
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
<button
type="button"
aria-label="Plan loeschen"
aria-label="Plan löschen"
onClick={() => handleDeletePlan(p.id)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
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>
@@ -195,21 +300,7 @@ export function AppShell({ username }: { username: string }) {
))}
<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>
<span className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">Wissen</span>
<button
type="button"
onClick={() => {
@@ -219,12 +310,27 @@ export function AppShell({ username }: { username: string }) {
setSelectedScenarioId(null);
setSidebarOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
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" />
SPEZIFIKATION
So rechnet FPT
</button>
<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 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>
@@ -239,12 +345,12 @@ export function AppShell({ username }: { username: string }) {
{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">
<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="Menue schliessen"
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" />
@@ -259,7 +365,7 @@ export function AppShell({ username }: { username: string }) {
<button
type="button"
onClick={() => setSidebarOpen(true)}
aria-label="Menue oeffnen"
aria-label="Menü öffnen"
className="rounded-lg border border-border p-2 text-muted lg:hidden"
>
<Menu className="h-4 w-4" />
@@ -268,10 +374,10 @@ export function AppShell({ username }: { username: string }) {
{showSystemParams
? "Systemparameter"
: showSpec
? "Spezifikation"
? "So rechnet FPT"
: detail
? `${detail.meta.planName} · ${detail.meta.name}`
: "Uebersicht"}
: "Übersicht"}
</h1>
<ProfileMenu username={username} />
</header>
@@ -281,82 +387,64 @@ export function AppShell({ username }: { username: string }) {
{showSpec && <SpecView anchor={specAnchor} />}
{!showSpec && !showSystemParams && loading && <p className="text-sm text-muted">Laedt</p>}
{!showSpec && !showSystemParams && loading && <PlanSkeleton />}
{!showSpec && !showSystemParams && !loading && selectedScenarioId === null && (
<DashboardHome
username={username}
plans={plans}
creatingDemo={creatingDemo}
onSelect={openScenario}
onCreate={() => setShowNewPlan(true)}
onCreateGuided={() => setShowWizard(true)}
onCreateChooser={() => setShowNewPlanChooser(true)}
onCreateDemo={handleCreateDemo}
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"
>
<div className="flex flex-wrap items-center gap-2" data-tour="analysen">
<Button variant="secondary" onClick={() => setCopyFrom(detail.meta)}>
<Copy className="h-4 w-4" />
Neues Szenario aus diesem
</button>
</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"
>
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
<Trash2 className="h-4 w-4" />
Szenario loeschen
</button>
Szenario löschen
</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"
>
<Button variant="secondary" onClick={() => setShowCharts(true)}>
<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"
>
</Button>
<Button variant="secondary" onClick={() => setShowMonteCarlo(true)}>
<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"
>
</Button>
<Button variant="secondary" onClick={() => setShowSensitivity(true)}>
<Tornado className="h-4 w-4" />
Einflussfaktoren berechnen
</button>
<button
type="button"
Einflussfaktoren
</Button>
<Button
variant="secondary"
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>
</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`}
? "Unverändert gegenüber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`}
</span>
)}
</div>
@@ -367,24 +455,54 @@ export function AppShell({ username }: { username: string }) {
diff={diff}
onChanged={refreshCurrent}
onOpenSpec={openSpecAt}
onOpenSensitivity={() => setShowSensitivity(true)}
/>
</div>
)}
</main>
</div>
{showNewPlan && (
{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();
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 }
);
setShowNewPlan(false);
setShowEmptyPlanDialog(false);
await loadPlans();
openScenario(scenario.id);
toast("success", "Plan erstellt.");
}}
onClose={() => setShowNewPlan(false)}
onClose={() => setShowEmptyPlanDialog(false)}
/>
)}
@@ -432,14 +550,90 @@ export function AppShell({ username }: { username: string }) {
setCopyFrom(null);
await loadPlans();
openScenario(scenarioId);
toast("success", "Szenario erstellt.");
}}
/>
)}
<CommandPalette
open={showPalette}
onClose={() => setShowPalette(false)}
plans={plans}
actions={paletteActions}
onOpenScenario={openScenario}
/>
</div>
);
}
// Breiter Dialog fuer den Analyse-Bereich (Grafiken).
// 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,
@@ -450,10 +644,10 @@ function ChartsDialog({
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 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="flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
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">
@@ -469,7 +663,9 @@ function ChartsDialog({
);
}
// Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind.
// 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,
parentId,
@@ -494,7 +690,7 @@ function ScenarioTree({
{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 ${
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"
}`}
style={{ paddingLeft: `${12 + depth * 14}px` }}
@@ -512,16 +708,16 @@ function ScenarioTree({
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"
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 loeschen"
aria-label="Szenario löschen"
onClick={() => onDelete(s)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
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>
@@ -542,18 +738,24 @@ function ScenarioTree({
);
}
// Startansicht: Begruessung + Plan-Kacheln (Klick oeffnet das Basisszenario).
// Startansicht: Begrüssung + Plan-Kacheln (Klick öffnet das Basisszenario).
function DashboardHome({
username,
plans,
creatingDemo,
onSelect,
onCreate,
onCreateGuided,
onCreateChooser,
onCreateDemo,
onDelete,
}: {
username: string;
plans: PlanListItem[];
creatingDemo: boolean;
onSelect: (scenarioId: string) => void;
onCreate: () => void;
onCreateGuided: () => void;
onCreateChooser: () => void;
onCreateDemo: () => void;
onDelete: (id: string) => void;
}) {
return (
@@ -561,56 +763,71 @@ function DashboardHome({
<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.
Wähle einen Plan oder erstelle einen neuen. Jeder Plan enthält 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"
{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."
>
<Plus className="h-4 w-4" />
Neuer Plan
</button>
</div>
<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 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 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>
</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>
);
}
@@ -627,49 +844,37 @@ function PlanDialog({
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>
<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>
</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>
);
}
@@ -685,44 +890,32 @@ function CopyScenarioDialog({
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>
<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>
</div>
</Modal>
);
}