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
+262 -143
View File
@@ -2,8 +2,10 @@
import { useCallback, useEffect, useState } from "react";
import {
Copy,
FileText,
FolderKanban,
GitBranch,
LayoutDashboard,
Menu,
PiggyBank,
@@ -17,42 +19,39 @@ import { SpecView } from "@/components/SpecView";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client";
import type { PlanInput } from "@/lib/types";
import { computeScenarioDiff } from "@/lib/diff";
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
interface PlanListItem {
id: string;
name: string;
parentPlanId: string | null;
branchFromPhaseId: string | null;
phases: { id: string; name: string; sequenceNumber: number }[];
interface ScenarioDetail {
plan: PlanInput; // das Szenario selbst (berechenbare Einheit)
computed: PlanComputed;
base: PlanInput | null; // Eltern-Szenario als Vergleichsbasis
meta: ScenarioMeta & { planName: string };
}
export function AppShell({ username }: { username: string }) {
const [plans, setPlans] = useState<PlanListItem[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null);
const [selectedScenarioId, setSelectedScenarioId] = useState<string | null>(null);
const [detail, setDetail] = useState<ScenarioDetail | null>(null);
const [loading, setLoading] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showNewPlan, setShowNewPlan] = useState(false);
const [showScenario, setShowScenario] = useState(false);
// Die Spezifikation ist eine eigene Ansicht neben Uebersicht und Plan (schliessen sich aus).
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
const [showSpec, setShowSpec] = useState(false);
const loadPlans = useCallback(async (preferId?: string) => {
const loadPlans = useCallback(async () => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
setPlans(data.plans);
if (preferId) setSelectedPlanId(preferId);
return data.plans;
}, []);
// silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert,
// damit die Scrollposition (z. B. nach dem Schliessen eines Popups) erhalten bleibt.
const loadDetail = useCallback(async (planId: string, silent = false) => {
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
if (!silent) setLoading(true);
try {
const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/plans/${planId}`);
setDetail(data);
setDetail(await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`));
} finally {
if (!silent) setLoading(false);
}
@@ -64,26 +63,46 @@ export function AppShell({ username }: { username: string }) {
}, [loadPlans]);
useEffect(() => {
if (selectedPlanId) {
if (selectedScenarioId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf
loadDetail(selectedPlanId);
loadDetail(selectedScenarioId);
} else {
setDetail(null);
}
}, [selectedPlanId, loadDetail]);
}, [selectedScenarioId, loadDetail]);
function refreshCurrent() {
if (selectedPlanId) loadDetail(selectedPlanId, true);
if (selectedScenarioId) loadDetail(selectedScenarioId, true);
}
function openScenario(id: string) {
setSelectedScenarioId(id);
setShowSpec(false);
setSidebarOpen(false);
}
async function handleDeletePlan(id: string) {
if (!confirm("Diesen Plan wirklich loeschen?")) return;
if (!confirm("Diesen Plan mit ALLEN Szenarien wirklich loeschen?")) return;
await api.delete(`/api/plans/${id}`);
await loadPlans();
if (selectedPlanId === id) setSelectedPlanId(null);
const rest = await loadPlans();
if (!rest.some((p) => p.scenarios.some((s) => s.id === selectedScenarioId))) {
setSelectedScenarioId(null);
}
}
const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null;
async function handleDeleteScenario(s: ScenarioMeta) {
if (!confirm(`Szenario "${s.name}" wirklich loeschen?`)) return;
try {
await api.delete(`/api/scenarios/${s.id}`);
} catch (e) {
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
return;
}
await loadPlans();
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
}
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
const sidebar = (
<div className="flex h-full flex-col">
@@ -98,14 +117,12 @@ export function AppShell({ username }: { username: string }) {
<button
type="button"
onClick={() => {
setSelectedPlanId(null);
setSelectedScenarioId(null);
setShowSpec(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
selectedPlanId === null && !showSpec
? "bg-accent-soft text-accent-soft-fg"
: "text-muted hover:bg-surface-2"
selectedScenarioId === null && !showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<LayoutDashboard className="h-4 w-4" />
@@ -124,25 +141,31 @@ export function AppShell({ username }: { username: string }) {
</button>
</div>
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>}
{plans.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setSelectedPlanId(p.id);
setShowSpec(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
selectedPlanId === p.id && !showSpec
? "bg-accent-soft font-medium text-accent-soft-fg"
: "text-muted hover:bg-surface-2"
}`}
>
<FolderKanban className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{p.name}</span>
<span className="text-[11px] text-faint">{p.phases.length}</span>
</button>
<div key={p.id} className="mb-1">
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-faint" />
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
<button
type="button"
aria-label="Plan loeschen"
onClick={() => handleDeletePlan(p.id)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<ScenarioTree
scenarios={p.scenarios}
parentId={null}
depth={0}
selectedId={selectedScenarioId}
onSelect={openScenario}
onCopy={setCopyFrom}
onDelete={handleDeleteScenario}
/>
</div>
))}
<div className="mt-4 border-t border-border pt-3">
@@ -150,7 +173,7 @@ export function AppShell({ username }: { username: string }) {
type="button"
onClick={() => {
setShowSpec(true);
setSelectedPlanId(null);
setSelectedScenarioId(null);
setSidebarOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
@@ -165,16 +188,16 @@ export function AppShell({ username }: { username: string }) {
</div>
);
const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null;
return (
<div className="flex min-h-screen w-full">
{/* Sidebar Desktop */}
<aside className="hidden w-60 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
<aside className="hidden w-64 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
{/* Sidebar Mobile (Overlay) */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
<aside className="absolute left-0 top-0 h-full w-64 border-r border-border bg-surface shadow-xl">
<aside className="absolute left-0 top-0 h-full w-72 border-r border-border bg-surface shadow-xl">
<button
type="button"
onClick={() => setSidebarOpen(false)}
@@ -188,7 +211,6 @@ export function AppShell({ username }: { username: string }) {
</div>
)}
{/* Hauptbereich */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
<button
@@ -200,7 +222,11 @@ export function AppShell({ username }: { username: string }) {
<Menu className="h-4 w-4" />
</button>
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
{showSpec ? "Spezifikation" : activePlan ? activePlan.name : "Uebersicht"}
{showSpec
? "Spezifikation"
: detail
? `${detail.meta.planName} · ${detail.meta.name}`
: "Uebersicht"}
</h1>
<ProfileMenu username={username} />
</header>
@@ -210,80 +236,169 @@ export function AppShell({ username }: { username: string }) {
{!showSpec && loading && <p className="text-sm text-muted">Laedt</p>}
{!showSpec && !loading && selectedPlanId === null && (
{!showSpec && !loading && selectedScenarioId === null && (
<DashboardHome
username={username}
plans={plans}
onSelect={(id) => {
setSelectedPlanId(id);
setShowSpec(false);
}}
onSelect={openScenario}
onCreate={() => setShowNewPlan(true)}
onDelete={handleDeletePlan}
/>
)}
{!showSpec && !loading && detail && selectedPlanId && (
{!showSpec && !loading && detail && selectedScenarioId && (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-2">
{detail.plan.phases.length > 0 && (
<button
type="button"
onClick={() => setShowScenario(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Plus className="h-4 w-4" />
Szenario
</button>
)}
<button
type="button"
onClick={() => handleDeletePlan(selectedPlanId)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
onClick={() => setCopyFrom(detail.meta)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Trash2 className="h-4 w-4" />
Plan loeschen
<Copy className="h-4 w-4" />
Neues Szenario aus diesem
</button>
{!detail.meta.isBase && (
<button
type="button"
onClick={() => handleDeleteScenario(detail.meta)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-4 w-4" />
Szenario loeschen
</button>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
{diff.total === 0
? "Unveraendert gegenueber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenueber der Vorlage`}
</span>
)}
</div>
<PlanView plan={detail.plan} computed={detail.computed} onChanged={refreshCurrent} />
<PlanView plan={detail.plan} computed={detail.computed} diff={diff} onChanged={refreshCurrent} />
{detail.plan.phases.length > 0 && <Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />}
{detail.plan.phases.length > 0 && (
<Dashboard
plan={detail.plan}
computed={detail.computed}
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
/>
)}
</div>
)}
</main>
</div>
{/* Dialoge */}
{showNewPlan && (
<PlanDialog
onCreate={async (name, profile) => {
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name, ...profile });
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
"/api/plans",
{ name, ...profile }
);
setShowNewPlan(false);
await loadPlans(plan.id);
await loadPlans();
openScenario(scenario.id);
}}
onClose={() => setShowNewPlan(false)}
/>
)}
{showScenario && detail && selectedPlanId && (
<ScenarioDialog
phases={detail.plan.phases}
onCreate={async (name, branchFromPhaseId) => {
const { planId } = await api.post<{ planId: string }>(`/api/plans/${selectedPlanId}/scenario`, {
name,
branchFromPhaseId,
});
setShowScenario(false);
await loadPlans(planId);
{copyFrom && (
<CopyScenarioDialog
source={copyFrom}
onClose={() => setCopyFrom(null)}
onCreate={async (name) => {
const { scenarioId } = await api.post<{ scenarioId: string }>(
`/api/scenarios/${copyFrom.id}/copy`,
{ name }
);
setCopyFrom(null);
await loadPlans();
openScenario(scenarioId);
}}
onClose={() => setShowScenario(false)}
/>
)}
</div>
);
}
// Startansicht: Begruessung + Plan-Kacheln.
// Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind.
function ScenarioTree({
scenarios,
parentId,
depth,
selectedId,
onSelect,
onCopy,
onDelete,
}: {
scenarios: ScenarioMeta[];
parentId: string | null;
depth: number;
selectedId: string | null;
onSelect: (id: string) => void;
onCopy: (s: ScenarioMeta) => void;
onDelete: (s: ScenarioMeta) => void;
}) {
const level = scenarios.filter((s) => (s.parentScenarioId ?? null) === parentId);
if (level.length === 0) return null;
return (
<>
{level.map((s) => (
<div key={s.id}>
<div
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm ${
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
style={{ paddingLeft: `${12 + depth * 14}px` }}
>
<button type="button" onClick={() => onSelect(s.id)} className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
{s.isBase ? (
<PiggyBank className="h-3.5 w-3.5 shrink-0" />
) : (
<GitBranch className="h-3.5 w-3.5 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate" title={s.name}>{s.name}</span>
</button>
<button
type="button"
aria-label="Kopie erstellen"
title="Neues Szenario aus diesem"
onClick={() => onCopy(s)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover:opacity-100"
>
<Copy className="h-3.5 w-3.5" />
</button>
{!s.isBase && (
<button
type="button"
aria-label="Szenario loeschen"
onClick={() => onDelete(s)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<ScenarioTree
scenarios={scenarios}
parentId={s.id}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
onCopy={onCopy}
onDelete={onDelete}
/>
</div>
))}
</>
);
}
// Startansicht: Begruessung + Plan-Kacheln (Klick oeffnet das Basisszenario).
function DashboardHome({
username,
plans,
@@ -293,7 +408,7 @@ function DashboardHome({
}: {
username: string;
plans: PlanListItem[];
onSelect: (id: string) => void;
onSelect: (scenarioId: string) => void;
onCreate: () => void;
onDelete: (id: string) => void;
}) {
@@ -302,42 +417,46 @@ function DashboardHome({
<div>
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
<p className="mt-1 text-sm text-muted">
Waehlen Sie einen Plan oder erstellen Sie einen neuen, um Ihre finanzielle Zukunft zu planen.
Waehlen Sie einen Plan oder erstellen Sie einen neuen. Jeder Plan enthaelt ein Basisszenario
und beliebig viele Varianten davon.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{plans.map((p) => (
<div
key={p.id}
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
onClick={() => onSelect(p.id)}
>
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-fg">{p.name}</div>
<div className="text-xs text-muted">
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
{p.parentPlanId ? " · Szenario" : ""}
{plans.map((p) => {
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
const others = p.scenarios.length - 1;
return (
<div
key={p.id}
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
onClick={() => base && onSelect(base.id)}
>
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-fg">{p.name}</div>
<div className="text-xs text-muted">
Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""}
</div>
</div>
<button
type="button"
aria-label="Plan loeschen"
onClick={(e) => {
e.stopPropagation();
onDelete(p.id);
}}
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<button
type="button"
aria-label="Plan loeschen"
onClick={(e) => {
e.stopPropagation();
onDelete(p.id);
}}
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
);
})}
<button
type="button"
@@ -359,7 +478,7 @@ function PlanDialog({
onCreate: (name: string, profile: ProfileDraft) => void;
onClose: () => void;
}) {
const [name, setName] = useState("Basisplan");
const [name, setName] = useState("Meine Planung");
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft);
const [saving, setSaving] = useState(false);
@@ -370,6 +489,10 @@ function PlanDialog({
className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-fg">Neuen Plan erstellen</h2>
<p className="text-xs text-muted">
Es wird automatisch ein <strong className="text-fg">Basisszenario</strong> angelegt. Weitere
Szenarien entstehen spaeter als Kopien davon.
</p>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
<input
@@ -406,24 +529,28 @@ function PlanDialog({
);
}
function ScenarioDialog({
phases,
function CopyScenarioDialog({
source,
onCreate,
onClose,
}: {
phases: { id: string; name: string }[];
onCreate: (name: string, branchFromPhaseId: string) => void;
source: ScenarioMeta;
onCreate: (name: string) => void;
onClose: () => void;
}) {
const [name, setName] = useState("Neues Szenario");
const [branchFromPhaseId, setBranchFromPhaseId] = useState(phases[phases.length - 1]?.id ?? "");
const [name, setName] = useState(`${source.name} Variante`);
const [saving, setSaving] = useState(false);
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-fg">Szenario erstellen</h2>
<h2 className="text-base font-semibold text-fg">Neues Szenario</h2>
<p className="text-xs text-muted">
Vollstaendige Kopie von <strong className="text-fg">{source.name}</strong>. Aenderungen darin
werden anschliessend farblich hervorgehoben.
</p>
<input
autoFocus
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
@@ -431,25 +558,17 @@ function ScenarioDialog({
onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios"
/>
<label className="text-xs text-muted">Verzweigen ab Phase</label>
<select
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
value={branchFromPhaseId}
onChange={(e) => setBranchFromPhaseId(e.target.value)}
>
{phases.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate(name, branchFromPhaseId)}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover"
disabled={saving}
onClick={() => {
setSaving(true);
onCreate(name.trim() || "Szenario");
}}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
Erstellen
{saving ? "..." : "Erstellen"}
</button>
<button
type="button"
+9 -8
View File
@@ -20,11 +20,12 @@ interface PlanListItem {
export function Dashboard({
plan,
computed,
allPlans,
siblings,
}: {
plan: PlanInput;
computed: PlanComputed;
allPlans: PlanListItem[];
// Die uebrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
}) {
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
@@ -36,7 +37,7 @@ export function Dashboard({
}
setCompareIds((prev) => [...prev, id]);
if (!compareData[id]) {
const data = await api.get<{ computed: PlanComputed }>(`/api/plans/${id}`);
const data = await api.get<{ computed: PlanComputed }>(`/api/scenarios/${id}`);
setCompareData((prev) => ({ ...prev, [id]: data.computed }));
}
}
@@ -45,11 +46,11 @@ export function Dashboard({
const result: TimelineSeries[] = [{ label: plan.name, color: PALETTE[0], computed }];
compareIds.forEach((id, i) => {
const c = compareData[id];
const name = allPlans.find((p) => p.id === id)?.name ?? id;
const name = siblings.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: PALETTE[(i + 1) % PALETTE.length], computed: c });
});
return result;
}, [plan.name, computed, compareIds, compareData, allPlans]);
}, [plan.name, computed, compareIds, compareData, siblings]);
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
@@ -87,7 +88,7 @@ export function Dashboard({
[computed]
);
const otherPlans = allPlans.filter((p) => p.id !== plan.id);
const otherPlans = siblings;
const lastPhase = computed.phases[computed.phases.length - 1];
return (
@@ -117,7 +118,7 @@ export function Dashboard({
Vermoegensverlauf nach Alter
</h3>
<a
href={`/api/plans/${plan.id}/export`}
href={`/api/scenarios/${plan.id}/export`}
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" />
@@ -126,7 +127,7 @@ export function Dashboard({
</div>
{otherPlans.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
<span className="text-xs text-muted">Vergleichen mit:</span>
<span className="text-xs text-muted">Szenarien vergleichen:</span>
{otherPlans.map((p) => (
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
<input
+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.");