This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{nextPhaseName}" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user