"use client"; import { useCallback, useEffect, useState } from "react"; import { BarChart3, CalendarClock, Check, Dices, Eye, FileSpreadsheet, GitBranch, History, Layers, LineChart as LineChartIcon, Pencil, Plus, SlidersHorizontal, Tornado, Trash2, X, } from "lucide-react"; import { Button, useConfirm, useToast } from "@/components/ui"; import { api } from "@/lib/api-client"; import { formatChf } from "@/lib/format"; import { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses"; import type { PersonRole } from "@/lib/types"; export type PlanTab = "dashboard" | "scenarios" | "actuals" | "analyses" | "reports"; // --- Typen der Dashboard-Antwort --- interface DashboardResponse { plan: { id: string; name: string; householdType: "SINGLE" | "COUPLE"; startYear: number | null; persons: { role: PersonRole; name: string | null; age: number }[]; }; counts: { scenarios: number; actuals: number; analyses: number }; base: { scenarioId: string; name: string; phaseCount: number; endNominal: number; endReal: number; ruinAge: number | null; actualEndNominal: number | null; actualYears: number[]; } | null; scenarios: { id: string; name: string; isBase: boolean; parentScenarioId: string | null; version: string | null; versionComment: string | null; elementCount: number; phaseCount: number; versionCount: number; endNominal: number; endReal: number; ruinAge: number | null; }[]; } function useDashboard(planId: string, reloadKey: number) { const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; (async () => { try { const d = await api.get(`/api/plans/${planId}/dashboard`); if (!cancelled) setData(d); } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden."); } })(); return () => { cancelled = true; }; }, [planId, reloadKey]); return { data, error }; } // Anklickbare Kennzahl-Kachel. Ohne `onClick` bleibt sie eine reine Anzeige. function Stat({ label, value, hint, tone, icon: Icon, onClick, }: { label: string; value: string; hint?: string; tone?: "danger" | "success"; icon?: typeof Layers; onClick?: () => void; }) { const toneClass = tone === "danger" ? "text-danger" : tone === "success" ? "text-success" : "text-fg"; const body = ( <>
{Icon && } {label}
{value}
{hint &&
{hint}
} ); if (!onClick) { return
{body}
; } return ( ); } // ========================================================================================= // Plan-Dashboard // ========================================================================================= export function PlanDashboardView({ planId, onNavigate, onRenamed, }: { planId: string; onNavigate: (tab: PlanTab) => void; onRenamed: () => void; }) { const [reloadKey, setReloadKey] = useState(0); const { data, error } = useDashboard(planId, reloadKey); const [editing, setEditing] = useState(false); const [draftName, setDraftName] = useState(""); const [saving, setSaving] = useState(false); const toast = useToast(); if (error) return

{error}

; if (!data) return

Wird geladen…

; const { plan, counts, base } = data; const persons = plan.persons .map((p) => `${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")} (${p.age})`) .join(" · "); // Jüngster erfasster Ist-Datensatz -- er bestimmt, gegen welchen Stand verglichen wird. const latestActualYear = base && base.actualYears.length > 0 ? Math.max(...base.actualYears) : null; const delta = base && base.actualEndNominal !== null ? base.actualEndNominal - base.endNominal : null; async function saveName() { const name = draftName.trim(); if (!name) return; setSaving(true); try { await api.patch(`/api/plans/${planId}`, { name }); setEditing(false); setReloadKey((k) => k + 1); onRenamed(); toast("success", "Plan umbenannt."); } catch (e) { toast("error", e instanceof Error ? e.message : "Umbenennen fehlgeschlagen."); } finally { setSaving(false); } } return (
{editing ? (
setDraftName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void saveName(); if (e.key === "Escape") setEditing(false); }} maxLength={120} className="rounded-lg border border-border bg-input px-3 py-1.5 text-lg font-semibold text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25" />
) : (

{plan.name}

)}

{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons} {plan.startYear ? ` · Planstart ${plan.startYear}` : ""}

{/* Anklickbare Kacheln -- jede führt in ihren Bereich. */}
onNavigate("scenarios")} /> onNavigate("actuals")} /> onNavigate("analyses")} /> onNavigate("reports")} />
{base && (

Basisszenario «{base.name}» alle Kennzahlen beziehen sich hierauf

{base.ruinAge !== null ? ( ) : ( )}
{base.actualEndNominal !== null && delta !== null && (

= 0 ? "border-success bg-success/10 text-fg" : "border-danger bg-danger-soft text-fg" }`} > Mit den effektiven Werten{latestActualYear ? ` von ${latestActualYear}` : ""} liegt das Endvermögen (nominal) bei {formatChf(base.actualEndNominal)} – eine Abweichung von{" "} = 0 ? "text-success" : "text-danger"}> {delta >= 0 ? "+" : ""} {formatChf(delta)} {" "} gegenüber dem Plan.

)}
)}
); } // ========================================================================================= // Szenario-Liste // ========================================================================================= export function ScenarioListView({ planId, reloadKey = 0, onOpenMatrix, onOpenHistory, onCopyFrom, onDelete, }: { planId: string; reloadKey?: number; onOpenMatrix: (scenarioId: string) => void; onOpenHistory: (scenarioId: string) => void; // Kopiervorlage ist frei wählbar -- nicht mehr zwingend das Basisszenario. onCopyFrom: (scenarioId: string) => void; onDelete: (scenarioId: string) => void; }) { const { data, error } = useDashboard(planId, reloadKey); if (error) return

{error}

; if (!data) return

Wird geladen…

; const nameById = new Map(data.scenarios.map((s) => [s.id, s.name])); const baseId = data.scenarios.find((s) => s.isBase)?.id ?? data.scenarios[0]?.id; return (

Szenarien

{baseId && ( )}
{data.scenarios.map((s) => ( // Die ganze Zeile öffnet die Matrix -- der frühere «Matrix»-Knopf entfällt. onOpenMatrix(s.id)} className={`cursor-pointer border-t border-border transition-colors hover:bg-accent-soft/40 ${ s.isBase ? "bg-accent-soft/20" : "" }`} > {/* Aktionen: stopPropagation, sonst öffnet der Zeilenklick die Matrix. */} ))}
Szenario Version Phasen Elemente Endvermögen (nom.) Kapital reicht Aktionen
{s.name} {s.isBase && ( Basis )}
{s.parentScenarioId && (
aus {nameById.get(s.parentScenarioId) ?? "…"}
)}
{s.version ?? "–"}
{s.versionComment && (
«{s.versionComment}»
)}
{s.phaseCount} {s.elementCount} {formatChf(s.endNominal)} {s.ruinAge !== null ? ( Alter {s.ruinAge} ) : ( Planende )} e.stopPropagation()}>
{!s.isBase && ( )}
); } // ========================================================================================= // Analysen: vier Kacheln + Liste der gespeicherten // ========================================================================================= export type ToolKind = "CHART" | "MONTE_CARLO" | "SENSITIVITY" | "LIVESIM"; const TILES: { kind: ToolKind; title: string; icon: typeof BarChart3; blurb: string }[] = [ { kind: "CHART", title: "Grafiken", icon: LineChartIcon, blurb: "Vermögensverlauf, Einkommen vs. Ausgaben oder Vermögensaufteilung – wähle eine Grafik und ob nominal/real und Plan/effektiv gezeigt werden.", }, { kind: "LIVESIM", title: "Live-Simulation", icon: SlidersHorizontal, blurb: "Dreh an Reglern (Rendite, Ausgaben, Inflation …) und sieh die Wirkung sofort – ohne etwas zu speichern.", }, { kind: "MONTE_CARLO", title: "Monte-Carlo", icon: Dices, blurb: "Tausende Zufallspfade zeigen, wie wahrscheinlich deine Planung hält und dein Ziel erreicht wird.", }, { kind: "SENSITIVITY", title: "Einflussfaktoren", icon: Tornado, blurb: "Welche deiner Annahmen entscheidet überhaupt über das Ergebnis? Ein Tornado ordnet sie nach Wirkung.", }, ]; function typeIcon(t: AnalysisType) { return t === "MONTE_CARLO" ? Dices : t === "SENSITIVITY" ? Tornado : BarChart3; } export function AnalysesView({ planId, onLaunch, onOpenSaved, }: { planId: string; onLaunch: (kind: ToolKind) => void; onOpenSaved: (id: string) => void; }) { const [saved, setSaved] = useState(null); const [error, setError] = useState(null); const confirm = useConfirm(); const toast = useToast(); const load = useCallback(async () => { try { const d = await api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`); setSaved(d.analyses); } catch (e) { setError(e instanceof Error ? e.message : "Konnte nicht geladen werden."); } }, [planId]); useEffect(() => { let cancelled = false; (async () => { try { const d = await api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`); if (!cancelled) setSaved(d.analyses); } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden."); } })(); return () => { cancelled = true; }; }, [planId]); async function remove(a: SavedAnalysisMeta) { const ok = await confirm({ title: "Analyse löschen?", message: `«${a.name}» wird entfernt.`, confirmLabel: "Löschen", danger: true }); if (!ok) return; try { await api.delete(`/api/plans/${planId}/analyses/${a.id}`); await load(); toast("success", "Analyse gelöscht."); } catch (e) { toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen."); } } const dt = (iso: string) => new Date(iso).toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" }); return (

Analysen

{/* Vier Kacheln, die beim Darüberfahren (oder Antippen) umklappen. */}
{TILES.map((t) => ( onLaunch(t.kind)} /> ))}

Gespeicherte Analysen

{error &&

{error}

} {!saved && !error &&

Wird geladen…

} {saved && saved.length === 0 && (

Noch nichts gespeichert. In jedem Werkzeug oben kannst du ein Ergebnis festhalten – es erscheint dann hier.

)} {saved && saved.length > 0 && (
{saved.map((a) => { const Icon = typeIcon(a.type); return (
{a.name}
{ANALYSIS_TYPE_LABEL[a.type]} · {dt(a.createdAt)} {a.scenarioName ? ` · ${a.scenarioName}${a.versionLabel ? ` ${a.versionLabel}` : ""}` : ""} ·{" "} {a.metric === "real" ? "real" : "nominal"} · {sourceLabel(a.source)} {a.summary ? ` · ${a.summary}` : ""}
); })}
)}
); } function FlipTile({ tile, onClick }: { tile: (typeof TILES)[number]; onClick: () => void }) { const [flipped, setFlipped] = useState(false); const Icon = tile.icon; return ( ); }