Initial commit: FPT Financial Planning Tool
Deploy App / deploy (push) Successful in 3m3s

This commit is contained in:
2026-07-08 19:19:39 +02:00
commit 4f990c9686
60 changed files with 12436 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { PhaseCard } from "@/components/PhaseCard";
import { TransitionPanel } from "@/components/TransitionPanel";
import { Dashboard } from "@/components/Dashboard";
import { HouseholdSettings } from "@/components/HouseholdSettings";
import { api } from "@/lib/api-client";
import type { HouseholdInput, PlanInput } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
interface PlanListItem {
id: string;
name: string;
parentPlanId: string | null;
branchFromPhaseId: string | null;
phases: { id: string; name: string; sequenceNumber: number }[];
}
export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInput }) {
const [household, setHousehold] = useState(initialHousehold);
const [showSettings, setShowSettings] = useState(false);
const [plans, setPlans] = useState<PlanListItem[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null);
const [loading, setLoading] = useState(true);
const [showNewPlan, setShowNewPlan] = useState(false);
const [showScenario, setShowScenario] = useState(false);
const loadPlans = useCallback(async (preferId?: string) => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
setPlans(data.plans);
if (preferId) {
setSelectedPlanId(preferId);
} else if (!selectedPlanId && data.plans.length > 0) {
setSelectedPlanId(data.plans[0].id);
}
return data.plans;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadDetail = useCallback(async (planId: string) => {
setLoading(true);
try {
const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/plans/${planId}`);
setDetail(data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf beim Mount, kein synchrones setState
loadPlans();
}, [loadPlans]);
useEffect(() => {
if (selectedPlanId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf, kein synchrones setState
loadDetail(selectedPlanId);
} else {
setDetail(null);
setLoading(false);
}
}, [selectedPlanId, loadDetail]);
function refreshCurrent() {
if (selectedPlanId) loadDetail(selectedPlanId);
}
async function handleAddPhase() {
if (!selectedPlanId) return;
const lastPhase = detail?.plan.phases[detail.plan.phases.length - 1];
await api.post(`/api/plans/${selectedPlanId}/phases`, {
name: lastPhase ? `Neue Phase ${detail!.plan.phases.length + 1}` : "Erste Lebensphase",
durationYears: 10,
incomeMode: "HOUSEHOLD",
});
refreshCurrent();
}
async function handleDeletePlan(id: string) {
if (!confirm("Diesen Plan wirklich loeschen?")) return;
await api.delete(`/api/plans/${id}`);
const remaining = await loadPlans();
if (selectedPlanId === id) {
setSelectedPlanId(remaining[0]?.id ?? null);
}
}
return (
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-8">
<header className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">
Financial Planning Tool
</h1>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowSettings((v) => !v)}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Grundprofil
</button>
<button
type="button"
onClick={async () => {
await api.post("/api/auth/logout");
window.location.href = "/login";
}}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Abmelden
</button>
</div>
</header>
{showSettings && (
<HouseholdSettings
household={household}
onUpdated={setHousehold}
onClose={() => setShowSettings(false)}
/>
)}
{/* Tab-Leiste */}
<div className="flex flex-wrap items-center gap-2 border-b border-zinc-200 pb-2 dark:border-zinc-700">
{plans.map((p) => (
<div key={p.id} className="flex items-center">
<button
type="button"
onClick={() => setSelectedPlanId(p.id)}
className={`rounded-t-md px-3 py-1.5 text-sm font-medium ${
selectedPlanId === p.id
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
}`}
>
{p.name}
</button>
{selectedPlanId === p.id && (
<button
type="button"
onClick={() => handleDeletePlan(p.id)}
className="ml-1 text-xs text-zinc-400 hover:text-red-600"
aria-label="Plan loeschen"
>
</button>
)}
</div>
))}
<div className="relative">
<button
type="button"
onClick={() => setShowNewPlan((v) => !v)}
className="rounded-md px-2 py-1.5 text-sm text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
+ Plan
</button>
{showNewPlan && (
<NewPlanPopover
onCreate={async (name) => {
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name });
setShowNewPlan(false);
await loadPlans(plan.id);
}}
onClose={() => setShowNewPlan(false)}
/>
)}
</div>
{detail && detail.plan.phases.length > 0 && (
<div className="relative">
<button
type="button"
onClick={() => setShowScenario((v) => !v)}
className="rounded-md px-2 py-1.5 text-sm text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
+ Szenario
</button>
{showScenario && (
<ScenarioPopover
phases={detail.plan.phases}
onCreate={async (name, branchFromPhaseId) => {
const { planId } = await api.post<{ planId: string }>(
`/api/plans/${selectedPlanId}/scenario`,
{ name, branchFromPhaseId }
);
setShowScenario(false);
await loadPlans(planId);
}}
onClose={() => setShowScenario(false)}
/>
)}
</div>
)}
</div>
{loading && <p className="text-sm text-zinc-500">Laedt</p>}
{!loading && plans.length === 0 && (
<p className="text-sm text-zinc-500">
Noch kein Plan vorhanden. Erstellen Sie oben Ihren ersten Plan.
</p>
)}
{!loading && detail && (
<>
<div className="flex flex-col gap-3">
{detail.plan.phases.map((phase, i) => {
const computedPhase = detail.computed.phases.find((c) => c.id === phase.id)!;
const nextPhase = detail.plan.phases[i + 1];
return (
<div key={phase.id} className="flex flex-col gap-3">
<PhaseCard
household={household}
phase={phase}
computed={computedPhase}
isLast={i === detail.plan.phases.length - 1}
onChanged={refreshCurrent}
/>
{nextPhase && (
<TransitionPanel phase={phase} computed={computedPhase} nextPhaseName={nextPhase.name} />
)}
</div>
);
})}
</div>
<button
type="button"
onClick={handleAddPhase}
className="self-start rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
+ Phase hinzufuegen
</button>
{detail.plan.phases.length > 0 && (
<Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />
)}
</>
)}
</div>
);
}
function NewPlanPopover({ onCreate, onClose }: { onCreate: (name: string) => void; onClose: () => void }) {
const [name, setName] = useState("Basisplan");
return (
<div className="absolute left-0 top-10 z-10 w-64 rounded-md border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<input
className="mb-2 w-full rounded-md border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Plans"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate(name)}
className="rounded-md bg-zinc-900 px-2 py-1 text-xs text-white dark:bg-zinc-100 dark:text-zinc-900"
>
Erstellen
</button>
<button type="button" onClick={onClose} className="text-xs text-zinc-500">
Abbrechen
</button>
</div>
</div>
);
}
function ScenarioPopover({
phases,
onCreate,
onClose,
}: {
phases: { id: string; name: string }[];
onCreate: (name: string, branchFromPhaseId: string) => void;
onClose: () => void;
}) {
const [name, setName] = useState("Neues Szenario");
const [branchFromPhaseId, setBranchFromPhaseId] = useState(phases[phases.length - 1]?.id ?? "");
return (
<div className="absolute left-0 top-10 z-10 w-72 rounded-md border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<input
className="mb-2 w-full rounded-md border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios"
/>
<label className="mb-1 block text-xs text-zinc-500">Verzweigen ab Phase</label>
<select
className="mb-2 w-full rounded-md border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800"
value={branchFromPhaseId}
onChange={(e) => setBranchFromPhaseId(e.target.value)}
>
{phases.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate(name, branchFromPhaseId)}
className="rounded-md bg-zinc-900 px-2 py-1 text-xs text-white dark:bg-zinc-100 dark:text-zinc-900"
>
Erstellen
</button>
<button type="button" onClick={onClose} className="text-xs text-zinc-500">
Abbrechen
</button>
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { api } from "@/lib/api-client";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
const PALETTE = ["#3f3f46", "#2563eb", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
interface PlanListItem {
id: string;
name: string;
}
export function Dashboard({
plan,
computed,
allPlans,
}: {
plan: PlanInput;
computed: PlanComputed;
allPlans: PlanListItem[];
}) {
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
async function toggleCompare(id: string) {
if (compareIds.includes(id)) {
setCompareIds((prev) => prev.filter((p) => p !== id));
return;
}
setCompareIds((prev) => [...prev, id]);
if (!compareData[id]) {
const data = await api.get<{ computed: PlanComputed }>(`/api/plans/${id}`);
setCompareData((prev) => ({ ...prev, [id]: data.computed }));
}
}
const series: TimelineSeries[] = useMemo(() => {
const result: TimelineSeries[] = [{ label: plan.name, color: PALETTE[0], computed }];
compareIds.forEach((id, i) => {
const c = compareData[id];
const name = allPlans.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: PALETTE[(i + 1) % PALETTE.length], computed: c });
});
return result;
}, [plan.name, computed, compareIds, compareData, allPlans]);
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);
}
return Array.from(keys);
}, [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.endContribution;
return row;
}),
[computed]
);
const otherPlans = allPlans.filter((p) => p.id !== plan.id);
const lastPhase = computed.phases[computed.phases.length - 1];
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="Endvermoegen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
<StatCard label="Endvermoegen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
<StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." />
</div>
<section className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">Vermoegensverlauf</h3>
<a
href={`/api/plans/${plan.id}/export`}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
CSV-Export
</a>
</div>
{otherPlans.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
<span className="text-xs text-zinc-500">Vergleichen mit:</span>
{otherPlans.map((p) => (
<label key={p.id} className="flex items-center gap-1 text-xs text-zinc-600 dark:text-zinc-300">
<input
type="checkbox"
checked={compareIds.includes(p.id)}
onChange={() => toggleCompare(p.id)}
/>
{p.name}
</label>
))}
</div>
)}
<WealthChart series={series} />
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<h3 className="mb-3 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
Vermoegensaufteilung pro Phase (Endvermoegen)
</h3>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-zinc-200 dark:stroke-zinc-700" />
<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" ? v.toLocaleString("de-CH", { maximumFractionDigits: 0 }) : v
}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
{barKeys.map((key, i) => (
<Bar key={key} dataKey={key} stackId="a" fill={PALETTE[i % PALETTE.length]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
</section>
</div>
);
}
function StatCard({ label, value, help }: { label: string; value: number; help?: string }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="text-xs text-zinc-500 dark:text-zinc-400" title={help}>
{label}
</div>
<div className="mt-1 text-xl font-semibold text-zinc-900 dark:text-zinc-50">
{value.toLocaleString("de-CH", { maximumFractionDigits: 0 })} CHF
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { InfoBubble } from "@/components/InfoBubble";
const baseInputClass =
"w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100";
export function FieldLabel({ label, help }: { label: string; help?: string }) {
return (
<label className="mb-1 flex items-center text-xs font-medium text-zinc-600 dark:text-zinc-400">
{label}
{help && <InfoBubble text={help} />}
</label>
);
}
export function NumberField({
label,
help,
value,
onChange,
step,
min,
max,
}: {
label: string;
help?: string;
value: number;
onChange: (value: number) => void;
step?: number;
min?: number;
max?: number;
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<input
type="number"
className={baseInputClass}
value={Number.isFinite(value) ? value : 0}
step={step ?? "any"}
min={min}
max={max}
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
/>
</div>
);
}
export function TextField({
label,
help,
value,
onChange,
placeholder,
}: {
label: string;
help?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<input
type="text"
className={baseInputClass}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
export function SelectField<T extends string>({
label,
help,
value,
onChange,
options,
}: {
label: string;
help?: string;
value: T;
onChange: (value: T) => void;
options: { value: T; label: string }[];
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<select
className={baseInputClass}
value={value}
onChange={(e) => onChange(e.target.value as T)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { NumberField, SelectField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import type { HouseholdInput, HouseholdType } from "@/lib/types";
export function HouseholdSettings({
household,
onUpdated,
onClose,
}: {
household: HouseholdInput;
onUpdated: (household: HouseholdInput) => void;
onClose: () => void;
}) {
const [householdType, setHouseholdType] = useState<HouseholdType>(household.householdType);
const [inflationRateDefault, setInflationRateDefault] = useState(household.inflationRateDefault);
const [persons, setPersons] = useState(
household.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge }))
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleTypeChange(type: HouseholdType) {
setHouseholdType(type);
if (type === "SINGLE") {
setPersons((p) => p.slice(0, 1));
} else if (persons.length < 2) {
setPersons((p) => [...p, { role: "PERSON_B" as const, age: 35, retirementAge: 65 }]);
}
}
async function handleSubmit() {
setSaving(true);
setError(null);
try {
const { household: updated } = await api.patch<{ household: HouseholdInput }>("/api/household", {
householdType,
inflationRateDefault,
persons,
});
onUpdated(updated);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-4 rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<SelectField
label="Haushaltsform"
value={householdType}
onChange={handleTypeChange}
options={[
{ value: "SINGLE", label: "Einzelperson" },
{ value: "COUPLE", label: "Paar (zwei Personen)" },
]}
/>
{persons.map((person, index) => (
<div key={person.role} className="grid grid-cols-2 gap-3">
<NumberField
label={`Alter (${person.role === "PERSON_A" ? "Person A" : "Person B"})`}
value={person.age}
onChange={(v) => setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, age: v } : p)))}
/>
<NumberField
label="Geplantes Pensionierungsalter"
value={person.retirementAge}
onChange={(v) =>
setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, retirementAge: v } : p)))
}
/>
</div>
))}
<NumberField
label="Erwartete Inflationsrate (%)"
value={inflationRateDefault}
step={0.1}
onChange={setInflationRateDefault}
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex gap-2">
<button
type="button"
disabled={saving}
onClick={handleSubmit}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
>
{saving ? "Speichern..." : "Speichern"}
</button>
<button
type="button"
onClick={onClose}
className="rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200"
>
Abbrechen
</button>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useState } from "react";
export function InfoBubble({ text }: { text: string }) {
const [open, setOpen] = useState(false);
return (
<span className="relative inline-flex align-middle ml-1">
<button
type="button"
aria-label="Hilfe anzeigen"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onClick={() => setOpen((o) => !o)}
className="flex h-4 w-4 items-center justify-center rounded-full bg-zinc-200 text-[10px] font-semibold text-zinc-600 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
i
</button>
{open && (
<span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-md border border-zinc-200 bg-white p-2 text-xs leading-snug text-zinc-700 shadow-lg dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">
{text}
</span>
)}
</span>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api-client";
import { NumberField, SelectField } from "@/components/FormField";
import type { HouseholdInput, HouseholdType, PersonRole } from "@/lib/types";
interface PersonDraft {
role: PersonRole;
age: number;
retirementAge: number;
}
export function Onboarding({ onDone }: { onDone: (household: HouseholdInput) => void }) {
const [householdType, setHouseholdType] = useState<HouseholdType>("SINGLE");
const [inflationRateDefault, setInflationRateDefault] = useState(1.5);
const [persons, setPersons] = useState<PersonDraft[]>([
{ role: "PERSON_A", age: 35, retirementAge: 65 },
]);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
function handleTypeChange(type: HouseholdType) {
setHouseholdType(type);
if (type === "SINGLE") {
setPersons((p) => p.slice(0, 1));
} else if (persons.length < 2) {
setPersons((p) => [...p, { role: "PERSON_B", age: 35, retirementAge: 65 }]);
}
}
function updatePerson(index: number, patch: Partial<PersonDraft>) {
setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, ...patch } : p)));
}
async function handleSubmit() {
setSaving(true);
setError(null);
try {
const { household } = await api.post<{ household: HouseholdInput }>("/api/household", {
householdType,
inflationRateDefault,
persons,
});
onDone(household);
} catch (e) {
setError(e instanceof Error ? e.message : "Unbekannter Fehler.");
} finally {
setSaving(false);
}
}
return (
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col justify-center px-6 py-16">
<h1 className="mb-2 text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
Willkommen beim Financial Planning Tool
</h1>
<p className="mb-8 text-sm text-zinc-600 dark:text-zinc-400">
Bevor es losgeht, brauchen wir ein paar Eckdaten zu Ihrem Haushalt.
</p>
<div className="flex flex-col gap-5 rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900">
<SelectField
label="Haushaltsform"
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen."
value={householdType}
onChange={handleTypeChange}
options={[
{ value: "SINGLE", label: "Einzelperson" },
{ value: "COUPLE", label: "Paar (zwei Personen)" },
]}
/>
{persons.map((person, index) => (
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-md bg-zinc-50 p-3 dark:bg-zinc-800/50">
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
{householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"}
</div>
<NumberField
label="Aktuelles Alter"
help="Ihr heutiges Alter in vollen Jahren."
value={person.age}
onChange={(v) => updatePerson(index, { age: v })}
/>
<NumberField
label="Geplantes Pensionierungsalter"
help="Das Alter, in dem Sie voraussichtlich in Rente gehen moechten. Dient nur der groben Orientierung bei der Phasenplanung."
value={person.retirementAge}
onChange={(v) => updatePerson(index, { retirementAge: v })}
/>
</div>
))}
<NumberField
label="Erwartete Inflationsrate (%)"
help="Langfristige Annahme zur jaehrlichen Teuerung. Kann pro Lebensphase individuell ueberschrieben werden."
value={inflationRateDefault}
step={0.1}
onChange={setInflationRateDefault}
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<button
type="button"
disabled={saving}
onClick={handleSubmit}
className="mt-2 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
>
{saving ? "Speichern..." : "Weiter"}
</button>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { LineChart, Line, ResponsiveContainer } from "recharts";
import { PhaseForm } from "@/components/PhaseForm";
import { api } from "@/lib/api-client";
import type { HouseholdInput, PhaseInput } from "@/lib/types";
import type { PhaseComputed } from "@/lib/calculations";
function formatChf(value: number) {
return value.toLocaleString("de-CH", { maximumFractionDigits: 0 });
}
export function PhaseCard({
household,
phase,
computed,
isLast,
onChanged,
}: {
household: HouseholdInput;
phase: PhaseInput;
computed: PhaseComputed;
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-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-4 px-4 py-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
>
<span className="text-zinc-400">{expanded ? "▾" : "▸"}</span>
<div className="flex-1">
<div className="flex items-baseline gap-2">
<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>
{computed.savingsWarning && (
<span className="text-xs text-amber-600 dark:text-amber-400"> 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="h-8 w-24">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={sparklineData}>
<Line type="monotone" dataKey="value" stroke="#3f3f46" strokeWidth={1.5} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
{isLast && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded-md border border-zinc-300 px-2 py-1 text-xs text-zinc-500 hover:bg-red-50 hover:text-red-600 dark:border-zinc-600 dark:hover:bg-red-950"
>
{deleting ? "…" : "Loeschen"}
</span>
)}
</button>
{expanded && (
<PhaseForm
household={household}
phase={phase}
onSaved={() => {
onChanged();
}}
onCancel={() => setExpanded(false)}
/>
)}
</div>
);
}
+475
View File
@@ -0,0 +1,475 @@
"use client";
import { useState } from "react";
import { NumberField, SelectField, TextField } from "@/components/FormField";
import { api } from "@/lib/api-client";
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;
onSaved: () => void;
onCancel: () => void;
}
export function PhaseForm({ household, phase, 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;
const allocated = securities.reduce((s, sec) => s + sec.annualContribution, 0);
const overAllocated = allocated > savingsQuota;
async function handleSave() {
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-6 border-t border-zinc-200 p-4 dark:border-zinc-700">
{/* Basis */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="col-span-2 sm:col-span-2">
<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 dieser Phase (%)"
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>
{/* Einkommen */}
<Section title="Einkommen">
{incomeEntries.map((entry, i) => (
<div key={entry.id} className="grid grid-cols-[1fr_1fr_auto] items-end gap-2">
{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)))
}
/>
)}
<NumberField
label="Geschaetztes 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)))
}
/>
<RemoveButton onClick={() => setIncomeEntries((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
))}
<AddButton
label="+ Einkommensposten"
onClick={() =>
setIncomeEntries((prev) => [
...prev,
{ id: tempId(), personId: incomeMode === "PER_PERSON" ? household.persons[0]?.id ?? null : null, label: null, amount: 0 },
])
}
/>
</Section>
{/* Ausgaben */}
<Section title="Ausgaben">
{expenseEntries.map((entry, i) => (
<div key={entry.id} className="grid grid-cols-[1fr_auto] items-end gap-2">
<NumberField
label="Geschaetzte Gesamtausgaben (CHF/Jahr)"
help="Saemtliche laufenden Kosten des Haushalts pro Jahr - inkl. Lebenshaltung, Hypothekarzinsen, laufende Einkommens- und Vermoegenssteuern. Keine separate Kategorisierung noetig."
value={entry.amount}
onChange={(v) =>
setExpenseEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
}
/>
<RemoveButton onClick={() => setExpenseEntries((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
))}
<AddButton
label="+ Ausgabenposten"
onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])}
/>
<div
className={`rounded-md px-3 py-2 text-sm ${
overAllocated
? "bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-300"
: "bg-zinc-50 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
}`}
>
Verfuegbare Sparquote (CHF/Jahr): <strong>{savingsQuota.toLocaleString("de-CH")}</strong>
{" "} zugewiesen an Wertschriften: {allocated.toLocaleString("de-CH")}
{overAllocated && " ⚠ Die zugewiesenen Sparbeitraege uebersteigen die verfuegbare Sparquote."}
</div>
</Section>
{/* Wertschriften */}
<Section title="Wertschriften">
{securities.map((s, i) => (
<div key={s.id} className="grid grid-cols-2 gap-2 rounded-md bg-zinc-50 p-3 sm:grid-cols-5 dark:bg-zinc-800/50">
<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)))}
/>
<NumberField
label="Startwert (CHF)"
help="Wert dieser Position zu Beginn der Phase."
value={s.startValue}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, startValue: v } : x)))}
/>
<NumberField
label="Erwartete 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)))}
/>
<NumberField
label="Jaehrlicher Sparbeitrag (CHF)"
help="Der Betrag aus Ihrer verfuegbaren Sparquote, den Sie jaehrlich in diese Position investieren moechten."
value={s.annualContribution}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, annualContribution: v } : x)))}
/>
<div className="flex items-end gap-2">
<div className="flex-1">
<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" },
]}
/>
</div>
<RemoveButton onClick={() => setSecurities((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
</div>
))}
<AddButton
label="+ Wertschrift"
onClick={() =>
setSecurities((prev) => [
...prev,
{ id: tempId(), name: "", startValue: 0, expectedReturn: 0, annualContribution: 0, ownerTag: "HOUSEHOLD", saleTaxRate: 0 },
])
}
/>
</Section>
{/* Immobilien */}
<Section title="Immobilien">
{realEstates.map((re, i) => (
<div key={re.id} className="grid grid-cols-2 gap-2 rounded-md bg-zinc-50 p-3 sm:grid-cols-4 dark:bg-zinc-800/50">
<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)))}
/>
<NumberField
label="Aktueller Marktwert (CHF)"
help="Geschaetzter heutiger Verkehrswert der Liegenschaft."
value={re.marketValue}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, marketValue: v } : x)))}
/>
<NumberField
label="Aktuelle Hypothek (CHF)"
help="Ausstehender Hypothekarbetrag."
value={re.mortgage}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))}
/>
<NumberField
label="Wertsteigerung (%/Jahr)"
help="Ihre Annahme zur Wertentwicklung der Immobilie pro Jahr."
step={0.1}
value={re.valueGrowth}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, valueGrowth: v } : x)))}
/>
<NumberField
label="Jaehrliche Amortisation (CHF)"
help="Betrag, um den die Hypothek pro Jahr reduziert wird."
value={re.amortization}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))}
/>
<NumberField
label="Geschaetzter Verkaufspreis (CHF)"
help="Nur bei geplantem Verkauf am Ende der Phase auszufuellen."
value={re.salePrice ?? 0}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v || null } : x)))}
/>
<NumberField
label="Geschaetzte Grundstueckgewinnsteuer (%)"
help="Kantonale Steuer auf den Verkaufsgewinn, ca. 10-30% je nach Kanton und Besitzdauer."
value={re.saleTaxRate}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, saleTaxRate: v } : x)))}
/>
<div className="flex items-end">
<RemoveButton onClick={() => setRealEstates((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
</div>
))}
<AddButton
label="+ Immobilie"
onClick={() =>
setRealEstates((prev) => [
...prev,
{ id: tempId(), name: "", marketValue: 0, mortgage: 0, valueGrowth: 0, amortization: 0, salePrice: null, saleTaxRate: 20 },
])
}
/>
</Section>
{/* Sondereinnahmen/-ausgaben */}
<Section title="Sondereinnahmen / -ausgaben">
{oneTimeEvents.map((ev, i) => (
<div key={ev.id} className="grid grid-cols-[auto_1fr_2fr_auto] items-end gap-2">
<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" },
]}
/>
<NumberField
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)))}
/>
<RemoveButton onClick={() => setOneTimeEvents((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
))}
<AddButton
label="+ Sondereinnahme/-ausgabe"
onClick={() => setOneTimeEvents((prev) => [...prev, { id: tempId(), type: "EXPENSE", amount: 0, description: null }])}
/>
</Section>
{/* Pensionierung */}
<Section title="Pensionierung (optional)">
{retirementInfos.map((r, i) => (
<div key={r.id} className="grid grid-cols-2 gap-2 rounded-md bg-zinc-50 p-3 sm:grid-cols-4 dark:bg-zinc-800/50">
<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) }))}
/>
<NumberField
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)))}
/>
<NumberField
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)))}
/>
<NumberField
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="Geschaetzte 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)))}
/>
<div className="flex items-end">
<RemoveButton onClick={() => setRetirementInfos((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
</div>
))}
<AddButton
label="+ Pensionierungsangaben"
onClick={() =>
setRetirementInfos((prev) => [
...prev,
{
id: tempId(),
personId: household.persons[0]?.id ?? "",
ahvAmount: 0,
pkPensionAmount: 0,
lumpSumAmount: 0,
lumpSumTaxRate: 8,
},
])
}
/>
</Section>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex gap-2">
<button
type="button"
disabled={saving}
onClick={handleSave}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
>
{saving ? "Speichern..." : "Speichern"}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-2">
<h4 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">{title}</h4>
{children}
</section>
);
}
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="self-start text-xs font-medium text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
{label}
</button>
);
}
function RemoveButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-label="Entfernen"
className="rounded-md border border-zinc-300 px-2 py-1.5 text-xs text-zinc-500 hover:bg-red-50 hover:text-red-600 dark:border-zinc-600 dark:hover:bg-red-950"
>
</button>
);
}
+219
View File
@@ -0,0 +1,219 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api-client";
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;
// Referenzwerte fuer die Anzeige/Berechnung des verfuegbaren Startkapitals
carryOverValue: number;
originalValue: number;
saleTaxRate: number;
}
export function TransitionPanel({
phase,
computed,
nextPhaseName,
}: {
phase: PhaseInput;
computed: PhaseComputed;
nextPhaseName: string;
}) {
const [items, setItems] = useState<ItemDraft[]>([]);
const [loaded, setLoaded] = 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,
carryOverValue: c?.endValue ?? 0,
originalValue: c?.startValue ?? 0,
saleTaxRate: s.saleTaxRate,
};
}),
...phase.realEstates.map((re) => {
const c = computed.realEstates.find((cr) => cr.id === re.id);
return {
positionType: "REAL_ESTATE" as const,
id: re.id,
name: re.name,
decision: "CARRY_OVER" as TransitionDecision,
salePrice: re.marketValue,
carryOverValue: c?.endNetIfKept ?? 0,
originalValue: re.marketValue,
saleTaxRate: re.saleTaxRate,
};
}),
];
try {
const data = await api.get<{ transition: { items: { positionType: string; securityId: string | null; realEstateId: string | null; decision: TransitionDecision; salePrice: 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;
}
}
}
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-md bg-zinc-100 px-4 py-3 text-xs text-zinc-500 dark:bg-zinc-800">
Uebergang wird geladen
</div>
);
}
const totalAvailableCapital = items.reduce((sum, it) => {
if (it.positionType === "SECURITY") {
if (it.decision === "CARRY_OVER") return sum;
const gain = Math.max(0, it.carryOverValue - it.originalValue);
const tax = gain * (it.saleTaxRate / 100);
return sum + (it.carryOverValue - tax);
}
if (it.decision === "CARRY_OVER") return sum;
const salePrice = it.salePrice ?? 0;
const gain = Math.max(0, salePrice - it.originalValue);
const tax = gain * (it.saleTaxRate / 100);
return sum + (salePrice - 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,
})),
});
setSaved(true);
} 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 gap-3 rounded-md border border-dashed border-zinc-300 bg-zinc-50 p-4 dark:border-zinc-600 dark:bg-zinc-800/40">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
Uebergang {nextPhaseName}
</div>
<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 / Wert</th>
</tr>
</thead>
<tbody>
{items.map((it, i) => (
<tr key={`${it.positionType}-${it.id}`} className="border-t border-zinc-200 dark:border-zinc-700">
<td className="py-2 pr-2">{it.name}</td>
<td className="py-2 pr-2">
<select
className="rounded-md border border-zinc-300 bg-white px-2 py-1 text-xs dark:border-zinc-600 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">Uebernehmen</option>
<option value="SELL">Verkaufen</option>
</select>
</td>
<td className="py-2">
{it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? (
<input
type="number"
className="w-32 rounded-md border border-zinc-300 bg-white px-2 py-1 text-xs dark:border-zinc-600 dark:bg-zinc-900"
value={it.salePrice ?? 0}
onChange={(e) =>
setItems((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, salePrice: e.target.valueAsNumber || 0 } : x))
)
}
/>
) : (
<span className="text-xs text-zinc-500">
{it.decision === "CARRY_OVER" ? it.carryOverValue.toLocaleString("de-CH") : it.carryOverValue.toLocaleString("de-CH")} CHF
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="rounded-md bg-white px-3 py-2 text-sm dark:bg-zinc-900">
Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{totalAvailableCapital.toLocaleString("de-CH")} CHF</strong>
<p className="mt-1 text-xs text-zinc-500">
Dieser Betrag kann anschliessend frei auf neue oder bestehende Wertschriften der Folgephase verteilt werden
(Startwert der jeweiligen Wertschrift in &quot;{nextPhaseName}&quot; manuell anpassen).
</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-md bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
>
{saving ? "Speichern..." : "Uebergang speichern"}
</button>
{saved && <span className="text-xs text-emerald-600 dark:text-emerald-400">Gespeichert.</span>}
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import {
CartesianGrid,
Legend,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { PlanComputed } from "@/lib/calculations";
export interface TimelineSeries {
label: string;
color: string;
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.
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;
}
}
const data = Array.from({ length: maxYear + 1 }, (_, y) => merged[y] ?? { year: y });
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 }} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip
formatter={(v) =>
typeof v === "number" ? v.toLocaleString("de-CH", { maximumFractionDigits: 0 }) : 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`}
type="monotone"
dataKey={`${s.label} (nominal)`}
stroke={s.color}
strokeWidth={2}
dot={false}
/>
))}
{series.map((s) => (
<Line
key={`${s.label}-real`}
type="monotone"
dataKey={`${s.label} (real)`}
stroke={s.color}
strokeWidth={2}
strokeDasharray="5 3"
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}