Rework core model: financial elements as plan-wide entities across phases; derived phase types (Erwerb/Pension/Misch) with retirement-capped durations and per-plan retirement age; AHV (gap years + couple ceiling), PK payout/annuity, 3a, real estate, other assets/debts; horizontal timeline with retirement markers; phase x element matrix with detail panel; savings/consumption quota + available-capital key figures with red status
Deploy App / deploy (push) Successful in 1m57s

This commit is contained in:
2026-07-13 07:55:58 +02:00
parent b3a3b565ac
commit b775ab77cb
27 changed files with 2407 additions and 2166 deletions
+349
View File
@@ -0,0 +1,349 @@
"use client";
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { MoneyField, NumberField, SelectField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import { CATEGORY_LABELS, num } from "@/lib/elements";
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
export interface CellContext {
kind: "phase" | "transition";
phaseId: string; // bei transition: die fromPhaseId
ownerWorking: boolean;
isConsumption: boolean;
durationYears: number;
isRetirementTransition: boolean;
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
}
interface Props {
element: { id: string; category: ElementCategory; name: string; ownerRole: string | null };
context: CellContext;
phaseData: PhaseData;
transitionData: TransitionData;
onSaved: () => void;
onDeleteElement: () => void;
}
export function ElementDetail({ element, context, phaseData, transitionData, onSaved, onDeleteElement }: Props) {
const [pd, setPd] = useState<PhaseData>({ ...phaseData });
const [td, setTd] = useState<TransitionData>({ ...transitionData });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const isTransition = context.kind === "transition";
async function save() {
setSaving(true);
setError(null);
try {
if (isTransition) {
await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td);
} else {
await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, pd);
}
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
{CATEGORY_LABELS[element.category]}
{isTransition ? " · Uebergang" : ""}
</div>
<div className="text-base font-semibold text-zinc-900 dark:text-zinc-100">{element.name}</div>
</div>
<button
type="button"
onClick={onDeleteElement}
className="flex items-center gap-1 rounded-lg border border-zinc-300 px-2 py-1 text-xs text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:bg-red-950"
>
<Trash2 className="h-3.5 w-3.5" /> Element loeschen
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{isTransition ? renderTransitionFields() : renderPhaseFields()}
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div>
<button
type="button"
disabled={saving}
onClick={save}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{saving ? "Speichern..." : "Speichern"}
</button>
</div>
</div>
);
function setP(patch: Partial<PhaseData>) {
setPd((prev) => ({ ...prev, ...patch }));
}
function setT(patch: Partial<TransitionData>) {
setTd((prev) => ({ ...prev, ...patch }));
}
function renderPhaseFields() {
switch (element.category) {
case "INCOME":
return (
<MoneyField label="Jahreseinkommen (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />
);
case "EXPENSE":
return (
<MoneyField label="Jahresausgaben (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />
);
case "AHV":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der
Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente.
</p>
);
}
return (
<NumberField
label="Geplante Ausfalljahre"
help="Jahre ohne AHV-Beitraege in dieser Phase. Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44."
value={num(pd.gapYears)}
min={0}
max={context.durationYears}
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
/>
);
case "PENSION_FUND":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollstaendig bezogen&quot;.
</p>
);
}
return (
<>
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
<MoneyField
label="Jaehrliche Einzahlung (CHF)"
help="Arbeitnehmer- und Arbeitgeberbeitraege. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)."
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
</>
);
case "PILLAR_3A":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen.
</p>
);
}
return (
<>
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
<NumberField
label="Jaehrliche Einzahlung (CHF)"
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK). Wird von der Sparquote abgezogen. Schritte von 100.`}
step={100}
min={0}
max={PILLAR_3A_MAX_ANNUAL}
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: Math.max(0, Math.min(PILLAR_3A_MAX_ANNUAL, Math.round(v / 100) * 100)) })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
</>
);
case "REAL_ESTATE":
return (
<>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek."
value={num(pd.amortization)}
onChange={(v) => setP({ amortization: v })}
/>
</>
);
case "OTHER_ASSET":
return (
<>
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
<MoneyField
label={context.isConsumption ? "Jaehrliche Bezugsrate (CHF)" : "Jaehrlicher Sparbeitrag (CHF)"}
help={
context.isConsumption
? "In dieser Verzehrphase wird dieser Betrag jaehrlich entnommen und deckt die Verzehrquote."
: "Wird von der Sparquote abgezogen."
}
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
</>
);
case "OTHER_DEBT":
return (
<>
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
<MoneyField label="Jaehrliche Tilgung (CHF)" value={num(pd.annualRepayment)} onChange={(v) => setP({ annualRepayment: v })} />
</>
);
}
}
function renderTransitionFields() {
switch (element.category) {
case "INCOME":
case "EXPENSE":
case "AHV":
return (
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
Lebensphase uebernommen und koennen dort angepasst werden.
</p>
);
case "PENSION_FUND":
if (context.isRetirementTransition) {
const mode = td.payoutMode ?? "PENSION";
return (
<>
<SelectField
label="Bezugsart bei Pensionierung"
value={mode}
onChange={(v: "CAPITAL" | "PENSION" | "COMBI") => setT({ payoutMode: v })}
options={[
{ value: "PENSION", label: "Rente" },
{ value: "CAPITAL", label: "Kapitalbezug" },
{ value: "COMBI", label: "Kombination" },
]}
/>
{(mode === "PENSION" || mode === "COMBI") && (
<NumberField
label="Umwandlungssatz (%)"
help="Jaehrliche Rente = verrentetes Kapital x Umwandlungssatz."
step={0.1}
value={num(td.conversionRate, 6)}
onChange={(v) => setT({ conversionRate: v })}
/>
)}
{(mode === "CAPITAL" || mode === "COMBI") && (
<NumberField
label="Kapitalbezugssteuer (%)"
step={0.5}
value={num(td.capitalTaxRate, 8)}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
)}
{mode === "COMBI" && (
<MoneyField
label="Davon Kapitalbezug (CHF)"
help={`Der Rest wird verrentet. Maximal ${context.carriedEndValue.toLocaleString("de-CH")}.`}
value={num(td.capitalAmount)}
max={context.carriedEndValue}
onChange={(v) => setT({ capitalAmount: v })}
/>
)}
</>
);
}
return (
<MoneyField
label="PK-Bezug (CHF)"
help={`Optionaler Bezug. Maximal ${context.carriedEndValue.toLocaleString("de-CH")} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
case "PILLAR_3A":
if (context.isRetirementTransition) {
return (
<NumberField
label="Kapitalbezugssteuer (%)"
help="Die Saeule 3a wird bei Pensionierung vollstaendig bezogen."
step={0.5}
value={num(td.capitalTaxRate, 8)}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
);
}
return (
<MoneyField
label="3a-Bezug (CHF)"
help={`Maximal ${context.carriedEndValue.toLocaleString("de-CH")} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
case "REAL_ESTATE": {
const decision = td.decision ?? "HOLD";
return (
<>
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
]}
/>
{decision === "SELL" && (
<>
<MoneyField label="Verkaufspreis (CHF)" value={num(td.salePrice)} onChange={(v) => setT({ salePrice: v })} />
<NumberField
label="Grundstueckgewinnsteuer (%)"
step={1}
value={num(td.saleTaxRate, 20)}
onChange={(v) => setT({ saleTaxRate: v })}
/>
</>
)}
</>
);
}
case "OTHER_ASSET": {
const decision = td.decision ?? "HOLD";
return (
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
]}
/>
);
}
case "OTHER_DEBT":
return (
<MoneyField
label="Sofortige Tilgung (CHF)"
help="Wird sofort getilgt und vom verfuegbaren Kapital der naechsten Phase abgezogen."
value={num(td.immediateRepayment)}
onChange={(v) => setT({ immediateRepayment: v })}
/>
);
}
}
}