Files
FPT/src/components/PlanViews.tsx
T
admGitAICDS 0761f6b3e2
Deploy App / deploy (push) Successful in 1m4s
Effektive Werte: Immobilien-Bugfix, Bearbeiten; Sidebar-Ebenen, Ring-Klick
Immobilien-Bugfix (gravierend):
- Der Ist-Wizard belegte den Immobilienwert mit dem EIGENKAPITAL vor
  (ElementYearPoint.value), waehrend Erfassung und Rechenkern den
  VERKEHRSWERT erwarten (propertyValue). Der Kern setzte die Zahl als
  Verkehrswert ein und liess die Hypothek stehen -> das Eigenkapital brach
  im Ist-Jahr um genau die Hypothek ein, meist ins Negative.
- Sichtbar als negative Gesamt-Abweichung trotz reiner Lohnerhoehung und als
  "wegbrechendes" Wohneigentum in der Vermoegensaufteilung.
- Feld ist neu als "Verkehrswert + Restschuld" beschriftet; 3 Regressionstests.

Ist-Datensaetze bearbeitbar:
- Klick auf die Zeile (oder "Bearbeiten") oeffnet den Satz erneut
- neuer Endpunkt PUT /api/plans/<id>/actuals/<setId>
- beim Bearbeiten ueberschreiben die Planwerte die erfassten Zahlen nicht

Ring-Klick in der Vermoegensaufteilung repariert:
- Recharts 3 reicht kein activePayload mehr durch (nur activeIndex) --
  der Handler feuerte nie, der Ring zeigte immer das Planende

Seitenleiste sauber dreistufig:
- Ebene 1 Plaene, Ebene 2 die vier Bereiche mit buendigen Symbolen,
  Ebene 3 nur die Szenarien (verschachtelt nach Herkunft)

Szenario-Liste zeigt neben der Version deren Kommentar.

SPEZIFIKATION 0.32 (neue Kapitel 3.9.6, 3.9.7). 275 -> 278 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:52:23 +02:00

607 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<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 };
}
// 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 = (
<>
<div className="flex items-center gap-1.5 text-xs text-muted">
{Icon && <Icon className="h-3.5 w-3.5 text-accent" />}
{label}
</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${toneClass}`}>{value}</div>
{hint && <div className="mt-0.5 text-[11px] text-faint">{hint}</div>}
</>
);
if (!onClick) {
return <div className="rounded-2xl border border-border bg-surface p-4 shadow-sm">{body}</div>;
}
return (
<button
type="button"
onClick={onClick}
className="group rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
>
{body}
<div className="mt-1 text-[11px] font-medium text-accent opacity-0 transition-opacity group-hover:opacity-100">
Öffnen
</div>
</button>
);
}
// =========================================================================================
// 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 <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(" · ");
// 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 (
<div className="flex flex-col gap-6">
<div>
{editing ? (
<div className="flex flex-wrap items-center gap-2">
<input
autoFocus
value={draftName}
onChange={(e) => 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"
/>
<Button size="sm" onClick={saveName} disabled={saving}>
<Check className="h-4 w-4" /> Speichern
</Button>
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="group flex items-center gap-2">
<h2 className="text-lg font-semibold text-fg">{plan.name}</h2>
<button
type="button"
aria-label="Plan umbenennen"
title="Plan umbenennen"
onClick={() => {
setDraftName(plan.name);
setEditing(true);
}}
className="rounded-md p-1 text-faint opacity-60 transition-opacity hover:bg-surface-2 hover:text-fg group-hover:opacity-100"
>
<Pencil className="h-4 w-4" />
</button>
</div>
)}
<p className="text-sm text-muted">
{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons}
{plan.startYear ? ` · Planstart ${plan.startYear}` : ""}
</p>
</div>
{/* Anklickbare Kacheln -- jede führt in ihren Bereich. */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Stat
label="Szenarien"
value={String(counts.scenarios)}
hint="Varianten dieser Planung"
icon={Layers}
onClick={() => onNavigate("scenarios")}
/>
<Stat
label="Effektive Werte"
value={String(counts.actuals)}
hint="erfasste Ist-Datensätze"
icon={CalendarClock}
onClick={() => onNavigate("actuals")}
/>
<Stat
label="Gespeicherte Analysen"
value={String(counts.analyses)}
hint="Grafiken, Simulationen, Tornados"
icon={BarChart3}
onClick={() => onNavigate("analyses")}
/>
<Stat
label="Berichte"
value="PDF"
hint="erzeugen und wieder herunterladen"
icon={FileSpreadsheet}
onClick={() => onNavigate("reports")}
/>
</div>
{base && (
<div className="rounded-2xl border border-border bg-surface p-5 shadow-sm">
<h3 className="mb-3 flex flex-wrap items-baseline gap-2 text-sm font-semibold text-fg">
Basisszenario «{base.name}»
<span className="text-[11px] font-normal text-faint">alle Kennzahlen beziehen sich hierauf</span>
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Stat label="Endvermögen (nominal)" value={formatChf(base.endNominal)} />
<Stat label="Endvermögen (real)" value={formatChf(base.endReal)} />
<Stat label="Lebensphasen" value={String(base.phaseCount)} />
{base.ruinAge !== null ? (
<Stat label="Kapital reicht" value={`bis Alter ${base.ruinAge}`} tone="danger" hint="danach aufgebraucht" />
) : (
<Stat label="Kapital reicht" value="bis Planende" tone="success" />
)}
</div>
{base.actualEndNominal !== null && delta !== null && (
<p
className={`mt-4 rounded-xl border px-3 py-2 text-xs ${
delta >= 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 <strong>{formatChf(base.actualEndNominal)}</strong> eine Abweichung von{" "}
<strong className={delta >= 0 ? "text-success" : "text-danger"}>
{delta >= 0 ? "+" : ""}
{formatChf(delta)}
</strong>{" "}
gegenüber dem Plan.
</p>
)}
</div>
)}
</div>
);
}
// =========================================================================================
// 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 <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]));
const baseId = data.scenarios.find((s) => s.isBase)?.id ?? data.scenarios[0]?.id;
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>
{baseId && (
<Button onClick={() => onCopyFrom(baseId)}>
<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">Phasen</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) => (
// Die ganze Zeile öffnet die Matrix -- der frühere «Matrix»-Knopf entfällt.
<tr
key={s.id}
onClick={() => onOpenMatrix(s.id)}
className={`cursor-pointer border-t border-border transition-colors hover:bg-accent-soft/40 ${
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">
<div className="tabular-nums text-muted">{s.version ?? ""}</div>
{s.versionComment && (
<div className="max-w-48 truncate text-[11px] text-faint" title={s.versionComment}>
«{s.versionComment}»
</div>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.phaseCount}</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>
{/* Aktionen: stopPropagation, sonst öffnet der Zeilenklick die Matrix. */}
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
title="Änderungshistorie"
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"
title="Neues Szenario aus diesem"
onClick={() => onCopyFrom(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"
>
<Plus className="h-3.5 w-3.5" /> Kopie
</button>
{!s.isBase && (
<button
type="button"
aria-label="Szenario löschen"
title="Szenario löschen"
onClick={() => onDelete(s.id)}
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>
</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-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
>
{!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>
);
}