d04e07fdfb
Deploy App / deploy (push) Successful in 54s
Zweispalter: links Regler, rechts waehlbare Grafik, oben Kennzahlen mit Differenz zum unveraenderten Plan. Keine eigene Rechenlogik -- die Regler nutzen dieselben Transformationen wie der Tornado. Neu: applyElementDriver / tunableElements fuer einzeln regelbare Element-Renditen; livesim.ts; AllocationChart aus dem Dashboard geloest. computePlan misst 0.2 ms auf 60 Jahren -> synchron, ohne Debounce. Spezifikation 0.18 (4.15 und 9.27 neu), 15 Tests (124 -> 139). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
78 lines
3.2 KiB
TypeScript
78 lines
3.2 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo } from "react";
|
|
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
|
import { formatChf } from "@/lib/format";
|
|
import type { PlanComputed } from "@/lib/calculations";
|
|
|
|
export const CHART_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
|
|
|
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
|
|
|
// Gestapelte Vermögensaufteilung je Phase (Beginn und Ende). Eigene Komponente, weil sie
|
|
// sowohl im Grafiken-Dialog als auch in der Live-Simulation gebraucht wird.
|
|
export function AllocationChart({ computed, height = 288 }: { computed: PlanComputed; height?: number }) {
|
|
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
|
|
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
|
|
const assetEls = useMemo(() => {
|
|
const info = new Map<string, { name: string; any: boolean }>();
|
|
for (const phase of computed.phases) {
|
|
for (const el of phase.elements) {
|
|
if (!ASSET_CATS.includes(el.category)) continue;
|
|
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
|
|
cur.name = el.name;
|
|
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
|
|
info.set(el.elementId, cur);
|
|
}
|
|
}
|
|
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
|
|
}, [computed]);
|
|
|
|
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende.
|
|
const barData = useMemo(
|
|
() =>
|
|
computed.phases.flatMap((phase) => {
|
|
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
|
|
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
|
|
for (const el of phase.elements) {
|
|
if (!ASSET_CATS.includes(el.category)) continue;
|
|
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
|
|
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
|
|
}
|
|
return [beginn, ende];
|
|
}),
|
|
[computed]
|
|
);
|
|
|
|
if (assetEls.length === 0) {
|
|
return <p className="text-sm text-muted">Dieser Plan enthält keine Vermögenselemente.</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="w-full" style={{ height }}>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
|
|
<YAxis
|
|
tick={{ fontSize: 11 }}
|
|
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
|
/>
|
|
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
|
{assetEls.map((el, i) => (
|
|
<Bar
|
|
key={el.id}
|
|
dataKey={el.id}
|
|
name={el.name}
|
|
stackId="a"
|
|
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
|
|
isAnimationActive={false}
|
|
/>
|
|
))}
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|