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
+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>