0761f6b3e2
Deploy App / deploy (push) Successful in 1m4s
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>
581 lines
24 KiB
TypeScript
581 lines
24 KiB
TypeScript
"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>
|
||
);
|
||
}
|
||
|