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
Deploy App / deploy (push) Successful in 1m57s
This commit is contained in:
@@ -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 "Vollstaendig bezogen".
|
||||
</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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user