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) { if (isTransition) {
await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td); await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td);
} else { } 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(); onSaved();
} catch (e) { } catch (e) {
+69 -11
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
AlertCircle, AlertCircle,
Building2, Building2,
@@ -96,6 +96,17 @@ export function PlanView({
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null); const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null); const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
const [showCashInit, setShowCashInit] = useState(false); 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 columns = useMemo<Column[]>(() => {
const cols: Column[] = []; const cols: Column[] = [];
@@ -239,6 +250,25 @@ export function PlanView({
return ( return (
<div className="flex flex-col gap-5"> <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} /> <Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} />
{/* Plan-Profil */} {/* Plan-Profil */}
@@ -312,6 +342,7 @@ export function PlanView({
key={col.phase.id} key={col.phase.id}
phase={col.phase} phase={col.phase}
personLabel={personLabel} personLabel={personLabel}
mode={valueMode}
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })} onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
active={selected?.type === "phase" && selected.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"}`} } ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"}`}
> >
<span className="whitespace-nowrap"> <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> </span>
</td> </td>
) : ( ) : (
@@ -402,7 +435,7 @@ export function PlanView({
ce?.locked ? "text-faint" : "text-fg" ce?.locked ? "text-faint" : "text-fg"
}`} }`}
> >
{phaseCellContent(ce)} {phaseCellContent(ce, col.phase, valueMode)}
</td> </td>
); );
} }
@@ -628,31 +661,57 @@ export function PlanView({
// Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl. // Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl.
const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"]; 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) return "";
if (ce.note) return ce.note; 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)) { if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
return ( return (
<span className="whitespace-nowrap"> <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> </span>
); );
} }
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
return `Rente ${valStr(ce.startValue, dS, mode)}`;
}
return ce.summary || ""; return ce.summary || "";
} }
function PhaseHeader({ function PhaseHeader({
phase, phase,
personLabel, personLabel,
mode,
onClick, onClick,
active, active,
}: { }: {
phase: PhaseComputed; phase: PhaseComputed;
personLabel: (role: string) => string; personLabel: (role: string) => string;
mode: ValueMode;
onClick: () => void; onClick: () => void;
active: boolean; active: boolean;
}) { }) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote"; const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd;
return ( return (
<th <th
onClick={onClick} onClick={onClick}
@@ -680,18 +739,17 @@ function PhaseHeader({
{personLabel(p.role)} {p.startAge} {p.endAge} {personLabel(p.role)} {p.startAge} {p.endAge}
</div> </div>
))} ))}
<div>Einkommen {formatChf(phase.incomeStart)} {formatChf(phase.incomeEnd)}</div> <div>Einkommen {valStr(phase.incomeStart, dS, mode)} {valStr(phase.incomeEnd, dE, mode)}</div>
<div>Ausgaben {formatChf(phase.expenseStart)} {formatChf(phase.expenseEnd)}</div> <div>Ausgaben {valStr(phase.expenseStart, dS, mode)} {valStr(phase.expenseEnd, dE, mode)}</div>
<div> <div>
{quotaLabel} {formatChf(phase.quotaStart)} {formatChf(phase.quotaEnd)} {quotaLabel} {valStr(phase.quotaStart, dS, mode)} {valStr(phase.quotaEnd, dE, mode)}
</div> </div>
<div className={phase.cashNegative ? "text-danger font-medium" : ""}> <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>
<div> <div>
Vermoegen {formatChf(phase.startWealthNominal)} {formatChf(phase.endWealthNominal)} Vermoegen {valStr(phase.startWealthNominal, dS, mode)} {valStr(phase.endWealthNominal, dE, mode)}
</div> </div>
<div className="text-faint">real {formatChf(phase.endWealthReal)}</div>
</div> </div>
</th> </th>
); );
+4 -1
View File
@@ -62,7 +62,8 @@ export interface PhaseComputed {
elements: ElementPhaseComputed[]; elements: ElementPhaseComputed[];
startWealthNominal: number; // inkl. Cash startWealthNominal: number; // inkl. Cash
endWealthNominal: 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; endWealthReal: number;
} }
@@ -408,6 +409,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const cashEnd = Math.round(cash); const cashEnd = Math.round(cash);
const startWealthNominal = Math.round(wealthStart + cashStart); const startWealthNominal = Math.round(wealthStart + cashStart);
const endWealthNominal = Math.round(wealthEnd + cashEnd); const endWealthNominal = Math.round(wealthEnd + cashEnd);
const cumulativeInflationStart = cumulativeInflation;
cumulativeInflation = cumulativeInflation * Math.pow(1 + phaseInflation / 100, duration); cumulativeInflation = cumulativeInflation * Math.pow(1 + phaseInflation / 100, duration);
result.push({ result.push({
@@ -433,6 +435,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
elements: orderedElements.map((e) => ecById.get(e.id)!), elements: orderedElements.map((e) => ecById.get(e.id)!),
startWealthNominal, startWealthNominal,
endWealthNominal, endWealthNominal,
cumulativeInflationStart,
cumulativeInflationEnd: cumulativeInflation, cumulativeInflationEnd: cumulativeInflation,
endWealthReal: endWealthNominal / cumulativeInflation, endWealthReal: endWealthNominal / cumulativeInflation,
}); });