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:
@@ -10,8 +10,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { PhaseCard, formatAges } from "@/components/PhaseCard";
|
||||
import { TransitionPanel } from "@/components/TransitionPanel";
|
||||
import { PlanView } from "@/components/PlanView";
|
||||
import { Dashboard } from "@/components/Dashboard";
|
||||
import { HouseholdSettings } from "@/components/HouseholdSettings";
|
||||
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||
@@ -81,16 +80,6 @@ export function AppShell({
|
||||
if (selectedPlanId) loadDetail(selectedPlanId);
|
||||
}
|
||||
|
||||
async function handleAddPhase() {
|
||||
if (!selectedPlanId || !detail) return;
|
||||
await api.post(`/api/plans/${selectedPlanId}/phases`, {
|
||||
name: detail.plan.phases.length === 0 ? "Erste Lebensphase" : `Neue Phase ${detail.plan.phases.length + 1}`,
|
||||
durationYears: 10,
|
||||
incomeMode: "HOUSEHOLD",
|
||||
});
|
||||
refreshCurrent();
|
||||
}
|
||||
|
||||
async function handleDeletePlan(id: string) {
|
||||
if (!confirm("Diesen Plan wirklich loeschen?")) return;
|
||||
await api.delete(`/api/plans/${id}`);
|
||||
@@ -232,16 +221,7 @@ export function AppShell({
|
||||
|
||||
{!loading && detail && selectedPlanId && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Plan-Kopf mit Aktionen */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddPhase}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-dashed border-indigo-300 bg-indigo-50/50 px-3 py-1.5 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Phase hinzufuegen
|
||||
</button>
|
||||
{detail.plan.phases.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -262,64 +242,12 @@ export function AppShell({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Phasen mit Lebenslinie */}
|
||||
<div className="flex flex-col">
|
||||
{detail.plan.phases.map((phase, i) => {
|
||||
const computedPhase = detail.computed.phases.find((c) => c.id === phase.id)!;
|
||||
const nextPhase = detail.plan.phases[i + 1];
|
||||
const startAges = computedPhase.ages.map((a) => a.startAge).join("·");
|
||||
return (
|
||||
<div key={phase.id} className="flex gap-3">
|
||||
{/* Lebenslinie */}
|
||||
<div className="hidden w-12 flex-col items-center sm:flex">
|
||||
<div
|
||||
title={`Alter zu Beginn: ${formatAges(computedPhase)}`}
|
||||
className="flex h-9 w-12 items-center justify-center rounded-full border border-indigo-200 bg-indigo-50 text-[11px] font-semibold text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/15 dark:text-indigo-300"
|
||||
>
|
||||
{startAges}
|
||||
</div>
|
||||
<div className="w-px flex-1 bg-indigo-200 dark:bg-indigo-500/30" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3 pb-3">
|
||||
<PhaseCard
|
||||
household={household}
|
||||
phase={phase}
|
||||
computed={computedPhase}
|
||||
isFirst={i === 0}
|
||||
isLast={i === detail.plan.phases.length - 1}
|
||||
onChanged={refreshCurrent}
|
||||
/>
|
||||
{nextPhase && (
|
||||
<TransitionPanel
|
||||
phase={phase}
|
||||
computed={computedPhase}
|
||||
nextPhaseName={nextPhase.name}
|
||||
onChanged={refreshCurrent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{detail.computed.phases.length > 0 && (
|
||||
<div className="hidden w-12 flex-col items-center sm:flex">
|
||||
<div
|
||||
title="Alter am Ende der letzten Phase"
|
||||
className="flex h-9 w-12 items-center justify-center rounded-full border border-zinc-300 bg-white text-[11px] font-semibold text-zinc-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
>
|
||||
{detail.computed.phases[detail.computed.phases.length - 1].ages
|
||||
.map((a) => a.endAge)
|
||||
.join("·")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail.plan.phases.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
Dieser Plan hat noch keine Phasen. Fuegen Sie oben die erste Lebensphase hinzu.
|
||||
</p>
|
||||
)}
|
||||
<PlanView
|
||||
plan={detail.plan}
|
||||
household={household}
|
||||
computed={detail.computed}
|
||||
onChanged={refreshCurrent}
|
||||
/>
|
||||
|
||||
{detail.plan.phases.length > 0 && (
|
||||
<Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />
|
||||
|
||||
@@ -50,23 +50,28 @@ export function Dashboard({
|
||||
return result;
|
||||
}, [plan.name, computed, compareIds, compareData, allPlans]);
|
||||
|
||||
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||||
const barKeys = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
for (const phase of computed.phases) {
|
||||
for (const s of phase.securities) keys.add(s.name);
|
||||
for (const re of phase.realEstates) keys.add(re.name);
|
||||
for (const el of phase.elements) {
|
||||
if (ASSET_CATS.includes(el.category) && el.endValue > 0) keys.add(el.name);
|
||||
}
|
||||
}
|
||||
return Array.from(keys);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [computed]);
|
||||
|
||||
const barData = useMemo(
|
||||
() =>
|
||||
computed.phases.map((phase) => {
|
||||
const row: Record<string, number | string> = { phase: phase.name };
|
||||
for (const s of phase.securities) row[s.name] = s.endValue;
|
||||
for (const re of phase.realEstates) row[re.name] = re.endNet;
|
||||
for (const el of phase.elements) {
|
||||
if (ASSET_CATS.includes(el.category) && el.endValue > 0) row[el.name] = el.endValue;
|
||||
}
|
||||
return row;
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[computed]
|
||||
);
|
||||
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export function NumberField({
|
||||
step={step ?? "any"}
|
||||
min={min}
|
||||
max={max}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LineChart, Line, ResponsiveContainer } from "recharts";
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Trash2, Users } from "lucide-react";
|
||||
import { PhaseForm } from "@/components/PhaseForm";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import type { HouseholdInput, PhaseInput } from "@/lib/types";
|
||||
import type { PhaseComputed } from "@/lib/calculations";
|
||||
|
||||
// Formatiert die Altersspannen der Personen einer Phase, z. B. "35–45" (Single)
|
||||
// oder "A 35–45 · B 33–43" (Paar).
|
||||
export function formatAges(computed: PhaseComputed): string {
|
||||
if (computed.ages.length === 0) return "";
|
||||
if (computed.ages.length === 1) {
|
||||
const a = computed.ages[0];
|
||||
return `${a.startAge}–${a.endAge}`;
|
||||
}
|
||||
return computed.ages
|
||||
.map((a) => `${a.role === "PERSON_A" ? "A" : "B"} ${a.startAge}–${a.endAge}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
export function PhaseCard({
|
||||
household,
|
||||
phase,
|
||||
computed,
|
||||
isFirst,
|
||||
isLast,
|
||||
onChanged,
|
||||
}: {
|
||||
household: HouseholdInput;
|
||||
phase: PhaseInput;
|
||||
computed: PhaseComputed;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const sparklineData = [computed.startWealthNominal, ...computed.yearlyNominal].map((v, i) => ({
|
||||
year: i,
|
||||
value: v,
|
||||
}));
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/api/phases/${phase.id}`);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-zinc-200/70 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-zinc-50 sm:gap-4 dark:hover:bg-zinc-800/50"
|
||||
>
|
||||
<span className="text-indigo-500 dark:text-indigo-400">
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="font-medium text-zinc-900 dark:text-zinc-100">{phase.name}</span>
|
||||
<span className="text-xs text-zinc-500">{phase.durationYears} Jahre</span>
|
||||
<span className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400">
|
||||
<Users className="h-3 w-3" />
|
||||
Alter {formatAges(computed)}
|
||||
</span>
|
||||
{computed.savingsWarning && (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-3 w-3" /> Sparquote ueberschritten
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Start {formatChf(computed.startWealthNominal)} CHF → Ende {formatChf(computed.endWealthNominal)} CHF (nominal)
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden h-8 w-24 sm:block">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={sparklineData}>
|
||||
<Line type="monotone" dataKey="value" stroke="#4f46e5" strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isLast && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
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:border-red-500/30 dark:hover:bg-red-950"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{deleting ? "…" : "Loeschen"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<PhaseForm
|
||||
key={`${phase.id}:${phase.securities.length}:${phase.realEstates.length}:${phase.incomingCapital}`}
|
||||
household={household}
|
||||
phase={phase}
|
||||
isFirstPhase={isFirst}
|
||||
onSaved={() => {
|
||||
onChanged();
|
||||
}}
|
||||
onCancel={() => setExpanded(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { NumberField, TextField } from "@/components/FormField";
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { HouseholdInput, PhaseInput } from "@/lib/types";
|
||||
|
||||
export function PhaseDetail({
|
||||
phase,
|
||||
maxDurationYears,
|
||||
isLast,
|
||||
household,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
phase: PhaseInput;
|
||||
maxDurationYears: number | null;
|
||||
isLast: boolean;
|
||||
household: HouseholdInput;
|
||||
onSaved: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(phase.name);
|
||||
const [durationYears, setDurationYears] = useState(phase.durationYears);
|
||||
const [inflationRate, setInflationRate] = useState<number | null>(phase.inflationRate);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const cap = maxDurationYears;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.put(`/api/phases/${phase.id}`, { name, durationYears, inflationRate });
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return;
|
||||
try {
|
||||
await api.delete(`/api/phases/${phase.id}`);
|
||||
onDeleted();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
|
||||
Lebensphase
|
||||
</div>
|
||||
{isLast && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
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" /> Phase loeschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
||||
<NumberField
|
||||
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
|
||||
help={cap != null ? "Die Dauer ist ans naechste Pensionsereignis gekappt." : undefined}
|
||||
value={durationYears}
|
||||
min={1}
|
||||
max={cap ?? undefined}
|
||||
onChange={(v) => setDurationYears(cap != null ? Math.min(v, cap) : v)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Inflationsrate (%)"
|
||||
help="Ueberschreibt die Standardannahme aus dem Grundprofil."
|
||||
value={inflationRate ?? household.inflationRateDefault}
|
||||
step={0.1}
|
||||
onChange={setInflationRate}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Gift,
|
||||
Home,
|
||||
Plus,
|
||||
PiggyBank,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
X,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import type {
|
||||
ExpenseEntryInput,
|
||||
HouseholdInput,
|
||||
IncomeEntryInput,
|
||||
IncomeMode,
|
||||
OneTimeEventInput,
|
||||
OneTimeEventType,
|
||||
OwnerTag,
|
||||
PhaseInput,
|
||||
RealEstateInput,
|
||||
RetirementInfoInput,
|
||||
SecurityInput,
|
||||
} from "@/lib/types";
|
||||
|
||||
let tempIdCounter = 0;
|
||||
function tempId() {
|
||||
tempIdCounter += 1;
|
||||
return `tmp-${tempIdCounter}`;
|
||||
}
|
||||
|
||||
function personLabel(household: HouseholdInput, personId: string | null) {
|
||||
if (!personId) return "Haushalt";
|
||||
const person = household.persons.find((p) => p.id === personId);
|
||||
if (!person) return "Haushalt";
|
||||
return person.role === "PERSON_A" ? "Person A" : "Person B";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
household: HouseholdInput;
|
||||
phase: PhaseInput;
|
||||
isFirstPhase: boolean;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PhaseForm({ household, phase, isFirstPhase, onSaved, onCancel }: Props) {
|
||||
const [name, setName] = useState(phase.name);
|
||||
const [durationYears, setDurationYears] = useState(phase.durationYears);
|
||||
const [inflationRate, setInflationRate] = useState<number | null>(phase.inflationRate);
|
||||
const [incomeMode, setIncomeMode] = useState<IncomeMode>(phase.incomeMode);
|
||||
const [incomeEntries, setIncomeEntries] = useState<IncomeEntryInput[]>(phase.incomeEntries);
|
||||
const [expenseEntries, setExpenseEntries] = useState<ExpenseEntryInput[]>(
|
||||
phase.expenseEntries.length > 0 ? phase.expenseEntries : [{ id: tempId(), label: null, amount: 0 }]
|
||||
);
|
||||
const [securities, setSecurities] = useState<SecurityInput[]>(phase.securities);
|
||||
const [realEstates, setRealEstates] = useState<RealEstateInput[]>(phase.realEstates);
|
||||
const [oneTimeEvents, setOneTimeEvents] = useState<OneTimeEventInput[]>(phase.oneTimeEvents);
|
||||
const [retirementInfos, setRetirementInfos] = useState<RetirementInfoInput[]>(phase.retirementInfos);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const totalIncome = incomeEntries.reduce((s, e) => s + e.amount, 0);
|
||||
const totalExpense = expenseEntries.reduce((s, e) => s + e.amount, 0);
|
||||
const savingsQuota = totalIncome - totalExpense;
|
||||
// Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der
|
||||
// Wertschriften gegen dieselbe verfuegbare Sparquote (ein gemeinsamer Topf).
|
||||
const allocated =
|
||||
securities.reduce((s, sec) => s + sec.annualContribution, 0) +
|
||||
realEstates.reduce((s, re) => s + re.amortization, 0);
|
||||
const savingsRemaining = savingsQuota - allocated > 0.5;
|
||||
|
||||
const allocatedStartCapital = securities.reduce(
|
||||
(s, sec) => s + Math.max(0, sec.startValue - sec.carriedBaseValue),
|
||||
0
|
||||
);
|
||||
const startCapitalRemaining = phase.incomingCapital - allocatedStartCapital > 0.5;
|
||||
|
||||
// Live-Kappung: pro Feld das noch verfuegbare Budget (eigener Anteil zaehlt nicht
|
||||
// gegen sich selbst, damit man einen bestehenden Wert wieder erhoehen/senken kann).
|
||||
function maxContributionFor(current: number): number {
|
||||
return Math.max(0, savingsQuota - (allocated - current));
|
||||
}
|
||||
function maxStartValueFor(sec: SecurityInput): number | undefined {
|
||||
// In der ersten Phase wird der Ist-Bestand frei erfasst -- kein Limit.
|
||||
if (isFirstPhase) return undefined;
|
||||
const ownExtra = Math.max(0, sec.startValue - sec.carriedBaseValue);
|
||||
const remaining = Math.max(0, phase.incomingCapital - (allocatedStartCapital - ownExtra));
|
||||
return sec.carriedBaseValue + remaining;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const missingPurchasePrice = realEstates.find((re) => !re.purchasePrice || re.purchasePrice <= 0);
|
||||
if (missingPurchasePrice) {
|
||||
setError(
|
||||
`Bitte fuer "${missingPurchasePrice.name || "Immobilie"}" einen Kaufpreis groesser als 0 eintragen.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.put(`/api/phases/${phase.id}`, {
|
||||
name,
|
||||
durationYears,
|
||||
inflationRate,
|
||||
incomeMode,
|
||||
incomeEntries: incomeEntries.map((e) => ({
|
||||
personId: incomeMode === "PER_PERSON" ? e.personId : null,
|
||||
label: e.label,
|
||||
amount: e.amount,
|
||||
})),
|
||||
expenseEntries: expenseEntries.map((e) => ({ label: e.label, amount: e.amount })),
|
||||
securities,
|
||||
realEstates,
|
||||
oneTimeEvents,
|
||||
retirementInfos,
|
||||
});
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 border-t border-zinc-200 p-4 dark:border-zinc-800">
|
||||
{/* Basis-Kopfzeile */}
|
||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="col-span-2 lg:col-span-1">
|
||||
<TextField
|
||||
label="Bezeichnung der Lebensphase"
|
||||
help="Ein frei waehlbarer Name, z. B. 'Kinder zuhause' oder 'Fruehpensionierung'."
|
||||
value={name}
|
||||
onChange={setName}
|
||||
/>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Dauer (Jahre)"
|
||||
help="Wie viele Jahre umfasst diese Lebensphase?"
|
||||
value={durationYears}
|
||||
min={1}
|
||||
onChange={setDurationYears}
|
||||
/>
|
||||
<NumberField
|
||||
label="Inflationsrate (%)"
|
||||
help="Ueberschreibt fuer diese Phase die im Grundprofil hinterlegte Standardannahme."
|
||||
value={inflationRate ?? household.inflationRateDefault}
|
||||
step={0.1}
|
||||
onChange={setInflationRate}
|
||||
/>
|
||||
{household.householdType === "COUPLE" && (
|
||||
<SelectField
|
||||
label="Einkommen eingeben als"
|
||||
help="Pro Person einzeln oder direkt als gemeinsamer Betrag fuer den Haushalt."
|
||||
value={incomeMode}
|
||||
onChange={setIncomeMode}
|
||||
options={[
|
||||
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||||
{ value: "PER_PERSON", label: "Pro Person" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Matrix: Kategorien als Spalten */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
{/* Einkommen & Ausgaben */}
|
||||
<CollapsibleColumn
|
||||
title="Einkommen & Ausgaben"
|
||||
icon={<Wallet className="h-4 w-4" />}
|
||||
summary={`${formatChf(totalIncome)} / ${formatChf(totalExpense)}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-zinc-400">Einkommen</div>
|
||||
{incomeEntries.map((entry, i) => (
|
||||
<EntryCard key={entry.id} onRemove={() => setIncomeEntries((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||
{incomeMode === "PER_PERSON" ? (
|
||||
<SelectField
|
||||
label="Person"
|
||||
value={(entry.personId ?? household.persons[0]?.id ?? "") as string}
|
||||
onChange={(v) =>
|
||||
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, personId: v } : e)))
|
||||
}
|
||||
options={household.persons.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.role === "PERSON_A" ? "Person A" : "Person B",
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
label="Bezeichnung (optional)"
|
||||
value={entry.label ?? ""}
|
||||
onChange={(v) =>
|
||||
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, label: v || null } : e)))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jahreseinkommen (CHF)"
|
||||
help="Ihr erwartetes Bruttoeinkommen pro Jahr waehrend dieser Lebensphase."
|
||||
value={entry.amount}
|
||||
onChange={(v) =>
|
||||
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
|
||||
}
|
||||
/>
|
||||
</EntryCard>
|
||||
))}
|
||||
<AddButton
|
||||
label="Einkommensposten"
|
||||
onClick={() =>
|
||||
setIncomeEntries((prev) => [
|
||||
...prev,
|
||||
{ id: tempId(), personId: incomeMode === "PER_PERSON" ? household.persons[0]?.id ?? null : null, label: null, amount: 0 },
|
||||
])
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-400">Ausgaben</div>
|
||||
{expenseEntries.map((entry, i) => (
|
||||
<EntryCard key={entry.id} onRemove={() => setExpenseEntries((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||
<MoneyField
|
||||
label="Gesamtausgaben (CHF/Jahr)"
|
||||
help="Saemtliche laufenden Kosten des Haushalts pro Jahr - inkl. Lebenshaltung, Hypothekarzinsen, laufende Einkommens- und Vermoegenssteuern."
|
||||
value={entry.amount}
|
||||
onChange={(v) =>
|
||||
setExpenseEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
|
||||
}
|
||||
/>
|
||||
</EntryCard>
|
||||
))}
|
||||
<AddButton
|
||||
label="Ausgabenposten"
|
||||
onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleColumn>
|
||||
|
||||
{/* Wertschriften */}
|
||||
<CollapsibleColumn
|
||||
title="Wertschriften"
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
summary={`${securities.length}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{securities.map((s, i) => (
|
||||
<EntryCard key={s.id} onRemove={() => setSecurities((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||
<TextField
|
||||
label="Name"
|
||||
help="Frei waehlbare Bezeichnung, z. B. 'Globaler ETF' oder 'Schweizer Aktien'."
|
||||
value={s.name}
|
||||
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Startwert (CHF)"
|
||||
help={
|
||||
isFirstPhase
|
||||
? "Wert dieser Position zu Beginn der Phase."
|
||||
: "Wert zu Beginn der Phase. Erhoehungen gegenueber dem uebernommenen Wert werden vom verfuegbaren Startkapital abgezogen."
|
||||
}
|
||||
value={s.startValue}
|
||||
max={maxStartValueFor(s)}
|
||||
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, startValue: v } : x)))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Rendite (%/Jahr)"
|
||||
help="Ihre Annahme zur durchschnittlichen jaehrlichen Wertentwicklung dieser Anlage."
|
||||
step={0.1}
|
||||
value={s.expectedReturn}
|
||||
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Sparbeitrag (CHF/Jahr)"
|
||||
help="Der Betrag aus Ihrer verfuegbaren Sparquote, den Sie jaehrlich in diese Position investieren. Wird automatisch auf die verbleibende Sparquote begrenzt."
|
||||
value={s.annualContribution}
|
||||
max={maxContributionFor(s.annualContribution)}
|
||||
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, annualContribution: v } : x)))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Gehoert zu"
|
||||
help="Rein informativ: Person A, Person B oder gemeinsam. Hat keinen Einfluss auf die Berechnung."
|
||||
value={s.ownerTag}
|
||||
onChange={(v: OwnerTag) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, ownerTag: v } : x)))}
|
||||
options={[
|
||||
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||||
{ value: "PERSON_A", label: "Person A" },
|
||||
{ value: "PERSON_B", label: "Person B" },
|
||||
]}
|
||||
/>
|
||||
</EntryCard>
|
||||
))}
|
||||
<AddButton
|
||||
label="Wertschrift"
|
||||
onClick={() =>
|
||||
setSecurities((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: tempId(),
|
||||
name: "",
|
||||
startValue: 0,
|
||||
expectedReturn: 0,
|
||||
annualContribution: 0,
|
||||
ownerTag: "HOUSEHOLD",
|
||||
saleTaxRate: 0,
|
||||
carriedBaseValue: 0,
|
||||
},
|
||||
])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleColumn>
|
||||
|
||||
{/* Immobilien */}
|
||||
<CollapsibleColumn title="Immobilien" icon={<Home className="h-4 w-4" />} summary={`${realEstates.length}`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{realEstates.map((re, i) => (
|
||||
<EntryCard key={re.id} onRemove={() => setRealEstates((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||
<TextField
|
||||
label="Bezeichnung"
|
||||
help="Z. B. 'Eigenheim' oder 'Ferienwohnung'."
|
||||
value={re.name}
|
||||
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Kaufpreis (CHF)"
|
||||
help="Pflichtfeld. Der Kaufpreis bleibt ueber die ganze Haltedauer fix -- nur die Hypothek sinkt durch Amortisation."
|
||||
value={re.purchasePrice}
|
||||
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, purchasePrice: v } : x)))}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Hypothek (CHF)"
|
||||
help="Ausstehender Hypothekarbetrag zu Beginn der Phase."
|
||||
value={re.mortgage}
|
||||
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Amortisation (CHF/Jahr)"
|
||||
help="Betrag, um den die Hypothek pro Jahr reduziert wird. Zaehlt zusammen mit den Sparbeitraegen gegen die verfuegbare Sparquote."
|
||||
value={re.amortization}
|
||||
max={maxContributionFor(re.amortization)}
|
||||
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))}
|
||||
/>
|
||||
</EntryCard>
|
||||
))}
|
||||
<AddButton
|
||||
label="Immobilie"
|
||||
onClick={() =>
|
||||
setRealEstates((prev) => [
|
||||
...prev,
|
||||
{ id: tempId(), name: "", purchasePrice: 0, mortgage: 0, amortization: 0 },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleColumn>
|
||||
|
||||
{/* Sondereinnahmen / -ausgaben */}
|
||||
<CollapsibleColumn
|
||||
title="Sondereinnahmen / -ausgaben"
|
||||
icon={<Gift className="h-4 w-4" />}
|
||||
summary={`${oneTimeEvents.length}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{oneTimeEvents.map((ev, i) => (
|
||||
<EntryCard key={ev.id} onRemove={() => setOneTimeEvents((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||
<SelectField
|
||||
label="Art"
|
||||
help="Einmalige Einnahme (z. B. Erbschaft) oder einmalige Ausgabe (z. B. Poolbau)."
|
||||
value={ev.type}
|
||||
onChange={(v: OneTimeEventType) =>
|
||||
setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, type: v } : x)))
|
||||
}
|
||||
options={[
|
||||
{ value: "INCOME", label: "Einnahme" },
|
||||
{ value: "EXPENSE", label: "Ausgabe" },
|
||||
]}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Betrag (CHF)"
|
||||
value={ev.amount}
|
||||
onChange={(v) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, amount: v } : x)))}
|
||||
/>
|
||||
<TextField
|
||||
label="Beschreibung"
|
||||
value={ev.description ?? ""}
|
||||
onChange={(v) =>
|
||||
setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, description: v || null } : x)))
|
||||
}
|
||||
/>
|
||||
</EntryCard>
|
||||
))}
|
||||
<AddButton
|
||||
label="Sondereintrag"
|
||||
onClick={() =>
|
||||
setOneTimeEvents((prev) => [...prev, { id: tempId(), type: "EXPENSE", amount: 0, description: null }])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleColumn>
|
||||
|
||||
{/* Pensionierung */}
|
||||
<CollapsibleColumn
|
||||
title="Pensionierung"
|
||||
icon={<PiggyBank className="h-4 w-4" />}
|
||||
summary={`${retirementInfos.length}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{retirementInfos.map((r, i) => (
|
||||
<EntryCard key={r.id} onRemove={() => setRetirementInfos((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||
<SelectField
|
||||
label="Person"
|
||||
value={r.personId}
|
||||
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, personId: v } : x)))}
|
||||
options={household.persons.map((p) => ({ value: p.id, label: personLabel(household, p.id) }))}
|
||||
/>
|
||||
<MoneyField
|
||||
label="AHV-Rente (CHF/Jahr)"
|
||||
help="Zusammengesetzt mit der PK-Rente zur 'Erwarteten Rente'. Bei Ehepaaren max. 1.5x AHV-Maximalrente gemeinsam."
|
||||
value={r.ahvAmount}
|
||||
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, ahvAmount: v } : x)))}
|
||||
/>
|
||||
<MoneyField
|
||||
label="PK-Rente (CHF/Jahr)"
|
||||
help="Pensionskassenrente (2. Saeule)."
|
||||
value={r.pkPensionAmount}
|
||||
onChange={(v) =>
|
||||
setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x)))
|
||||
}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Kapitalbezug brutto (CHF)"
|
||||
help="Zusammengesetzt aus Saeule 3a und/oder Kapitalbezug aus der Pensionskasse."
|
||||
value={r.lumpSumAmount}
|
||||
onChange={(v) =>
|
||||
setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumAmount: v } : x)))
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Kapitalbezugssteuer (%)"
|
||||
help="Realistische Bandbreite: ca. 3-15% des Bruttobetrags."
|
||||
value={r.lumpSumTaxRate}
|
||||
onChange={(v) =>
|
||||
setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumTaxRate: v } : x)))
|
||||
}
|
||||
/>
|
||||
</EntryCard>
|
||||
))}
|
||||
<AddButton
|
||||
label="Pensionierungsangaben"
|
||||
onClick={() =>
|
||||
setRetirementInfos((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: tempId(),
|
||||
personId: household.persons[0]?.id ?? "",
|
||||
ahvAmount: 0,
|
||||
pkPensionAmount: 0,
|
||||
lumpSumAmount: 0,
|
||||
lumpSumTaxRate: 8,
|
||||
},
|
||||
])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleColumn>
|
||||
</div>
|
||||
|
||||
{/* Budget-Status */}
|
||||
<div className="flex flex-col gap-1.5 rounded-xl bg-indigo-50/60 px-3 py-2 text-sm dark:bg-indigo-500/10">
|
||||
{!isFirstPhase && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusDot ok={!startCapitalRemaining} />
|
||||
Verfuegbares Startkapital (aus Verkaeufen der Vorphase): <strong>{formatChf(phase.incomingCapital)}</strong> CHF
|
||||
{" "}— zugewiesen: {formatChf(allocatedStartCapital)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusDot ok={!savingsRemaining} />
|
||||
Verfuegbare Sparquote (CHF/Jahr): <strong>{formatChf(savingsQuota)}</strong>
|
||||
{" "}— zugewiesen: {formatChf(allocated)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Eine einklappbare Kategorien-Spalte der Matrix (Phase x Kategorie).
|
||||
function CollapsibleColumn({
|
||||
title,
|
||||
icon,
|
||||
summary,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
summary?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<section className="flex flex-col self-start rounded-xl border border-zinc-100 bg-zinc-50/60 dark:border-zinc-800 dark:bg-zinc-800/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-2.5 text-left"
|
||||
>
|
||||
<span className="text-indigo-500 dark:text-indigo-400">{icon}</span>
|
||||
<span className="flex-1 text-sm font-semibold text-zinc-800 dark:text-zinc-100">{title}</span>
|
||||
{summary != null && (
|
||||
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">
|
||||
{summary}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-zinc-400">
|
||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</span>
|
||||
</button>
|
||||
{open && <div className="px-3 pb-3">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Kompakte Karte fuer einen einzelnen Eintrag (Felder vertikal gestapelt).
|
||||
function EntryCard({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) {
|
||||
return (
|
||||
<div className="relative flex flex-col gap-2 rounded-lg border border-zinc-200/70 bg-white p-2.5 pr-8 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label="Entfernen"
|
||||
className="absolute right-1.5 top-1.5 rounded-md p-1 text-zinc-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-1 self-start text-xs font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ ok }: { ok: boolean }) {
|
||||
return ok ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" aria-label="Vollstaendig verteilt" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 shrink-0 text-red-500" aria-label="Noch nicht vollstaendig verteilt" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CreditCard,
|
||||
Home,
|
||||
Landmark,
|
||||
PiggyBank,
|
||||
Plus,
|
||||
ShoppingCart,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { Timeline } from "@/components/Timeline";
|
||||
import { ElementDetail, type CellContext } from "@/components/ElementDetail";
|
||||
import { PhaseDetail } from "@/components/PhaseDetail";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
CATEGORY_ORDER,
|
||||
PERSON_ONLY_CATEGORIES,
|
||||
num,
|
||||
type ElementCategory,
|
||||
} from "@/lib/elements";
|
||||
import { resolveRetirementAge, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
||||
import type { ElementInput, HouseholdInput, PlanInput } from "@/lib/types";
|
||||
|
||||
const PERSON_A_COLOR = "#4f46e5";
|
||||
const PERSON_B_COLOR = "#0ea5e9";
|
||||
|
||||
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
||||
INCOME: <Wallet className="h-4 w-4" />,
|
||||
EXPENSE: <ShoppingCart className="h-4 w-4" />,
|
||||
AHV: <Landmark className="h-4 w-4" />,
|
||||
PENSION_FUND: <Building2 className="h-4 w-4" />,
|
||||
PILLAR_3A: <PiggyBank className="h-4 w-4" />,
|
||||
REAL_ESTATE: <Home className="h-4 w-4" />,
|
||||
OTHER_ASSET: <TrendingUp className="h-4 w-4" />,
|
||||
OTHER_DEBT: <CreditCard className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const TRANSITION_CATEGORIES: ElementCategory[] = [
|
||||
"PENSION_FUND",
|
||||
"PILLAR_3A",
|
||||
"REAL_ESTATE",
|
||||
"OTHER_ASSET",
|
||||
"OTHER_DEBT",
|
||||
];
|
||||
|
||||
type Column =
|
||||
| { kind: "phase"; phase: PhaseComputed }
|
||||
| { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed };
|
||||
|
||||
type Selection =
|
||||
| { type: "phaseCell"; elementId: string; phaseId: string }
|
||||
| { type: "transitionCell"; elementId: string; fromPhaseId: string }
|
||||
| { type: "phase"; phaseId: string };
|
||||
|
||||
export function PlanView({
|
||||
plan,
|
||||
household,
|
||||
computed,
|
||||
onChanged,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
household: HouseholdInput;
|
||||
computed: PlanComputed;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Selection | null>(null);
|
||||
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const columns = useMemo<Column[]>(() => {
|
||||
const cols: Column[] = [];
|
||||
computed.phases.forEach((p, i) => {
|
||||
cols.push({ kind: "phase", phase: p });
|
||||
if (i < computed.phases.length - 1) {
|
||||
cols.push({ kind: "transition", fromPhase: p, toPhase: computed.phases[i + 1] });
|
||||
}
|
||||
});
|
||||
return cols;
|
||||
}, [computed.phases]);
|
||||
|
||||
const personAxes = household.persons.map((p) => ({
|
||||
role: p.role,
|
||||
label: p.role === "PERSON_A" ? "Person A" : "Person B",
|
||||
currentAge: p.age,
|
||||
retirementAge: resolveRetirementAge(p.role, plan, p.retirementAge),
|
||||
color: p.role === "PERSON_A" ? PERSON_A_COLOR : PERSON_B_COLOR,
|
||||
}));
|
||||
|
||||
const elementsByCategory = useMemo(() => {
|
||||
const map = new Map<ElementCategory, ElementInput[]>();
|
||||
for (const cat of CATEGORY_ORDER) map.set(cat, []);
|
||||
for (const e of [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex)) {
|
||||
map.get(e.category)!.push(e);
|
||||
}
|
||||
return map;
|
||||
}, [plan.elements]);
|
||||
|
||||
function computedElement(phaseId: string, elementId: string) {
|
||||
return computed.phases.find((p) => p.id === phaseId)?.elements.find((e) => e.elementId === elementId);
|
||||
}
|
||||
|
||||
function isRetirementTransition(element: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
|
||||
if (!element.ownerRole || element.ownerRole === "HOUSEHOLD") return false;
|
||||
const before = fromPhase.persons.find((p) => p.role === element.ownerRole);
|
||||
const after = toPhase.persons.find((p) => p.role === element.ownerRole);
|
||||
return !!before?.working && !!after && !after.working;
|
||||
}
|
||||
|
||||
async function handleAddPhase() {
|
||||
await api.post(`/api/plans/${plan.id}/phases`, {});
|
||||
onChanged();
|
||||
}
|
||||
|
||||
const hasPhases = computed.phases.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Timeline phases={computed.phases} persons={personAxes} />
|
||||
|
||||
{/* Pensionsalter-Overrides */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-zinc-200/70 bg-white px-4 py-3 text-sm shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-400">Pensionsalter (Plan)</span>
|
||||
{household.persons.map((p) => (
|
||||
<label key={p.role} className="flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-300">
|
||||
{p.role === "PERSON_A" ? "Person A" : "Person B"}:
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={resolveRetirementAge(p.role, plan, p.retirementAge)}
|
||||
className="w-16 rounded-lg border border-zinc-300 px-2 py-1 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-800"
|
||||
onBlur={async (e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(val)) return;
|
||||
await api.patch(`/api/plans/${plan.id}`, {
|
||||
[p.role === "PERSON_A" ? "retirementAgeA" : "retirementAgeB"]: val,
|
||||
});
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<span className="text-[11px] text-zinc-400">Standard aus Grundprofil, hier pro Plan uebersteuerbar.</span>
|
||||
</div>
|
||||
|
||||
{!hasPhases && (
|
||||
<div className="rounded-xl border border-dashed border-zinc-300 bg-white p-8 text-center dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<p className="text-sm text-zinc-500">Dieser Plan hat noch keine Lebensphasen.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddPhase}
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 dark:bg-indigo-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Erste Lebensphase
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPhases && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Finanzielles Element
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddPhase}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-dashed border-indigo-300 bg-indigo-50/50 px-3 py-1.5 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Lebensphase
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Matrix */}
|
||||
{hasPhases && (
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-200/70 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-20 min-w-44 border-b border-r border-zinc-200 bg-zinc-50 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-800/60">
|
||||
Finanzielle Elemente
|
||||
</th>
|
||||
{columns.map((col) =>
|
||||
col.kind === "phase" ? (
|
||||
<PhaseHeader
|
||||
key={col.phase.id}
|
||||
phase={col.phase}
|
||||
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
|
||||
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
|
||||
/>
|
||||
) : (
|
||||
<th
|
||||
key={`t-${col.fromPhase.id}`}
|
||||
className="border-b border-r border-zinc-200 bg-indigo-50/40 px-2 py-2 text-center text-[11px] font-medium text-indigo-500 dark:border-zinc-800 dark:bg-indigo-500/5"
|
||||
>
|
||||
Uebergang
|
||||
</th>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const els = elementsByCategory.get(cat)!;
|
||||
if (els.length === 0) return null;
|
||||
const collapsed = collapsedCats.has(cat);
|
||||
return (
|
||||
<FragmentRows key={cat}>
|
||||
<tr className="bg-zinc-50/60 dark:bg-zinc-800/30">
|
||||
<td
|
||||
className="sticky left-0 z-10 cursor-pointer border-b border-r border-zinc-200 bg-zinc-50/90 px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-800/60"
|
||||
onClick={() =>
|
||||
setCollapsedCats((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cat)) next.delete(cat);
|
||||
else next.add(cat);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold text-zinc-600 dark:text-zinc-300">
|
||||
{collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
<span className="text-indigo-500 dark:text-indigo-400">{CATEGORY_ICON[cat]}</span>
|
||||
{CATEGORY_LABELS[cat]}
|
||||
</span>
|
||||
</td>
|
||||
<td colSpan={columns.length} className="border-b border-zinc-200 dark:border-zinc-800" />
|
||||
</tr>
|
||||
{!collapsed &&
|
||||
els.map((el) => (
|
||||
<tr key={el.id} className="hover:bg-zinc-50/50 dark:hover:bg-zinc-800/20">
|
||||
<td className="sticky left-0 z-10 border-b border-r border-zinc-200 bg-white px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="truncate text-xs font-medium text-zinc-800 dark:text-zinc-200">{el.name}</div>
|
||||
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||||
<div className="text-[10px] text-zinc-400">
|
||||
{el.ownerRole === "PERSON_A" ? "Person A" : "Person B"}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{columns.map((col) => {
|
||||
if (col.kind === "phase") {
|
||||
const ce = computedElement(col.phase.id, el.id);
|
||||
const isSel =
|
||||
selected?.type === "phaseCell" &&
|
||||
selected.elementId === el.id &&
|
||||
selected.phaseId === col.phase.id;
|
||||
return (
|
||||
<td
|
||||
key={col.phase.id}
|
||||
onClick={() => setSelected({ type: "phaseCell", elementId: el.id, phaseId: col.phase.id })}
|
||||
className={`cursor-pointer border-b border-r border-zinc-200 px-2 py-1.5 text-center text-xs dark:border-zinc-800 ${
|
||||
isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : ""
|
||||
} ${ce?.locked ? "text-zinc-400" : "text-zinc-700 dark:text-zinc-200"}`}
|
||||
>
|
||||
{ce?.summary ?? "–"}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
const canTransition = TRANSITION_CATEGORIES.includes(el.category);
|
||||
const isSel =
|
||||
selected?.type === "transitionCell" &&
|
||||
selected.elementId === el.id &&
|
||||
selected.fromPhaseId === col.fromPhase.id;
|
||||
return (
|
||||
<td
|
||||
key={`t-${col.fromPhase.id}`}
|
||||
onClick={() =>
|
||||
canTransition &&
|
||||
setSelected({ type: "transitionCell", elementId: el.id, fromPhaseId: col.fromPhase.id })
|
||||
}
|
||||
className={`border-b border-r border-zinc-200 px-2 py-1.5 text-center text-[11px] dark:border-zinc-800 ${
|
||||
canTransition ? "cursor-pointer text-indigo-500" : "text-zinc-300 dark:text-zinc-600"
|
||||
} ${isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-indigo-50/30 dark:bg-indigo-500/5"}`}
|
||||
>
|
||||
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : "→"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</FragmentRows>
|
||||
);
|
||||
})}
|
||||
{plan.elements.length === 0 && (
|
||||
<tr>
|
||||
<td className="sticky left-0 bg-white px-3 py-4 text-xs text-zinc-400 dark:bg-zinc-900" colSpan={columns.length + 1}>
|
||||
Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail-Panel */}
|
||||
{selected && (
|
||||
<div className="rounded-xl border border-indigo-200 bg-white p-4 shadow-sm dark:border-indigo-500/30 dark:bg-zinc-900">
|
||||
{renderDetail()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<AddElementDialog
|
||||
household={household}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onCreate={async (payload) => {
|
||||
await api.post(`/api/plans/${plan.id}/elements`, payload);
|
||||
setShowAdd(false);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderDetail() {
|
||||
if (!selected) return null;
|
||||
|
||||
if (selected.type === "phase") {
|
||||
const phase = computed.phases.find((p) => p.id === selected.phaseId);
|
||||
const phaseInput = plan.phases.find((p) => p.id === selected.phaseId);
|
||||
if (!phase || !phaseInput) return null;
|
||||
const isLast = phase.sequenceNumber === computed.phases.length;
|
||||
return (
|
||||
<PhaseDetail
|
||||
phase={phaseInput}
|
||||
maxDurationYears={phase.maxDurationYears}
|
||||
isLast={isLast}
|
||||
household={household}
|
||||
onSaved={onChanged}
|
||||
onDeleted={() => {
|
||||
setSelected(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const element = plan.elements.find((e) => e.id === selected.elementId);
|
||||
if (!element) return null;
|
||||
|
||||
if (selected.type === "phaseCell") {
|
||||
const phase = computed.phases.find((p) => p.id === selected.phaseId)!;
|
||||
const ownerWorking = element.ownerRole && element.ownerRole !== "HOUSEHOLD"
|
||||
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
|
||||
: phase.type !== "PENSION";
|
||||
const ce = computedElement(phase.id, element.id);
|
||||
const context: CellContext = {
|
||||
kind: "phase",
|
||||
phaseId: phase.id,
|
||||
ownerWorking,
|
||||
isConsumption: phase.isConsumption,
|
||||
durationYears: phase.durationYears,
|
||||
isRetirementTransition: false,
|
||||
carriedEndValue: ce?.endValue ?? 0,
|
||||
};
|
||||
return (
|
||||
<ElementDetail
|
||||
element={element}
|
||||
context={context}
|
||||
phaseData={element.phaseValues[phase.id] ?? {}}
|
||||
transitionData={{}}
|
||||
onSaved={onChanged}
|
||||
onDeleteElement={() => deleteElement(element.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// transitionCell
|
||||
const fromPhase = computed.phases.find((p) => p.id === selected.fromPhaseId)!;
|
||||
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
|
||||
const toPhase = computed.phases[toIndex];
|
||||
const ce = computedElement(fromPhase.id, element.id);
|
||||
const context: CellContext = {
|
||||
kind: "transition",
|
||||
phaseId: fromPhase.id,
|
||||
ownerWorking: true,
|
||||
isConsumption: fromPhase.isConsumption,
|
||||
durationYears: fromPhase.durationYears,
|
||||
isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false,
|
||||
carriedEndValue: ce?.endValue ?? 0,
|
||||
};
|
||||
return (
|
||||
<ElementDetail
|
||||
element={element}
|
||||
context={context}
|
||||
phaseData={{}}
|
||||
transitionData={element.transitionValues[fromPhase.id] ?? {}}
|
||||
onSaved={onChanged}
|
||||
onDeleteElement={() => deleteElement(element.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteElement(id: string) {
|
||||
if (!confirm("Dieses Element wirklich loeschen (aus allen Phasen)?")) return;
|
||||
await api.delete(`/api/elements/${id}`);
|
||||
setSelected(null);
|
||||
onChanged();
|
||||
}
|
||||
|
||||
function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string {
|
||||
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||
switch (el.category) {
|
||||
case "REAL_ESTATE":
|
||||
case "OTHER_ASSET":
|
||||
return td.decision === "SELL" ? "Verkauf" : "Halten";
|
||||
case "PENSION_FUND":
|
||||
if (isRetirementTransition(el, fromPhase, toPhase)) {
|
||||
return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : "Rente";
|
||||
}
|
||||
return num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "→";
|
||||
case "PILLAR_3A":
|
||||
if (isRetirementTransition(el, fromPhase, toPhase)) return "Bezug";
|
||||
return num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "→";
|
||||
case "OTHER_DEBT":
|
||||
return num(td.immediateRepayment) > 0 ? "Tilgung" : "→";
|
||||
default:
|
||||
return "→";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function PhaseHeader({ phase, onClick, active }: { phase: PhaseComputed; onClick: () => void; active: boolean }) {
|
||||
const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote";
|
||||
return (
|
||||
<th
|
||||
onClick={onClick}
|
||||
className={`min-w-40 cursor-pointer border-b border-r border-zinc-200 px-2 py-2 text-left align-top dark:border-zinc-800 ${
|
||||
active ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-white dark:bg-zinc-900"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-xs font-semibold text-zinc-800 dark:text-zinc-100">{phase.name}</span>
|
||||
{phase.incomplete ? (
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||
<span className="rounded bg-zinc-100 px-1 text-[10px] text-zinc-500 dark:bg-zinc-800">
|
||||
{phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-400">{phase.durationYears} J.</span>
|
||||
<span className="text-[10px] text-zinc-400">Alter {phase.persons.map((p) => p.startAge).join("/")}</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-0.5 text-[10px] leading-tight text-zinc-500 dark:text-zinc-400">
|
||||
<div>Einkommen {formatChf(phase.incomeTotal)}</div>
|
||||
<div>Ausgaben {formatChf(phase.expenseTotal)}</div>
|
||||
<div className={phase.quotaComplete ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
|
||||
{quotaLabel} {formatChf(Math.abs(phase.quota))}
|
||||
</div>
|
||||
<div className={phase.availableCapitalComplete ? "" : "text-red-600 dark:text-red-400"}>
|
||||
Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)}
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function FragmentRows({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AddElementDialog({
|
||||
household,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
household: HouseholdInput;
|
||||
onClose: () => void;
|
||||
onCreate: (payload: { category: ElementCategory; name: string; ownerRole: string | null }) => void;
|
||||
}) {
|
||||
const [category, setCategory] = useState<ElementCategory>("INCOME");
|
||||
const [name, setName] = useState("");
|
||||
const [ownerRole, setOwnerRole] = useState<string>(household.householdType === "COUPLE" ? "PERSON_A" : "PERSON_A");
|
||||
|
||||
const needsPerson = PERSON_ONLY_CATEGORIES.includes(category);
|
||||
const isCouple = household.householdType === "COUPLE";
|
||||
|
||||
const ownerOptions = needsPerson
|
||||
? isCouple
|
||||
? [
|
||||
{ value: "PERSON_A", label: "Person A" },
|
||||
{ value: "PERSON_B", label: "Person B" },
|
||||
]
|
||||
: [{ value: "PERSON_A", label: "Person A" }]
|
||||
: isCouple
|
||||
? [
|
||||
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||||
{ value: "PERSON_A", label: "Person A" },
|
||||
{ value: "PERSON_B", label: "Person B" },
|
||||
]
|
||||
: [
|
||||
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||||
{ value: "PERSON_A", label: "Person A" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-md flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
|
||||
>
|
||||
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">Finanzielles Element</h2>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => {
|
||||
const c = e.target.value as ElementCategory;
|
||||
setCategory(c);
|
||||
if (PERSON_ONLY_CATEGORIES.includes(c) && ownerRole === "HOUSEHOLD") setOwnerRole("PERSON_A");
|
||||
if (!name) setName(CATEGORY_LABELS[c]);
|
||||
}}
|
||||
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
||||
>
|
||||
{CATEGORY_ORDER.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{CATEGORY_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Bezeichnung</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={CATEGORY_LABELS[category]}
|
||||
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Zuordnung</label>
|
||||
<select
|
||||
value={ownerRole}
|
||||
onChange={(e) => setOwnerRole(e.target.value)}
|
||||
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
||||
>
|
||||
{ownerOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCreate({ category, name: name.trim() || CATEGORY_LABELS[category], ownerRole })}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
Erstellen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Flag } from "lucide-react";
|
||||
import type { PhaseComputed } from "@/lib/calculations";
|
||||
|
||||
interface PersonAxis {
|
||||
role: "PERSON_A" | "PERSON_B";
|
||||
label: string;
|
||||
currentAge: number;
|
||||
retirementAge: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem
|
||||
// Pensionsalter je Person und Trennlinien an den Phasengrenzen.
|
||||
export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons: PersonAxis[] }) {
|
||||
if (phases.length === 0 || persons.length === 0) return null;
|
||||
|
||||
const totalYears = phases.reduce((s, p) => s + p.durationYears, 0);
|
||||
const minAge = Math.min(...persons.map((p) => p.currentAge));
|
||||
const maxAge = minAge + totalYears;
|
||||
const span = Math.max(1, maxAge - minAge);
|
||||
|
||||
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
|
||||
|
||||
// Phasengrenzen (kumulierte Jahre).
|
||||
const boundaries: { year: number; label: string }[] = [];
|
||||
let acc = 0;
|
||||
for (const p of phases) {
|
||||
boundaries.push({ year: acc, label: p.name });
|
||||
acc += p.durationYears;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">Zeitachse</h3>
|
||||
<div className="flex gap-3 text-xs text-zinc-500">
|
||||
{persons.map((p) => (
|
||||
<span key={p.role} className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
|
||||
{p.label} (heute {p.currentAge})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative pt-6">
|
||||
{/* Pensionsmarker je Person */}
|
||||
{persons.map((p, i) =>
|
||||
p.retirementAge > minAge && p.retirementAge < maxAge ? (
|
||||
<div
|
||||
key={p.role}
|
||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
||||
style={{ left: pct(p.retirementAge) }}
|
||||
title={`${p.label}: Pensionierung mit ${p.retirementAge}`}
|
||||
>
|
||||
<Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} />
|
||||
<span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}>
|
||||
{p.retirementAge}
|
||||
</span>
|
||||
<div className="mt-0.5 h-3 w-px" style={{ backgroundColor: p.color, marginTop: i * 2 }} />
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{/* Achse */}
|
||||
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-indigo-200 to-indigo-400 dark:from-indigo-500/30 dark:to-indigo-500/60">
|
||||
{boundaries.slice(1).map((b) => (
|
||||
<div
|
||||
key={b.year}
|
||||
className="absolute top-0 h-2 w-px bg-white/70 dark:bg-zinc-900/70"
|
||||
style={{ left: pct(minAge + b.year) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Alters-Beschriftung */}
|
||||
<div className="mt-1 flex justify-between text-[11px] text-zinc-500">
|
||||
<span>{minAge} J.</span>
|
||||
<span>{maxAge} J.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowDown, CheckCircle2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { MoneyInput } from "@/components/FormField";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { floorToThousand, formatChf } from "@/lib/format";
|
||||
import type { PhaseInput, TransitionDecision } from "@/lib/types";
|
||||
import type { PhaseComputed } from "@/lib/calculations";
|
||||
|
||||
interface ItemDraft {
|
||||
positionType: "SECURITY" | "REAL_ESTATE";
|
||||
id: string;
|
||||
name: string;
|
||||
decision: TransitionDecision;
|
||||
salePrice: number | null;
|
||||
// Nur fuer Immobilien editierbar (poppt bei "Verkaufen" auf); bei Wertschriften der
|
||||
// fixe, am Wertpapier hinterlegte Steuersatz.
|
||||
saleTaxRate: number;
|
||||
// Referenzwerte fuer die Anzeige/Berechnung des verfuegbaren Startkapitals
|
||||
carryOverValue: number; // Wert bei "Halten": Endwert (Wertschrift) bzw. Nettowert (Immobilie)
|
||||
originalValue: number; // Wertschrift: Startwert: Immobilie: Kaufpreis
|
||||
remainingMortgage: number; // nur Immobilien: Resthypothek am Ende der Phase
|
||||
}
|
||||
|
||||
export function TransitionPanel({
|
||||
phase,
|
||||
computed,
|
||||
nextPhaseName,
|
||||
onChanged,
|
||||
}: {
|
||||
phase: PhaseInput;
|
||||
computed: PhaseComputed;
|
||||
nextPhaseName: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<ItemDraft[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
const initial: ItemDraft[] = [
|
||||
...phase.securities.map((s) => {
|
||||
const c = computed.securities.find((cs) => cs.id === s.id);
|
||||
return {
|
||||
positionType: "SECURITY" as const,
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
decision: "CARRY_OVER" as TransitionDecision,
|
||||
salePrice: null,
|
||||
saleTaxRate: s.saleTaxRate,
|
||||
carryOverValue: c?.endValue ?? 0,
|
||||
originalValue: c?.startValue ?? 0,
|
||||
remainingMortgage: 0,
|
||||
};
|
||||
}),
|
||||
...phase.realEstates.map((re) => {
|
||||
const c = computed.realEstates.find((cr) => cr.id === re.id);
|
||||
const remainingMortgage = c ? c.mortgages[phase.durationYears] : 0;
|
||||
return {
|
||||
positionType: "REAL_ESTATE" as const,
|
||||
id: re.id,
|
||||
name: re.name,
|
||||
decision: "CARRY_OVER" as TransitionDecision,
|
||||
salePrice: re.purchasePrice,
|
||||
saleTaxRate: 20,
|
||||
carryOverValue: c?.endNet ?? 0,
|
||||
originalValue: re.purchasePrice,
|
||||
remainingMortgage,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
try {
|
||||
const data = await api.get<{
|
||||
transition: {
|
||||
items: {
|
||||
positionType: string;
|
||||
securityId: string | null;
|
||||
realEstateId: string | null;
|
||||
decision: TransitionDecision;
|
||||
salePrice: number | null;
|
||||
saleTaxRate: number | null;
|
||||
}[];
|
||||
} | null;
|
||||
}>(`/api/phases/${phase.id}/transition`);
|
||||
if (cancelled) return;
|
||||
if (data.transition) {
|
||||
for (const savedItem of data.transition.items) {
|
||||
const target = initial.find(
|
||||
(it) => it.id === (savedItem.securityId ?? savedItem.realEstateId)
|
||||
);
|
||||
if (target) {
|
||||
target.decision = savedItem.decision;
|
||||
if (savedItem.salePrice != null) target.salePrice = savedItem.salePrice;
|
||||
if (savedItem.saleTaxRate != null) target.saleTaxRate = savedItem.saleTaxRate;
|
||||
}
|
||||
}
|
||||
}
|
||||
setItems(initial);
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
setItems(initial);
|
||||
setLoaded(true);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase.id]);
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="mx-2 rounded-xl bg-zinc-100 px-4 py-3 text-xs text-zinc-500 dark:bg-zinc-800">
|
||||
Uebergang wird geladen…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalAvailableCapital = floorToThousand(
|
||||
items.reduce((sum, it) => {
|
||||
if (it.decision === "CARRY_OVER") return sum;
|
||||
if (it.positionType === "SECURITY") {
|
||||
const gain = Math.max(0, it.carryOverValue - it.originalValue);
|
||||
const tax = gain * (it.saleTaxRate / 100);
|
||||
return sum + (it.carryOverValue - tax);
|
||||
}
|
||||
const salePrice = it.salePrice ?? 0;
|
||||
const gain = Math.max(0, salePrice - it.originalValue);
|
||||
const tax = gain * (it.saleTaxRate / 100);
|
||||
return sum + (salePrice - it.remainingMortgage - tax);
|
||||
}, 0)
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
await api.put(`/api/phases/${phase.id}/transition`, {
|
||||
items: items.map((it) => ({
|
||||
positionType: it.positionType,
|
||||
securityId: it.positionType === "SECURITY" ? it.id : null,
|
||||
realEstateId: it.positionType === "REAL_ESTATE" ? it.id : null,
|
||||
decision: it.decision,
|
||||
salePrice: it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? it.salePrice : null,
|
||||
saleTaxRate: it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? it.saleTaxRate : null,
|
||||
})),
|
||||
});
|
||||
setSaved(true);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-2 flex flex-col rounded-xl border border-dashed border-indigo-200 bg-indigo-50/40 dark:border-indigo-500/30 dark:bg-indigo-500/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full flex-wrap items-center gap-1.5 px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400"
|
||||
>
|
||||
<span>
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
<span className="flex-1">Uebergang → {nextPhaseName}</span>
|
||||
<span className="normal-case tracking-normal text-zinc-500 dark:text-zinc-400">
|
||||
Startkapital aus Verkaeufen: <strong className="text-indigo-600 dark:text-indigo-400">{formatChf(totalAvailableCapital)} CHF</strong>
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-zinc-500">
|
||||
<th className="pb-1 font-normal">Position</th>
|
||||
<th className="pb-1 font-normal">Entscheidung</th>
|
||||
<th className="pb-1 font-normal">Verkaufspreis</th>
|
||||
<th className="pb-1 font-normal">Grundstueckgewinnsteuer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it, i) => (
|
||||
<tr key={`${it.positionType}-${it.id}`} className="border-t border-zinc-200 dark:border-zinc-800">
|
||||
<td className="py-2 pr-2">{it.name}</td>
|
||||
<td className="py-2 pr-2">
|
||||
<select
|
||||
className="rounded-lg border border-zinc-300 bg-white px-2 py-1 text-xs focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-900"
|
||||
value={it.decision}
|
||||
onChange={(e) =>
|
||||
setItems((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, decision: e.target.value as TransitionDecision } : x))
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="CARRY_OVER">{it.positionType === "REAL_ESTATE" ? "Halten" : "Uebernehmen"}</option>
|
||||
<option value="SELL">Verkaufen</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
{it.decision === "SELL" ? (
|
||||
it.positionType === "REAL_ESTATE" ? (
|
||||
<MoneyInput
|
||||
className="w-32"
|
||||
value={it.salePrice ?? 0}
|
||||
onChange={(v) =>
|
||||
setItems((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v } : x)))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">{formatChf(it.carryOverValue)} CHF</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">{formatChf(it.carryOverValue)} CHF</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? (
|
||||
<input
|
||||
type="number"
|
||||
className="w-20 rounded-lg border border-zinc-300 bg-white px-2 py-1 text-xs focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-900"
|
||||
value={it.saleTaxRate}
|
||||
onChange={(e) =>
|
||||
setItems((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, saleTaxRate: e.target.valueAsNumber || 0 } : x))
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="rounded-xl bg-white px-3 py-2 text-sm shadow-sm dark:bg-zinc-900">
|
||||
Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{formatChf(totalAvailableCapital)} CHF</strong>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Wird beim Speichern automatisch in "{nextPhaseName}" als verfuegbares Startkapital hinterlegt.
|
||||
Gehaltene/uebernommene Positionen erscheinen dort automatisch mit ihrem Endwert (Wertschriften) bzw.
|
||||
Kaufpreis/Resthypothek (Immobilien) als neue Ausgangswerte.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className="self-start rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
{saving ? "Speichern..." : "Uebergang speichern"}
|
||||
</button>
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" /> Gespeichert.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
@@ -20,59 +19,41 @@ export interface TimelineSeries {
|
||||
computed: PlanComputed;
|
||||
}
|
||||
|
||||
function buildTimeline(computed: PlanComputed) {
|
||||
const points: { year: number; nominal: number; real: number }[] = [
|
||||
{ year: 0, nominal: computed.phases[0]?.startWealthNominal ?? 0, real: computed.phases[0]?.startWealthNominal ?? 0 },
|
||||
];
|
||||
const boundaries: { year: number; name: string }[] = [];
|
||||
let year = 0;
|
||||
for (const phase of computed.phases) {
|
||||
boundaries.push({ year, name: phase.name });
|
||||
for (let y = 0; y < phase.durationYears; y++) {
|
||||
year += 1;
|
||||
points.push({ year, nominal: phase.yearlyNominal[y], real: phase.yearlyReal[y] });
|
||||
}
|
||||
}
|
||||
return { points, boundaries };
|
||||
}
|
||||
|
||||
// Liniendiagramm ueber alle Phasen, nominal + real, mit Markierungen an den
|
||||
// Phasengrenzen (TDD Kapitel 4.5 / 14). Unterstuetzt optional mehrere ueberlagerte
|
||||
// Plaene fuer den Szenario-Vergleich.
|
||||
// Liniendiagramm: Endvermoegen (nominal + real) je Lebensphase. Unterstuetzt mehrere
|
||||
// ueberlagerte Plaene fuer den Szenario-Vergleich.
|
||||
export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
||||
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
||||
return <p className="text-sm text-zinc-500">Noch keine Phasen vorhanden.</p>;
|
||||
}
|
||||
|
||||
const primary = buildTimeline(series[0].computed);
|
||||
const maxYear = Math.max(...series.map((s) => buildTimeline(s.computed).points.length - 1));
|
||||
|
||||
const merged: Record<number, Record<string, number>> = {};
|
||||
for (const s of series) {
|
||||
const tl = buildTimeline(s.computed);
|
||||
for (const p of tl.points) {
|
||||
merged[p.year] = merged[p.year] ?? { year: p.year };
|
||||
merged[p.year][`${s.label} (nominal)`] = p.nominal;
|
||||
merged[p.year][`${s.label} (real)`] = p.real;
|
||||
// Datenpunkte je Phasen-Index; X-Achse = Phasenname des Hauptplans.
|
||||
const maxLen = Math.max(...series.map((s) => s.computed.phases.length));
|
||||
const data = Array.from({ length: maxLen }, (_, i) => {
|
||||
const row: Record<string, number | string> = {
|
||||
phase: series[0].computed.phases[i]?.name ?? `Phase ${i + 1}`,
|
||||
};
|
||||
for (const s of series) {
|
||||
const p = s.computed.phases[i];
|
||||
if (p) {
|
||||
row[`${s.label} (nominal)`] = Math.round(p.endWealthNominal);
|
||||
row[`${s.label} (real)`] = Math.round(p.endWealthReal);
|
||||
}
|
||||
}
|
||||
}
|
||||
const data = Array.from({ length: maxYear + 1 }, (_, y) => merged[y] ?? { year: y });
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-80 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-zinc-200 dark:stroke-zinc-700" />
|
||||
<XAxis dataKey="year" tick={{ fontSize: 11 }} label={{ value: "Jahr", position: "insideBottomRight", offset: -4, fontSize: 11 }} />
|
||||
<XAxis dataKey="phase" tick={{ fontSize: 11 }} />
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||
/>
|
||||
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{primary.boundaries.slice(1).map((b) => (
|
||||
<ReferenceLine key={b.year} x={b.year} stroke="#a1a1aa" strokeDasharray="2 2" />
|
||||
))}
|
||||
{series.map((s) => (
|
||||
<Line
|
||||
key={`${s.label}-nominal`}
|
||||
|
||||
Reference in New Issue
Block a user