Vererbung Einkommen/Ausgaben + Real/Nominal-Umschalter
Deploy App / deploy (push) Successful in 1m2s

Punkt 1: Beim Speichern eines Einkommens-/Ausgaben-Basiswerts, der dem fortgeschriebenen
Wert der Vorphase entspricht, wird KEIN Override gespeichert. Damit wirken sich Aenderungen
in frueheren Phasen automatisch auf Folgephasen aus (live vererbt); nur ein bewusst
abweichender Wert bleibt fix.

Punkt 2: Anzeige-Umschalter oben links (Nominal / Beide / Real), pro Geraet gespeichert.
Alle Betraege in Matrix, Cash-Zeile und Phasenkopf zeigen je nach Wahl den nominalen,
realen (kaufkraftbereinigten) oder beide Werte. Engine liefert dazu cumulativeInflationStart
(Deflator zu Phasenbeginn) zusaetzlich zum Phasenende.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 13:19:34 +02:00
parent 5076dcb68f
commit 310ebc66fb
3 changed files with 86 additions and 13 deletions
+13 -1
View File
@@ -455,7 +455,19 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
if (isTransition) {
await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td);
} else {
await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, pd);
const payload: PhaseData = { ...pd };
// Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Aenderungen in
// frueheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
if (
(element.category === "INCOME" || element.category === "EXPENSE") &&
context.carried &&
typeof payload.amount === "number" &&
payload.amount === Math.round(context.derivedStart)
) {
delete payload.amount;
}
await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, payload);
}
onSaved();
} catch (e) {
+69 -11
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
Building2,
@@ -96,6 +96,17 @@ export function PlanView({
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
const [showCashInit, setShowCashInit] = useState(false);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
useEffect(() => {
const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null;
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Uebernahme der gespeicherten Wahl
if (stored === "nominal" || stored === "both" || stored === "real") setValueMode(stored);
}, []);
function changeValueMode(m: ValueMode) {
setValueMode(m);
if (typeof window !== "undefined") window.localStorage.setItem(VALUE_MODE_KEY, m);
}
const columns = useMemo<Column[]>(() => {
const cols: Column[] = [];
@@ -239,6 +250,25 @@ export function PlanView({
return (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Anzeige</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{(["nominal", "both", "real"] as ValueMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => changeValueMode(m)}
className={`rounded-md px-2.5 py-1 font-medium ${
valueMode === m ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{m === "nominal" ? "Nominal" : m === "both" ? "Beide" : "Real"}
</button>
))}
</div>
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
</div>
<Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} />
{/* Plan-Profil */}
@@ -312,6 +342,7 @@ export function PlanView({
key={col.phase.id}
phase={col.phase}
personLabel={personLabel}
mode={valueMode}
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
/>
@@ -346,7 +377,9 @@ export function PlanView({
} ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"}`}
>
<span className="whitespace-nowrap">
{formatChf(col.phase.cashStart)} <span className="text-faint"></span> {formatChf(col.phase.cashEnd)}
{valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "}
<span className="text-faint"></span>{" "}
{valStr(col.phase.cashEnd, col.phase.cumulativeInflationEnd, valueMode)}
</span>
</td>
) : (
@@ -402,7 +435,7 @@ export function PlanView({
ce?.locked ? "text-faint" : "text-fg"
}`}
>
{phaseCellContent(ce)}
{phaseCellContent(ce, col.phase, valueMode)}
</td>
);
}
@@ -628,31 +661,57 @@ export function PlanView({
// Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl.
const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"];
function phaseCellContent(ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined): React.ReactNode {
type ValueMode = "nominal" | "both" | "real";
const VALUE_MODE_KEY = "fpt-value-mode";
// Realwert = nominal / kumulierte Inflation (Kaufkraft zum Planbeginn).
function realOf(nominal: number, deflator: number): number {
return Math.round(nominal / (deflator || 1));
}
function valStr(nominal: number, deflator: number, mode: ValueMode): string {
if (mode === "real") return formatChf(realOf(nominal, deflator));
if (mode === "both") return `${formatChf(nominal)} (${formatChf(realOf(nominal, deflator))})`;
return formatChf(nominal);
}
function phaseCellContent(
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
phase: PhaseComputed,
mode: ValueMode
): React.ReactNode {
if (!ce) return "";
if (ce.note) return ce.note;
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd;
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
return (
<span className="whitespace-nowrap">
{formatChf(ce.startValue)} <span className="text-faint"></span> {formatChf(ce.endValue)}
{valStr(ce.startValue, dS, mode)} <span className="text-faint"></span> {valStr(ce.endValue, dE, mode)}
</span>
);
}
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
return `Rente ${valStr(ce.startValue, dS, mode)}`;
}
return ce.summary || "";
}
function PhaseHeader({
phase,
personLabel,
mode,
onClick,
active,
}: {
phase: PhaseComputed;
personLabel: (role: string) => string;
mode: ValueMode;
onClick: () => void;
active: boolean;
}) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd;
return (
<th
onClick={onClick}
@@ -680,18 +739,17 @@ function PhaseHeader({
{personLabel(p.role)} {p.startAge} {p.endAge}
</div>
))}
<div>Einkommen {formatChf(phase.incomeStart)} {formatChf(phase.incomeEnd)}</div>
<div>Ausgaben {formatChf(phase.expenseStart)} {formatChf(phase.expenseEnd)}</div>
<div>Einkommen {valStr(phase.incomeStart, dS, mode)} {valStr(phase.incomeEnd, dE, mode)}</div>
<div>Ausgaben {valStr(phase.expenseStart, dS, mode)} {valStr(phase.expenseEnd, dE, mode)}</div>
<div>
{quotaLabel} {formatChf(phase.quotaStart)} {formatChf(phase.quotaEnd)}
{quotaLabel} {valStr(phase.quotaStart, dS, mode)} {valStr(phase.quotaEnd, dE, mode)}
</div>
<div className={phase.cashNegative ? "text-danger font-medium" : ""}>
Cash {formatChf(phase.cashStart)} {formatChf(phase.cashEnd)}
Cash {valStr(phase.cashStart, dS, mode)} {valStr(phase.cashEnd, dE, mode)}
</div>
<div>
Vermoegen {formatChf(phase.startWealthNominal)} {formatChf(phase.endWealthNominal)}
Vermoegen {valStr(phase.startWealthNominal, dS, mode)} {valStr(phase.endWealthNominal, dE, mode)}
</div>
<div className="text-faint">real {formatChf(phase.endWealthReal)}</div>
</div>
</th>
);
+4 -1
View File
@@ -62,7 +62,8 @@ export interface PhaseComputed {
elements: ElementPhaseComputed[];
startWealthNominal: number; // inkl. Cash
endWealthNominal: number; // inkl. Cash
cumulativeInflationEnd: number;
cumulativeInflationStart: number; // Kaufkraft-Deflator zu Phasenbeginn (Startwerte)
cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Endwerte)
endWealthReal: number;
}
@@ -408,6 +409,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const cashEnd = Math.round(cash);
const startWealthNominal = Math.round(wealthStart + cashStart);
const endWealthNominal = Math.round(wealthEnd + cashEnd);
const cumulativeInflationStart = cumulativeInflation;
cumulativeInflation = cumulativeInflation * Math.pow(1 + phaseInflation / 100, duration);
result.push({
@@ -433,6 +435,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
elements: orderedElements.map((e) => ecById.get(e.id)!),
startWealthNominal,
endWealthNominal,
cumulativeInflationStart,
cumulativeInflationEnd: cumulativeInflation,
endWealthReal: endWealthNominal / cumulativeInflation,
});