"use client"; import { useEffect, useMemo, useState } from "react"; import { ArrowLeft, ArrowRight, CalendarClock, Pencil, Plus, Trash2, X } from "lucide-react"; import { InfoBubble } from "@/components/InfoBubble"; import { Button, useConfirm, useToast } from "@/components/ui"; import { api } from "@/lib/api-client"; import { computePlan } from "@/lib/calculations"; import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements"; import { formatChf } from "@/lib/format"; import { actualKindOf, resolveActuals, toPlanYear, type ActualsSetInput } from "@/lib/actuals"; import { resolveRootElementId } from "@/lib/montecarlo"; import type { ElementCategory } from "@/lib/elements"; import type { PlanInput } from "@/lib/types"; interface StoredSet extends ActualsSetInput { author: string; } // Ein Eingabefeld im Wizard -- je Wurzel-Element eines oder (bei Immobilien) zwei. interface Row { rootId: string; name: string; category: ElementCategory; scenarioNames: string[]; planValue: number; // Vorbelegung aus dem Basisszenario für das gewählte Jahr planMortgage?: number; kind: ReturnType; } const dt = (iso: string) => new Date(`${iso}T00:00:00Z`).toLocaleDateString("de-CH", { day: "2-digit", month: "long", year: "numeric" }); interface LoadedScenario { id: string; name: string; isBase: boolean; plan: PlanInput; } export function ActualsDialog({ planId, planName, scenarioMetas, initial, embedded = false, onClose, onChanged, }: { planId: string; planName: string; // Alle Szenarien dieses Plans (nur Kopfdaten) -- die Pläne werden hier nachgeladen. scenarioMetas: { id: string; name: string; isBase: boolean }[]; // Das bereits geöffnete Szenario, damit der Dialog sofort etwas anzeigen kann. initial: LoadedScenario; // Eingebettet in den Hauptbereich (Plan-Ansicht) statt als Pop-up -- ohne Overlay, // ohne Backdrop, ohne Schliessen-Knopf. embedded?: boolean; onClose: () => void; onChanged: () => void; }) { const [loaded, setLoaded] = useState>(() => ({ [initial.id]: initial })); // Die übrigen Szenarien nachladen: Ein Ist-Satz gilt für ALLE, also müssen auch Elemente // erscheinen, die es nur in einem Nebenszenario gibt. useEffect(() => { const missing = scenarioMetas.filter((m) => m.id !== initial.id); if (missing.length === 0) return; let cancelled = false; (async () => { try { const entries = await Promise.all( missing.map(async (m) => { const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${m.id}`); return [m.id, { id: m.id, name: m.name, isBase: m.isBase, plan: data.plan }] as const; }) ); if (!cancelled) setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) })); } catch { // Fehlende Nebenszenarien sind verschmerzbar -- der Wizard zeigt dann weniger Zeilen. } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [scenarioMetas.map((m) => m.id).join(","), initial.id]); const scenarios = useMemo( () => scenarioMetas.map((m) => loaded[m.id]).filter((s): s is LoadedScenario => !!s), [scenarioMetas, loaded] ); const [sets, setSets] = useState(null); const [error, setError] = useState(null); const [mode, setMode] = useState<"list" | "wizard">("list"); const [step, setStep] = useState<1 | 2>(1); const [busy, setBusy] = useState(false); const confirm = useConfirm(); const toast = useToast(); // Schritt 1 const [recordedOn, setRecordedOn] = useState(() => new Date().toISOString().slice(0, 10)); const [comment, setComment] = useState(""); // Schritt 2 const [values, setValues] = useState>({}); const [cash, setCash] = useState(0); // null = neuer Datensatz; sonst die id des gerade bearbeiteten (dann PUT statt POST). const [editingId, setEditingId] = useState(null); const base = scenarios.find((s) => s.isBase) ?? scenarios[0]; const year = Number(recordedOn.slice(0, 4)); useEffect(() => { let cancelled = false; (async () => { try { const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`); if (!cancelled) setSets(data.sets); } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden."); } })(); return () => { cancelled = true; }; }, [planId]); // Herkunft aller Elemente über alle Szenarien -- Grundlage der Wurzel-Auflösung. const origins = useMemo( () => scenarios.flatMap((s) => s.plan.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId ?? null }))), [scenarios] ); // Alle Elemente ALLER Szenarien, zusammengefasst auf ihre Wurzel. Vorbelegt mit dem // berechneten Stand des Basisszenarios im gewählten Jahr; fehlt das Element dort, wird es // aus dem erstbesten Szenario geholt, das es kennt. const rows: Row[] = useMemo(() => { const sourceById = new Map(origins.map((o) => [o.id, o.sourceElementId])); const byRoot = new Map(); const ordered = [base, ...scenarios.filter((s) => s.id !== base?.id)].filter(Boolean); for (const sc of ordered) { const planYear = toPlanYear(year, sc.plan.startYear); const computed = computePlan(sc.plan); for (const el of sc.plan.elements) { const kind = actualKindOf(el.category); const root = resolveRootElementId(el.id, sourceById); // Jahresstand aus dem Verlauf: genau der Wert, den der Plan für dieses Jahr vorsieht. const yearly = computed.phases .flatMap((p) => p.elements.filter((e) => e.elementId === el.id).flatMap((e) => e.yearly)) .find((y) => y.year === planYear); // Die AHV ist nur erfassbar, wenn die Rente zum Stichtag bereits läuft -- vorher gibt // es keinen Stand, den man ablesen könnte. if (el.category === "AHV" && !(yearly && yearly.value > 0)) continue; const existing = byRoot.get(root); if (existing) { if (!existing.scenarioNames.includes(sc.name)) existing.scenarioNames.push(sc.name); continue; } byRoot.set(root, { rootId: root, name: el.name, category: el.category, scenarioNames: [sc.name], // Bei einer Immobilie ist `yearly.value` das EIGENKAPITAL (Verkehrswert − Hypothek). // Erfasst und gerechnet wird aber der VERKEHRSWERT -- er steht in `propertyValue`. // Wurde hier bis 0.31 das Eigenkapital vorbelegt, setzte der Rechenkern es als // Verkehrswert ein, während die Hypothek stehen blieb: Das Eigenkapital brach im // Ist-Jahr schlagartig ein (typischerweise ins Negative). planValue: kind === "PROPERTY" ? Math.round(yearly?.propertyValue ?? 0) : Math.round(Math.abs(yearly?.value ?? 0)), planMortgage: kind === "PROPERTY" ? Math.round(yearly?.mortgage ?? 0) : undefined, kind, }); } } return [...byRoot.values()].sort((a, b) => { const ca = CATEGORY_ORDER.indexOf(a.category); const cb = CATEGORY_ORDER.indexOf(b.category); return ca !== cb ? ca - cb : a.name.localeCompare(b.name, "de-CH"); }); }, [scenarios, base, origins, year]); // Der geplante Cash-Bestand im gewählten Jahr -- Vorbelegung für das Cash-Feld. const planCash = useMemo(() => { if (!base) return 0; const planYear = toPlanYear(year, base.plan.startYear); if (planYear === null) return 0; // Der Stand am Ende GENAU DIESES Jahres -- nicht am Ende der Phase. In einer Phase über // zehn Jahre, in der sich das Cash-Konto füllt, sind das zwei völlig verschiedene Zahlen. const computed = computePlan(base.plan); return computed.yearly.find((y) => y.year === planYear)?.cash ?? 0; }, [base, year]); function startWizard() { setEditingId(null); setValues({}); setCash(planCash); setComment(""); setRecordedOn(new Date().toISOString().slice(0, 10)); setStep(1); setMode("wizard"); } // Bestehenden Datensatz zum Bearbeiten oeffnen. Die gespeicherten Werte sind bereits auf // WURZEL-Element-IDs erfasst -- genau die Form, mit der der Wizard arbeitet. function startEdit(set: StoredSet) { setEditingId(set.id); setRecordedOn(set.recordedOn); setComment(set.comment ?? ""); setValues({ ...set.values }); setCash(typeof set.cash === "number" ? set.cash : 0); setStep(1); setMode("wizard"); } // Beim Wechsel auf Schritt 2 mit den Planwerten vorbelegen -- der Nutzer überschreibt nur, // was tatsächlich abweicht. function goToStep2() { // Beim Bearbeiten stehen die erfassten Werte bereits -- sie duerfen nicht durch die // Planwerte ersetzt werden. Nur Zeilen, fuer die nichts erfasst ist (z. B. ein spaeter // hinzugekommenes Element), werden ergaenzt. const prefill: Record = {}; for (const r of rows) { if (editingId && values[r.rootId]) { prefill[r.rootId] = values[r.rootId]; continue; } prefill[r.rootId] = r.kind === "PROPERTY" ? { value: r.planValue, mortgage: r.planMortgage ?? 0 } : { value: r.planValue }; } setValues(prefill); if (!editingId) setCash(planCash); setStep(2); } async function reload() { const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`); setSets(data.sets); } async function save() { setBusy(true); try { const payload = { recordedOn, comment: comment.trim() || undefined, cash, values }; if (editingId) { await api.put(`/api/plans/${planId}/actuals/${editingId}`, payload); } else { await api.post(`/api/plans/${planId}/actuals`, payload); } toast("success", `Effektive Werte für ${dt(recordedOn)} ${editingId ? "aktualisiert" : "erfasst"}.`); await reload(); setEditingId(null); setMode("list"); onChanged(); } catch (e) { toast("error", e instanceof Error ? e.message : "Speichern fehlgeschlagen."); } finally { setBusy(false); } } async function remove(set: StoredSet) { const ok = await confirm({ title: "Datensatz löschen?", message: `Die effektiven Werte vom ${dt(set.recordedOn)} werden entfernt. Der Plan selbst bleibt unverändert.`, confirmLabel: "Löschen", danger: true, }); if (!ok) return; try { await api.delete(`/api/plans/${planId}/actuals/${set.id}`); await reload(); onChanged(); toast("success", "Datensatz gelöscht."); } catch (e) { toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen."); } } // Wie viele Elemente weichen ab? Kleine Orientierungshilfe in der Liste. const deviationCount = (set: ActualsSetInput) => { const resolved = resolveActuals([set], base?.plan ?? scenarios[0].plan, origins); return resolved.length === 0 ? 0 : Object.keys(resolved[0].byElementId).length; }; const setRow = (rootId: string, patch: { value?: number; mortgage?: number }) => setValues((prev) => ({ ...prev, [rootId]: { ...prev[rootId], ...patch } })); const body = ( <>

Effektive Werte

Plan «{planName}». Was tatsächlich eingetreten ist – der Plan selbst bleibt unverändert.

{!embedded && ( )}
{mode === "list" && ( <>
Ein Datensatz hält fest, wie es an einem Stichtag wirklich aussah. Die Berechnung läuft dann ein zweites Mal: gleiche Mechanik, aber ab jedem erfassten Jahr mit den echten Zahlen. Werte, die du weglässt, laufen unverändert auf ihrer Planlinie weiter. Ein Datensatz gilt für alle Szenarien dieses Plans – die Wirklichkeit ist dieselbe, egal wogegen man sie hält.
{error &&

{error}

} {!sets && !error &&

Wird geladen…

} {sets && sets.length === 0 && (

Noch keine effektiven Werte erfasst.

)} {sets && sets.length > 0 && (
{sets.map((s, i) => (
startEdit(s)} title="Zum Bearbeiten anklicken" className={`flex cursor-pointer flex-wrap items-center gap-3 rounded-xl border p-3 transition-colors hover:border-accent ${ i === 0 ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2" }`} >
{dt(s.recordedOn)} {i === 0 && ( aktuellster )}
rechnet ab {s.year} · {deviationCount(s)} Werte · {s.author} {typeof s.cash === "number" && ` · Cash ${formatChf(s.cash)}`}
{s.comment &&
«{s.comment}»
}
))}
)}
)} {mode === "wizard" && step === 1 && ( <>
{editingId ? "Bearbeiten" : "Erfassen"} · Schritt 1 von 2 · Stichtag
setRecordedOn(e.target.value)} className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg" />
setComment(e.target.value)} placeholder="z. B. «nach Jahresabschluss»" className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg" />

Gerechnet wird ab dem Jahr {year}. Die Vorbelegung im nächsten Schritt zeigt, was dein Plan für dieses Jahr vorsieht – du überschreibst nur, was tatsächlich anders ist.

)} {mode === "wizard" && step === 2 && ( <>
{editingId ? "Bearbeiten" : "Erfassen"} · Schritt 2 von 2 · Werte per {dt(recordedOn)}
{rows.map((r, i) => { const header = i === 0 || rows[i - 1].category !== r.category ? r.category : null; const v = values[r.rootId] ?? {}; return ( setRow(r.rootId, patch)} /> ); })}
Element Laut Plan {year} Effektiv
Cash
Cash-Konto {formatChf(planCash)}

Einkommen und Ausgaben bitte als Jahresbetrag erfassen (nominal, wie tatsächlich geflossen). Bei Immobilien zählt der Verkehrswert und die Restschuld getrennt. Was du auf dem Planwert stehen lässt, wird als «keine Abweichung» gewertet.

)} ); // Eingebettet: schlichter Container im Hauptbereich. Sonst: Pop-up mit Overlay. if (embedded) { return
{body}
; } return (
e.stopPropagation()} className="ui-pop flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"> {body}
); } function FragmentRow({ header, row, value, onChange, }: { header: ElementCategory | null; row: Row; value: { value?: number; mortgage?: number }; onChange: (patch: { value?: number; mortgage?: number }) => void; }) { return ( <> {header && ( {CATEGORY_LABELS[header]} )}
{row.name}
{row.scenarioNames.length > 1 && (
gilt für {row.scenarioNames.join(", ")}
)} {row.kind === "FLOW" &&
Jahresbetrag
} {/* Klarstellen, dass hier der Verkehrswert steht -- nicht das Eigenkapital, das die Matrix zeigt. */} {row.kind === "PROPERTY" &&
Verkehrswert + Restschuld
} {formatChf(row.planValue)} {row.kind === "PROPERTY" && (
Hypothek {formatChf(row.planMortgage ?? 0)}
)} onChange({ value: v })} /> {row.kind === "PROPERTY" && (
onChange({ mortgage: v })} label="Hypothek" />
)} ); } function NumInput({ value, onChange, label }: { value: number; onChange: (v: number) => void; label?: string }) { return (
{label && {label}} onChange(Math.round(Number(e.target.value) || 0))} className="w-32 rounded border border-border bg-surface px-2 py-1 text-right text-sm text-fg" />
); }