Phasenkopf-Ueberarbeitung: zweizeilige Werte, Verfuegbares Kapital, Verteil-Werkzeuge
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:
2026-07-19 12:30:36 +02:00
parent c2fb82b0de
commit 9252f7188d
5 changed files with 1199 additions and 26 deletions
+173 -19
View File
@@ -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>
);