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:
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { phaseDataSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert die Werte eines Elements innerhalb einer Lebensphase (Upsert).
|
||||
@@ -29,5 +30,6 @@ export async function PUT(
|
||||
update: { data: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
|
||||
const patchSchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
@@ -22,6 +23,7 @@ export async function PATCH(
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } });
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -37,5 +39,6 @@ export async function DELETE(
|
||||
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.financialElement.delete({ where: { id: element.id } });
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { transitionDataSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert den Übergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
|
||||
@@ -29,5 +30,6 @@ export async function PUT(
|
||||
update: { data: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPhase } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { cashTransitionSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 übernehmen oder
|
||||
@@ -27,5 +28,6 @@ export async function PUT(
|
||||
data: { cashTransition: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(phase.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { maxPhaseDuration } from "@/lib/calculations";
|
||||
|
||||
const updatePhaseSchema = z.object({
|
||||
@@ -47,6 +48,7 @@ export async function PUT(
|
||||
durationYears: duration ?? undefined,
|
||||
},
|
||||
});
|
||||
await touchScenario(existing.scenarioId, userId);
|
||||
return NextResponse.json({ phase: { id: phase.id } });
|
||||
}
|
||||
|
||||
@@ -70,5 +72,6 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
await prisma.phase.delete({ where: { id: phaseId } });
|
||||
await touchScenario(phase.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
|
||||
const personSchema = z.object({
|
||||
role: z.enum(["PERSON_A", "PERSON_B"]),
|
||||
@@ -81,6 +82,8 @@ export async function POST(request: NextRequest) {
|
||||
include: { scenarios: true },
|
||||
});
|
||||
|
||||
// Das frisch angelegte Basisszenario startet mit Version 1.0.
|
||||
await touchScenario(plan.scenarios[0].id, userId);
|
||||
return NextResponse.json(
|
||||
{ plan: { id: plan.id }, scenario: { id: plan.scenarios[0].id } },
|
||||
{ status: 201 }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
|
||||
const copySchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
@@ -90,5 +91,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return created.id;
|
||||
});
|
||||
|
||||
// Das kopierte Szenario startet mit einer eigenen Version 1.0.
|
||||
await touchScenario(newId, userId);
|
||||
return NextResponse.json({ scenarioId: newId }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
|
||||
|
||||
const createSchema = z.object({
|
||||
@@ -65,5 +66,6 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ element: { id: element.id } }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
|
||||
import { num, type PhaseData } from "@/lib/elements";
|
||||
@@ -74,6 +75,7 @@ export async function POST(
|
||||
return created;
|
||||
});
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { toPlanInput, getOwnedScenario, getOwnedScenarioWithMeta } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
|
||||
|
||||
@@ -77,6 +78,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
},
|
||||
});
|
||||
});
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
@@ -88,6 +90,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
"initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
|
||||
},
|
||||
});
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { restoreImpact, restoreVersion } from "@/lib/versioning-db";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
async function ownedVersion(scenarioId: string, versionId: string, userId: string) {
|
||||
return prisma.scenarioVersion.findFirst({
|
||||
where: { id: versionId, scenarioId, scenario: { plan: { userId } } },
|
||||
});
|
||||
}
|
||||
|
||||
// Ein einzelner Stand: der Snapshot selbst, das daraus Gerechnete und -- als Vorwarnung für
|
||||
// den Wiederherstellen-Knopf -- welche Kind-Szenarien dabei ihre Diff-Basis verlieren würden.
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId, versionId } = await params;
|
||||
|
||||
const version = await ownedVersion(scenarioId, versionId, userId);
|
||||
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
|
||||
|
||||
const snapshot = version.snapshot as unknown as PlanInput;
|
||||
|
||||
return NextResponse.json({
|
||||
version: {
|
||||
id: version.id,
|
||||
major: version.major,
|
||||
minor: version.minor,
|
||||
comment: version.comment,
|
||||
isMajor: version.isMajor,
|
||||
createdAt: version.createdAt,
|
||||
},
|
||||
plan: snapshot,
|
||||
computed: computePlan(snapshot),
|
||||
impact: await restoreImpact(scenarioId, snapshot),
|
||||
});
|
||||
}
|
||||
|
||||
// Setzt das Szenario auf diesen Stand zurück. Löscht KEINE Historie: Der wiederhergestellte
|
||||
// Stand wird selbst als neue Version mit Herkunftsvermerk festgehalten.
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId, versionId } = await params;
|
||||
|
||||
const version = await ownedVersion(scenarioId, versionId, userId);
|
||||
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
|
||||
|
||||
const ref = await restoreVersion(scenarioId, versionId, userId);
|
||||
if (!ref) return NextResponse.json({ error: "Wiederherstellen fehlgeschlagen." }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ version: ref });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { createMajorVersion } from "@/lib/versioning-db";
|
||||
import { isValidMajorComment } from "@/lib/versioning";
|
||||
|
||||
async function ownedScenario(scenarioId: string, userId: string) {
|
||||
return prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
|
||||
}
|
||||
|
||||
// Änderungshistorie eines Szenarios, neueste zuerst. Ohne die Snapshots -- die sind je
|
||||
// Version zig Kilobyte gross und werden erst beim Anzeigen einzeln geladen.
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await ownedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const rows = await prisma.scenarioVersion.findMany({
|
||||
where: { scenarioId },
|
||||
orderBy: [{ major: "desc" }, { minor: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
major: true,
|
||||
minor: true,
|
||||
comment: true,
|
||||
isMajor: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdBy: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
currentMajor: scenario.currentMajor,
|
||||
versions: rows.map((r) => ({
|
||||
id: r.id,
|
||||
major: r.major,
|
||||
minor: r.minor,
|
||||
comment: r.comment,
|
||||
isMajor: r.isMajor,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
author: r.createdBy.username,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const majorSchema = z.object({ comment: z.string().min(3).max(500) });
|
||||
|
||||
// Legt den aktuellen Stand als HAUPTVERSION fest. Der Kommentar ist Pflicht -- eine
|
||||
// Hauptversion ohne Begründung wäre nur eine Zahl.
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await ownedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = majorSchema.safeParse(await request.json());
|
||||
if (!parsed.success || !isValidMajorComment(parsed.data.comment)) {
|
||||
return NextResponse.json({ error: "Bitte einen Kommentar zur Hauptversion angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const ref = await createMajorVersion(scenarioId, userId, parsed.data.comment);
|
||||
if (!ref) return NextResponse.json({ error: "Hauptversion konnte nicht angelegt werden." }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ version: ref }, { status: 201 });
|
||||
}
|
||||
@@ -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." />
|
||||
);
|
||||
}
|
||||
@@ -59,5 +59,32 @@ describe("Datenbank-Migrationen", () => {
|
||||
expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
|
||||
}
|
||||
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
|
||||
|
||||
// --- Versionierung ---
|
||||
expect(tables, "Tabelle ScenarioVersion fehlt").toContain("ScenarioVersion");
|
||||
expect(scenCols, "Scenario.currentMajor fehlt").toContain("currentMajor");
|
||||
|
||||
const verCols = await cols("ScenarioVersion");
|
||||
for (const c of ["scenarioId", "major", "minor", "comment", "isMajor", "createdById", "snapshot"]) {
|
||||
expect(verCols, `ScenarioVersion.${c} fehlt`).toContain(c);
|
||||
}
|
||||
|
||||
// Bestehende Szenarien müssen nach der Migration eine gültige Hauptversion tragen --
|
||||
// sonst stünde ein vor der Migration angelegter Plan ohne Version da.
|
||||
const major = await db.query<{ column_default: string | null; is_nullable: string }>(
|
||||
`SELECT column_default, is_nullable FROM information_schema.columns
|
||||
WHERE table_name='Scenario' AND column_name='currentMajor'`
|
||||
);
|
||||
expect(major.rows[0].is_nullable).toBe("NO");
|
||||
expect(major.rows[0].column_default).toContain("1");
|
||||
|
||||
// A.B muss je Szenario eindeutig sein, sonst kollidieren zwei Sitzungen auf derselben
|
||||
// Nummer und die Historie wird mehrdeutig.
|
||||
const idx = (
|
||||
await db.query<{ indexname: string }>(
|
||||
`SELECT indexname FROM pg_indexes WHERE tablename='ScenarioVersion'`
|
||||
)
|
||||
).rows.map((r) => r.indexname);
|
||||
expect(idx).toContain("ScenarioVersion_scenarioId_major_minor_key");
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Wächter über die Vollständigkeit der Historie.
|
||||
//
|
||||
// Die Versionierung hängt daran, dass JEDER inhaltsverändernde Endpunkt `touchScenario`
|
||||
// aufruft. Ein vergessener Pfad fällt nicht auf -- er erzeugt einfach still keine Version,
|
||||
// und die Historie hat eine Lücke, die man erst Wochen später bemerkt. Dieser Test liest die
|
||||
// Route-Dateien und prüft das statisch.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const API = path.join(process.cwd(), "src", "app", "api");
|
||||
|
||||
function routeFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...routeFiles(full));
|
||||
else if (entry.name === "route.ts") out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Endpunkte, die schreiben, aber bewusst KEINE Version erzeugen -- jeweils mit Begründung.
|
||||
const EXEMPT: Record<string, string> = {
|
||||
"auth/login": "kein Szenario betroffen",
|
||||
"auth/logout": "kein Szenario betroffen",
|
||||
"auth/register": "kein Szenario betroffen",
|
||||
"auth/change-password": "kein Szenario betroffen",
|
||||
"plans/[planId]": "ändert nur den Plan-Namen bzw. löscht den ganzen Plan – kein Szenario-Inhalt",
|
||||
"scenarios/[scenarioId]/versions": "erzeugt Versionen selbst (Hauptversion / Wiederherstellen)",
|
||||
"scenarios/[scenarioId]/versions/[versionId]": "erzeugt Versionen selbst",
|
||||
};
|
||||
|
||||
describe("Versionierung: Abdeckung der Schreibpfade", () => {
|
||||
const files = routeFiles(API);
|
||||
|
||||
it("findet überhaupt Endpunkte", () => {
|
||||
expect(files.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("ruft in jedem inhaltsverändernden Endpunkt touchScenario auf", () => {
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
const rel = path
|
||||
.relative(API, file)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\/route\.ts$/, "");
|
||||
|
||||
// Schreibt dieser Endpunkt überhaupt?
|
||||
const mutates = /export async function (POST|PUT|PATCH|DELETE)/.test(src);
|
||||
if (!mutates) continue;
|
||||
if (rel in EXEMPT) continue;
|
||||
|
||||
// Ein DELETE, das das Szenario selbst entfernt, braucht keine Version mehr.
|
||||
const onlyDeletesScenario =
|
||||
rel === "scenarios/[scenarioId]" && !/export async function (POST|PUT|PATCH)/.test(src);
|
||||
if (onlyDeletesScenario) continue;
|
||||
|
||||
if (!src.includes("touchScenario(")) missing.push(rel);
|
||||
}
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`Diese Endpunkte verändern Inhalte, erzeugen aber keine Version:\n ${missing.join(
|
||||
"\n "
|
||||
)}\nEntweder touchScenario ergänzen oder in EXEMPT mit Begründung eintragen.`
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("hält die Ausnahmeliste frei von Karteileichen", () => {
|
||||
// Eine Ausnahme für einen Endpunkt, den es nicht mehr gibt, verschleiert später eine
|
||||
// echte Lücke.
|
||||
const known = new Set(
|
||||
files.map((f) => path.relative(API, f).replace(/\\/g, "/").replace(/\/route\.ts$/, ""))
|
||||
);
|
||||
const stale = Object.keys(EXEMPT).filter((k) => !known.has(k));
|
||||
expect(stale, `Ausnahmen ohne zugehörigen Endpunkt: ${stale.join(", ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
// Datenbank-Anbindung der Versionierung. Die Entscheidungslogik (wann eine Version entsteht,
|
||||
// wie sie nummeriert wird) liegt in `versioning.ts` und ist dort ohne Datenbank getestet.
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { planInclude, toPlanInput } from "@/lib/queries";
|
||||
import {
|
||||
assessRestore,
|
||||
decideVersion,
|
||||
nextMajor,
|
||||
planRestore,
|
||||
restoreComment,
|
||||
type LatestVersion,
|
||||
type RestoreImpact,
|
||||
type VersionRef,
|
||||
} from "@/lib/versioning";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const asJson = (plan: PlanInput) => plan as unknown as Prisma.InputJsonValue;
|
||||
|
||||
async function loadPlanInput(scenarioId: string): Promise<PlanInput | null> {
|
||||
const scenario = await prisma.scenario.findUnique({ where: { id: scenarioId }, include: planInclude });
|
||||
return scenario ? toPlanInput(scenario) : null;
|
||||
}
|
||||
|
||||
async function loadLatest(scenarioId: string): Promise<LatestVersion | null> {
|
||||
const v = await prisma.scenarioVersion.findFirst({
|
||||
where: { scenarioId },
|
||||
orderBy: [{ major: "desc" }, { minor: "desc" }],
|
||||
});
|
||||
if (!v) return null;
|
||||
return {
|
||||
id: v.id,
|
||||
major: v.major,
|
||||
minor: v.minor,
|
||||
isMajor: v.isMajor,
|
||||
createdById: v.createdById,
|
||||
updatedAt: v.updatedAt,
|
||||
snapshot: v.snapshot as unknown as PlanInput,
|
||||
};
|
||||
}
|
||||
|
||||
// Wird nach JEDEM inhaltsveraendernden Schreibvorgang aufgerufen. Legt je nach Entscheidung
|
||||
// eine neue Nebenversion an, aktualisiert die laufende oder tut nichts.
|
||||
//
|
||||
// Bewusst fehlertolerant: Schlaegt das Festhalten der Version fehl, darf das die eigentliche
|
||||
// Aenderung des Nutzers nicht scheitern lassen -- die Historie ist Begleitinformation, nicht
|
||||
// der Zweck der Anfrage.
|
||||
export async function touchScenario(scenarioId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
const next = await loadPlanInput(scenarioId);
|
||||
if (!next) return;
|
||||
|
||||
const latest = await loadLatest(scenarioId);
|
||||
const decision = decideVersion(latest, next, userId, new Date());
|
||||
|
||||
if (decision.action === "skip") return;
|
||||
|
||||
if (decision.action === "update") {
|
||||
await prisma.scenarioVersion.update({
|
||||
where: { id: decision.versionId },
|
||||
data: { snapshot: asJson(next) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.scenarioVersion.create({
|
||||
data: {
|
||||
scenarioId,
|
||||
major: decision.major,
|
||||
minor: decision.minor,
|
||||
createdById: userId,
|
||||
snapshot: asJson(next),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Versionierung fehlgeschlagen", { scenarioId, err });
|
||||
}
|
||||
}
|
||||
|
||||
// Legt den aktuellen Stand als HAUPTVERSION fest. Anders als eine Nebenversion wird sie nie
|
||||
// zusammengefasst und nie nachtraeglich veraendert.
|
||||
export async function createMajorVersion(
|
||||
scenarioId: string,
|
||||
userId: string,
|
||||
comment: string
|
||||
): Promise<VersionRef | null> {
|
||||
const snapshot = await loadPlanInput(scenarioId);
|
||||
if (!snapshot) return null;
|
||||
|
||||
const latest = await loadLatest(scenarioId);
|
||||
const ref = nextMajor(latest);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.scenarioVersion.create({
|
||||
data: {
|
||||
scenarioId,
|
||||
major: ref.major,
|
||||
minor: ref.minor,
|
||||
comment: comment.trim(),
|
||||
isMajor: true,
|
||||
createdById: userId,
|
||||
snapshot: asJson(snapshot),
|
||||
},
|
||||
}),
|
||||
prisma.scenario.update({ where: { id: scenarioId }, data: { currentMajor: ref.major } }),
|
||||
]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
// Welche Bezuege wuerde ein Wiederherstellen in den Kind-Szenarien brechen? Wird vor dem
|
||||
// eigentlichen Wiederherstellen abgefragt, damit der Dialog warnen kann.
|
||||
export async function restoreImpact(scenarioId: string, snapshot: PlanInput): Promise<RestoreImpact> {
|
||||
const children = await prisma.scenario.findMany({
|
||||
where: { parentScenarioId: scenarioId },
|
||||
select: {
|
||||
name: true,
|
||||
elements: { select: { sourceElementId: true } },
|
||||
phases: { select: { sourcePhaseId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return assessRestore(
|
||||
snapshot,
|
||||
children.map((c) => ({
|
||||
name: c.name,
|
||||
elementSourceIds: c.elements.map((e) => e.sourceElementId).filter((x): x is string => !!x),
|
||||
phaseSourceIds: c.phases.map((p) => p.sourcePhaseId).filter((x): x is string => !!x),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Setzt das Szenario auf den Stand einer Version zurueck.
|
||||
//
|
||||
// IDs von Phasen und Elementen werden ERHALTEN (der Snapshot traegt sie mit). Wuerden hier
|
||||
// neue IDs entstehen, verloeren alle Kind-Szenarien ihre Diff-Basis: `sourceElementId` zeigt
|
||||
// auf die IDs dieses Szenarios, und jedes Element wuerde schlagartig als "neu" statt als
|
||||
// "geaendert" gelten.
|
||||
//
|
||||
// Das Wiederherstellen loescht KEINE Historie -- es legt anschliessend eine neue Version an.
|
||||
export async function restoreVersion(
|
||||
scenarioId: string,
|
||||
versionId: string,
|
||||
userId: string
|
||||
): Promise<VersionRef | null> {
|
||||
const version = await prisma.scenarioVersion.findFirst({ where: { id: versionId, scenarioId } });
|
||||
if (!version) return null;
|
||||
const snap = version.snapshot as unknown as PlanInput;
|
||||
|
||||
// Was zu tun ist, entscheidet die reine Funktion `planRestore` (dort getestet); hier wird
|
||||
// der Plan nur noch ausgefuehrt.
|
||||
const [curPhases, curElements, curPersons] = await Promise.all([
|
||||
prisma.phase.findMany({ where: { scenarioId }, select: { id: true } }),
|
||||
prisma.financialElement.findMany({ where: { scenarioId }, select: { id: true } }),
|
||||
prisma.person.findMany({ where: { scenarioId }, select: { role: true } }),
|
||||
]);
|
||||
const plan = planRestore(snap, {
|
||||
phaseIds: curPhases.map((p) => p.id),
|
||||
elementIds: curElements.map((e) => e.id),
|
||||
personRoles: curPersons.map((p) => p.role),
|
||||
});
|
||||
|
||||
const phaseById = new Map(snap.phases.map((p) => [p.id, p]));
|
||||
const elementById = new Map(snap.elements.map((e) => [e.id, e]));
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Profil.
|
||||
await tx.scenario.update({
|
||||
where: { id: scenarioId },
|
||||
data: {
|
||||
householdType: snap.householdType,
|
||||
inflationRateDefault: snap.inflationRateDefault,
|
||||
initialCash: snap.initialCash,
|
||||
startYear: snap.startYear ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Personen: an der Rolle festgemacht (je Szenario eindeutig).
|
||||
for (const p of snap.persons) {
|
||||
await tx.person.upsert({
|
||||
where: { scenarioId_role: { scenarioId, role: p.role } },
|
||||
create: { id: p.id, scenarioId, role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge },
|
||||
update: { name: p.name, age: p.age, retirementAge: p.retirementAge },
|
||||
});
|
||||
}
|
||||
if (plan.deletePersonRoles.length > 0) {
|
||||
await tx.person.deleteMany({
|
||||
where: { scenarioId, role: { in: plan.deletePersonRoles as never } },
|
||||
});
|
||||
}
|
||||
|
||||
// Was der alte Stand nicht kannte, verschwindet -- samt seiner Werte (Cascade).
|
||||
if (plan.deletePhaseIds.length > 0) {
|
||||
await tx.phase.deleteMany({ where: { id: { in: plan.deletePhaseIds } } });
|
||||
}
|
||||
if (plan.deleteElementIds.length > 0) {
|
||||
await tx.financialElement.deleteMany({ where: { id: { in: plan.deleteElementIds } } });
|
||||
}
|
||||
|
||||
// Sequenznummern zuerst auf negative Werte parken: Sie sind je Szenario eindeutig, und
|
||||
// beim Zurueckgehen koennen sich alte und neue Nummern ueberschneiden.
|
||||
for (const [i, id] of plan.parkPhaseIds.entries()) {
|
||||
await tx.phase.update({ where: { id }, data: { sequenceNumber: -1 - i } });
|
||||
}
|
||||
|
||||
const phaseData = (id: string) => {
|
||||
const ph = phaseById.get(id)!;
|
||||
return {
|
||||
sequenceNumber: ph.sequenceNumber,
|
||||
name: ph.name,
|
||||
durationYears: ph.durationYears,
|
||||
cashTransition: (ph.cashTransition ?? {}) as Prisma.InputJsonValue,
|
||||
sourcePhaseId: ph.sourcePhaseId ?? null,
|
||||
};
|
||||
};
|
||||
for (const id of plan.updatePhaseIds) await tx.phase.update({ where: { id }, data: phaseData(id) });
|
||||
for (const id of plan.createPhaseIds) {
|
||||
await tx.phase.create({ data: { id, scenarioId, ...phaseData(id) } });
|
||||
}
|
||||
|
||||
const elementData = (id: string) => {
|
||||
const el = elementById.get(id)!;
|
||||
return {
|
||||
category: el.category,
|
||||
name: el.name,
|
||||
ownerRole: el.ownerRole ?? null,
|
||||
orderIndex: el.orderIndex,
|
||||
sourceElementId: el.sourceElementId ?? null,
|
||||
};
|
||||
};
|
||||
for (const id of plan.updateElementIds) {
|
||||
await tx.financialElement.update({ where: { id }, data: elementData(id) });
|
||||
}
|
||||
for (const id of plan.createElementIds) {
|
||||
await tx.financialElement.create({ data: { id, scenarioId, ...elementData(id) } });
|
||||
}
|
||||
|
||||
// Werte vollstaendig ersetzen statt abgleichen -- der Snapshot ist die Wahrheit.
|
||||
const allElementIds = [...plan.updateElementIds, ...plan.createElementIds];
|
||||
if (allElementIds.length > 0) {
|
||||
await tx.elementPhaseValue.deleteMany({ where: { elementId: { in: allElementIds } } });
|
||||
await tx.elementTransitionValue.deleteMany({ where: { elementId: { in: allElementIds } } });
|
||||
}
|
||||
for (const { elementId, phaseId } of plan.phaseValueKeys) {
|
||||
await tx.elementPhaseValue.create({
|
||||
data: {
|
||||
elementId,
|
||||
phaseId,
|
||||
data: elementById.get(elementId)!.phaseValues[phaseId] as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const { elementId, fromPhaseId } of plan.transitionValueKeys) {
|
||||
await tx.elementTransitionValue.create({
|
||||
data: {
|
||||
elementId,
|
||||
fromPhaseId,
|
||||
data: elementById.get(elementId)!.transitionValues[fromPhaseId] as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Der wiederhergestellte Stand ist selbst eine neue Version -- mit Herkunftsvermerk, und
|
||||
// ohne dass irgendetwas aus der Historie verloren geht.
|
||||
const latest = await loadLatest(scenarioId);
|
||||
const current = await loadPlanInput(scenarioId);
|
||||
if (!current) return null;
|
||||
|
||||
const created = await prisma.scenarioVersion.create({
|
||||
data: {
|
||||
scenarioId,
|
||||
major: latest?.major ?? 1,
|
||||
minor: (latest?.minor ?? 0) + 1,
|
||||
comment: restoreComment({ major: version.major, minor: version.minor }),
|
||||
createdById: userId,
|
||||
snapshot: asJson(current),
|
||||
},
|
||||
});
|
||||
|
||||
return { major: created.major, minor: created.minor };
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
assessRestore,
|
||||
canonicalJson,
|
||||
COALESCE_WINDOW_MS,
|
||||
decideVersion,
|
||||
formatVersion,
|
||||
isValidMajorComment,
|
||||
nextMajor,
|
||||
planRestore,
|
||||
restoreComment,
|
||||
sameSnapshot,
|
||||
type LatestVersion,
|
||||
} from "@/lib/versioning";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
function snap(overrides: Partial<PlanInput> = {}): PlanInput {
|
||||
return {
|
||||
id: "s",
|
||||
name: "Szenario",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 1.5,
|
||||
initialCash: 0,
|
||||
startYear: 2026,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} }],
|
||||
elements: [
|
||||
{
|
||||
id: "e1",
|
||||
category: "INCOME",
|
||||
name: "Lohn",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 100000 } },
|
||||
transitionValues: {},
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
function latest(over: Partial<LatestVersion> = {}): LatestVersion {
|
||||
return {
|
||||
id: "v1",
|
||||
major: 1,
|
||||
minor: 3,
|
||||
isMajor: false,
|
||||
createdById: "u1",
|
||||
updatedAt: new Date("2026-07-19T10:00:00Z"),
|
||||
snapshot: snap(),
|
||||
...over,
|
||||
} as LatestVersion;
|
||||
}
|
||||
|
||||
const T0 = new Date("2026-07-19T10:00:00Z");
|
||||
const after = (ms: number) => new Date(T0.getTime() + ms);
|
||||
|
||||
describe("canonicalJson", () => {
|
||||
it("ist unabhängig von der Schlüsselreihenfolge", () => {
|
||||
// Genau der Fall aus der Praxis: `toPlanInput` baut phaseValues aus Datenbankzeilen auf,
|
||||
// deren Reihenfolge nicht garantiert ist.
|
||||
const a = { phaseValues: { p1: { amount: 1 }, p2: { amount: 2 } } };
|
||||
const b = { phaseValues: { p2: { amount: 2 }, p1: { amount: 1 } } };
|
||||
expect(canonicalJson(a)).toBe(canonicalJson(b));
|
||||
// Ein echter Unterschied bleibt aber einer.
|
||||
expect(canonicalJson(a)).not.toBe(canonicalJson({ phaseValues: { p1: { amount: 9 } } }));
|
||||
});
|
||||
|
||||
it("lässt die Reihenfolge von Listen in Ruhe", () => {
|
||||
// Bei Phasen ist die Reihenfolge inhaltlich bedeutsam.
|
||||
expect(canonicalJson([1, 2])).not.toBe(canonicalJson([2, 1]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("decideVersion", () => {
|
||||
it("legt für den allerersten Stand 1.0 an", () => {
|
||||
expect(decideVersion(null, snap(), "u1", T0)).toEqual({ action: "create", major: 1, minor: 0 });
|
||||
});
|
||||
|
||||
it("tut nichts, wenn sich inhaltlich nichts geändert hat", () => {
|
||||
// Dialog geöffnet, unverändert gespeichert -- darf keine Version erzeugen.
|
||||
const d = decideVersion(latest(), snap(), "u1", after(1000));
|
||||
expect(d.action).toBe("skip");
|
||||
});
|
||||
|
||||
it("fasst Schreibvorgänge derselben Sitzung zu EINER Nebenversion zusammen", () => {
|
||||
// Der Kern der Entscheidung: Der Verteil-Dialog schreibt einmal je Zielelement, der
|
||||
// Assistent ~14 Mal. Ohne Zusammenfassung wäre die Historie ein Tastenprotokoll.
|
||||
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u1", after(60_000));
|
||||
expect(d).toEqual({ action: "update", versionId: "v1" });
|
||||
});
|
||||
|
||||
it("beginnt nach Ablauf des Fensters eine neue Nebenversion", () => {
|
||||
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u1", after(COALESCE_WINDOW_MS + 1));
|
||||
expect(d).toEqual({ action: "create", major: 1, minor: 4 });
|
||||
});
|
||||
|
||||
it("fasst Änderungen verschiedener Benutzer nie zusammen", () => {
|
||||
// Vorbereitung auf den Finanzberater: fremde Änderungen dürfen nicht unter einem Namen
|
||||
// zusammenlaufen, auch nicht innerhalb des Zeitfensters.
|
||||
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u2", after(1000));
|
||||
expect(d).toEqual({ action: "create", major: 1, minor: 4 });
|
||||
});
|
||||
|
||||
it("verändert eine Hauptversion nie nachträglich", () => {
|
||||
// Eine Hauptversion ist ein bewusst gesetzter Schnitt. Die nächste Änderung beginnt A.1,
|
||||
// auch wenn sie eine Sekunde später kommt.
|
||||
const d = decideVersion(
|
||||
latest({ isMajor: true, major: 2, minor: 0 }),
|
||||
snap({ initialCash: 5000 }),
|
||||
"u1",
|
||||
after(1000)
|
||||
);
|
||||
expect(d).toEqual({ action: "create", major: 2, minor: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hauptversionen", () => {
|
||||
it("zählt die Hauptversion hoch und setzt die Nebenversion zurück", () => {
|
||||
expect(nextMajor({ major: 1, minor: 7 })).toEqual({ major: 2, minor: 0 });
|
||||
expect(nextMajor(null)).toEqual({ major: 1, minor: 0 });
|
||||
});
|
||||
|
||||
it("verlangt einen echten Kommentar", () => {
|
||||
expect(isValidMajorComment("Vor dem Hauskauf")).toBe(true);
|
||||
expect(isValidMajorComment(" ")).toBe(false);
|
||||
expect(isValidMajorComment("ok")).toBe(false);
|
||||
});
|
||||
|
||||
it("formatiert A.B", () => {
|
||||
expect(formatVersion({ major: 2, minor: 0 })).toBe("2.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assessRestore", () => {
|
||||
const s = snap(); // kennt Phase p1 und Element e1
|
||||
|
||||
it("meldet nichts, wenn alle Bezüge der Kinder im alten Stand existieren", () => {
|
||||
const impact = assessRestore(s, [
|
||||
{ name: "Frühpension", elementSourceIds: ["e1"], phaseSourceIds: ["p1"] },
|
||||
]);
|
||||
expect(impact.affectedChildren).toEqual([]);
|
||||
expect(impact.lostElementIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("meldet Kind-Szenarien, deren Diff-Basis wegfällt", () => {
|
||||
// Das Kind zeigt auf ein Element, das es im wiederhergestellten Stand noch nicht gab.
|
||||
// Dann erschiene es dort schlagartig als "neu" statt als "geändert".
|
||||
const impact = assessRestore(s, [
|
||||
{ name: "Frühpension", elementSourceIds: ["e1", "e99"], phaseSourceIds: ["p1"] },
|
||||
{ name: "Umzug", elementSourceIds: ["e1"], phaseSourceIds: ["p1"] },
|
||||
]);
|
||||
expect(impact.affectedChildren).toEqual(["Frühpension"]);
|
||||
expect(impact.lostElementIds).toEqual(["e99"]);
|
||||
expect(impact.lostPhaseIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("erkennt auch verlorene Phasenbezüge und zählt jede ID nur einmal", () => {
|
||||
const impact = assessRestore(s, [
|
||||
{ name: "A", elementSourceIds: [], phaseSourceIds: ["p9"] },
|
||||
{ name: "B", elementSourceIds: [], phaseSourceIds: ["p9"] },
|
||||
]);
|
||||
expect(impact.lostPhaseIds).toEqual(["p9"]);
|
||||
expect(impact.affectedChildren).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planRestore", () => {
|
||||
// Alter Stand: zwei Phasen, ein Element mit Werten in beiden.
|
||||
const old = snap({
|
||||
phases: [
|
||||
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
|
||||
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
|
||||
],
|
||||
elements: [
|
||||
{
|
||||
id: "e1",
|
||||
category: "INCOME",
|
||||
name: "Lohn",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 100000 }, p2: { amount: 0 } },
|
||||
transitionValues: { p1: {} },
|
||||
},
|
||||
],
|
||||
} as unknown as Partial<PlanInput>);
|
||||
|
||||
it("erhält bestehende IDs, statt neu anzulegen", () => {
|
||||
// Der Kern: Kind-Szenarien zeigen über sourceElementId auf genau diese IDs.
|
||||
const plan = planRestore(old, { phaseIds: ["p1", "p2"], elementIds: ["e1"], personRoles: ["PERSON_A"] });
|
||||
expect(plan.updatePhaseIds).toEqual(["p1", "p2"]);
|
||||
expect(plan.updateElementIds).toEqual(["e1"]);
|
||||
expect(plan.createPhaseIds).toEqual([]);
|
||||
expect(plan.createElementIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("entfernt, was der alte Stand nicht kannte", () => {
|
||||
const plan = planRestore(old, {
|
||||
phaseIds: ["p1", "p2", "p3"],
|
||||
elementIds: ["e1", "e2"],
|
||||
personRoles: ["PERSON_A", "PERSON_B"],
|
||||
});
|
||||
expect(plan.deletePhaseIds).toEqual(["p3"]);
|
||||
expect(plan.deleteElementIds).toEqual(["e2"]);
|
||||
// Der Haushalt war damals eine Einzelperson -- Person B muss weg.
|
||||
expect(plan.deletePersonRoles).toEqual(["PERSON_B"]);
|
||||
});
|
||||
|
||||
it("legt neu an, was seither gelöscht wurde", () => {
|
||||
const plan = planRestore(old, { phaseIds: ["p1"], elementIds: [], personRoles: ["PERSON_A"] });
|
||||
expect(plan.createPhaseIds).toEqual(["p2"]);
|
||||
expect(plan.createElementIds).toEqual(["e1"]);
|
||||
expect(plan.updatePhaseIds).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("parkt nur die überlebenden Phasen zum Umnummerieren", () => {
|
||||
// Die zu löschenden sind vorher schon weg -- sie zu parken wäre ein Fehler.
|
||||
const plan = planRestore(old, { phaseIds: ["p2", "p3"], elementIds: [], personRoles: [] });
|
||||
expect(plan.parkPhaseIds).toEqual(["p2"]);
|
||||
expect(plan.deletePhaseIds).toEqual(["p3"]);
|
||||
});
|
||||
|
||||
it("verwirft Werte, die auf inzwischen entfernte Phasen zeigen", () => {
|
||||
// Ein Element trägt einen Wert für eine Phase, die dieser Stand gar nicht kannte.
|
||||
const withOrphan = snap({
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} }],
|
||||
elements: [
|
||||
{
|
||||
id: "e1",
|
||||
category: "INCOME",
|
||||
name: "Lohn",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 1 }, pX: { amount: 2 } },
|
||||
transitionValues: { pX: {} },
|
||||
},
|
||||
],
|
||||
} as unknown as Partial<PlanInput>);
|
||||
|
||||
const plan = planRestore(withOrphan, { phaseIds: ["p1"], elementIds: ["e1"], personRoles: [] });
|
||||
expect(plan.phaseValueKeys).toEqual([{ elementId: "e1", phaseId: "p1" }]);
|
||||
// Ein Übergangswert nach einer nicht existierenden Phase würde beim Schreiben auf einen
|
||||
// Fremdschlüsselfehler laufen.
|
||||
expect(plan.transitionValueKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("führt alle Werte des alten Standes auf", () => {
|
||||
const plan = planRestore(old, { phaseIds: ["p1", "p2"], elementIds: ["e1"], personRoles: [] });
|
||||
expect(plan.phaseValueKeys).toHaveLength(2);
|
||||
expect(plan.transitionValueKeys).toEqual([{ elementId: "e1", fromPhaseId: "p1" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreComment", () => {
|
||||
it("hält die Herkunft fest", () => {
|
||||
expect(restoreComment({ major: 1, minor: 3 })).toBe("Wiederhergestellt aus 1.3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameSnapshot", () => {
|
||||
it("erkennt inhaltliche Gleichheit trotz anderer Schlüsselreihenfolge", () => {
|
||||
const a = snap();
|
||||
const b = snap({ elements: [{ ...snap().elements[0] }] });
|
||||
expect(sameSnapshot(a, b)).toBe(true);
|
||||
expect(sameSnapshot(a, snap({ inflationRateDefault: 2 }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// Versionierung je Szenario (SPEZIFIKATION 3.8, 4.16).
|
||||
//
|
||||
// Version A.B:
|
||||
// B (Nebenversion) -- automatisch, EINE je Bearbeitungssitzung.
|
||||
// A (Hauptversion) -- manuell, mit Pflichtkommentar; setzt B auf 0 zurueck.
|
||||
//
|
||||
// Warum nicht je Schreibvorgang: FPT hat keinen Speichern-Knopf, jede Aenderung schreibt
|
||||
// sofort. Ein Durchlauf des Plan-Assistenten macht ~14 Schreibvorgaenge, ein Klick im
|
||||
// Verteil-Dialog einen pro Zielelement. Eine Version je Schreibvorgang waere ein
|
||||
// Tastenprotokoll, keine Historie. Stattdessen fassen wir alle Schreibvorgaenge innerhalb
|
||||
// eines Zeitfensters zu EINER Nebenversion zusammen (siehe 9.28).
|
||||
//
|
||||
// Dieses Modul enthaelt die reine Entscheidungslogik (ohne I/O), damit sie ohne Datenbank
|
||||
// testbar ist. Die Datenbank-Anbindung liegt in `versioning-db.ts`.
|
||||
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Innerhalb dieses Fensters aktualisiert ein weiterer Schreibvorgang die bestehende
|
||||
// Nebenversion, statt eine neue anzulegen.
|
||||
export const COALESCE_WINDOW_MS = 10 * 60 * 1000; // 10 Minuten
|
||||
|
||||
export interface VersionRef {
|
||||
major: number;
|
||||
minor: number;
|
||||
}
|
||||
|
||||
export function formatVersion(v: VersionRef): string {
|
||||
return `${v.major}.${v.minor}`;
|
||||
}
|
||||
|
||||
// Der jeweils letzte Stand, gegen den entschieden wird.
|
||||
export interface LatestVersion extends VersionRef {
|
||||
id: string;
|
||||
isMajor: boolean;
|
||||
createdById: string;
|
||||
updatedAt: Date;
|
||||
snapshot: PlanInput;
|
||||
}
|
||||
|
||||
export type VersionDecision =
|
||||
| { action: "skip"; reason: string }
|
||||
| { action: "update"; versionId: string }
|
||||
| { action: "create"; major: number; minor: number };
|
||||
|
||||
// Serialisiert mit SORTIERTEN Schluesseln. Noetig, weil `toPlanInput` die Werte je Phase als
|
||||
// Objekt aufbaut (`phaseValues[phaseId] = ...`) und die Reihenfolge dieser Schluessel aus der
|
||||
// Datenbank-Zeilenfolge stammt -- die ist nicht garantiert. Ohne Sortierung wuerden zwei
|
||||
// inhaltlich identische Staende als verschieden gelten und die Historie mit Scheinversionen
|
||||
// fluten.
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, val) => {
|
||||
if (val === null || typeof val !== "object" || Array.isArray(val)) return val;
|
||||
return Object.fromEntries(Object.entries(val as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
||||
});
|
||||
}
|
||||
|
||||
// Vergleicht zwei Staende inhaltlich.
|
||||
export function sameSnapshot(a: PlanInput, b: PlanInput): boolean {
|
||||
return canonicalJson(a) === canonicalJson(b);
|
||||
}
|
||||
|
||||
// Entscheidet, was ein Schreibvorgang mit der Historie macht.
|
||||
//
|
||||
// `latest` ist die zuletzt angelegte Version des Szenarios (oder null, wenn es noch keine
|
||||
// gibt). `now` und `userId` beschreiben den aktuellen Schreibvorgang.
|
||||
export function decideVersion(
|
||||
latest: LatestVersion | null,
|
||||
next: PlanInput,
|
||||
userId: string,
|
||||
now: Date
|
||||
): VersionDecision {
|
||||
// Allererster Stand: 1.0.
|
||||
if (!latest) return { action: "create", major: 1, minor: 0 };
|
||||
|
||||
// Nichts veraendert -- z. B. Dialog geoeffnet und unveraendert gespeichert. Ohne diese
|
||||
// Pruefung sammelt die Historie Versionen, die nichts unterscheiden.
|
||||
if (sameSnapshot(latest.snapshot, next)) {
|
||||
return { action: "skip", reason: "unveraendert" };
|
||||
}
|
||||
|
||||
const withinWindow = now.getTime() - latest.updatedAt.getTime() < COALESCE_WINDOW_MS;
|
||||
|
||||
// Eine Hauptversion ist ein bewusst gesetzter Schnitt -- sie wird nie nachtraeglich
|
||||
// veraendert, auch nicht innerhalb des Fensters. Die naechste Aenderung beginnt A.1.
|
||||
if (latest.isMajor) {
|
||||
return { action: "create", major: latest.major, minor: latest.minor + 1 };
|
||||
}
|
||||
|
||||
// Fortsetzung derselben Sitzung: bestehende Nebenversion aktualisieren. An den Benutzer
|
||||
// gebunden, damit spaeter (Finanzberater) nicht fremde Aenderungen unter einem Namen
|
||||
// zusammenlaufen.
|
||||
if (withinWindow && latest.createdById === userId) {
|
||||
return { action: "update", versionId: latest.id };
|
||||
}
|
||||
|
||||
return { action: "create", major: latest.major, minor: latest.minor + 1 };
|
||||
}
|
||||
|
||||
// Naechste Hauptversion. Der Kommentar ist Pflicht -- eine Hauptversion ohne Begruendung
|
||||
// waere nur eine Zahl.
|
||||
export function nextMajor(latest: VersionRef | null): VersionRef {
|
||||
return { major: (latest?.major ?? 0) + 1, minor: 0 };
|
||||
}
|
||||
|
||||
export function isValidMajorComment(comment: string): boolean {
|
||||
return comment.trim().length >= 3;
|
||||
}
|
||||
|
||||
// --- Wiederherstellen ------------------------------------------------------------------
|
||||
|
||||
// Kind-Szenarien zeigen ueber `sourceElementId` / `sourcePhaseId` auf die IDs DIESES
|
||||
// Szenarios -- darauf beruht die Abweichungs-Markierung. Beim Wiederherstellen bleiben die
|
||||
// IDs erhalten; trotzdem kann ein Bezug brechen, wenn der alte Stand ein Element oder eine
|
||||
// Phase noch gar nicht enthielt. Das laesst sich nicht verhindern, aber ankuendigen.
|
||||
export interface RestoreImpact {
|
||||
lostElementIds: string[];
|
||||
lostPhaseIds: string[];
|
||||
affectedChildren: string[]; // Namen der betroffenen Kind-Szenarien
|
||||
}
|
||||
|
||||
export function assessRestore(
|
||||
snapshot: PlanInput,
|
||||
children: { name: string; elementSourceIds: string[]; phaseSourceIds: string[] }[]
|
||||
): RestoreImpact {
|
||||
const haveElements = new Set(snapshot.elements.map((e) => e.id));
|
||||
const havePhases = new Set(snapshot.phases.map((p) => p.id));
|
||||
|
||||
const lostElementIds = new Set<string>();
|
||||
const lostPhaseIds = new Set<string>();
|
||||
const affectedChildren: string[] = [];
|
||||
|
||||
for (const child of children) {
|
||||
const lostE = child.elementSourceIds.filter((id) => !haveElements.has(id));
|
||||
const lostP = child.phaseSourceIds.filter((id) => !havePhases.has(id));
|
||||
if (lostE.length === 0 && lostP.length === 0) continue;
|
||||
lostE.forEach((id) => lostElementIds.add(id));
|
||||
lostP.forEach((id) => lostPhaseIds.add(id));
|
||||
affectedChildren.push(child.name);
|
||||
}
|
||||
|
||||
return {
|
||||
lostElementIds: [...lostElementIds],
|
||||
lostPhaseIds: [...lostPhaseIds],
|
||||
affectedChildren,
|
||||
};
|
||||
}
|
||||
|
||||
// Kommentar der Version, die durch ein Wiederherstellen entsteht. Das Wiederherstellen
|
||||
// LOESCHT keine Historie -- es legt einen neuen Stand oben drauf.
|
||||
export function restoreComment(from: VersionRef): string {
|
||||
return `Wiederhergestellt aus ${formatVersion(from)}`;
|
||||
}
|
||||
|
||||
// --- Bauplan des Wiederherstellens -----------------------------------------------------
|
||||
//
|
||||
// Das Wiederherstellen ist der einzige destruktive Pfad der Anwendung. Damit er pruefbar
|
||||
// ist, entscheidet diese reine Funktion, WAS geschehen soll; `versioning-db.ts` fuehrt den
|
||||
// Plan nur noch aus. Drei Feinheiten stecken darin:
|
||||
//
|
||||
// 1. IDs bleiben erhalten -- sonst verlieren Kind-Szenarien ihre Diff-Basis.
|
||||
// 2. `Phase.sequenceNumber` ist je Szenario eindeutig. Beim Zurueckgehen koennen sich alte
|
||||
// und neue Nummern ueberschneiden (z. B. Phase X hatte 2, soll wieder 1 werden, waehrend
|
||||
// eine andere noch auf 1 sitzt). Deshalb werden bestehende Phasen zuerst auf negative
|
||||
// Nummern geparkt und danach auf ihre Zielnummer gesetzt.
|
||||
// 3. Werte zu inzwischen entfernten Phasen werden verworfen, nicht wiederhergestellt.
|
||||
|
||||
export interface RestorePlan {
|
||||
deletePhaseIds: string[];
|
||||
deleteElementIds: string[];
|
||||
parkPhaseIds: string[]; // vor dem Umnummerieren auf negative Werte schieben
|
||||
createPhaseIds: string[];
|
||||
updatePhaseIds: string[];
|
||||
createElementIds: string[];
|
||||
updateElementIds: string[];
|
||||
deletePersonRoles: string[];
|
||||
// Werte je Element, bereits auf die ueberlebenden Phasen gefiltert.
|
||||
phaseValueKeys: { elementId: string; phaseId: string }[];
|
||||
transitionValueKeys: { elementId: string; fromPhaseId: string }[];
|
||||
}
|
||||
|
||||
export interface CurrentState {
|
||||
phaseIds: string[];
|
||||
elementIds: string[];
|
||||
personRoles: string[];
|
||||
}
|
||||
|
||||
export function planRestore(snapshot: PlanInput, current: CurrentState): RestorePlan {
|
||||
const keepPhaseIds = snapshot.phases.map((p) => p.id);
|
||||
const keepElementIds = snapshot.elements.map((e) => e.id);
|
||||
const keepRoles = snapshot.persons.map((p) => p.role);
|
||||
|
||||
const keepPhaseSet = new Set(keepPhaseIds);
|
||||
const havePhase = new Set(current.phaseIds);
|
||||
const haveElement = new Set(current.elementIds);
|
||||
|
||||
const phaseValueKeys: { elementId: string; phaseId: string }[] = [];
|
||||
const transitionValueKeys: { elementId: string; fromPhaseId: string }[] = [];
|
||||
for (const el of snapshot.elements) {
|
||||
for (const phaseId of Object.keys(el.phaseValues)) {
|
||||
// Werte zu einer Phase, die dieser Stand nicht kannte, gehoeren nirgendwohin.
|
||||
if (keepPhaseSet.has(phaseId)) phaseValueKeys.push({ elementId: el.id, phaseId });
|
||||
}
|
||||
for (const fromPhaseId of Object.keys(el.transitionValues)) {
|
||||
if (keepPhaseSet.has(fromPhaseId)) transitionValueKeys.push({ elementId: el.id, fromPhaseId });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deletePhaseIds: current.phaseIds.filter((id) => !keepPhaseSet.has(id)),
|
||||
deleteElementIds: current.elementIds.filter((id) => !keepElementIds.includes(id)),
|
||||
// Nur die Phasen parken, die bleiben -- die anderen sind vorher schon weg.
|
||||
parkPhaseIds: current.phaseIds.filter((id) => keepPhaseSet.has(id)),
|
||||
createPhaseIds: keepPhaseIds.filter((id) => !havePhase.has(id)),
|
||||
updatePhaseIds: keepPhaseIds.filter((id) => havePhase.has(id)),
|
||||
createElementIds: keepElementIds.filter((id) => !haveElement.has(id)),
|
||||
updateElementIds: keepElementIds.filter((id) => haveElement.has(id)),
|
||||
deletePersonRoles: current.personRoles.filter((r) => !keepRoles.includes(r as never)),
|
||||
phaseValueKeys,
|
||||
transitionValueKeys,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user