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

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