Phasenkopf-Ueberarbeitung: zweizeilige Werte, Verfuegbares Kapital, Verteil-Werkzeuge
Deploy App / deploy (push) Successful in 1m5s
Deploy App / deploy (push) Successful in 1m5s
Rein an der Oberflaeche und als neue Bearbeitungswerkzeuge -- keine Aenderung
an Berechnung, Datenmodell oder API. Beide Verteil-Dialoge schreiben nur
bestehende Felder ueber bestehende Endpunkte.
1) Zweizeilige Wertdarstellung im Modus "Beide": Realwert in Klammern in
eigener Zeile UNTER dem nominalen Wert (Kopf + Matrix-Zellen), Pfeil auf
beiden Zeilen. Dadurch schmalere Spalten und jede Kennzahl umbruchfrei.
2) "Sparquote" / "Verzehrquote" statt "Quote" / "Verzehr".
3) Neuer Kopf-Block "Verfuegbares Kapital" (ab Phase 2, nur wenn > 0):
Topf, davon verteilt, Rest auf Cash -- vollstaendig aus der Cash-Bruecke
abgeleitet (capitalPot).
4) Zwei Verteil-Popups mit Live-Vorschau (erneutes computePlan im Browser):
- "Kapital verteilen": Zusatzeinlage (PK/3a/Vermoegen, Phasenwert) +
Sonderamortisation/Sofort-Tilgung (Uebergangswert der Vorphase);
Rest bleibt automatisch auf Cash, Ueberverteilung wird als Luecke gemeldet
- "Sparquote/Bezug verteilen": jaehrliche Raten; zeigt Quote erstes Jahr,
letztes Jahr UND absolut ueber die Phase; warnt, wenn die Quote sinkt
(flache Rate wuerde spaeter Cash-Loch reissen). PK bewusst ausgeschlossen
(Beitrag aus Bruttolohn, belastet Cash nicht).
Neues reines Modul distribution.ts (capitalPot, quotaSummary, applyPatches).
8 Tests (103 -> 111) -- u.a. residual-Kontrolle gegen die Cash-Bruecke und
Nachweis, dass die Quote ueber die Phase sinkt.
SPEZIFIKATION auf 0.14: neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3
ueberarbeitet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+173
-19
@@ -24,6 +24,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Timeline } from "@/components/Timeline";
|
||||
import { Sparkline } from "@/components/Sparkline";
|
||||
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
|
||||
import { capitalPot } from "@/lib/distribution";
|
||||
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
|
||||
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
||||
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||
@@ -141,6 +143,9 @@ export function PlanView({
|
||||
const [showAddPhase, setShowAddPhase] = useState(false);
|
||||
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
|
||||
const [showTour, setShowTour] = useState(false);
|
||||
// Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup,
|
||||
// weil beide mehrere Elemente auf einmal bearbeiten.
|
||||
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
|
||||
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
||||
|
||||
// Tour beim ersten Besuch eines Plans mit Phasen automatisch starten.
|
||||
@@ -484,6 +489,10 @@ export function PlanView({
|
||||
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
|
||||
onClick={() => setPanel({ kind: "phase", phaseId: col.phase.id })}
|
||||
onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })}
|
||||
onDistributeCapital={
|
||||
col.phase.sequenceNumber > 1 ? () => setDistribute({ kind: "capital", phaseId: col.phase.id }) : null
|
||||
}
|
||||
onDistributeRates={() => setDistribute({ kind: "rates", phaseId: col.phase.id })}
|
||||
active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
|
||||
/>
|
||||
) : (
|
||||
@@ -768,6 +777,36 @@ export function PlanView({
|
||||
);
|
||||
})()}
|
||||
|
||||
{distribute?.kind === "capital" && (
|
||||
<CapitalDistributionDialog
|
||||
key={`cap-${distribute.phaseId}`}
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
phaseId={distribute.phaseId}
|
||||
onClose={() => setDistribute(null)}
|
||||
onSaved={() => {
|
||||
setDistribute(null);
|
||||
toast("success", "Kapital verteilt.");
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{distribute?.kind === "rates" && (
|
||||
<RateDistributionDialog
|
||||
key={`rate-${distribute.phaseId}`}
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
phaseId={distribute.phaseId}
|
||||
onClose={() => setDistribute(null)}
|
||||
onSaved={() => {
|
||||
setDistribute(null);
|
||||
toast("success", "Raten gespeichert.");
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTour && <Tour onClose={() => setShowTour(false)} />}
|
||||
</div>
|
||||
);
|
||||
@@ -932,12 +971,60 @@ const VALUE_MODE_KEY = "fpt-value-mode";
|
||||
function realOf(nominal: number, deflator: number): number {
|
||||
return Math.round(nominal / (deflator || 1));
|
||||
}
|
||||
|
||||
// Reiner Text (fuer Titel/Tooltips und einzeilige Faelle).
|
||||
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);
|
||||
}
|
||||
|
||||
// Im Modus "Beide" steht der Realwert in Klammern in einer EIGENEN Zeile unter dem nominalen
|
||||
// Wert -- nebeneinander wird die Zeile zu lang und die Spalten unnoetig breit. Der Pfeil
|
||||
// wiederholt sich auf der zweiten Zeile, damit der Zeitbezug Start -> Ende erhalten bleibt.
|
||||
function ValuePair({
|
||||
start,
|
||||
end,
|
||||
deflatorStart,
|
||||
deflatorEnd,
|
||||
mode,
|
||||
}: {
|
||||
start: number;
|
||||
end: number;
|
||||
deflatorStart: number;
|
||||
deflatorEnd: number;
|
||||
mode: ValueMode;
|
||||
}) {
|
||||
const arrow = <span className="text-faint">→</span>;
|
||||
return (
|
||||
<span className="inline-flex flex-col">
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
{mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "}
|
||||
{mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)}
|
||||
</span>
|
||||
{mode === "both" && (
|
||||
<span className="whitespace-nowrap tabular-nums text-faint">
|
||||
({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Einzelwert, gleiche Konvention.
|
||||
function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) {
|
||||
return (
|
||||
<span className="inline-flex flex-col">
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
{mode === "real" ? formatChf(realOf(value, deflator)) : formatChf(value)}
|
||||
</span>
|
||||
{mode === "both" && (
|
||||
<span className="whitespace-nowrap tabular-nums text-faint">({formatChf(realOf(value, deflator))})</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function phaseCellContent(
|
||||
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
|
||||
phase: PhaseComputed,
|
||||
@@ -949,14 +1036,17 @@ function phaseCellContent(
|
||||
const dS = phase.cumulativeInflationStart;
|
||||
const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd;
|
||||
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{valStr(ce.startValue, dS, mode)} <span className="text-faint">→</span> {valStr(ce.endValue, dE, mode)}
|
||||
</span>
|
||||
);
|
||||
return <ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} />;
|
||||
}
|
||||
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
|
||||
return `Rente ${valStr(ce.startValue, dS, mode)}`;
|
||||
return (
|
||||
<span className="inline-flex flex-col">
|
||||
<span className="whitespace-nowrap">Rente {mode === "real" ? formatChf(realOf(ce.startValue, dS)) : formatChf(ce.startValue)}</span>
|
||||
{mode === "both" && (
|
||||
<span className="whitespace-nowrap tabular-nums text-faint">({formatChf(realOf(ce.startValue, dS))})</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return ce.summary || "–";
|
||||
}
|
||||
@@ -968,6 +1058,8 @@ function PhaseHeader({
|
||||
diffKind,
|
||||
onClick,
|
||||
onExpand,
|
||||
onDistributeCapital,
|
||||
onDistributeRates,
|
||||
active,
|
||||
}: {
|
||||
phase: PhaseComputed;
|
||||
@@ -976,9 +1068,14 @@ function PhaseHeader({
|
||||
diffKind: "changed" | "added" | "removed" | null;
|
||||
onClick: () => void;
|
||||
onExpand: () => void;
|
||||
// null = Phase 1: dort gibt es kein verteilbares Übergangs-Kapital (additionalInvestment
|
||||
// wird in der ersten Phase von der Berechnung ignoriert, dort zählt der Startwert).
|
||||
onDistributeCapital: (() => void) | null;
|
||||
onDistributeRates: () => void;
|
||||
active: boolean;
|
||||
}) {
|
||||
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
|
||||
const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote";
|
||||
const pot = capitalPot(phase);
|
||||
const dS = phase.cumulativeInflationStart;
|
||||
const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermögen)
|
||||
const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote)
|
||||
@@ -1026,27 +1123,84 @@ function PhaseHeader({
|
||||
</span>
|
||||
<span className="text-[10px] text-faint">{phase.durationYears} J.</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-0.5 text-[10px] leading-tight text-muted">
|
||||
{phase.persons.map((p) => (
|
||||
<div key={p.role} className="text-faint">
|
||||
{personLabel(p.role)} {p.startAge} → {p.endAge}
|
||||
<div className="mt-1 flex flex-col gap-1 text-[10px] leading-tight text-muted">
|
||||
<div>
|
||||
{phase.persons.map((p) => (
|
||||
<div key={p.role} className="whitespace-nowrap text-faint">
|
||||
{personLabel(p.role)} {p.startAge} → {p.endAge}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Verfügbares Kapital -- die Grösse, die man für die Planung DIESER Phase braucht.
|
||||
Nur zeigen, wenn überhaupt etwas da ist. */}
|
||||
{onDistributeCapital && pot.total > 0 && (
|
||||
<div className="rounded-md bg-surface-2 px-1.5 py-1">
|
||||
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap">
|
||||
<span className="font-medium text-fg">Verfügbares Kapital</span>
|
||||
<ValueSingle value={pot.total} deflator={dS} mode={mode} />
|
||||
</div>
|
||||
{pot.allocatedInvestments + pot.allocatedRepayments > 0 && (
|
||||
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap pl-2 text-faint">
|
||||
<span>davon verteilt</span>
|
||||
<ValueSingle value={pot.allocatedInvestments + pot.allocatedRepayments} deflator={dS} mode={mode} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap pl-2 text-faint">
|
||||
<span>Rest auf Cash</span>
|
||||
<ValueSingle value={pot.rest} deflator={dS} mode={mode} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDistributeCapital();
|
||||
}}
|
||||
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
|
||||
>
|
||||
Kapital verteilen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className={phase.isConsumption ? "text-danger" : undefined}>
|
||||
{quotaLabel} {valStr(phase.quotaStart, dS, mode)} → {valStr(phase.quotaEnd, dF, mode)}
|
||||
)}
|
||||
|
||||
{/* Spar- bzw. Verzehrquote */}
|
||||
<div className="rounded-md bg-surface-2 px-1.5 py-1">
|
||||
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap">
|
||||
<span className={`font-medium ${phase.isConsumption ? "text-danger" : "text-fg"}`}>{quotaLabel}</span>
|
||||
<ValuePair start={phase.quotaStart} end={phase.quotaEnd} deflatorStart={dS} deflatorEnd={dF} mode={mode} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDistributeRates();
|
||||
}}
|
||||
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
|
||||
>
|
||||
{phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="font-medium text-fg">
|
||||
Vermögen {valStr(phase.startWealthNominal, dS, mode)} → {valStr(phase.endWealthNominal, dE, mode)}
|
||||
|
||||
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap font-medium text-fg">
|
||||
<span>Vermögen</span>
|
||||
<ValuePair
|
||||
start={phase.startWealthNominal}
|
||||
end={phase.endWealthNominal}
|
||||
deflatorStart={dS}
|
||||
deflatorEnd={dE}
|
||||
mode={mode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && (
|
||||
<div className={phase.oneOffOutflow > phase.oneOffInflow ? "text-danger" : "text-success"}>
|
||||
<div className={`whitespace-nowrap ${phase.oneOffOutflow > phase.oneOffInflow ? "text-danger" : "text-success"}`}>
|
||||
{phase.oneOffInflow > 0 ? `+ ${phase.oneOffInflowLabel ?? "Zufluss"}` : ""}
|
||||
{phase.oneOffInflow > 0 && phase.oneOffOutflow > 0 ? " · " : ""}
|
||||
{phase.oneOffOutflow > 0 ? `− ${phase.oneOffOutflowLabel ?? "Kosten"}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{/* Alles Weitere (Einkommen, Ausgaben, Raten, Kapitalflüsse) steht in der
|
||||
Detailansicht -- erreichbar über das Expand-Icon oben. */}
|
||||
{/* Alles Weitere (Einkommen, Ausgaben, Raten im Detail, Kapitalinvestitionen) steht
|
||||
in der Detailansicht -- erreichbar über das Expand-Icon oben. */}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user