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:
+43
-9
@@ -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/<id>/actuals/<setId>`. 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/<planId>/actuals/<setId>`.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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