Einmalige Sonderein-/ausgaben am Cash-Uebergang (Roadmap Nr. 1)
Deploy App / deploy (push) Successful in 1m51s
Deploy App / deploy (push) Successful in 1m51s
Der Cash-Uebergang zwischen zwei Phasen ist neu ein eigener Entscheid: 1:1 uebernehmen / einmaliger Zufluss / einmalige Kosten / beides. Die Betraege gehen direkt aufs Cash-Konto und bleiben aus der Spar-/Verzehrquote heraus. - Zufluss NOMINAL erfasst, real angezeigt (wie Einkommen), optionaler Steuersatz (Default 0 %). Kosten REAL erfasst, nominal angezeigt (wie Ausgaben). Umrechnung ueber den Bestands-Deflator an der Phasengrenze. - Entscheid startet unbeantwortet und zaehlt im "offen"-Badge mit; eine neue Phase erzeugt damit automatisch einen offenen Cash-Entscheid am neuen Uebergang. - Eigene Kopf-Kennzahlen statt Vermischung mit Kapitalzufluss/-investitionen: eine Erbschaft ist kein Verkaufserloes, ein Poolbau keine Investition. - Cash ist kein FinancialElement -> der Entscheid haengt als JSON an der Von-Phase (neue Spalte Phase.cashTransition + Migration). Szenario-Kopie nimmt ihn mit. Fuenf Regressionstests ergaenzt (13 -> 18). Spezifikation auf v0.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+144
-5
@@ -20,10 +20,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Timeline } from "@/components/Timeline";
|
||||
import {
|
||||
CashTransitionFields,
|
||||
ElementDetail,
|
||||
ElementPhaseFields,
|
||||
ElementTransitionFields,
|
||||
cashTransitionSummary,
|
||||
isCashTransitionAnswered,
|
||||
isTransitionAnswered,
|
||||
withCashTransitionDefaults,
|
||||
withTransitionDefaults,
|
||||
type CellContext,
|
||||
} from "@/components/ElementDetail";
|
||||
@@ -37,6 +41,7 @@ import {
|
||||
CATEGORY_ORDER,
|
||||
PERSON_ONLY_CATEGORIES,
|
||||
num,
|
||||
type CashTransitionData,
|
||||
type ElementCategory,
|
||||
type PhaseData,
|
||||
type TransitionData,
|
||||
@@ -96,6 +101,8 @@ 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);
|
||||
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
|
||||
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
|
||||
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -205,9 +212,14 @@ export function PlanView({
|
||||
return false;
|
||||
}
|
||||
|
||||
function cashTransitionFor(phaseId: string): CashTransitionData {
|
||||
return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {};
|
||||
}
|
||||
|
||||
// Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze.
|
||||
// Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zaehlt mit.
|
||||
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
|
||||
let n = 0;
|
||||
let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
|
||||
for (const el of plan.elements) {
|
||||
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
|
||||
if (transitionInactive(el, fromPhase)) continue;
|
||||
@@ -379,9 +391,23 @@ export function PlanView({
|
||||
</span>
|
||||
</td>
|
||||
) : (
|
||||
<td key={`cash-t-${col.fromPhase.id}`} className="border-b border-r border-border px-2 py-1.5 text-center text-[11px] text-faint">
|
||||
→
|
||||
</td>
|
||||
(() => {
|
||||
// Uebergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben.
|
||||
const ct = cashTransitionFor(col.fromPhase.id);
|
||||
const open = !isCashTransitionAnswered(ct);
|
||||
return (
|
||||
<td
|
||||
key={`cash-t-${col.fromPhase.id}`}
|
||||
onClick={() => setEditCashTransition(col.fromPhase.id)}
|
||||
title="Einmalige Sonderein-/ausgaben"
|
||||
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
|
||||
open ? "bg-accent font-semibold text-accent-fg" : "bg-accent-soft/40 text-accent"
|
||||
}`}
|
||||
>
|
||||
{cashTransitionSummary(ct)}
|
||||
</td>
|
||||
);
|
||||
})()
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
@@ -520,6 +546,26 @@ export function PlanView({
|
||||
/>
|
||||
)}
|
||||
|
||||
{editCashTransition && (() => {
|
||||
const fromPhase = computed.phases.find((p) => p.id === editCashTransition);
|
||||
if (!fromPhase) return null;
|
||||
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
|
||||
const toPhase = computed.phases[toIndex];
|
||||
return (
|
||||
<CashTransitionDialog
|
||||
key={`ct-${editCashTransition}`}
|
||||
fromPhase={fromPhase}
|
||||
toPhase={toPhase}
|
||||
initial={cashTransitionFor(fromPhase.id)}
|
||||
onClose={() => setEditCashTransition(null)}
|
||||
onSaved={() => {
|
||||
setEditCashTransition(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{reviewFromPhaseId && (() => {
|
||||
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
||||
if (!fromPhase) return null;
|
||||
@@ -531,6 +577,7 @@ export function PlanView({
|
||||
fromPhase={fromPhase}
|
||||
toPhase={toPhase}
|
||||
elements={els}
|
||||
initialCash={cashTransitionFor(fromPhase.id)}
|
||||
buildContext={(el) => buildTransitionContext(fromPhase, toPhase, el)}
|
||||
isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)}
|
||||
onClose={() => setReviewFromPhaseId(null)}
|
||||
@@ -753,6 +800,22 @@ function PhaseHeader({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
{phase.oneOffInflow > 0 && (
|
||||
<div className="text-success">
|
||||
+ {phase.oneOffInflowLabel ?? "Einmaliger Zufluss"} {valStr(phase.oneOffInflow, dS, mode)}
|
||||
</div>
|
||||
)}
|
||||
{phase.oneOffOutflow > 0 && (
|
||||
<div className="text-danger">
|
||||
− {phase.oneOffOutflowLabel ?? "Einmalige Kosten"} {valStr(phase.oneOffOutflow, dS, mode)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="my-1 border-t border-border" />
|
||||
<div>
|
||||
Vermoegen {valStr(phase.startWealthNominal, dS, mode)} → {valStr(phase.endWealthNominal, dE, mode)}
|
||||
@@ -1034,6 +1097,7 @@ function TransitionReviewDialog({
|
||||
fromPhase,
|
||||
toPhase,
|
||||
elements,
|
||||
initialCash,
|
||||
buildContext,
|
||||
isRetirement,
|
||||
onClose,
|
||||
@@ -1042,6 +1106,7 @@ function TransitionReviewDialog({
|
||||
fromPhase: PhaseComputed;
|
||||
toPhase: PhaseComputed | undefined;
|
||||
elements: ElementInput[];
|
||||
initialCash: CashTransitionData;
|
||||
buildContext: (el: ElementInput) => CellContext;
|
||||
isRetirement: (el: ElementInput) => boolean;
|
||||
onClose: () => void;
|
||||
@@ -1052,6 +1117,7 @@ function TransitionReviewDialog({
|
||||
elements.map((e) => [e.id, withTransitionDefaults(e.category, isRetirement(e), e.transitionValues[fromPhase.id] ?? {})])
|
||||
)
|
||||
);
|
||||
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initialCash));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -1059,6 +1125,7 @@ function TransitionReviewDialog({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct);
|
||||
for (const e of elements) {
|
||||
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {});
|
||||
}
|
||||
@@ -1077,9 +1144,31 @@ function TransitionReviewDialog({
|
||||
Bezug). Danach werden gehaltene Werte automatisch in die nächste Phase fortgeschrieben.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Cash zuerst: einmalige Sonderein-/ausgaben betreffen jeden Uebergang. */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-accent">
|
||||
<Wallet className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-fg">Cash</span>
|
||||
<span className="text-xs text-faint">Einmalige Sonderein-/ausgaben</span>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-accent-soft-fg">
|
||||
Einmalige Ereignisse wie Erbschaft, Autokauf oder Poolbau werden hier direkt dem Cash-Konto
|
||||
gutgeschrieben bzw. belastet.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<CashTransitionFields
|
||||
ct={ct}
|
||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{elements.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||
An diesem Übergang gibt es keine zu entscheidenden Positionen.
|
||||
An diesem Übergang gibt es sonst keine zu entscheidenden Positionen.
|
||||
</p>
|
||||
)}
|
||||
{elements.map((el) => {
|
||||
@@ -1119,6 +1208,56 @@ function TransitionReviewDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Dialog: Cash-Uebergang (einmalige Sonderein-/ausgaben) ---
|
||||
function CashTransitionDialog({
|
||||
fromPhase,
|
||||
toPhase,
|
||||
initial,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
fromPhase: PhaseComputed;
|
||||
toPhase: PhaseComputed | undefined;
|
||||
initial: CashTransitionData;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
// Vorbelegung, damit ein blosses "Speichern" den sichtbaren Default (1:1) persistiert.
|
||||
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initial));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogShell title="Uebergang: Cash" onClose={onClose} wide>
|
||||
<div className="text-xs text-muted">
|
||||
Einmalige Sonderein-/ausgaben · {fromPhase.name} → {toPhase?.name ?? "Ende"}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<CashTransitionFields
|
||||
ct={ct}
|
||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Dialog: Cash-Anfangswert (erste Lebensphase) ---
|
||||
function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
|
||||
const [value, setValue] = useState(plan.initialCash);
|
||||
|
||||
Reference in New Issue
Block a user