Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
Deploy App / deploy (push) Successful in 1m43s
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:
+85
-14
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user