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
+550
View File
@@ -0,0 +1,550 @@
"use client";
// Zwei Verteil-Werkzeuge im Phasenkopf:
// 1. "Kapital verteilen" -- der beim Übergang zugeflossene Topf (Verkäufe, Bezüge,
// Erbschaft, Cash der Vorphase) auf Zusatzeinlagen, Sonderamortisation und Sofort-
// Tilgung verteilen; der Rest bleibt automatisch auf dem Cash.
// 2. "Sparquote verteilen" -- die laufende Spar- bzw. Verzehrquote auf jährliche Raten
// verteilen.
//
// Beide schreiben ausschliesslich BESTEHENDE Felder über die bestehenden Endpunkte. Die
// Live-Vorschau entsteht, indem der Plan mit den Entwurfswerten kopiert und erneut durch
// computePlan geschickt wird -- die angezeigte Wirkung ist dadurch per Konstruktion exakt
// die spätere, inklusive aller Kappungen.
import { useMemo, useState } from "react";
import { AlertTriangle, Coins, PiggyBank } from "lucide-react";
import { Button, Modal } from "@/components/ui";
import { MoneyField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { CarryWarning } from "@/components/ElementDetail";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import { computePlan } from "@/lib/calculations";
import { CATEGORY_LABELS, num, type PhaseData, type TransitionData } from "@/lib/elements";
import {
applyPhasePatches,
applyTransitionPatches,
capitalPot,
quotaSummary,
type PhasePatch,
type TransitionPatch,
} from "@/lib/distribution";
import type { PlanInput } from "@/lib/types";
// --- gemeinsame Bausteine -----------------------------------------------------------------
function SummaryRow({
label,
value,
help,
strong,
tone,
}: {
label: string;
value: number;
help?: string;
strong?: boolean;
tone?: "danger" | "success" | "muted";
}) {
const color = tone === "danger" ? "text-danger" : tone === "success" ? "text-success" : strong ? "text-fg" : "text-muted";
return (
<div className="flex items-baseline justify-between gap-3 whitespace-nowrap">
<span className={`flex items-center text-xs ${strong ? "font-semibold text-fg" : "text-muted"}`}>
{label}
{help && <InfoBubble text={help} />}
</span>
<span className={`tabular-nums ${strong ? "text-sm font-semibold" : "text-xs"} ${color}`}>
{formatChf(value)}
</span>
</div>
);
}
// --- 1. Kapital verteilen -----------------------------------------------------------------
interface CapitalTarget {
elementId: string;
name: string;
category: string;
kind: "investment" | "amortization" | "repayment";
max: number | undefined; // Kappung (Restschuld); bei Investitionen unbegrenzt
hint: string;
}
export function CapitalDistributionDialog({
plan,
computed,
phaseId,
onClose,
onSaved,
}: {
plan: PlanInput;
computed: ReturnType<typeof computePlan>;
phaseId: string;
onClose: () => void;
onSaved: () => void;
}) {
const phaseIndex = computed.phases.findIndex((p) => p.id === phaseId);
const phase = computed.phases[phaseIndex];
const prevPhase = computed.phases[phaseIndex - 1];
// Ziele bestimmen. Zusatzeinlagen liegen in DIESER Phase, Sonderamortisation und
// Sofort-Tilgung dagegen am ÜBERGANG davor (also an der Vorphase) -- deshalb werden beim
// Speichern zwei verschiedene Endpunkte angesprochen.
const targets = useMemo<CapitalTarget[]>(() => {
const out: CapitalTarget[] = [];
for (const e of plan.elements) {
const ce = phase?.elements.find((x) => x.elementId === e.id);
const prevCe = prevPhase?.elements.find((x) => x.elementId === e.id);
if (!ce || ce.status !== "ACTIVE") continue;
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A" || e.category === "OTHER_ASSET") {
out.push({
elementId: e.id,
name: e.name,
category: e.category,
kind: "investment",
max: undefined,
hint: "Zusatzeinlage aus dem verfügbaren Kapital erhöht den Startwert in dieser Phase.",
});
} else if (e.category === "REAL_ESTATE") {
const rest = prevCe?.mortgageEnd ?? 0;
// Beim Verkauf gibt es nichts mehr zu amortisieren.
const sold = e.transitionValues[prevPhase?.id ?? ""]?.decision === "SELL";
if (rest > 0 && !sold) {
out.push({
elementId: e.id,
name: e.name,
category: e.category,
kind: "amortization",
max: rest,
hint: `Sonderamortisation: Einmaltilgung der Hypothek am Übergang. Maximal ${formatChf(rest)} (Resthypothek).`,
});
}
} else if (e.category === "OTHER_DEBT") {
const owed = prevCe ? Math.abs(Math.min(0, prevCe.endValue)) : 0;
if (owed > 0) {
out.push({
elementId: e.id,
name: e.name,
category: e.category,
kind: "repayment",
max: owed,
hint: `Sofortige Tilgung am Übergang. Maximal ${formatChf(owed)} (Restschuld).`,
});
}
}
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase/prevPhase folgen phaseId
}, [plan.elements, phaseId]);
// Entwurf mit den aktuell gespeicherten Werten vorbelegen.
const [draft, setDraft] = useState<Record<string, number>>(() => {
const d: Record<string, number> = {};
for (const t of targets) {
const e = plan.elements.find((x) => x.id === t.elementId)!;
d[t.elementId] =
t.kind === "investment"
? Math.round(num(e.phaseValues[phaseId]?.additionalInvestment))
: t.kind === "amortization"
? Math.round(num(e.transitionValues[prevPhase?.id ?? ""]?.extraAmortization))
: Math.round(num(e.transitionValues[prevPhase?.id ?? ""]?.immediateRepayment));
}
return d;
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Live-Vorschau: Plan mit den Entwurfswerten neu rechnen.
const preview = useMemo(() => {
const phasePatches: PhasePatch[] = targets
.filter((t) => t.kind === "investment")
.map((t) => ({ elementId: t.elementId, field: "additionalInvestment" as const, value: draft[t.elementId] ?? 0 }));
const transPatches: TransitionPatch[] = targets
.filter((t) => t.kind !== "investment")
.map((t) => ({
elementId: t.elementId,
field: (t.kind === "amortization" ? "extraAmortization" : "immediateRepayment") as keyof TransitionData,
value: draft[t.elementId] ?? 0,
}));
let next = applyPhasePatches(plan, phaseId, phasePatches);
if (prevPhase) next = applyTransitionPatches(next, prevPhase.id, transPatches);
const c = computePlan(next);
return c.phases.find((p) => p.id === phaseId)!;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft, plan, phaseId, targets]);
const pot = capitalPot(preview);
const laterPhases = computed.phases.length - (phase?.sequenceNumber ?? 0);
async function save() {
setSaving(true);
setError(null);
try {
for (const t of targets) {
const e = plan.elements.find((x) => x.id === t.elementId)!;
const value = draft[t.elementId] ?? 0;
if (t.kind === "investment") {
// Bestehende Felder der Phase erhalten -- der Endpunkt ersetzt den ganzen Satz.
const merged: PhaseData = { ...(e.phaseValues[phaseId] ?? {}), additionalInvestment: value };
await api.put(`/api/elements/${e.id}/phase/${phaseId}`, merged);
} else if (prevPhase) {
const existing: TransitionData = e.transitionValues[prevPhase.id] ?? {};
const merged: TransitionData =
t.kind === "amortization"
? { ...existing, extraAmortization: value }
: { ...existing, immediateRepayment: value };
await api.put(`/api/elements/${e.id}/transition/${prevPhase.id}`, merged);
}
}
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
setSaving(false);
}
}
return (
<Modal
title="Kapital verteilen"
subtitle={phase ? `${phase.name} · verfügbares Kapital beim Übergang in diese Phase` : undefined}
onClose={onClose}
wide
>
{/* Herkunft des Topfs */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-faint">
<Coins className="h-4 w-4" /> Woher das Kapital kommt
</div>
<div className="flex flex-col gap-1">
<SummaryRow label="Cash-Ende der Vorphase" value={pot.fromPreviousCash} />
{pot.capitalInflow !== 0 && (
<SummaryRow label="Kapitalzufluss (Verkäufe, PK-/3a-Bezüge)" value={pot.capitalInflow} />
)}
{pot.oneOffInflow !== 0 && <SummaryRow label="Einmaliger Zufluss" value={pot.oneOffInflow} />}
{pot.oneOffOutflow !== 0 && <SummaryRow label="Einmalige Kosten" value={-pot.oneOffOutflow} tone="danger" />}
<div className="my-1 border-t border-border" />
<SummaryRow label="Verfügbares Kapital" value={pot.total} strong />
</div>
</div>
{targets.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
In dieser Phase gibt es keine Ziele, auf die sich Kapital verteilen lässt. Das Kapital bleibt vollständig
auf dem Cash-Konto.
</p>
) : (
<div className="flex flex-col gap-2">
{targets.map((t) => (
<div key={t.elementId} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-fg">{t.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">
{t.kind === "investment" ? "Zusatzeinlage" : t.kind === "amortization" ? "Sonderamortisation" : "Sofortige Tilgung"}
</span>
<InfoBubble text={t.hint} />
</div>
<MoneyField
label="Betrag (CHF)"
value={draft[t.elementId] ?? 0}
max={t.max}
onChange={(v) => setDraft((prev) => ({ ...prev, [t.elementId]: v }))}
/>
</div>
))}
</div>
)}
{/* Live-Bilanz */}
<div
className={`rounded-xl border p-3 ${
pot.rest < 0 ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
}`}
>
<div className="flex flex-col gap-1">
<SummaryRow label="Verfügbares Kapital" value={pot.total} />
{pot.allocatedInvestments !== 0 && <SummaryRow label="davon investiert" value={-pot.allocatedInvestments} />}
{pot.allocatedRepayments !== 0 && (
<SummaryRow label="davon in Schulden getilgt" value={-pot.allocatedRepayments} />
)}
<div className="my-1 border-t border-border" />
<SummaryRow
label={pot.rest < 0 ? "Fehlbetrag (Cash wird negativ)" : "Rest bleibt auf dem Cash"}
value={pot.rest}
strong
tone={pot.rest < 0 ? "danger" : undefined}
help="Was du nicht verteilst, bleibt automatisch auf dem Cash-Konto dort steht es weiterhin zur Verfügung."
/>
</div>
{pot.rest < 0 && (
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Du verteilst mehr, als zur Verfügung steht. Das Cash-Konto startet negativ das Tool meldet die Phase
als Liquiditätslücke.
</p>
)}
</div>
{laterPhases > 0 && (
<div className="grid grid-cols-1">
<CarryWarning laterPhaseCount={laterPhases} />
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2 border-t border-border pt-3">
<Button disabled={saving} onClick={save}>
{saving ? "…" : "Verteilung speichern"}
</Button>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
</div>
</Modal>
);
}
// --- 2. Spar-/Verzehrquote verteilen ------------------------------------------------------
interface RateTarget {
elementId: string;
name: string;
category: string;
field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment";
label: string;
direction: "out" | "in";
hint: string;
}
export function RateDistributionDialog({
plan,
computed,
phaseId,
onClose,
onSaved,
}: {
plan: PlanInput;
computed: ReturnType<typeof computePlan>;
phaseId: string;
onClose: () => void;
onSaved: () => void;
}) {
const phase = computed.phases.find((p) => p.id === phaseId)!;
const targets = useMemo<RateTarget[]>(() => {
const out: RateTarget[] = [];
for (const e of plan.elements) {
const ce = phase?.elements.find((x) => x.elementId === e.id);
if (!ce || ce.status !== "ACTIVE") continue;
// Die PK ist bewusst NICHT dabei: Ihr Beitrag stammt aus dem Bruttolohn und belastet
// das Cash-Konto nicht -- er lässt sich also gar nicht aus der Quote verteilen.
if (e.category === "PILLAR_3A") {
out.push({
elementId: e.id, name: e.name, category: e.category, field: "annualContribution",
label: "Jährliche Einzahlung", direction: "out",
hint: "Fliesst jährlich vom Cash in die Säule 3a.",
});
} else if (e.category === "OTHER_ASSET") {
out.push({
elementId: e.id, name: e.name, category: e.category, field: "annualContribution",
label: "Jährlicher Sparbeitrag", direction: "out",
hint: "Fliesst jährlich vom Cash ins Vermögen.",
});
out.push({
elementId: e.id, name: e.name, category: e.category, field: "annualWithdrawal",
label: "Jährliche Bezugsrate", direction: "in",
hint: "Entnahme aus dem Vermögen ins Cash. Wird jährlich am vorhandenen Bestand gekappt.",
});
} else if (e.category === "REAL_ESTATE") {
out.push({
elementId: e.id, name: e.name, category: e.category, field: "amortization",
label: "Amortisation pro Jahr", direction: "out",
hint: "Reduziert die Hypothek. Endet automatisch, sobald sie abbezahlt ist.",
});
} else if (e.category === "OTHER_DEBT") {
out.push({
elementId: e.id, name: e.name, category: e.category, field: "annualRepayment",
label: "Tilgung pro Jahr", direction: "out",
hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.",
});
}
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase folgt phaseId
}, [plan.elements, phaseId]);
const key = (t: RateTarget) => `${t.elementId}:${t.field}`;
const [draft, setDraft] = useState<Record<string, number>>(() => {
const d: Record<string, number> = {};
for (const t of targets) {
const e = plan.elements.find((x) => x.id === t.elementId)!;
d[key(t)] = Math.round(num((e.phaseValues[phaseId] ?? {})[t.field]));
}
return d;
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const preview = useMemo(() => {
const patches: PhasePatch[] = targets.map((t) => ({
elementId: t.elementId,
field: t.field,
value: draft[key(t)] ?? 0,
}));
return computePlan(applyPhasePatches(plan, phaseId, patches)).phases.find((p) => p.id === phaseId)!;
}, [draft, plan, phaseId, targets]);
const q = quotaSummary(preview);
const laterPhases = computed.phases.length - phase.sequenceNumber;
const titel = q.isConsumption ? "Verzehrquote verteilen" : "Sparquote verteilen";
async function save() {
setSaving(true);
setError(null);
try {
// Je Element EIN Aufruf, auch wenn mehrere Felder betroffen sind.
const byElement = new Map<string, PhaseData>();
for (const t of targets) {
const e = plan.elements.find((x) => x.id === t.elementId)!;
const merged = byElement.get(t.elementId) ?? { ...(e.phaseValues[phaseId] ?? {}) };
(merged as Record<string, number>)[t.field] = draft[key(t)] ?? 0;
byElement.set(t.elementId, merged);
}
for (const [elementId, data] of byElement) {
await api.put(`/api/elements/${elementId}/phase/${phaseId}`, data);
}
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
setSaving(false);
}
}
return (
<Modal title={titel} subtitle={`${phase.name} · ${phase.durationYears} Jahre`} onClose={onClose} wide>
{/* Die drei Bezugsgrössen */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-faint">
<PiggyBank className="h-4 w-4" /> {q.isConsumption ? "Deine Verzehrquote" : "Deine Sparquote"}
</div>
<div className="flex flex-col gap-1">
<SummaryRow
label="Erstes Phasenjahr"
value={q.start}
help="Einkommen minus nominale Ausgaben im ersten Jahr dieser Phase."
/>
<SummaryRow
label="Letztes Phasenjahr"
value={q.end}
help="Die Quote verändert sich über die Phase: Das Einkommen wächst mit der Lohnerhöhung, die real erfassten Ausgaben mit der Inflation."
/>
<div className="my-1 border-t border-border" />
<SummaryRow
label={q.isConsumption ? "Absolut über die ganze Phase (Bedarf)" : "Absolut über die ganze Phase"}
value={q.total}
strong
help="Summe über alle Phasenjahre der Betrag, der insgesamt zur Verfügung steht bzw. fehlt. Bewusst nominal summiert, genau wie das Cash-Konto rechnet."
/>
</div>
{!q.isConsumption && q.end < q.start && (
<p className="mt-2 text-[11px] text-muted">
Achtung: Die Quote <strong className="text-fg">sinkt</strong> von {formatChf(q.start)} auf{" "}
{formatChf(q.end)}. Eine flache Jahresrate über {formatChf(q.end)} lässt sich in den späteren Jahren
nicht mehr aus der laufenden Quote decken sie zehrt dann am Cash-Bestand.
</p>
)}
</div>
{targets.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
In dieser Phase gibt es keine Elemente mit jährlichen Raten. Die ganze Quote läuft aufs Cash-Konto.
</p>
) : (
<div className="flex flex-col gap-2">
{targets.map((t) => (
<div key={key(t)} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-fg">{t.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}</span>
<span
className={`rounded px-1.5 py-0.5 text-[10px] ${
t.direction === "in" ? "bg-success/15 text-success" : "bg-surface-2 text-muted"
}`}
>
{t.direction === "in" ? "ins Cash" : "vom Cash"}
</span>
<InfoBubble text={t.hint} />
</div>
<MoneyField
label={`${t.label} (CHF/Jahr)`}
value={draft[key(t)] ?? 0}
onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))}
/>
</div>
))}
</div>
)}
{/* Live-Bilanz über die ganze Phase */}
<div
className={`rounded-xl border p-3 ${
preview.cashNegative ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
}`}
>
<div className="flex flex-col gap-1">
<SummaryRow label={q.isConsumption ? "Absoluter Bedarf" : "Absolut verfügbar"} value={q.total} />
{q.allocatedOut !== 0 && (
<SummaryRow
label="davon in Raten verplant"
value={-q.allocatedOut}
help="Summe aller jährlichen Raten über die Phasenjahre inklusive der automatischen Kappung, sobald eine Schuld abbezahlt ist."
/>
)}
{q.allocatedIn !== 0 && (
<SummaryRow
label="Bezugsraten ins Cash"
value={q.allocatedIn}
tone="success"
help="Tatsächlich entnommener Betrag am vorhandenen Bestand gekappt."
/>
)}
<div className="my-1 border-t border-border" />
<SummaryRow
label={q.netToCash < 0 ? "Zehrt am Cash-Bestand" : "Geht aufs Cash-Konto"}
value={q.netToCash}
strong
tone={q.netToCash < 0 ? "danger" : undefined}
help="Was nicht in Raten verplant ist, landet auf dem Cash-Konto. Ein negativer Wert bedeutet, dass der Cash-Bestand in dieser Phase abnimmt."
/>
<SummaryRow label="Cash am Phasenende" value={preview.cashEnd} tone={preview.cashEnd < 0 ? "danger" : undefined} />
</div>
{preview.cashNegative && (
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Mit dieser Verteilung fällt das Cash-Konto in dieser Phase unter 0 das ist eine Liquiditätslücke.
</p>
)}
</div>
{laterPhases > 0 && (
<div className="grid grid-cols-1">
<CarryWarning laterPhaseCount={laterPhases} />
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2 border-t border-border pt-3">
<Button disabled={saving} onClick={save}>
{saving ? "…" : "Verteilung speichern"}
</Button>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
</div>
</Modal>
);
}
+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>
);
+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import {
applyPhasePatches,
applyTransitionPatches,
capitalPot,
quotaSummary,
} from "@/lib/distribution";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
let idc = 0;
const nid = () => `d${idc++}`;
function el(
category: ElementCategory,
ownerRole: string | null,
phaseValues: Record<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {}
) {
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
}
function plan(opts: {
age: number;
retirementAge: number;
initialCash?: number;
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
elements: ReturnType<typeof el>[];
}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 1.5,
initialCash: opts.initialCash ?? 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
phases: opts.phases.map((p, i) => ({
id: p.id,
sequenceNumber: i + 1,
name: p.id,
durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {},
})),
elements: opts.elements,
};
}
// Zwei Phasen; am Übergang wird Vermögen verkauft und eine Erbschaft fliesst zu -- damit
// entsteht in Phase 2 ein verteilbarer Kapitaltopf.
function basePlan() {
const asset = el("OTHER_ASSET", "HOUSEHOLD", {
p1: { startValue: 200000, expectedReturn: 0 },
p2: { expectedReturn: 0 },
}, { p1: { decision: "SELL" } });
const debt = el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 60000 }, p2: {} }, { p1: {} });
const invest = el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: { expectedReturn: 0 } });
return {
p: plan({
age: 40,
retirementAge: 70,
initialCash: 10000,
phases: [
{ id: "p1", durationYears: 5, cashTransition: { mode: "INFLOW", inflowAmount: 100000, inflowTaxRate: 0 } },
{ id: "p2", durationYears: 5 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
asset,
debt,
invest,
],
}),
assetId: asset.id,
debtId: debt.id,
investId: invest.id,
};
}
describe("Verteilung: verfügbares Kapital", () => {
it("Topf setzt sich aus Vorphasen-Cash, Kapitalzufluss und Einmalzufluss zusammen", () => {
const { p } = basePlan();
const r = computePlan(p);
const pot = capitalPot(r.phases[1]);
// Der Topf ist die Summe der Zuflüsse minus einmaliger Kosten.
expect(pot.total).toBe(pot.fromPreviousCash + pot.capitalInflow + pot.oneOffInflow - pot.oneOffOutflow);
expect(pot.capitalInflow).toBe(200000); // Verkauf des Vermögens
expect(pot.oneOffInflow).toBe(100000); // Erbschaft, steuerfrei
// Nichts verteilt -> alles bleibt auf dem Cash.
expect(pot.allocatedInvestments).toBe(0);
expect(pot.allocatedRepayments).toBe(0);
expect(pot.rest).toBe(pot.total);
expect(pot.rest).toBe(r.phases[1].cashStart);
});
it("Zusatzeinlage verschiebt den Topf von Cash ins Vermögen, ohne ihn zu verändern", () => {
const { p, investId } = basePlan();
const vorher = capitalPot(computePlan(p).phases[1]);
const nachher = capitalPot(
computePlan(applyPhasePatches(p, "p2", [{ elementId: investId, field: "additionalInvestment", value: 150000 }]))
.phases[1]
);
expect(nachher.total).toBe(vorher.total); // der Topf selbst bleibt gleich gross
expect(nachher.allocatedInvestments).toBe(150000);
expect(nachher.rest).toBe(vorher.rest - 150000);
// Kontrolle: Topf = verteilt + Rest
expect(nachher.allocatedInvestments + nachher.allocatedRepayments + nachher.rest).toBe(nachher.total);
});
it("Sofort-Tilgung am Übergang zählt als verteiltes Kapital", () => {
const { p, debtId } = basePlan();
const nachher = capitalPot(
computePlan(applyTransitionPatches(p, "p1", [{ elementId: debtId, field: "immediateRepayment", value: 40000 }]))
.phases[1]
);
expect(nachher.allocatedRepayments).toBe(40000);
expect(nachher.allocatedInvestments + nachher.allocatedRepayments + nachher.rest).toBe(nachher.total);
});
it("Überverteilung drückt den Rest ins Minus (Liquiditätslücke)", () => {
const { p, investId } = basePlan();
const r = computePlan(
applyPhasePatches(p, "p2", [{ elementId: investId, field: "additionalInvestment", value: 999999 }])
);
const pot = capitalPot(r.phases[1]);
expect(pot.rest).toBeLessThan(0);
expect(r.phases[1].cashNegative).toBe(true);
});
});
describe("Verteilung: Spar-/Verzehrquote", () => {
it("absolute Quote ist die Summe über alle Phasenjahre -- und die Quote SINKT", () => {
const { p } = basePlan();
const r = computePlan(p);
const q = quotaSummary(r.phases[0]);
// Genau der Grund, warum eine flache Jahresrate gefährlich ist: Das Einkommen bleibt bei
// 0 % Lohnerhöhung nominal konstant, die real erfassten Ausgaben wachsen aber mit der
// Inflation. Die Quote schrumpft dadurch über die Phase.
expect(q.start).toBe(20000);
expect(q.end).toBeLessThan(q.start);
expect(q.end).toBe(Math.round(100000 - 80000 * Math.pow(1.015, 4)));
// Die absolute Quote liegt folgerichtig zwischen "5 x letztes Jahr" und "5 x erstes Jahr".
expect(q.total).toBeGreaterThan(5 * q.end);
expect(q.total).toBeLessThan(5 * q.start);
// Ohne verteilte Raten geht die ganze Quote aufs Cash.
expect(q.netToCash).toBe(q.total);
expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart);
expect(q.isConsumption).toBe(false);
});
it("verteilte Sparraten mindern den Rest, der aufs Cash geht", () => {
const { p, investId } = basePlan();
const r = computePlan(
applyPhasePatches(p, "p1", [{ elementId: investId, field: "annualContribution", value: 15000 }])
);
const q = quotaSummary(r.phases[0]);
expect(q.allocatedOut).toBe(75000); // 15'000 x 5 Jahre
expect(q.netToCash).toBe(q.total - q.allocatedOut + q.allocatedIn);
// Kontrolle gegen die offiziellen Kennzahlen der Phase.
expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart);
});
it("Bezugsraten zählen als Zufluss und werden am Bestand gekappt", () => {
// Verzehrphase: Ausgaben über Einkommen, finanziert aus dem Vermögen.
const p = plan({
age: 66,
retirementAge: 65,
initialCash: 0,
phases: [{ id: "p1", durationYears: 5 }],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 40000, teuerungsausgleich: 0 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 70000, teuerungsausgleich: 0 } }),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0, annualWithdrawal: 30000 } }),
],
});
const r = computePlan(p);
const q = quotaSummary(r.phases[0]);
expect(q.isConsumption).toBe(true);
expect(q.total).toBeLessThan(0);
// Der Bestand von 50'000 lässt nur 50'000 Entnahme zu, nicht 5 x 30'000.
expect(q.allocatedIn).toBe(50000);
expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart);
});
});
describe("Verteilung: Entwurfswerte anwenden", () => {
it("lässt den Ausgangsplan unberührt und erhält bestehende Felder", () => {
const { p, assetId, investId } = basePlan();
const snapshot = JSON.stringify(p);
const geaendert = applyPhasePatches(p, "p2", [
{ elementId: investId, field: "additionalInvestment", value: 1000 },
]);
expect(JSON.stringify(p)).toBe(snapshot);
// Bestehendes Feld derselben Phase bleibt erhalten.
expect(geaendert.elements.find((e) => e.id === investId)!.phaseValues.p2.expectedReturn).toBe(0);
// Übergangs-Entscheide dürfen nicht verloren gehen.
const mitTilgung = applyTransitionPatches(p, "p1", [
{ elementId: assetId, field: "partialSaleAmount", value: 500 },
]);
expect(mitTilgung.elements.find((e) => e.id === assetId)!.transitionValues.p1.decision).toBe("SELL");
expect(mitTilgung.elements.find((e) => e.id === assetId)!.transitionValues.p1.partialSaleAmount).toBe(500);
});
});
+144
View File
@@ -0,0 +1,144 @@
// Verteilung von verfügbarem Kapital und laufender Spar-/Verzehrquote.
//
// Reine Orchestrierung: Beide Werkzeuge schreiben ausschliesslich Felder, die es längst gibt
// (additionalInvestment, extraAmortization, immediateRepayment, annualContribution,
// annualWithdrawal, amortization, annualRepayment). An der Berechnung ändert sich nichts --
// die Vorschau entsteht, indem der Plan mit den Entwurfswerten kopiert und erneut durch
// computePlan geschickt wird. Dadurch ist die angezeigte Wirkung per Konstruktion exakt die
// spätere, inklusive aller Kappungen (z. B. Bezugsrate am Bestand, Amortisation an der
// Restschuld).
import type { PhaseComputed } from "@/lib/calculations";
import type { PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
// --- Verfügbares Kapital ------------------------------------------------------------------
//
// Der "Topf" ist das Kapital, das beim Übergang IN diese Phase zur Verfügung steht -- also
// bevor etwas davon investiert oder in Schulden gesteckt wurde. Alle Bestandteile stammen aus
// der Cash-Brücke (seit 0.11), es braucht keine zusätzliche Berechnung:
//
// Topf = Cash-Ende der Vorphase + Kapitalzufluss + einmaliger Zufluss einmalige Kosten
// = cashStart + Investitionen + Sofort-Tilgungen
//
// Die zweite Form ist die, die hier verwendet wird: Sie zerlegt den Topf direkt in
// "schon verteilt" und "Rest auf Cash".
export interface CapitalPot {
total: number; // gesamter verteilbarer Betrag
fromPreviousCash: number; // Cash-Endbestand der Vorphase
capitalInflow: number; // Verkäufe, PK-/3a-Bezüge
oneOffInflow: number; // Erbschaft o. ä. (netto nach Steuer)
oneOffOutflow: number; // einmalige Kosten (mindern den Topf)
allocatedInvestments: number; // Zusatzeinlagen in PK/3a/Vermögen
allocatedRepayments: number; // Sonderamortisation + Sofort-Tilgung
rest: number; // bleibt auf dem Cash-Konto (= cashStart)
}
export function capitalPot(phase: PhaseComputed): CapitalPot {
const c = phase.cashBridge;
return {
total: c.cashStart + c.investments + c.immediateRepay,
fromPreviousCash: c.openingCash,
capitalInflow: c.capitalInflow,
oneOffInflow: c.oneOffInflow,
oneOffOutflow: c.oneOffOutflow,
allocatedInvestments: c.investments,
allocatedRepayments: c.immediateRepay,
rest: c.cashStart,
};
}
// --- Spar-/Verzehrquote -------------------------------------------------------------------
//
// Die Quote ist KEIN fester Betrag: Einkommen wächst mit der Lohnerhöhung, Ausgaben mit der
// Inflation. Deshalb werden drei Grössen ausgewiesen -- erstes Jahr, letztes Jahr und die
// Summe über die ganze Phase ("absolute Quote"). Die Summe ist die Grösse, gegen die sich
// eine flache Jahresrate sinnvoll verteilen lässt.
//
// Hinweis zur Einheit: `total` summiert nominale Franken verschiedener Jahre -- exakt so, wie
// das Cash-Konto im Modell funktioniert. Für die Frage "wie viel Cash steht über die Phase
// zur Verfügung" ist das die richtige Grösse, als Kaufkraft-Aussage taugt sie nicht.
export interface QuotaSummary {
start: number; // Quote im ersten Phasenjahr
end: number; // Quote im letzten Phasenjahr
total: number; // Summe über alle Phasenjahre
allocatedOut: number; // Sparbeiträge + Amortisationen + Tilgungen, über die Phase summiert
allocatedIn: number; // Bezugsraten, über die Phase summiert
netToCash: number; // was unter dem Strich aufs Cash geht (= cashEnd cashStart)
isConsumption: boolean;
}
export function quotaSummary(phase: PhaseComputed): QuotaSummary {
const c = phase.cashBridge;
const allocatedOut = c.savingRates + c.debtRates;
return {
start: phase.quotaStart,
end: phase.quotaEnd,
total: c.quotaTotal,
allocatedOut,
allocatedIn: c.withdrawals,
netToCash: c.quotaTotal - allocatedOut + c.withdrawals,
isConsumption: c.quotaTotal < 0,
};
}
// --- Entwurfswerte auf einen Plan anwenden (für die Live-Vorschau) ------------------------
// Beide Funktionen sind rein und lassen den Ausgangsplan unberührt.
export interface PhasePatch {
elementId: string;
field: keyof PhaseData;
value: number;
}
export function applyPhasePatches(plan: PlanInput, phaseId: string, patches: PhasePatch[]): PlanInput {
if (patches.length === 0) return plan;
const byElement = new Map<string, PhasePatch[]>();
for (const p of patches) {
const list = byElement.get(p.elementId) ?? [];
list.push(p);
byElement.set(p.elementId, list);
}
return {
...plan,
elements: plan.elements.map((e) => {
const list = byElement.get(e.id);
if (!list) return e;
const merged: PhaseData = { ...(e.phaseValues[phaseId] ?? {}) };
for (const p of list) (merged as Record<string, number>)[p.field as string] = p.value;
return { ...e, phaseValues: { ...e.phaseValues, [phaseId]: merged } };
}),
};
}
export interface TransitionPatch {
elementId: string;
field: keyof TransitionData;
value: number;
}
export function applyTransitionPatches(
plan: PlanInput,
fromPhaseId: string,
patches: TransitionPatch[]
): PlanInput {
if (patches.length === 0) return plan;
const byElement = new Map<string, TransitionPatch[]>();
for (const p of patches) {
const list = byElement.get(p.elementId) ?? [];
list.push(p);
byElement.set(p.elementId, list);
}
return {
...plan,
elements: plan.elements.map((e) => {
const list = byElement.get(e.id);
if (!list) return e;
// Bestehende Entscheide (decision, salePrice, payoutMode …) bleiben erhalten -- hier
// werden nur die Betragsfelder überschrieben.
const merged: TransitionData = { ...(e.transitionValues[fromPhaseId] ?? {}) };
for (const p of list) (merged as Record<string, number>)[p.field as string] = p.value;
return { ...e, transitionValues: { ...e.transitionValues, [fromPhaseId]: merged } };
}),
};
}