Versionierung und Aenderungshistorie je Szenario
Deploy App / deploy (push) Successful in 1m48s

Version A.B: B automatisch je Bearbeitungssitzung (10-Minuten-Fenster,
unveraenderte Staende erzeugen keine), A manuell mit Pflichtkommentar.

Eine Version haelt den vollstaendigen Zustand als PlanInput-JSON -- dadurch
ist die Versionsauswahl in allen vier Analysewerkzeugen fast kostenlos,
bei Monte-Carlo je Szenario einzeln.

Wiederherstellen erhaelt die IDs (sonst verlieren Kind-Szenarien ihre
Diff-Basis) und legt den Stand selbst als neue Version an. Wo ein Bezug
trotzdem bricht, warnt der Dialog vorher namentlich.

Statischer Waechter-Test: jeder schreibende Endpunkt loest eine Version aus.
Migration gegen echtes Postgres verifiziert.

Spezifikation 0.19 (3.8 und 9.28 neu), 25 Tests (139 -> 164).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:09:10 +02:00
parent d04e07fdfb
commit d023534a03
28 changed files with 2021 additions and 13 deletions
+16
View File
@@ -9,6 +9,7 @@ import {
FileText,
FolderKanban,
GitBranch,
History,
LayoutDashboard,
Menu,
PiggyBank,
@@ -25,6 +26,7 @@ import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { LiveSimDialog } from "@/components/LiveSimDialog";
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
import { SpecView } from "@/components/SpecView";
import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
@@ -87,6 +89,7 @@ function AppShellInner({ username }: { username: string }) {
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const [showSensitivity, setShowSensitivity] = useState(false);
const [showLiveSim, setShowLiveSim] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false);
const [showPalette, setShowPalette] = useState(false);
@@ -423,6 +426,10 @@ function AppShellInner({ username }: { username: string }) {
<BarChart3 className="h-4 w-4" />
Grafiken
</Button>
<Button variant="secondary" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4" />
Änderungshistorie
</Button>
<Button variant="secondary" onClick={() => setShowLiveSim(true)}>
<SlidersHorizontal className="h-4 w-4" />
Live-Simulation
@@ -540,6 +547,15 @@ function AppShellInner({ username }: { username: string }) {
<LiveSimDialog plan={detail.plan} onClose={() => setShowLiveSim(false)} />
)}
{showHistory && detail && (
<VersionHistoryDialog
scenarioId={detail.meta.id}
scenarioName={detail.meta.name}
onClose={() => setShowHistory(false)}
onRestored={refreshCurrent}
/>
)}
{showPlanTraces && detail && (
<PlanTraceDialog
computed={computePlan(detail.plan, undefined, { explain: true })}
+28 -4
View File
@@ -3,6 +3,8 @@
import { useMemo, useState } from "react";
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { computePlan } from "@/lib/calculations";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { api } from "@/lib/api-client";
@@ -16,8 +18,8 @@ interface PlanListItem {
}
export function Dashboard({
plan,
computed,
plan: currentPlan,
computed: currentComputed,
siblings,
}: {
plan: PlanInput;
@@ -25,6 +27,14 @@ export function Dashboard({
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
}) {
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
const computed = useMemo(
() => (versionId === "current" ? currentComputed : computePlan(plan)),
[versionId, currentComputed, plan]
);
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
@@ -55,6 +65,13 @@ export function Dashboard({
return (
<div className="flex flex-col gap-6">
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={setVersionId}
loading={versionLoading}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
@@ -78,12 +95,19 @@ export function Dashboard({
<LineChartIcon className="h-4 w-4 text-accent" />
Vermögensverlauf nach Alter
</h3>
{/* Der Export liest immer das Szenario aus der Datenbank -- also den aktuellen
Stand, nicht die betrachtete Version. Das wird beschriftet statt verschwiegen. */}
<a
href={`/api/scenarios/${plan.id}/export`}
href={`/api/scenarios/${currentPlan.id}/export`}
title={
versionId === "current"
? undefined
: "Der CSV-Export liefert immer den aktuellen Stand, nicht die betrachtete Version."
}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted hover:bg-surface-2"
>
<Download className="h-3.5 w-3.5" />
CSV-Export
CSV-Export{versionId === "current" ? "" : " (aktueller Stand)"}
</a>
</div>
{otherPlans.length > 0 && (
+14 -1
View File
@@ -6,6 +6,7 @@ import { AllocationChart } from "@/components/AllocationChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { WealthChart } from "@/components/WealthChart";
import { InfoBubble } from "@/components/InfoBubble";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { computePlan } from "@/lib/calculations";
import { formatChf } from "@/lib/format";
import {
@@ -43,7 +44,9 @@ const CHARTS: { id: ChartId; label: string; hint: string }[] = [
const BASE_COLOR = "#9ca3af";
const LIVE_COLOR = "#4f46e5";
export function LiveSimDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
// Geregelt wird wahlweise am Arbeitsstand oder an einer festgehaltenen Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
const [expandReturns, setExpandReturns] = useState(false);
const [values, setValues] = useState<SliderValues>({});
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
@@ -108,6 +111,16 @@ export function LiveSimDialog({ plan, onClose }: { plan: PlanInput; onClose: ()
</button>
</div>
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={(id) => {
setVersionId(id);
setValues({});
}}
loading={versionLoading}
/>
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
gespeichert</strong> dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
+44 -2
View File
@@ -5,6 +5,8 @@ import { Area, CartesianGrid, ComposedChart, Legend, Line, LineChart, Responsive
import { Dices, X } from "lucide-react";
import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
import { computePlan } from "@/lib/calculations";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import {
@@ -137,6 +139,10 @@ export function MonteCarloDialog({
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
// Gewaehlter Stand je Szenario (Default: Arbeitsstand) und die dazu geladenen Snapshots.
const [versionByScenario, setVersionByScenario] = useState<Record<string, string>>({});
const [versionPlans, setVersionPlans] = useState<Record<string, PlanInput>>({});
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null);
// Fächer/Bänder stammen aus der historischen Welt.
const [fanRes, setFanRes] = useState<ScenarioMcResult[] | null>(null);
@@ -169,12 +175,40 @@ export function MonteCarloDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scenarioKey, meta.id]);
async function chooseVersion(scenarioId: string, versionId: string) {
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: versionId }));
clearResults();
if (versionId === CURRENT) return;
const key = `${scenarioId}:${versionId}`;
if (versionPlans[key]) return;
try {
const loadedPlan = await loadVersionPlan(scenarioId, versionId);
if (loadedPlan) setVersionPlans((prev) => ({ ...prev, [key]: loadedPlan }));
} catch {
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: CURRENT }));
}
}
const allLoaded = useMemo(
() => scenarios.map((s) => loaded[s.id]).filter((s): s is LoadedScenario => !!s),
[scenarios, loaded]
);
const groups = useMemo(() => buildElementGroups(allLoaded, selectedIds), [allLoaded, selectedIds]);
// Ersetzt je Szenario den Arbeitsstand durch den gewaehlten Snapshot. Der Snapshot ist
// bereits ein PlanInput, das Gerechnete entsteht lokal.
const resolved = useMemo(() => {
const out: Record<string, LoadedScenario> = {};
for (const sc of allLoaded) {
const vid = versionByScenario[sc.id] ?? CURRENT;
const snap = vid === CURRENT ? null : versionPlans[`${sc.id}:${vid}`];
out[sc.id] = snap ? { ...sc, plan: snap, computed: computePlan(snap) } : sc;
}
return out;
}, [allLoaded, versionByScenario, versionPlans]);
const resolvedList = useMemo(() => Object.values(resolved), [resolved]);
const groups = useMemo(() => buildElementGroups(resolvedList, selectedIds), [resolvedList, selectedIds]);
function draftFor(g: ElementGroup): ElementDraft {
return drafts[g.rootId] ?? { mean: "", level: defaultVolatilityLevel(g.category), manualSigma: "10" };
@@ -193,7 +227,7 @@ export function MonteCarloDialog({
clearResults();
}
const selected = selectedIds.map((id) => loaded[id]).filter((s): s is LoadedScenario => !!s);
const selected = selectedIds.map((id) => resolved[id]).filter((s): s is LoadedScenario => !!s);
const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category)));
const missingHist = inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === "");
@@ -362,6 +396,14 @@ export function MonteCarloDialog({
{s.isBase && <span className="rounded bg-surface px-1.5 text-[10px] text-muted">Basis</span>}
{!isLoaded && <span className="text-[11px] text-faint">lädt</span>}
</label>
{checked && (
<VersionSelect
compact
scenarioId={s.id}
value={versionByScenario[s.id] ?? CURRENT}
onChange={(vid) => void chooseVersion(s.id, vid)}
/>
)}
{checked && typeof nachlass === "number" && (
<span className="text-[11px] text-muted" title="Planungs-Endbetrag dieses Szenarios (read-only)">
Planungs-Endbetrag: <strong className="text-fg">{formatChf(Math.max(0, nachlass))}</strong>
+14 -1
View File
@@ -5,6 +5,7 @@ import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Toolt
import { Tornado, X } from "lucide-react";
import { RequiredNumberField, SelectField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { formatChf } from "@/lib/format";
import {
computeTornado,
@@ -34,7 +35,9 @@ function formatRange(low: number, high: number, unit: DriverDef["unit"]): string
return `${sign(low)} ${suffix}${sign(high)} ${suffix}`;
}
export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
// Gerechnet wird wahlweise auf dem Arbeitsstand oder auf einer festgehaltenen Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]);
const [metric, setMetric] = useState<TornadoMetric>("real");
@@ -103,6 +106,16 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
</button>
</div>
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={(id) => {
setVersionId(id);
setResult(null);
}}
loading={versionLoading}
/>
{/* Erklärung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
+327
View File
@@ -0,0 +1,327 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AlertTriangle, Eye, History, RotateCcw, Tag, X } from "lucide-react";
import { VersionMatrix } from "@/components/VersionMatrix";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
import type { RestoreImpact } from "@/lib/versioning";
export interface VersionRow {
id: string;
major: number;
minor: number;
comment: string | null;
isMajor: boolean;
createdAt: string;
updatedAt: string;
author: string;
}
interface VersionDetail {
version: { id: string; major: number; minor: number; comment: string | null; isMajor: boolean };
plan: PlanInput;
computed: PlanComputed;
impact: RestoreImpact;
}
const dt = (iso: string) =>
new Date(iso).toLocaleString("de-CH", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
export function VersionHistoryDialog({
scenarioId,
scenarioName,
onClose,
onRestored,
}: {
scenarioId: string;
scenarioName: string;
onClose: () => void;
onRestored: () => void;
}) {
const [rows, setRows] = useState<VersionRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<VersionDetail | null>(null);
const [busy, setBusy] = useState(false);
const [majorComment, setMajorComment] = useState("");
const [showMajorForm, setShowMajorForm] = useState(false);
const confirm = useConfirm();
const toast = useToast();
// Nachladen nach einer Aktion (Wiederherstellen, Hauptversion) -- nicht beim Öffnen.
const load = useCallback(async () => {
try {
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
setRows(data.versions);
} catch (e) {
setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
}
}, [scenarioId]);
// Erstes Laden. Das Cancel-Flag verhindert ein setState nach dem Schliessen des Dialogs.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
if (!cancelled) setRows(data.versions);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [scenarioId]);
async function openDetail(id: string) {
setBusy(true);
try {
setDetail(await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${id}`));
} catch (e) {
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
} finally {
setBusy(false);
}
}
async function restore(row: VersionRow) {
// Erst die Auswirkung holen: Kind-Szenarien können ihre Diff-Basis verlieren, und das
// muss VOR dem Klick auf dem Tisch liegen, nicht danach.
setBusy(true);
let impact: RestoreImpact;
try {
const d = await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${row.id}`);
impact = d.impact;
} catch (e) {
setBusy(false);
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
return;
}
setBusy(false);
const warn =
impact.affectedChildren.length > 0
? `\n\nAchtung: ${impact.affectedChildren.length === 1 ? "Das Szenario" : "Die Szenarien"} ` +
`«${impact.affectedChildren.join("», «")}» ${impact.affectedChildren.length === 1 ? "hängt" : "hängen"} ` +
`an diesem Szenario. Dieser Stand kannte ${impact.lostElementIds.length} Element(e) und ` +
`${impact.lostPhaseIds.length} Phase(n) noch nicht, auf die dort verwiesen wird die Abweichungs-Markierung ` +
`zeigt sie danach als «neu» statt als «geändert».`
: "";
const ok = await confirm({
title: `Auf Version ${row.major}.${row.minor} zurücksetzen?`,
message:
`Das Szenario «${scenarioName}» wird vollständig auf diesen Stand zurückgesetzt. ` +
`Es geht nichts verloren: Der wiederhergestellte Stand wird als neue Version festgehalten, ` +
`die bisherige Historie bleibt vollständig erhalten.${warn}`,
confirmLabel: "Wiederherstellen",
danger: impact.affectedChildren.length > 0,
});
if (!ok) return;
setBusy(true);
try {
const res = await api.post<{ version: { major: number; minor: number } }>(
`/api/scenarios/${scenarioId}/versions/${row.id}`,
{}
);
toast("success", `Wiederhergestellt aus ${row.major}.${row.minor} neue Version ${res.version.major}.${res.version.minor}.`);
await load();
onRestored();
} catch (e) {
toast("error", e instanceof Error ? e.message : "Wiederherstellen fehlgeschlagen.");
} finally {
setBusy(false);
}
}
async function createMajor() {
setBusy(true);
try {
const res = await api.post<{ version: { major: number; minor: number } }>(
`/api/scenarios/${scenarioId}/versions`,
{ comment: majorComment }
);
toast("success", `Hauptversion ${res.version.major}.${res.version.minor} festgelegt.`);
setMajorComment("");
setShowMajorForm(false);
await load();
onRestored();
} catch (e) {
toast("error", e instanceof Error ? e.message : "Hauptversion konnte nicht angelegt werden.");
} finally {
setBusy(false);
}
}
// --- Detailansicht (nur lesen) ---
if (detail) {
const last = detail.computed.phases[detail.computed.phases.length - 1];
return (
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={() => setDetail(null)}>
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-5xl flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<Eye className="h-5 w-5 text-accent" />
Version {detail.version.major}.{detail.version.minor}
{detail.version.isMajor && (
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[10px] font-semibold text-accent-soft-fg">
Hauptversion
</span>
)}
</h2>
<p className="mt-0.5 text-xs text-muted">
Nur-Lese-Ansicht dieses Standes. {detail.version.comment && `«${detail.version.comment}»`}
</p>
</div>
<button type="button" onClick={() => setDetail(null)} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{last && (
<div className="flex flex-wrap gap-4 rounded-xl border border-border bg-surface-2 p-3 text-xs">
<span className="text-muted">
Endvermögen nominal: <strong className="text-fg">{formatChf(last.endWealthNominal)}</strong>
</span>
<span className="text-muted">
real: <strong className="text-fg">{formatChf(last.endWealthReal)}</strong>
</span>
<span className="text-muted">
Kapital reicht:{" "}
<strong className={detail.computed.ruinAge === null ? "text-success" : "text-danger"}>
{detail.computed.ruinAge === null ? "bis Planende" : `bis Alter ${detail.computed.ruinAge}`}
</strong>
</span>
</div>
)}
<VersionMatrix computed={detail.computed} />
</div>
</div>
);
}
// --- Liste ---
return (
<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="ui-pop flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<History className="h-5 w-5 text-accent" /> Änderungshistorie
</h2>
<p className="mt-0.5 text-xs text-muted">
Szenario «{scenarioName}». Eine Nebenversion entsteht je Bearbeitungssitzung, nicht je
einzelner Änderung sonst wäre die Liste ein Tastenprotokoll.
</p>
</div>
<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>
{/* Hauptversion festlegen */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
{showMajorForm ? (
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-fg">
Wofür steht diese Hauptversion? <span className="text-danger">*</span>
</label>
<input
autoFocus
value={majorComment}
onChange={(e) => setMajorComment(e.target.value)}
placeholder="z. B. «Stand nach Beratungsgespräch, vor dem Hauskauf»"
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
<div className="flex items-center gap-2">
<Button size="sm" disabled={majorComment.trim().length < 3 || busy} onClick={createMajor}>
<Tag className="h-3.5 w-3.5" /> Als Hauptversion festlegen
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(false)}>
Abbrechen
</Button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted">
Einen bewusst gesetzten Meilenstein festhalten mit Begründung.
</span>
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(true)}>
<Tag className="h-3.5 w-3.5" /> Aktuellen Stand als Hauptversion festlegen
</Button>
</div>
)}
</div>
{error && <p className="text-xs text-danger">{error}</p>}
{!rows && !error && <p className="text-xs text-muted">Historie wird geladen</p>}
{rows && rows.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Für dieses Szenario ist noch keine Version festgehalten. Die erste entsteht mit der
nächsten Änderung.
</p>
)}
{rows && rows.length > 0 && (
<div className="flex flex-col gap-2">
{rows.map((r, i) => (
<div
key={r.id}
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
r.isMajor ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
}`}
>
<span className="flex w-16 shrink-0 items-center gap-1.5 font-semibold text-fg">
{r.isMajor && <Tag className="h-3.5 w-3.5 text-accent" />}
{r.major}.{r.minor}
</span>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted">
{r.author} · {dt(r.updatedAt)}
{i === 0 && (
<span className="ml-1.5 rounded bg-surface px-1.5 py-0.5 text-[10px] text-faint">
aktueller Stand
</span>
)}
</div>
{r.comment && <div className="mt-0.5 truncate text-xs text-fg">«{r.comment}»</div>}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button size="sm" variant="secondary" disabled={busy} onClick={() => openDetail(r.id)}>
<Eye className="h-3.5 w-3.5" /> Anzeigen
</Button>
<Button size="sm" variant="secondary" disabled={busy || i === 0} onClick={() => restore(r)}>
<RotateCcw className="h-3.5 w-3.5" /> Wiederherstellen
</Button>
</div>
</div>
))}
</div>
)}
<p className="flex items-start gap-1.5 text-[11px] text-faint">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
Wiederherstellen löscht nichts: Der zurückgesetzte Stand wird selbst als neue Version
festgehalten. Hängen Szenarien an diesem, wird vorher gewarnt.
</p>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { Fragment } from "react";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
import type { ElementCategory } from "@/lib/elements";
// Nur-Lese-Matrix eines festgehaltenen Standes. Bewusst NICHT die Bearbeitungs-Matrix aus
// PlanView: Die trägt Inspector-Anbindung, Übergangs-Ampeln, Diff-Markierung und Dialoge --
// alles ohne Bedeutung für einen alten Stand, den man nur ansehen kann. Diese Ansicht zeigt
// stattdessen genau das, was eine Version ausmacht: Phasen, Elemente, Werte.
export function VersionMatrix({ computed }: { computed: PlanComputed }) {
const phases = computed.phases;
if (phases.length === 0) {
return <p className="text-sm text-muted">Dieser Stand enthielt noch keine Lebensphasen.</p>;
}
// Elemente in der gewohnten Kategorie-Reihenfolge, über alle Phasen gesammelt.
const elements = (() => {
const seen = new Map<string, { name: string; category: ElementCategory; ownerRole: string | null }>();
for (const p of phases) {
for (const e of p.elements) {
if (!seen.has(e.elementId)) {
seen.set(e.elementId, { name: e.name, category: e.category, ownerRole: e.ownerRole });
}
}
}
return [...seen.entries()]
.map(([id, v]) => ({ id, ...v }))
.sort((a, b) => {
const ca = CATEGORY_ORDER.indexOf(a.category);
const cb = CATEGORY_ORDER.indexOf(b.category);
return ca !== cb ? ca - cb : a.name.localeCompare(b.name, "de-CH");
});
})();
// Kategoriewechsel als Zwischenüberschrift -- dieselbe Gliederung wie in der Planansicht.
// Vorab bestimmt statt während des Renderns mitgezählt: Eine Variable, die der Render-Lauf
// fortschreibt, verhält sich bei erneutem Rendern nicht mehr gleich.
const headerAt = new Map<string, ElementCategory>();
elements.forEach((el, i) => {
if (i === 0 || el.category !== elements[i - 1].category) headerAt.set(el.id, el.category);
});
return (
<div className="max-h-[60vh] overflow-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 z-10">
<tr className="bg-surface-2 text-xs text-faint">
<th className="sticky left-0 z-20 bg-surface-2 px-3 py-2 text-left font-semibold">Element</th>
{phases.map((p) => (
<th key={p.id} className="whitespace-nowrap px-3 py-2 text-right font-semibold">
<div className="text-fg">{p.name}</div>
<div className="font-normal text-faint">{p.durationYears} J.</div>
</th>
))}
</tr>
</thead>
<tbody>
{elements.map((el) => {
const header = headerAt.get(el.id) ?? null;
return (
<Fragment key={el.id}>
{header && (
<tr className="border-t border-border bg-surface-2/60">
<td
colSpan={phases.length + 1}
className="sticky left-0 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint"
>
{CATEGORY_LABELS[header]}
</td>
</tr>
)}
<tr className="border-t border-border">
<td className="sticky left-0 z-10 bg-surface px-3 py-2">
<span className="font-medium text-fg">{el.name}</span>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<span className="ml-1.5 text-[10px] text-faint">
{el.ownerRole === "PERSON_A" ? "A" : "B"}
</span>
)}
</td>
{phases.map((p) => {
const c = p.elements.find((x) => x.elementId === el.id);
if (!c) return <td key={p.id} className="px-3 py-2 text-right text-faint"></td>;
return (
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right">
<div className={c.status === "ACTIVE" ? "text-fg" : "text-faint line-through"}>
{formatChf(c.startValue)}
</div>
{c.endValue !== c.startValue && (
<div className="text-[11px] text-muted"> {formatChf(c.endValue)}</div>
)}
</td>
);
})}
</tr>
</Fragment>
);
})}
{/* Vermögen je Phase -- die Kennzahl, für die der ganze Stand steht. */}
<tr className="border-t-2 border-border bg-surface-2">
<td className="sticky left-0 z-10 bg-surface-2 px-3 py-2 text-xs font-semibold text-fg">
Vermögen am Phasenende
</td>
{phases.map((p) => (
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right text-xs font-semibold text-fg">
{formatChf(p.endWealthNominal)}
<div className="font-normal text-faint">({formatChf(p.endWealthReal)} real)</div>
</td>
))}
</tr>
</tbody>
</table>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useState } from "react";
import { History } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { api } from "@/lib/api-client";
import type { PlanInput } from "@/lib/types";
// Kennzeichnet den aktuellen (ungespeicherten) Arbeitsstand -- im Gegensatz zu einer
// festgehaltenen Version.
export const CURRENT = "current";
export interface VersionOption {
id: string;
label: string; // "1.4" bzw. "1.4 (Hauptversion)"
isMajor: boolean;
comment: string | null;
}
// Lädt die Versionsliste eines Szenarios. Bewusst ohne Snapshots -- die kommen erst beim
// tatsächlichen Auswählen dazu.
export function useVersionOptions(scenarioId: string | null): VersionOption[] {
const [options, setOptions] = useState<VersionOption[]>([]);
useEffect(() => {
if (!scenarioId) return;
let cancelled = false;
(async () => {
try {
const data = await api.get<{
versions: { id: string; major: number; minor: number; isMajor: boolean; comment: string | null }[];
}>(`/api/scenarios/${scenarioId}/versions`);
if (cancelled) return;
setOptions(
data.versions.map((v) => ({
id: v.id,
label: `${v.major}.${v.minor}${v.isMajor ? " (Hauptversion)" : ""}`,
isMajor: v.isMajor,
comment: v.comment,
}))
);
} catch {
if (!cancelled) setOptions([]);
}
})();
return () => {
cancelled = true;
};
}, [scenarioId]);
return options;
}
// Holt den Plan-Stand einer Version. `CURRENT` liefert null -- dann gilt der Arbeitsstand.
export async function loadVersionPlan(scenarioId: string, versionId: string): Promise<PlanInput | null> {
if (versionId === CURRENT) return null;
const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${scenarioId}/versions/${versionId}`);
return data.plan;
}
// Ein kompakter Wähler je Szenario. Wird in allen vier Analysewerkzeugen verwendet, damit
// die Bedienung überall dieselbe ist.
export function VersionSelect({
scenarioId,
value,
onChange,
label,
compact,
}: {
scenarioId: string;
value: string;
onChange: (versionId: string) => void;
label?: string;
compact?: boolean;
}) {
const options = useVersionOptions(scenarioId);
return (
<label className={`flex items-center gap-1.5 ${compact ? "text-[11px]" : "text-xs"} text-muted`}>
{label && (
<span className="flex items-center font-medium">
<History className="mr-1 h-3.5 w-3.5 text-faint" />
{label}
</span>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`rounded-lg border border-border bg-surface px-2 py-1 text-fg ${compact ? "text-[11px]" : "text-xs"}`}
>
<option value={CURRENT}>Aktueller Stand</option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
{o.comment ? ` ${o.comment.slice(0, 40)}` : ""}
</option>
))}
</select>
</label>
);
}
// Hält die Auswahl "welcher Stand" für ein einzelnes Szenario. Solange `CURRENT` gewählt
// ist, wird der übergebene Arbeitsstand durchgereicht -- ohne Netzwerkzugriff.
//
// Der Snapshot ist bereits ein PlanInput, deshalb rechnen die Werkzeuge damit unverändert
// weiter; das Gerechnete entsteht bei ihnen lokal (computePlan ist rein und kostet ~0.2 ms).
export function useVersionedPlan(scenarioId: string, currentPlan: PlanInput) {
const [versionId, setVersionId] = useState<string>(CURRENT);
// Nur die geladenen Snapshots liegen im Zustand. Der Arbeitsstand ist bereits da und wird
// abgeleitet -- ihn in den Zustand zu spiegeln, hiesse ihn doppelt zu führen.
const [snapshots, setSnapshots] = useState<Record<string, PlanInput>>({});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (versionId === CURRENT || snapshots[versionId]) return;
let cancelled = false;
(async () => {
try {
const loaded = await loadVersionPlan(scenarioId, versionId);
if (cancelled) return;
if (loaded) setSnapshots((prev) => ({ ...prev, [versionId]: loaded }));
setError(null);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [scenarioId, versionId, snapshots]);
const plan = versionId === CURRENT ? currentPlan : (snapshots[versionId] ?? currentPlan);
// Solange der Snapshot fehlt und kein Fehler vorliegt, laeuft der Abruf noch. Abgeleitet
// statt als eigener Zustand -- ein Ladeflag, das nur den Abruf spiegelt, ist redundant.
const loading = versionId !== CURRENT && !snapshots[versionId] && !error;
return { versionId, setVersionId, plan, loading, error };
}
// Kopfzeile für die Analyse-Dialoge mit einem einzelnen Szenario.
export function VersionBar({
scenarioId,
versionId,
onChange,
loading,
}: {
scenarioId: string;
versionId: string;
onChange: (id: string) => void;
loading?: boolean;
}) {
return (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
<VersionSelect scenarioId={scenarioId} value={versionId} onChange={onChange} label="Berechnungsgrundlage" />
<VersionHint />
{loading && <span className="text-[11px] text-muted">wird geladen</span>}
{versionId !== CURRENT && !loading && (
<span className="rounded bg-attention-soft px-1.5 py-0.5 text-[10px] font-semibold text-attention-soft-fg">
Nicht der aktuelle Stand
</span>
)}
</div>
);
}
// Hinweiszeile für die Analyse-Dialoge.
export function VersionHint() {
return (
<InfoBubble text="Standardmässig rechnen die Werkzeuge mit dem aktuellen Arbeitsstand. Du kannst stattdessen jede festgehaltene Version wählen dann wird genau der damalige Stand gerechnet." />
);
}