Effektive Werte: Immobilien-Bugfix, Bearbeiten; Sidebar-Ebenen, Ring-Klick
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>
This commit is contained in:
2026-07-25 12:52:23 +02:00
parent b42f9b385f
commit 0761f6b3e2
8 changed files with 234 additions and 27 deletions
@@ -1,7 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
const valueSchema = z.object({
value: z.number().min(-1_000_000_000).max(1_000_000_000).optional(),
mortgage: z.number().min(0).max(1_000_000_000).optional(),
});
const updateSchema = z.object({
recordedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Datum im Format JJJJ-MM-TT"),
comment: z.string().max(500).optional(),
cash: z.number().min(-1_000_000_000).max(1_000_000_000).nullable().optional(),
values: z.record(z.string(), valueSchema),
});
// Ändert einen bestehenden Ist-Satz. Wie beim Anlegen gilt: Ein Ist-Satz ist eine Beobachtung,
// keine Planänderung -- er erzeugt also KEINE Szenario-Version. Der ursprüngliche Erfasser
// (`createdById`) bleibt stehen; korrigiert wird der Datensatz selbst.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ planId: string; setId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, setId } = await params;
const existing = await prisma.actualsSet.findFirst({ where: { id: setId, planId, plan: { userId } } });
if (!existing) return NextResponse.json({ error: "Datensatz nicht gefunden." }, { status: 404 });
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
const first = parsed.error.issues[0];
return NextResponse.json({ error: first?.message ?? "Ungültige Eingabe." }, { status: 400 });
}
const { recordedOn, comment, cash, values } = parsed.data;
const updated = await prisma.actualsSet.update({
where: { id: setId },
data: {
recordedOn: new Date(`${recordedOn}T00:00:00.000Z`),
// Für die Berechnung zählt nur die Jahreszahl (siehe POST).
year: Number(recordedOn.slice(0, 4)),
comment: comment?.trim() || null,
cash: typeof cash === "number" ? cash : null,
values,
},
});
return NextResponse.json({ set: { id: updated.id, year: updated.year } });
}
// Löscht einen Ist-Satz. Ein Ist-Satz ist eine Beobachtung, keine Planänderung -- deshalb
// gibt es hier weder Versionierung noch Wiederherstellung.
export async function DELETE(
@@ -24,7 +24,11 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
_count: { select: { elements: true, versions: true } },
// Höchste Version je Szenario -- die Liste zeigt die echte Nummer (z. B. «0.17»),
// nicht nur die Hauptversion.
versions: { orderBy: [{ major: "desc" }, { minor: "desc" }], take: 1, select: { major: true, minor: true } },
versions: {
orderBy: [{ major: "desc" }, { minor: "desc" }],
take: 1,
select: { major: true, minor: true, comment: true },
},
},
},
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
@@ -85,6 +89,9 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
parentScenarioId: s.parentScenarioId,
// Die tatsächliche aktuelle Version, nicht nur die Hauptversion. Ohne Historie noch keine.
version: top ? `${top.major}.${top.minor}` : null,
// Kommentar der aktuellen Version -- gesetzt wird er bei Hauptversionen und beim
// Wiederherstellen; Nebenversionen haben in der Regel keinen.
versionComment: top?.comment ?? null,
elementCount: s._count.elements,
phaseCount: computed.phases.length,
versionCount: s._count.versions,
+66 -9
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, CalendarClock, Plus, Trash2, X } from "lucide-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";
@@ -104,6 +104,8 @@ export function ActualsDialog({
// 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));
@@ -163,7 +165,15 @@ export function ActualsDialog({
name: el.name,
category: el.category,
scenarioNames: [sc.name],
planValue: Math.round(Math.abs(yearly?.value ?? 0)),
// 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,
});
@@ -189,9 +199,23 @@ export function ActualsDialog({
}, [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");
}
@@ -199,13 +223,20 @@ export function ActualsDialog({
// 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);
setCash(planCash);
if (!editingId) setCash(planCash);
setStep(2);
}
@@ -217,9 +248,15 @@ export function ActualsDialog({
async function save() {
setBusy(true);
try {
await api.post(`/api/plans/${planId}/actuals`, { recordedOn, comment: comment.trim() || undefined, cash, values });
toast("success", `Effektive Werte für ${dt(recordedOn)} erfasst.`);
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) {
@@ -298,7 +335,9 @@ export function ActualsDialog({
{sets.map((s, i) => (
<div
key={s.id}
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
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"
}`}
>
@@ -319,7 +358,20 @@ export function ActualsDialog({
</div>
<button
type="button"
onClick={() => remove(s)}
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
@@ -339,7 +391,9 @@ export function ActualsDialog({
{mode === "wizard" && step === 1 && (
<>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">Schritt 1 von 2 · Stichtag</div>
<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">
@@ -381,7 +435,7 @@ export function ActualsDialog({
{mode === "wizard" && step === 2 && (
<>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">
Schritt 2 von 2 · Werte per {dt(recordedOn)}
{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">
@@ -483,6 +537,9 @@ function FragmentRow({
<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)}
+9 -3
View File
@@ -126,9 +126,15 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp
<AreaChart
data={areaData}
margin={{ top: 8, right: 8, left: 8, bottom: 4 }}
onClick={(e) => {
const y = (e as { activePayload?: { payload?: { year?: number } }[] })?.activePayload?.[0]?.payload?.year;
if (typeof y === "number") setPickedYear(y);
style={{ cursor: "pointer" }}
// Recharts 3 reicht im Klick-Parameter NUR den aktiven Index durch -- das in
// Version 2 übliche `activePayload` gibt es nicht mehr. Über den Index in
// `areaData` kommt man an das Planjahr.
onClick={(state) => {
const raw = state?.activeIndex ?? state?.activeTooltipIndex;
const idx = typeof raw === "number" ? raw : Number(raw);
if (!Number.isInteger(idx) || idx < 0 || idx >= areaData.length) return;
setPickedYear(areaData[idx].year);
}}
>
<XAxis
+4 -4
View File
@@ -389,7 +389,7 @@ function AppShellInner({ username }: { username: string }) {
<button
type="button"
onClick={() => openPlanTab(p.id, tab)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-3 pr-3 text-left text-xs font-medium transition-colors ${
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-[1.625rem] pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === tab ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
@@ -444,7 +444,7 @@ function AppShellInner({ username }: { username: string }) {
Szenarien
</button>
</div>
{/* Der Baum erscheint nur aufgeklappt -- alle Szenarien gleich eingerückt. */}
{/* Ebene 3: nur unter "Szenarien", verschachtelt nach Herkunft. */}
{expandedTrees[p.id] && (
<ScenarioTree
scenarios={p.scenarios}
@@ -457,7 +457,7 @@ function AppShellInner({ username }: { username: string }) {
<button
type="button"
onClick={() => void openActualsTab(p.id)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-3 pr-3 text-left text-xs font-medium transition-colors ${
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-[1.625rem] pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === "actuals" ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
@@ -1017,7 +1017,7 @@ function ScenarioTree({
{ordered.map((s) => (
<div
key={s.id}
style={{ paddingLeft: `${2.25 + depthOf(s) * 0.75}rem` }}
style={{ paddingLeft: `${2.5 + depthOf(s) * 0.85}rem` }}
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm transition-colors ${
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
+9 -1
View File
@@ -53,6 +53,7 @@ interface DashboardResponse {
isBase: boolean;
parentScenarioId: string | null;
version: string | null;
versionComment: string | null;
elementCount: number;
phaseCount: number;
versionCount: number;
@@ -362,7 +363,14 @@ export function ScenarioListView({
</div>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.version ?? ""}</td>
<td className="px-3 py-2 text-right">
<div className="tabular-nums text-muted">{s.version ?? ""}</div>
{s.versionComment && (
<div className="max-w-48 truncate text-[11px] text-faint" title={s.versionComment}>
«{s.versionComment}»
</div>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.phaseCount}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.elementCount}</td>
<td className="px-3 py-2 text-right tabular-nums text-fg">{formatChf(s.endNominal)}</td>
+46
View File
@@ -342,3 +342,49 @@ describe("Effektive Flusswerte wirken in die Folgephase (Roadmap Nr. 44)", () =>
expect(valueAt(computed, "lohn", 6)).toBe(100000);
});
});
describe("Immobilie: Verkehrswert vs. Eigenkapital (Bugfix 0.31)", () => {
// Immobilie 900'000 mit 600'000 Hypothek -> Eigenkapital 300'000.
// Der Ist-Wizard belegt den Wert mit dem PLANWERT vor. Wuerde er dabei das Eigenkapital
// nehmen (statt des Verkehrswerts), setzte der Rechenkern 300'000 als Verkehrswert ein,
// waehrend die Hypothek bei 600'000 bliebe -- das Eigenkapital kippte auf -300'000.
function immoPlan(): PlanInput {
return {
id: "s", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0, startYear: 2020,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "E", durationYears: 10, cashTransition: {} }],
elements: [
{
id: "immo", category: "REAL_ESTATE", name: "Haus", ownerRole: "HOUSEHOLD", orderIndex: 1,
phaseValues: { p1: { purchasePrice: 900000, mortgage: 600000, amortization: 0, valueGrowth: 0 } },
transitionValues: {}, sourceElementId: null,
},
],
} as unknown as PlanInput;
}
it("fuehrt Verkehrswert und Eigenkapital getrennt in den Jahreswerten", () => {
const c = computePlan(immoPlan());
const y = c.phases[0].elements[0].yearly[0];
expect(y.value).toBe(300000); // Eigenkapital -- das zeigt die Matrix
expect(y.propertyValue).toBe(900000); // Verkehrswert -- den erfasst der Ist-Wizard
expect(y.mortgage).toBe(600000);
});
it("laesst das Eigenkapital unveraendert, wenn der Ist-Wert dem Plan entspricht", () => {
const p = immoPlan();
// So belegt der Wizard vor: Verkehrswert + Restschuld, beide auf Planniveau.
const actuals = resolveActuals([setAt(2023, { immo: { value: 900000, mortgage: 600000 } })], p, p.elements);
const computed = computePlan(p, undefined, { actuals });
expect(valueAt(computed, "immo", 4)).toBe(300000);
// Und der Sprung wird korrekt als "keine Abweichung" ausgewiesen.
expect(computed.phases[0].wealthBridge.actualsCorrection).toBe(0);
});
it("bildet eine echte Wertsteigerung sauber ab", () => {
const p = immoPlan();
const actuals = resolveActuals([setAt(2023, { immo: { value: 950000, mortgage: 580000 } })], p, p.elements);
const computed = computePlan(p, undefined, { actuals });
expect(valueAt(computed, "immo", 4)).toBe(370000); // 950'000 - 580'000
});
});