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:
@@ -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 })}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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." />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user