diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index d0f526c..a124aac 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.31 | -| **Datum** | 2026-07-24 | +| **Version** | 0.32 | +| **Datum** | 2026-07-25 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `96fdd00` inkl. Modul-Review 3 (Struktur & Grafiken) (Branch `main`) | +| **Codestand** | Arbeitsstand nach `b42f9b3` inkl. Modul-Review 3 (Nachbesserungen) (Branch `main`) | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.32 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3, Nachbesserungen – darunter ein gravierender Rechenfehler bei den effektiven Werten.** (1) **Immobilien-Bugfix (Kap. 3.9):** Der Ist-Wizard belegte den Immobilienwert mit dem **Eigenkapital** vor (`ElementYearPoint.value`), während Erfassung und Rechenkern den **Verkehrswert** erwarten. Der Rechenkern setzte den vorbelegten Wert als Verkehrswert ein, liess die Hypothek aber stehen – das Eigenkapital brach im Ist-Jahr schlagartig ein, typischerweise ins Negative. Sichtbar wurde das als **negative Gesamt-Abweichung, obwohl nur ein Lohn erhöht** wurde, und als «wegbrechendes» Wohneigentum in der Vermögensaufteilung. Neu wird `propertyValue` vorbelegt; das Feld ist als «Verkehrswert + Restschuld» beschriftet. Drei Regressionstests. (2) **Ist-Datensätze bearbeitbar:** Ein Klick auf die Zeile (oder «Bearbeiten») öffnet den erfassten Satz erneut; neuer Endpunkt `PUT /api/plans//actuals/`. Beim Bearbeiten überschreiben die Planwerte die erfassten Zahlen nicht mehr. (3) **Ring-Klick in der Vermögensaufteilung repariert:** Recharts 3 reicht im Klick-Parameter **kein `activePayload`** mehr durch (nur noch `activeIndex`) – der Handler feuerte nie, der Ring zeigte immer das Planende. (4) **Seitenleiste sauber dreistufig:** Ebene 1 Pläne, Ebene 2 die vier Bereiche (Szenarien, Effektive Werte, Analysen, Berichte) mit **bündigen Symbolen**, Ebene 3 nur die Szenarien – verschachtelt nach Herkunft. (5) Die **Szenario-Liste** zeigt neben der Version deren **Kommentar**. | | 0.31 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3 (Plan-/Szenario-Struktur, Dashboard, Grafiken).** (1) **Versionierung startet bei 0.1** statt 1.0 (Kap. 3.8): Ein Szenario läuft in der 0er-Reihe (0.1, 0.2, … 0.137), bis eine **Hauptversion** gesetzt wird – erst dann entsteht 1.0. Vorher begann jedes Szenario bereits bei 1.0, wodurch die Hauptversion ihre Bedeutung verlor. Die Szenario-Liste zeigt neu die **echte** Version statt «1.x», dazu eine Spalte **Phasen**. (2) **Plan-Dashboard:** Kacheln sind **anklickbar** und führen in ihren Bereich, neu inkl. **Berichte**; der Plan lässt sich über ein Stift-Symbol **umbenennen**; die Ist-Abweichung nennt das **Jahr** des jüngsten Ist-Datensatzes und ist bei einer positiven Abweichung **grün** statt rot. (3) **Szenario-Liste:** Ein Klick auf die **Zeile** öffnet die Matrix (der «Matrix»-Knopf entfällt), dazu je Zeile **Kopie** und **Löschen**; die Kopiervorlage ist damit frei wählbar und nicht mehr auf das Basisszenario festgelegt. Nach einer Löschung lädt die Liste neu (zeigte vorher den alten Stand). (4) **Seitenleiste:** Szenarien werden wieder **verschachtelt** dargestellt (Tiefe = Herkunftskette); «Effektive Werte», «Analysen» und «Berichte» stehen neu **bündig zum Knoten «Szenarien»** statt auf Höhe der einzelnen Szenarien. (5) **CSV-Export vollständig neu** (Kap. 3.6.5, neues Modul `csv.ts`): vier Blöcke – Kopf, Lebensphasen, **die ganze Matrix** (Elemente × Phasen mit Beginn/Ende und den Übergangs-Entscheiden im Klartext) und **Jahreswerte**; mit BOM, damit Excel die Umlaute erkennt. Vorher enthielt die Datei kein einziges finanzielles Element. (6) **Grafiken:** Ein **Szenario-Wähler** gilt neu für **alle drei** Grafiken (vorher nur der Vermögensverlauf, und der nur additiv). Der **Vergleichs-Fehler** ist behoben: `WealthChart` benutzte den Szenario-**Namen** als Datenschlüssel, wodurch sich gleichnamige Szenarien gegenseitig überschrieben (Legende zeigte beide, der Chart nur eine) – neu die **ID**; die stille Deckelung auf vier Serien entfällt. Die **Legende** ist eigenständig, erlaubt eine **freie Farbwahl je Serie** und erklärt den Linienstil (gestrichelt = Plan, durchgezogen = effektiv). Die **Vermögensaufteilung** ist neu eine **gestapelte Fläche über alle Planjahre** plus ein **Ring** für die relative Aufteilung zu einem wählbaren Zeitpunkt (vorher gestapelte Balken je Phase mit schräger Beschriftung). **Alle Diagrammfarben** kommen aus neuen Theme-Tokens (`--chart-1` … `--chart-6`, `--chart-grid`) statt fester Hex-Werte. (7) **Dokumentation nachgezogen:** Die Kapitel 2.1, 3.2.2–3.2.7 und 3.10 beschrieben noch den Stand **vor V7** (Grundprofil am Szenario, `parentPlanId`, `Scenario.startYear`, `window.confirm`, drei Sidebar-Unterpunkte). (8) Nebenbei: verstümmelte Hex-Farbe `--danger-soft` im Warm-Schema repariert, deutsche Plural-/Umlautfehler in den Übersichts-Kacheln, Dateiname des CSV-Exports transliteriert Umlaute statt sie zu `_` zu machen. 8 Tests ergänzt (267 → 275). | | 0.30 | 2026-07-24 | Claude (Opus 4.8) | **Tour-Korrekturen und 3a-Verschiebung.** (1) Der Tour-**Spotlight** wird neu aus **vier fixed-Flächen** um die Bounding-Box des Ziels gezeichnet (Kap. 9.24) statt aus einem `box-shadow`-Trick. Grund: Der Schatten liess sticky Matrix-Köpfe (hoher z-index) hell durchscheinen und wurde im Matrix-Scrollbereich abgeschnitten (dann blieb fast alles hell). Die vier Flächen funktionieren unabhängig von z-index und overflow und folgen dem Ziel per `requestAnimationFrame`. (2) **Tour-Schritte** überarbeitet: neu erklärt sind **Zeitachse** und **Endvermögen**; der vormals «Analysen»-Schritt beschreibt jetzt korrekt die **obere Funktions-Leiste** (die Analyse-Werkzeuge sind dort nicht mehr), und ein neuer Schritt zeigt das **linke Menü** (Analysen, Berichte, Effektive Werte). Unsichtbare Ziele (z. B. das Menü auf schmalen Screens) werden übersprungen. (3) Der Schalter **«Selbstständig – grosse Säule 3a»** wandert im Assistenten von Schritt 4 zu **Schritt 5**, weil er die 3a-Einzahlung (also die Sparraten-Verteilung) betrifft. Kein Eingriff in den Rechenkern; 267 Tests unverändert grün. | | 0.29 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 2, Feinschliff (Assistent, Tour, Ansicht).** (1) **Assistent:** neuer **Willkommens-Screen** vor Schritt 1 mit dem Gesamtbild der fünf Schritte, danach eine **persistente Schritt-Leiste** (links im breiten Modal, mobil als Fortschrittsbalken) – der aktuelle Schritt hervorgehoben, erledigte mit Haken, kommende gedämpft. Schritt «Vorsorge & Vermögen» fragt neu auch die **erwartete Rendite** bei PK, 3a und Wertschriften ab (vorher fest verdrahtet). Im Schritt «Sparen & Verteilen» rechnet die Sparquote-Vorschau **Hypothekarzinsen**, die als «noch nicht in den Ausgaben» markiert sind, korrekt zu den Ausgaben dazu (wie der Rechenkern bei `interestHandling: ADD`); der **noch nicht verteilte Rest** steht neu **prominent oben** (zwischen PK und Verteilung), und die Verteilung ist nach **Gemeinsam / Person A / Person B** gruppiert. (2) **Tour** (Kap. 9.24): statt der bisher bewusst schlichten Hervorhebung jetzt ein **Spotlight** – der Rest der Ansicht wird abgedunkelt (ein 9999-px-Kastenschatten, kein separates Overlay), der pulsierende Rahmen ist deutlich stärker, die Karte ist grösser und **springt** auf die dem Ziel gegenüberliegende Bildschirmhälfte (verdeckt es nie); neu mit **«Überspringen»**-Knopf. (3) **Szenario-Ansicht** (Kap. 3.7.7): Grundprofil, Zeitachse und die Kennzahlen (Endvermögen nominal + real, Ruinalter) bilden neu **einen kompakten Block** aus drei Spalten (25 / 50 / 25 %) statt zweier über die ganze Breite gezogener Zeilen. Neue Modal-Grösse `xwide`. Kein Eingriff in den Rechenkern; 267 Tests unverändert grün. | @@ -1626,6 +1627,30 @@ entfernt ihn ersatzlos; der Plan bleibt unberührt. Die beiden Historien bleiben Referenz: `src/lib/actuals.ts`, `src/lib/dataview.ts`, `src/components/ActualsDialog.tsx`, `src/components/AnalysisControls.tsx`. +### 3.9.6 Immobilien: Verkehrswert, nicht Eigenkapital + +Bei einer Immobilie führt der Rechenkern **zwei** Grössen: den **Verkehrswert** der Liegenschaft +und die **Restschuld**. Was die Matrix zeigt und was `ElementYearPoint.value` trägt, ist die +Differenz – das **Eigenkapital**. Der Verkehrswert steht separat in `propertyValue`. + +Der Ist-Wizard erfasst **Verkehrswert und Restschuld getrennt**, nie das Eigenkapital. Bis 0.31 +belegte er das Wertfeld irrtümlich mit `value` (dem Eigenkapital) vor: Der Rechenkern setzte +diese Zahl als Verkehrswert ein und liess die Hypothek unverändert, wodurch das Eigenkapital im +Ist-Jahr um genau die Hypothek einbrach – meist ins Negative. Weil ein Ist-Satz **alle** Zeilen +mitschreibt (auch die unveränderten), traf das jeden Satz, selbst wenn nur ein Lohn angepasst +wurde. Drei Regressionstests in `actuals.test.ts` halten die Trennung fest. + +### 3.9.7 Ist-Datensätze bearbeiten + +Ein erfasster Satz lässt sich über einen Klick auf seine Zeile (oder «Bearbeiten») erneut öffnen +und korrigieren – Stichtag, Kommentar, Cash und alle Werte. Der Wizard läuft dabei in beiden +Schritten wie beim Erfassen, überschreibt die bereits erfassten Zahlen aber **nicht** mit den +Planwerten; nur Zeilen ohne erfassten Wert (z. B. ein später hinzugekommenes Element) werden +ergänzt. Endpunkt: `PUT /api/plans//actuals/`. + +Wie das Anlegen erzeugt auch das Bearbeiten **keine** Szenario-Version – ein Ist-Satz ist eine +Beobachtung, keine Planänderung. Der ursprüngliche Erfasser bleibt vermerkt. + ## 3.10 Navigation auf Plan-Ebene und gespeicherte Analysen Die Seitenleiste ist zweistufig: Unter jedem **Plan** liegen vier Unterpunkte. Ein Klick auf den @@ -1639,9 +1664,16 @@ Die Seitenleiste ist zweistufig: Unter jedem **Plan** liegen vier Unterpunkte. E | Analysen | Vier Werkzeug-Kacheln + Liste gespeicherter Analysen | | Berichte | PDF-Berichte erzeugen und wieder herunterladen ([3.11](#311-pdf-berichte)) | -Die vier Unterpunkte stehen **bündig zum Knoten «Szenarien»**; nur die einzelnen Szenarien -darunter sind eingerückt – und zwar je nach Tiefe ihrer Herkunftskette, sodass in der -Seitenleiste sichtbar ist, woraus ein Szenario entstanden ist. +Die Seitenleiste ist damit **dreistufig**: + +| Ebene | Inhalt | +|---|---| +| **1** | die Pläne | +| **2** | je Plan die vier Bereiche: Szenarien · Effektive Werte · Analysen · Berichte – ihre Symbole stehen **auf einer Linie** (der Knoten «Szenarien» trägt zusätzlich den Auf-/Zuklapp-Pfeil) | +| **3** | **nur** unter «Szenarien»: die einzelnen Szenarien, **verschachtelt** nach ihrer Herkunftskette (beliebig tief) | + +So ist in der Seitenleiste sichtbar, woraus ein Szenario entstanden ist; die Szenario-Liste +zeigt dasselbe zusätzlich als Spalte «aus …». ### 3.10.1 Plan- vs. Szenario-Ebene der Kennzahlen @@ -1657,7 +1689,9 @@ Falls Ist-Werte erfasst sind, weist das Dashboard die **Abweichung** des Endverm dem Plan aus – benannt mit dem **Jahr des jüngsten Ist-Datensatzes** («Mit den effektiven Werten von 2032 …») und **grün**, wenn die Realität besser ist als der Plan, sonst rot. -Die **Szenario-Liste** zeigt je Szenario Name, die **aktuelle Version** (z. B. «0.17»), Anzahl +Die **Szenario-Liste** zeigt je Szenario Name, die **aktuelle Version** (z. B. «0.17») samt +ihrem **Kommentar** (sofern vorhanden – gesetzt wird er bei Hauptversionen und beim +Wiederherstellen), Anzahl **Phasen** und **Elemente**, das Endvermögen und ob das Kapital reicht; das Basisszenario ist farblich hervorgehoben, und die Herkunft (aus welchem Szenario kopiert) steht darunter. Ein Klick auf die **Zeile** öffnet die Matrix; je Zeile gibt es zusätzlich **Historie**, **Kopie** @@ -3909,7 +3943,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise | | `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % | | `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder | -| `actuals.test.ts` | 24 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung, Wirkung über die Phasengrenze hinaus | +| `actuals.test.ts` | 27 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung, Wirkung über die Phasengrenze hinaus | | `dataview.test.ts` | 7 | beide Sichten in einem Zug, Rückfall ohne Ist-Werte, Abweichungsmessung | | `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen | | `versioning.test.ts` | 23 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien | @@ -3924,7 +3958,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) | | `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP | | `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` | -| **Total** | **275** | | +| **Total** | **278** | | ## 8.2 Testfälle diff --git a/src/app/api/plans/[planId]/actuals/[setId]/route.ts b/src/app/api/plans/[planId]/actuals/[setId]/route.ts index 049e624..338d1fa 100644 --- a/src/app/api/plans/[planId]/actuals/[setId]/route.ts +++ b/src/app/api/plans/[planId]/actuals/[setId]/route.ts @@ -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( diff --git a/src/app/api/plans/[planId]/dashboard/route.ts b/src/app/api/plans/[planId]/dashboard/route.ts index f494600..6a8c81a 100644 --- a/src/app/api/plans/[planId]/dashboard/route.ts +++ b/src/app/api/plans/[planId]/dashboard/route.ts @@ -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, diff --git a/src/components/ActualsDialog.tsx b/src/components/ActualsDialog.tsx index d0b117e..59d747e 100644 --- a/src/components/ActualsDialog.tsx +++ b/src/components/ActualsDialog.tsx @@ -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>({}); const [cash, setCash] = useState(0); + // null = neuer Datensatz; sonst die id des gerade bearbeiteten (dann PUT statt POST). + const [editingId, setEditingId] = useState(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 = {}; 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) => (
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({
+ - {/* Der Baum erscheint nur aufgeklappt -- alle Szenarien gleich eingerückt. */} + {/* Ebene 3: nur unter "Szenarien", verschachtelt nach Herkunft. */} {expandedTrees[p.id] && ( 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) => (
)} - {s.version ?? "–"} + +
{s.version ?? "–"}
+ {s.versionComment && ( +
+ «{s.versionComment}» +
+ )} + {s.phaseCount} {s.elementCount} {formatChf(s.endNominal)} diff --git a/src/lib/actuals.test.ts b/src/lib/actuals.test.ts index 9a8032c..875b8bb 100644 --- a/src/lib/actuals.test.ts +++ b/src/lib/actuals.test.ts @@ -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 + }); +});