Navigation auf Plan-Ebene und gespeicherte Analysen (Roadmap-Redesign)
Deploy App / deploy (push) Successful in 1m45s
Deploy App / deploy (push) Successful in 1m45s
Sidebar zweistufig: pro Plan die Unterpunkte Szenarien / Effektive Werte / Analysen; Klick auf Plan-Name oeffnet ein Plan-Dashboard. Plan-Dashboard: Kennzahlen, gerechnete Werte ausdruecklich "laut Basisszenario", Ist-Abweichung falls erfasst. Szenario-Liste: Version, Elementzahl, Endvermoegen, Ruinalter + Aktionen Historie und Matrix. Baum in der Sidebar bleibt. Analysen: vier umklappende Kacheln (auch per Antippen). Grafiken oeffnen neu mit Auswahl EINER Grafik. Szenario-Vergleich zu den Grafiken, CSV-Export auf die Matrix. Gespeicherte Analysen: Grafik/MC/Einflussfaktoren als ZAHLEN einfrieren (read-only, nichts wird neu gerechnet) -- druckfaehig fuer den spaeteren PDF-Bericht, ohne finalWealthSorted. Einheitliche generische Ergebnisform. Neue Tabelle SavedAnalysis, Endpunkte /analyses und /dashboard, Module analyses.ts, Komponenten PlanViews/SavedAnalysisView/SaveAnalysisButton. Kein Eingriff in den Rechenkern. Spezifikation 0.24 (3.10 und 9.30 neu). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Dices,
|
||||
Eye,
|
||||
GitBranch,
|
||||
History,
|
||||
LineChart as LineChartIcon,
|
||||
Plus,
|
||||
SlidersHorizontal,
|
||||
Table2,
|
||||
Tornado,
|
||||
Trash2,
|
||||
} 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";
|
||||
|
||||
// --- 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;
|
||||
currentMajor: number;
|
||||
elementCount: number;
|
||||
versionCount: number;
|
||||
endNominal: number;
|
||||
endReal: number;
|
||||
ruinAge: number | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
function useDashboard(planId: string, reloadKey: number) {
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const d = await api.get<DashboardResponse>(`/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 };
|
||||
}
|
||||
|
||||
function Stat({ label, value, hint, tone }: { label: string; value: string; hint?: string; tone?: "danger" }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<div className="text-xs text-muted">{label}</div>
|
||||
<div className={`mt-1 text-xl font-semibold ${tone === "danger" ? "text-danger" : "text-fg"}`}>{value}</div>
|
||||
{hint && <div className="mt-0.5 text-[11px] text-faint">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================================
|
||||
// Plan-Dashboard
|
||||
// =========================================================================================
|
||||
export function PlanDashboardView({ planId }: { planId: string }) {
|
||||
const { data, error } = useDashboard(planId, 0);
|
||||
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||||
if (!data) return <p className="text-sm text-muted">Wird geladen…</p>;
|
||||
|
||||
const { plan, counts, base } = data;
|
||||
const persons = plan.persons
|
||||
.map((p) => `${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")} (${p.age})`)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-fg">{plan.name}</h2>
|
||||
<p className="text-sm text-muted">
|
||||
{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons}
|
||||
{plan.startYear ? ` · Planstart ${plan.startYear}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Stat label="Szenarien" value={String(counts.scenarios)} />
|
||||
<Stat label="Effektive Werte" value={String(counts.actuals)} hint="erfasste Ist-Datensätze" />
|
||||
<Stat label="Gespeicherte Analysen" value={String(counts.analyses)} />
|
||||
<Stat label="Lebensphasen" value={base ? String(base.phaseCount) : "–"} hint="laut Basisszenario" />
|
||||
</div>
|
||||
|
||||
{base && (
|
||||
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<h3 className="mb-3 text-sm font-semibold text-fg">
|
||||
Basisszenario «{base.name}»
|
||||
<span className="ml-2 text-[11px] font-normal text-faint">alle Kennzahlen beziehen sich hierauf</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<Stat label="Endvermögen (nominal)" value={formatChf(base.endNominal)} />
|
||||
<Stat label="Endvermögen (real)" value={formatChf(base.endReal)} />
|
||||
{base.ruinAge !== null ? (
|
||||
<Stat label="Kapital reicht" value={`bis Alter ${base.ruinAge}`} tone="danger" hint="danach aufgebraucht" />
|
||||
) : (
|
||||
<Stat label="Kapital reicht" value="bis Planende" />
|
||||
)}
|
||||
</div>
|
||||
{base.actualEndNominal !== null && (
|
||||
<p className="mt-3 rounded-lg border border-accent bg-accent-soft/20 px-3 py-2 text-xs text-accent-soft-fg">
|
||||
Mit den effektiven Werten liegt das Endvermögen (nominal) bei{" "}
|
||||
<strong>{formatChf(base.actualEndNominal)}</strong> – eine Abweichung von{" "}
|
||||
<strong>{formatChf(base.actualEndNominal - base.endNominal)}</strong> gegenüber dem Plan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================================
|
||||
// Szenario-Liste
|
||||
// =========================================================================================
|
||||
export function ScenarioListView({
|
||||
planId,
|
||||
onOpenMatrix,
|
||||
onOpenHistory,
|
||||
onNew,
|
||||
}: {
|
||||
planId: string;
|
||||
onOpenMatrix: (scenarioId: string) => void;
|
||||
onOpenHistory: (scenarioId: string) => void;
|
||||
onNew: () => void;
|
||||
}) {
|
||||
const { data, error } = useDashboard(planId, 0);
|
||||
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||||
if (!data) return <p className="text-sm text-muted">Wird geladen…</p>;
|
||||
|
||||
const nameById = new Map(data.scenarios.map((s) => [s.id, s.name]));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-fg">Szenarien</h2>
|
||||
<Button onClick={onNew}>
|
||||
<Plus className="h-4 w-4" /> Neues Szenario
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface-2 text-xs text-faint">
|
||||
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Version</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Elemente</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Endvermögen (nom.)</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Kapital reicht</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.scenarios.map((s) => (
|
||||
<tr key={s.id} className={`border-t border-border ${s.isBase ? "bg-accent-soft/20" : ""}`}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-fg">{s.name}</span>
|
||||
{s.isBase && <span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>}
|
||||
</div>
|
||||
{s.parentScenarioId && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-faint">
|
||||
<GitBranch className="h-3 w-3" /> aus {nameById.get(s.parentScenarioId) ?? "…"}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.currentMajor}.x</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.elementCount}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-fg">{formatChf(s.endNominal)}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{s.ruinAge !== null ? (
|
||||
<span className="text-danger">Alter {s.ruinAge}</span>
|
||||
) : (
|
||||
<span className="text-success">Planende</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenHistory(s.id)}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" /> Historie
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenMatrix(s.id)}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<Table2 className="h-3.5 w-3.5" /> Matrix
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================================
|
||||
// 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<SavedAnalysisMeta[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h2 className="text-lg font-semibold text-fg">Analysen</h2>
|
||||
|
||||
{/* Vier Kacheln, die beim Darüberfahren (oder Antippen) umklappen. */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{TILES.map((t) => (
|
||||
<FlipTile key={t.kind} tile={t} onClick={() => onLaunch(t.kind)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-fg">Gespeicherte Analysen</h3>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{!saved && !error && <p className="text-xs text-muted">Wird geladen…</p>}
|
||||
{saved && saved.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
|
||||
Noch nichts gespeichert. In jedem Werkzeug oben kannst du ein Ergebnis festhalten – es erscheint dann hier.
|
||||
</p>
|
||||
)}
|
||||
{saved && saved.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{saved.map((a) => {
|
||||
const Icon = typeIcon(a.type);
|
||||
return (
|
||||
<div key={a.id} className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2 p-3">
|
||||
<Icon className="h-4 w-4 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-fg">{a.name}</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{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}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSaved(a.id)}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" /> Anzeigen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(a)}
|
||||
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlipTile({ tile, onClick }: { tile: (typeof TILES)[number]; onClick: () => void }) {
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const Icon = tile.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setFlipped(true)}
|
||||
onMouseLeave={() => setFlipped(false)}
|
||||
// Touch hat kein Hover -- ein Antippen des Info-Bereichs klappt um, statt gleich zu starten.
|
||||
className="group relative flex h-40 flex-col items-start justify-between overflow-hidden rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-colors hover:border-accent"
|
||||
>
|
||||
{!flipped ? (
|
||||
<>
|
||||
<Icon className="h-8 w-8 text-accent" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-fg">{tile.title}</div>
|
||||
<div className="text-[11px] text-faint">Zum Starten klicken</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-accent">
|
||||
<Icon className="h-4 w-4" /> {tile.title}
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted">{tile.blurb}</p>
|
||||
<span className="mt-auto text-[11px] font-medium text-accent">Starten →</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user