Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
Deploy App / deploy (push) Successful in 1m43s

Groesste Umstrukturierung bisher. Der PLAN ist neu ein schlanker Behaelter ohne
Finanzdaten; die berechenbare Einheit ist das SZENARIO.

Modell:
- Jeder Plan bekommt beim Anlegen automatisch ein Basisszenario (isBase).
- Das Grundprofil liegt am Szenario, nicht am Plan -- nur so sind Szenarien mit
  abweichendem PENSIONSALTER moeglich (Fruehpensionierung), das in Person steckt.
- Neue Szenarien sind vollstaendige Kopien eines BELIEBIGEN Szenarios und haengen
  als Baum darunter (parentScenarioId); die Seitenleiste rueckt sie ein.
- Kopierte Phasen/Elemente tragen Herkunfts-Verweise (sourcePhaseId,
  sourceElementId). Ueber den Namen zu matchen waere fragil gewesen.

Abweichungs-Markierung (Diff gegen das Eltern-Szenario, live):
- geaendert = gelb, neu = gruen + Badge, entfernt = graue Geisterzeile.
- Markiert: Phasen-/Uebergangszellen, Element-Zeilen, Phasenkoepfe, Cash-Anfangswert,
  Cash-Uebergaenge, Grundprofil. Zaehler ueber der Matrix.
- Eigene Theme-Tokens fuer Hell/Dunkel/Warm -- ein fester Gelbwert waere im
  Dunkelschema unbrauchbar.

Charts vergleichen neu die Geschwister-Szenarien statt fremder Plaene.

Datenmodell/Migration:
- Neue Tabelle Plan; bisheriger Plan -> Scenario (IDs erhalten, damit alle
  Kind-Fremdschluessel gueltig bleiben); planId -> scenarioId in Person/Phase/
  FinancialElement. Bestehende Szenarien werden per rekursivem CTE demselben
  Behaelter zugeordnet, auch mehrfach verschachtelte.
- Migration VOR dem Deploy gegen echtes PostgreSQL verifiziert (PGlite, in-process),
  inkl. verschachtelter Szenarien und Cascade. Der Test ist als
  migrations.test.ts committet und sichert kuenftige Migrationen ab.

API neu unter /api/scenarios/*. 10 neue Tests (48 -> 58). Spezifikation auf v0.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 09:51:44 +02:00
parent 71137f7cea
commit a5d4868c58
25 changed files with 1336 additions and 486 deletions
+85 -14
View File
@@ -49,6 +49,7 @@ import {
type TransitionData,
} from "@/lib/elements";
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
import type { ScenarioDiff } from "@/lib/diff";
import type { ElementInput, PlanInput } from "@/lib/types";
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
@@ -91,12 +92,28 @@ type Selection =
export function PlanView({
plan,
computed,
diff,
onChanged,
}: {
plan: PlanInput;
computed: PlanComputed;
// Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
diff: ScenarioDiff | null;
onChanged: () => void;
}) {
// Markierungs-Klassen: geaendert = gelb, neu = gruen, entfernt = grau.
const cellDiff = (elementId: string, phaseId: string) =>
diff?.phaseCell.has(`${elementId}:${phaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : "";
const transDiff = (elementId: string, fromPhaseId: string) =>
diff?.transitionCell.has(`${elementId}:${fromPhaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : "";
const rowDiff = (elementId: string) => {
const k = diff?.elementRow.get(elementId);
return k === "added"
? "bg-diff-added-soft"
: k === "changed"
? "bg-diff-soft"
: "";
};
const [selected, setSelected] = useState<Selection | null>(null);
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
const [showAdd, setShowAdd] = useState(false);
@@ -275,7 +292,7 @@ export function PlanView({
}
async function handleAddPhase(payload: { name?: string; durationYears?: number }) {
await api.post(`/api/plans/${plan.id}/phases`, payload);
await api.post(`/api/scenarios/${plan.id}/phases`, payload);
setShowAddPhase(false);
onChanged();
}
@@ -307,8 +324,14 @@ export function PlanView({
<Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} />
{/* Plan-Profil */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Grundprofil (Plan)</span>
<div
className={`flex flex-wrap items-center gap-3 rounded-xl border px-4 py-3 text-sm shadow-sm ${
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
}`}
>
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
Grundprofil (Szenario){diff?.profileChanged ? " · abweichend" : ""}
</span>
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
@@ -385,6 +408,7 @@ export function PlanView({
phase={col.phase}
personLabel={personLabel}
mode={valueMode}
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
/>
@@ -416,7 +440,9 @@ export function PlanView({
title={isFirst ? "Cash-Anfangswert bearbeiten" : undefined}
className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${
isFirst ? "cursor-pointer hover:bg-accent-soft" : ""
} ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"}`}
} ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"} ${
isFirst && diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
}`}
>
<span className="whitespace-nowrap">
{valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "}
@@ -435,7 +461,11 @@ export function PlanView({
onClick={() => setEditCashTransition(col.fromPhase.id)}
title="Einmalige Sonderein-/ausgaben"
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
open ? "bg-accent font-semibold text-accent-fg" : "bg-accent-soft/40 text-accent"
open
? "bg-accent font-semibold text-accent-fg"
: diff?.cashTransitionCell.has(col.fromPhase.id)
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
: "bg-accent-soft/40 text-accent"
}`}
>
{cashTransitionSummary(ct)}
@@ -474,8 +504,13 @@ export function PlanView({
{!collapsed &&
els.map((el) => (
<tr key={el.id} className="hover:bg-surface-2">
<td className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
<div className="truncate text-xs font-medium text-fg">{el.name}</div>
<td className={`sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
<div className="flex items-center gap-1 truncate text-xs font-medium text-fg">
{el.name}
{diff?.elementRow.get(el.id) === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
</div>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
)}
@@ -487,9 +522,10 @@ export function PlanView({
<td
key={col.phase.id}
onClick={() => setEditPhaseCell({ elementId: el.id, phaseId: col.phase.id })}
title={cellDiff(el.id, col.phase.id) ? "Weicht von der Vorlage ab" : undefined}
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
ce?.locked ? "text-faint" : "text-fg"
}`}
} ${cellDiff(el.id, col.phase.id)}`}
>
{phaseCellContent(ce, col.phase, valueMode)}
</td>
@@ -505,9 +541,19 @@ export function PlanView({
canTransition &&
setEditTransition({ elementId: el.id, fromPhaseId: col.fromPhase.id })
}
title={transDiff(el.id, col.fromPhase.id) ? "Weicht von der Vorlage ab" : undefined}
className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
canTransition ? "cursor-pointer" : "text-faint"
} ${open ? "bg-accent font-semibold text-accent-fg" : canTransition ? "bg-accent-soft/40 text-accent" : ""}`}
} ${
// Offene Entscheide bleiben in Akzentfarbe; sonst gewinnt die Abweichungs-Markierung.
open
? "bg-accent font-semibold text-accent-fg"
: transDiff(el.id, col.fromPhase.id)
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
: canTransition
? "bg-accent-soft/40 text-accent"
: ""
}`}
>
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : locked ? "" : "→"}
</td>
@@ -518,7 +564,21 @@ export function PlanView({
</FragmentRows>
);
})}
{plan.elements.length === 0 && (
{/* Geisterzeilen: in der Vorlage vorhanden, in diesem Szenario geloescht. */}
{(diff?.removedElements ?? []).map((r) => (
<tr key={`removed-${r.id}`} className="bg-diff-removed-soft">
<td className="sticky left-0 z-10 border-b border-r border-border bg-diff-removed-soft px-3 py-1.5">
<div className="flex items-center gap-1 truncate text-xs font-medium text-diff-removed line-through">
{r.name}
</div>
<div className="text-[10px] text-diff-removed">{CATEGORY_LABELS[r.category]} · entfernt</div>
</td>
<td colSpan={columns.length} className="border-b border-border px-3 py-1.5 text-center text-[11px] text-diff-removed">
In diesem Szenario entfernt
</td>
</tr>
))}
{plan.elements.length === 0 && (diff?.removedElements.length ?? 0) === 0 && (
<tr>
<td className="sticky left-0 bg-surface px-3 py-4 text-xs text-faint" colSpan={columns.length + 1}>
Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
@@ -793,12 +853,14 @@ function PhaseHeader({
phase,
personLabel,
mode,
diffKind,
onClick,
active,
}: {
phase: PhaseComputed;
personLabel: (role: string) => string;
mode: ValueMode;
diffKind: "changed" | "added" | "removed" | null;
onClick: () => void;
active: boolean;
}) {
@@ -810,11 +872,20 @@ function PhaseHeader({
<th
onClick={onClick}
className={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active ? "bg-accent-soft" : "bg-surface"
active
? "bg-accent-soft"
: diffKind === "added"
? "bg-diff-added-soft"
: diffKind === "changed"
? "bg-diff-soft"
: "bg-surface"
}`}
>
<div className="flex items-center gap-1">
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
{diffKind === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
{phase.cashNegative ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
) : (
@@ -973,7 +1044,7 @@ function AddElementDialog({
setSaving(true);
setError(null);
try {
const { element } = await api.post<{ element: { id: string } }>(`/api/plans/${plan.id}/elements`, {
const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${plan.id}/elements`, {
category,
name: name.trim() || CATEGORY_LABELS[category],
ownerRole,
@@ -1128,7 +1199,7 @@ function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClo
setSaving(true);
setError(null);
try {
await api.patch(`/api/plans/${plan.id}`, draft);
await api.patch(`/api/scenarios/${plan.id}`, draft);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
@@ -1324,7 +1395,7 @@ function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClos
setSaving(true);
setError(null);
try {
await api.patch(`/api/plans/${plan.id}`, { initialCash: value });
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: value });
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");