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