Files
FPT/src/components/ActualsDialog.tsx
T
admGitAICDS 0761f6b3e2
Deploy App / deploy (push) Successful in 1m4s
Effektive Werte: Immobilien-Bugfix, Bearbeiten; Sidebar-Ebenen, Ring-Klick
Immobilien-Bugfix (gravierend):
- Der Ist-Wizard belegte den Immobilienwert mit dem EIGENKAPITAL vor
  (ElementYearPoint.value), waehrend Erfassung und Rechenkern den
  VERKEHRSWERT erwarten (propertyValue). Der Kern setzte die Zahl als
  Verkehrswert ein und liess die Hypothek stehen -> das Eigenkapital brach
  im Ist-Jahr um genau die Hypothek ein, meist ins Negative.
- Sichtbar als negative Gesamt-Abweichung trotz reiner Lohnerhoehung und als
  "wegbrechendes" Wohneigentum in der Vermoegensaufteilung.
- Feld ist neu als "Verkehrswert + Restschuld" beschriftet; 3 Regressionstests.

Ist-Datensaetze bearbeitbar:
- Klick auf die Zeile (oder "Bearbeiten") oeffnet den Satz erneut
- neuer Endpunkt PUT /api/plans/<id>/actuals/<setId>
- beim Bearbeiten ueberschreiben die Planwerte die erfassten Zahlen nicht

Ring-Klick in der Vermoegensaufteilung repariert:
- Recharts 3 reicht kein activePayload mehr durch (nur activeIndex) --
  der Handler feuerte nie, der Ring zeigte immer das Planende

Seitenleiste sauber dreistufig:
- Ebene 1 Plaene, Ebene 2 die vier Bereiche mit buendigen Symbolen,
  Ebene 3 nur die Szenarien (verschachtelt nach Herkunft)

Szenario-Liste zeigt neben der Version deren Kommentar.

SPEZIFIKATION 0.32 (neue Kapitel 3.9.6, 3.9.7). 275 -> 278 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:52:23 +02:00

581 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<typeof actualKindOf>;
}
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<Record<string, LoadedScenario>>(() => ({ [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<StoredSet[] | null>(null);
const [error, setError] = useState<string | null>(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<Record<string, { value?: number; mortgage?: number }>>({});
const [cash, setCash] = useState<number>(0);
// null = neuer Datensatz; sonst die id des gerade bearbeiteten (dann PUT statt POST).
const [editingId, setEditingId] = useState<string | null>(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<string, Row>();
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<string, { value?: number; mortgage?: number }> = {};
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 = (
<>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<CalendarClock className="h-5 w-5 text-accent" /> Effektive Werte
</h2>
<p className="mt-0.5 text-xs text-muted">
Plan «{planName}». Was tatsächlich eingetreten ist der Plan selbst bleibt unverändert.
</p>
</div>
{!embedded && (
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
)}
</div>
{mode === "list" && (
<>
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Ein Datensatz hält fest, wie es an einem Stichtag <strong className="text-fg">wirklich</strong> 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 <strong className="text-fg">alle Szenarien</strong> dieses Plans die
Wirklichkeit ist dieselbe, egal wogegen man sie hält.
</div>
{error && <p className="text-xs text-danger">{error}</p>}
{!sets && !error && <p className="text-xs text-muted">Wird geladen</p>}
{sets && sets.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Noch keine effektiven Werte erfasst.
</p>
)}
{sets && sets.length > 0 && (
<div className="flex flex-col gap-2">
{sets.map((s, i) => (
<div
key={s.id}
onClick={() => 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"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-fg">
{dt(s.recordedOn)}
{i === 0 && (
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">
aktuellster
</span>
)}
</div>
<div className="text-[11px] text-muted">
rechnet ab {s.year} · {deviationCount(s)} Werte · {s.author}
{typeof s.cash === "number" && ` · Cash ${formatChf(s.cash)}`}
</div>
{s.comment && <div className="mt-0.5 truncate text-xs text-fg">«{s.comment}»</div>}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
startEdit(s);
}}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:bg-surface-2 hover:text-fg"
>
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void remove(s);
}}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" /> Löschen
</button>
</div>
))}
</div>
)}
<div>
<Button onClick={startWizard}>
<Plus className="h-4 w-4" /> Effektive Werte erfassen
</Button>
</div>
</>
)}
{mode === "wizard" && step === 1 && (
<>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">
{editingId ? "Bearbeiten" : "Erfassen"} · Schritt 1 von 2 · Stichtag
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 flex items-center text-xs font-medium text-muted">
Datum der Erfassung
<InfoBubble text="Das exakte Datum erscheint in der Liste und auf der Zeitachse. Für die Berechnung zählt nur die Jahreszahl der Rechenkern arbeitet in ganzen Jahren ab Planbeginn." />
</label>
<input
type="date"
value={recordedOn}
onChange={(e) => setRecordedOn(e.target.value)}
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Notiz (optional)</label>
<input
value={comment}
onChange={(e) => 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"
/>
</div>
</div>
<p className="rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-muted">
Gerechnet wird ab dem Jahr <strong className="text-fg">{year}</strong>. Die Vorbelegung im nächsten
Schritt zeigt, was dein Plan für dieses Jahr vorsieht du überschreibst nur, was tatsächlich anders ist.
</p>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setMode("list")}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button onClick={goToStep2}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
</>
)}
{mode === "wizard" && step === 2 && (
<>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">
{editingId ? "Bearbeiten" : "Erfassen"} · Schritt 2 von 2 · Werte per {dt(recordedOn)}
</div>
<div className="max-h-[52vh] overflow-y-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 z-10 bg-surface-2">
<tr className="text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Element</th>
<th className="px-3 py-2 text-right font-semibold">Laut Plan {year}</th>
<th className="px-3 py-2 text-right font-semibold">Effektiv</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-border bg-surface-2/60">
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
Cash
</td>
</tr>
<tr className="border-t border-border">
<td className="px-3 py-2 font-medium text-fg">Cash-Konto</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(planCash)}</td>
<td className="px-3 py-2">
<NumInput value={cash} onChange={setCash} />
</td>
</tr>
{rows.map((r, i) => {
const header = i === 0 || rows[i - 1].category !== r.category ? r.category : null;
const v = values[r.rootId] ?? {};
return (
<FragmentRow
key={r.rootId}
header={header}
row={r}
value={v}
onChange={(patch) => setRow(r.rootId, patch)}
/>
);
})}
</tbody>
</table>
</div>
<p className="text-[11px] text-faint">
Einkommen und Ausgaben bitte als <strong>Jahresbetrag</strong> 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.
</p>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setStep(1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button disabled={busy} onClick={save}>
{busy ? "Speichern…" : "Speichern"}
</Button>
</div>
</>
)}
</>
);
// Eingebettet: schlichter Container im Hauptbereich. Sonst: Pop-up mit Overlay.
if (embedded) {
return <div className="flex flex-col gap-4">{body}</div>;
}
return (
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div onClick={(e) => 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}
</div>
</div>
);
}
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 && (
<tr className="border-t border-border bg-surface-2/60">
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
{CATEGORY_LABELS[header]}
</td>
</tr>
)}
<tr className="border-t border-border">
<td className="px-3 py-2">
<div className="font-medium text-fg">{row.name}</div>
{row.scenarioNames.length > 1 && (
<div className="text-[10px] text-faint">gilt für {row.scenarioNames.join(", ")}</div>
)}
{row.kind === "FLOW" && <div className="text-[10px] text-faint">Jahresbetrag</div>}
{/* Klarstellen, dass hier der Verkehrswert steht -- nicht das Eigenkapital, das die
Matrix zeigt. */}
{row.kind === "PROPERTY" && <div className="text-[10px] text-faint">Verkehrswert + Restschuld</div>}
</td>
<td className="px-3 py-2 text-right text-muted">
{formatChf(row.planValue)}
{row.kind === "PROPERTY" && (
<div className="text-[11px] text-faint">Hypothek {formatChf(row.planMortgage ?? 0)}</div>
)}
</td>
<td className="px-3 py-2">
<NumInput value={value.value ?? 0} onChange={(v) => onChange({ value: v })} />
{row.kind === "PROPERTY" && (
<div className="mt-1">
<NumInput
value={value.mortgage ?? 0}
onChange={(v) => onChange({ mortgage: v })}
label="Hypothek"
/>
</div>
)}
</td>
</tr>
</>
);
}
function NumInput({ value, onChange, label }: { value: number; onChange: (v: number) => void; label?: string }) {
return (
<div className="flex items-center justify-end gap-1">
{label && <span className="text-[10px] text-faint">{label}</span>}
<input
type="number"
value={Number.isFinite(value) ? value : 0}
onChange={(e) => 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"
/>
</div>
);
}