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:
+262
-143
@@ -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"
|
||||
|
||||
@@ -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
@@ -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