Vererbung Einkommen/Ausgaben + Real/Nominal-Umschalter
Deploy App / deploy (push) Successful in 1m2s
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:
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user