Detailansichten, Wasserfaelle und vollstaendige Rechenweg-Offenlegung
Deploy App / deploy (push) Successful in 57s
Deploy App / deploy (push) Successful in 57s
Roadmap Nr. 43 (Detailansichten) und Nr. 41 (Berechnungslogiken offenlegen).
Systemparameter-Ansicht:
- SYSTEM_PARAMETERS in constants.ts: Wert, Bedeutung, Herleitung, Quelle,
Stand -- aus derselben Datei, aus der gerechnet wird
Detailansichten (nur lesen, per Expand-Icon):
- Element: Verlauf ueber ALLE Planjahre (neu ElementPhaseComputed.yearly),
bei Immobilien Verkehrswert/Restschuld/Eigenkapital getrennt
- Phase: Vermoegensaufteilung + zwei Wasserfaelle
Zwei getrennte Wasserfaelle (WealthBridge / CashBridge):
- Sparraten, Amortisationen und Investitionen sind UMBUCHUNGEN und erscheinen
nur im Cash-Wasserfall -- als Vermoegensabgang gezeichnet wuerden sie einen
Verlust vortaeuschen, den es nicht gibt
- PK-Beitraege dagegen sind ein echter Vermoegenszugang (belasten kein Cash),
Verrentung ein echter Abgang (Kapital verlaesst die Bilanz)
- residual als Kontrollgroesse fuer die Vollstaendigkeit der Zerlegung
Rechenweg-Protokoll, vollstaendige Abdeckung:
- computePlan(plan, sample?, { explain }) protokolliert die Schritte, die es
ohnehin ausfuehrt -- die Erklaerung IST die Rechnung, statt einer zweiten
Formel-Implementierung im UI, die still abdriften koennte
- standardmaessig aus (Monte Carlo bleibt unberuehrt)
- Arithmetik nicht umgestellt: Zwischengroessen werden als Differenz
abgeleitet, damit die 43 Golden Tests bitgleich bleiben
- jeder Trace verlinkt in die SPEZIFIKATION; ein Test prueft gegen die echte
Datei, dass alle Verweise eine existierende Ueberschrift treffen
SPEZIFIKATION auf 0.11: neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21.
12 Tests ergaenzt (80 -> 92). Keine DB-Aenderung.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
CreditCard,
|
||||
Home,
|
||||
Landmark,
|
||||
Maximize2,
|
||||
PiggyBank,
|
||||
Plus,
|
||||
Settings2,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Timeline } from "@/components/Timeline";
|
||||
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||
import {
|
||||
CashTransitionFields,
|
||||
ElementDetail,
|
||||
@@ -46,7 +48,7 @@ import {
|
||||
type PhaseData,
|
||||
type TransitionData,
|
||||
} from "@/lib/elements";
|
||||
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
|
||||
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
||||
import type { ScenarioDiff } from "@/lib/diff";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
|
||||
@@ -92,12 +94,14 @@ export function PlanView({
|
||||
computed,
|
||||
diff,
|
||||
onChanged,
|
||||
onOpenSpec,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
// Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
|
||||
diff: ScenarioDiff | null;
|
||||
onChanged: () => void;
|
||||
onOpenSpec?: (anchor: string) => void;
|
||||
}) {
|
||||
// Markierungs-Klassen: geaendert = gelb, neu = gruen, entfernt = grau.
|
||||
const cellDiff = (elementId: string, phaseId: string) =>
|
||||
@@ -124,6 +128,16 @@ export function PlanView({
|
||||
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
|
||||
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
|
||||
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
||||
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Oeffnen erzeugt.
|
||||
const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null);
|
||||
|
||||
// Erklaerte Berechnung: bewusst NUR wenn eine Detailansicht offen ist. computePlan ist rein
|
||||
// und laeuft im Browser -- es braucht dafuer weder einen API-Aufruf noch eine groessere
|
||||
// Server-Antwort, und das Ergebnis ist per Konstruktion identisch zum Serverergebnis.
|
||||
const explained = useMemo(
|
||||
() => (detailFor ? computePlan(plan, undefined, { explain: true }) : null),
|
||||
[detailFor, plan]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null;
|
||||
@@ -405,6 +419,7 @@ export function PlanView({
|
||||
mode={valueMode}
|
||||
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
|
||||
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
|
||||
onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })}
|
||||
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
|
||||
/>
|
||||
) : (
|
||||
@@ -499,12 +514,24 @@ export function PlanView({
|
||||
{!collapsed &&
|
||||
els.map((el) => (
|
||||
<tr key={el.id} className="hover:bg-surface-2">
|
||||
<td className={`sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
|
||||
<div className="flex items-center gap-1 truncate text-xs font-medium text-fg">
|
||||
{el.name}
|
||||
<td className={`group/row sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-fg">
|
||||
<span className="min-w-0 flex-1 truncate">{el.name}</span>
|
||||
{diff?.elementRow.get(el.id) === "added" && (
|
||||
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Detailansicht ${el.name}`}
|
||||
title="Detailansicht (nur lesen)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailFor({ kind: "element", id: el.id });
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover/row:opacity-100"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||||
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
|
||||
@@ -706,6 +733,36 @@ export function PlanView({
|
||||
);
|
||||
})()}
|
||||
|
||||
{detailFor?.kind === "element" && explained && (() => {
|
||||
const el = plan.elements.find((e) => e.id === detailFor.id);
|
||||
if (!el) return null;
|
||||
return (
|
||||
<ElementDetailDialog
|
||||
key={`det-${detailFor.id}`}
|
||||
elementId={el.id}
|
||||
name={el.name}
|
||||
category={el.category}
|
||||
computed={explained}
|
||||
onClose={() => setDetailFor(null)}
|
||||
onOpenSpec={onOpenSpec}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{detailFor?.kind === "phase" && explained && (() => {
|
||||
const ph = explained.phases.find((p) => p.id === detailFor.id);
|
||||
if (!ph) return null;
|
||||
return (
|
||||
<PhaseDetailDialog
|
||||
key={`detph-${detailFor.id}`}
|
||||
phase={ph}
|
||||
isFirst={ph.sequenceNumber === 1}
|
||||
onClose={() => setDetailFor(null)}
|
||||
onOpenSpec={onOpenSpec}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{editPhaseCell && (() => {
|
||||
const element = plan.elements.find((e) => e.id === editPhaseCell.elementId);
|
||||
const phase = computed.phases.find((p) => p.id === editPhaseCell.phaseId);
|
||||
@@ -854,6 +911,7 @@ function PhaseHeader({
|
||||
mode,
|
||||
diffKind,
|
||||
onClick,
|
||||
onExpand,
|
||||
active,
|
||||
}: {
|
||||
phase: PhaseComputed;
|
||||
@@ -861,6 +919,7 @@ function PhaseHeader({
|
||||
mode: ValueMode;
|
||||
diffKind: "changed" | "added" | "removed" | null;
|
||||
onClick: () => void;
|
||||
onExpand: () => void;
|
||||
active: boolean;
|
||||
}) {
|
||||
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
|
||||
@@ -870,7 +929,7 @@ function PhaseHeader({
|
||||
return (
|
||||
<th
|
||||
onClick={onClick}
|
||||
className={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
||||
className={`group/ph min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
||||
active
|
||||
? "bg-accent-soft"
|
||||
: diffKind === "added"
|
||||
@@ -881,7 +940,7 @@ function PhaseHeader({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{phase.name}</span>
|
||||
{diffKind === "added" && (
|
||||
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
|
||||
)}
|
||||
@@ -890,6 +949,20 @@ function PhaseHeader({
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||||
)}
|
||||
{/* Der Kopf oeffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event
|
||||
deshalb stoppen, sonst gingen beide Dialoge gleichzeitig auf. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Detailansicht ${phase.name}`}
|
||||
title="Detailansicht (nur lesen)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExpand();
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover/ph:opacity-100"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||
<span className="rounded bg-surface-2 px-1 text-[10px] text-muted">
|
||||
|
||||
Reference in New Issue
Block a user