Effektive Werte: Immobilien-Bugfix, Bearbeiten; Sidebar-Ebenen, Ring-Klick
Deploy App / deploy (push) Successful in 1m4s
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:
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user