Dashboard-Grafiken neu: Asset-Aufteilung Beginn/Ende je Phase; Vermoegensverlauf nach Alter
Deploy App / deploy (push) Successful in 56s
Deploy App / deploy (push) Successful in 56s
- Balkendiagramm: je Phase zwei x-Kategorien (Beginn/Ende), gestapelt nach Asset-Element (Schluessel = elementId, damit gleiche Namen nicht kollidieren). Zeigt die Umschichtung; Ende Phase N liegt neben Beginn Phase N+1. - Liniendiagramm: x-Achse ist neu das Alter (Referenz Person A) von Startalter bis Ende der letzten Phase, mit Start- und Phasen-Endwerten (nominal + real), statt Phasenindex. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -51,25 +51,36 @@ export function Dashboard({
|
|||||||
}, [plan.name, computed, compareIds, compareData, allPlans]);
|
}, [plan.name, computed, compareIds, compareData, allPlans]);
|
||||||
|
|
||||||
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||||||
const barKeys = useMemo(() => {
|
|
||||||
const keys = new Set<string>();
|
// 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 phase of computed.phases) {
|
||||||
for (const el of phase.elements) {
|
for (const el of phase.elements) {
|
||||||
if (ASSET_CATS.includes(el.category) && el.endValue > 0) keys.add(el.name);
|
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 Array.from(keys);
|
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [computed]);
|
}, [computed]);
|
||||||
|
|
||||||
|
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende. Werte je Asset-Element.
|
||||||
const barData = useMemo(
|
const barData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
computed.phases.map((phase) => {
|
computed.phases.flatMap((phase) => {
|
||||||
const row: Record<string, number | string> = { phase: phase.name };
|
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) {
|
for (const el of phase.elements) {
|
||||||
if (ASSET_CATS.includes(el.category) && el.endValue > 0) row[el.name] = el.endValue;
|
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 row;
|
return [beginn, ende];
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[computed]
|
[computed]
|
||||||
@@ -90,7 +101,7 @@ export function Dashboard({
|
|||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
|
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||||
<LineChartIcon className="h-4 w-4 text-accent" />
|
<LineChartIcon className="h-4 w-4 text-accent" />
|
||||||
Vermoegensverlauf
|
Vermoegensverlauf nach Alter
|
||||||
</h3>
|
</h3>
|
||||||
<a
|
<a
|
||||||
href={`/api/plans/${plan.id}/export`}
|
href={`/api/plans/${plan.id}/export`}
|
||||||
@@ -119,23 +130,27 @@ export function Dashboard({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
<h3 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||||
<BarChart3 className="h-4 w-4 text-accent" />
|
<BarChart3 className="h-4 w-4 text-accent" />
|
||||||
Vermoegensaufteilung pro Phase (Endvermoegen)
|
Vermoegensaufteilung pro Phase (Beginn & Ende)
|
||||||
</h3>
|
</h3>
|
||||||
|
<p className="mb-3 text-xs text-muted">
|
||||||
|
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
|
||||||
|
Gesamtvolumen dem Beginn der naechsten – die Aufteilung kann durch Umschichtung abweichen.
|
||||||
|
</p>
|
||||||
<div className="h-72 w-full">
|
<div className="h-72 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
<XAxis dataKey="phase" tick={{ fontSize: 11 }} />
|
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||||
/>
|
/>
|
||||||
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
{barKeys.map((key, i) => (
|
{assetEls.map((el, i) => (
|
||||||
<Bar key={key} dataKey={key} stackId="a" fill={PALETTE[i % PALETTE.length]} />
|
<Bar key={el.id} dataKey={el.id} name={el.name} stackId="a" fill={PALETTE[i % PALETTE.length]} />
|
||||||
))}
|
))}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@@ -19,25 +19,38 @@ export interface TimelineSeries {
|
|||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liniendiagramm: Endvermoegen (nominal + real) je Lebensphase. Unterstuetzt mehrere
|
// Datenpunkte je Serie: Start (Beginn Phase 1) + Endwert je Phase, verortet auf dem
|
||||||
|
// Alter der Referenzperson (Person A). So laeuft die x-Achse ueber das Alter statt ueber
|
||||||
|
// die Phasen.
|
||||||
|
function pointsFor(computed: PlanComputed) {
|
||||||
|
const phases = computed.phases;
|
||||||
|
if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[];
|
||||||
|
const refAge = (ph: PlanComputed["phases"][number]) => ph.persons[0]?.startAge ?? 0;
|
||||||
|
const pts = [
|
||||||
|
{ age: refAge(phases[0]), nominal: Math.round(phases[0].startWealthNominal), real: Math.round(phases[0].startWealthNominal) },
|
||||||
|
];
|
||||||
|
for (const p of phases) {
|
||||||
|
pts.push({ age: refAge(p) + p.durationYears, nominal: Math.round(p.endWealthNominal), real: Math.round(p.endWealthReal) });
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liniendiagramm: Gesamtvermoegen (nominal + real) ueber das Alter. Unterstuetzt mehrere
|
||||||
// ueberlagerte Plaene fuer den Szenario-Vergleich.
|
// ueberlagerte Plaene fuer den Szenario-Vergleich.
|
||||||
export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
||||||
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
||||||
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datenpunkte je Phasen-Index; X-Achse = Phasenname des Hauptplans.
|
const withPoints = series.map((s) => ({ ...s, points: pointsFor(s.computed) }));
|
||||||
const maxLen = Math.max(...series.map((s) => s.computed.phases.length));
|
const ages = Array.from(new Set(withPoints.flatMap((s) => s.points.map((p) => p.age)))).sort((a, b) => a - b);
|
||||||
const data = Array.from({ length: maxLen }, (_, i) => {
|
|
||||||
const row: Record<string, number | string> = {
|
const data = ages.map((age) => {
|
||||||
phase: series[0].computed.phases[i]?.name ?? `Phase ${i + 1}`,
|
const row: Record<string, number | null> = { age };
|
||||||
};
|
for (const s of withPoints) {
|
||||||
for (const s of series) {
|
const pt = s.points.find((p) => p.age === age);
|
||||||
const p = s.computed.phases[i];
|
row[`${s.label} (nominal)`] = pt ? pt.nominal : null;
|
||||||
if (p) {
|
row[`${s.label} (real)`] = pt ? pt.real : null;
|
||||||
row[`${s.label} (nominal)`] = Math.round(p.endWealthNominal);
|
|
||||||
row[`${s.label} (real)`] = Math.round(p.endWealthReal);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
@@ -47,14 +60,23 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
|||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
<XAxis dataKey="phase" tick={{ fontSize: 11 }} />
|
<XAxis
|
||||||
|
dataKey="age"
|
||||||
|
type="number"
|
||||||
|
domain={["dataMin", "dataMax"]}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickFormatter={(v) => `${v} J.`}
|
||||||
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||||
/>
|
/>
|
||||||
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
<Tooltip
|
||||||
|
formatter={(v) => (typeof v === "number" ? formatChf(v) : v)}
|
||||||
|
labelFormatter={(v) => `Alter ${v}`}
|
||||||
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
{series.map((s) => (
|
{withPoints.map((s) => (
|
||||||
<Line
|
<Line
|
||||||
key={`${s.label}-nominal`}
|
key={`${s.label}-nominal`}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
@@ -62,9 +84,10 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
|||||||
stroke={s.color}
|
stroke={s.color}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
|
connectNulls
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{series.map((s) => (
|
{withPoints.map((s) => (
|
||||||
<Line
|
<Line
|
||||||
key={`${s.label}-real`}
|
key={`${s.label}-real`}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
@@ -73,6 +96,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeDasharray="5 3"
|
strokeDasharray="5 3"
|
||||||
dot={false}
|
dot={false}
|
||||||
|
connectNulls
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
|
|||||||
Reference in New Issue
Block a user