c2fb82b0de
Deploy App / deploy (push) Successful in 1m1s
Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert (103 Tests unveraendert gruen). Paket A (Fundament): - durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten, inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue) - neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation, Bestaetigungs-Dialog statt window.confirm, Toasts statt alert, Skeleton-Loader, EmptyState - eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent - Micro-Interactions mit prefers-reduced-motion-Fallback Paket B (Onboarding, Roadmap Nr. 10): - gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte - Beispielplan mit einem Klick; Uebergaenge absichtlich offen - interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar) - abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente, fehlende Pensionsphase, Ruin -> Einflussfaktoren) Paket C (Struktur): - Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen; Matrix bleibt sichtbar, Zellklick wechselt den Inhalt - Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht) - Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert - Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen" - Aktions-Icons ohne Hover sichtbar (Touch) Paket D (Extras): - Sparklines je Element-Zeile aus den 0.11-Verlaufswerten - Befehls-Palette (Ctrl/Cmd+K) - Ruin-Banner verlinkt auf die Einflussfaktoren Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind behoben -- npm run lint laeuft erstmals fehlerfrei. SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24; 3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
188 lines
7.7 KiB
TypeScript
188 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
|
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
|
|
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
|
|
import { SparquoteChart } from "@/components/SparquoteChart";
|
|
import { api } from "@/lib/api-client";
|
|
import { formatChf } from "@/lib/format";
|
|
import type { PlanComputed } from "@/lib/calculations";
|
|
import type { PlanInput } from "@/lib/types";
|
|
|
|
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
|
|
|
|
interface PlanListItem {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export function Dashboard({
|
|
plan,
|
|
computed,
|
|
siblings,
|
|
}: {
|
|
plan: PlanInput;
|
|
computed: PlanComputed;
|
|
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
|
siblings: 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/scenarios/${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 = siblings.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, siblings]);
|
|
|
|
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
|
|
|
// 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 }));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [computed]);
|
|
|
|
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende. Werte je Asset-Element.
|
|
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];
|
|
}),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[computed]
|
|
);
|
|
|
|
const otherPlans = siblings;
|
|
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-2">
|
|
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
|
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
|
</div>
|
|
|
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
|
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
|
<LineChartIcon className="h-4 w-4 text-accent" />
|
|
Einkommen vs. Ausgaben pro Jahr
|
|
</h3>
|
|
<p className="mb-3 text-xs text-muted">
|
|
Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- (grün) bzw. Verzehrquote (rot).
|
|
Die blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
|
</p>
|
|
<SparquoteChart computed={computed} />
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
|
<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">
|
|
<LineChartIcon className="h-4 w-4 text-accent" />
|
|
Vermögensverlauf nach Alter
|
|
</h3>
|
|
<a
|
|
href={`/api/scenarios/${plan.id}/export`}
|
|
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted hover:bg-surface-2"
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
CSV-Export
|
|
</a>
|
|
</div>
|
|
{otherPlans.length > 0 && (
|
|
<div className="mb-3 flex flex-wrap gap-2">
|
|
<span className="text-xs text-muted">Szenarien vergleichen:</span>
|
|
{otherPlans.map((p) => (
|
|
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
|
|
<input
|
|
type="checkbox"
|
|
checked={compareIds.includes(p.id)}
|
|
onChange={() => toggleCompare(p.id)}
|
|
/>
|
|
{p.name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
<WealthChart series={series} />
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
|
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
|
<BarChart3 className="h-4 w-4 text-accent" />
|
|
Vermögensaufteilung pro Phase (Beginn & Ende)
|
|
</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 nächsten – die Aufteilung kann durch Umschichtung abweichen.
|
|
</p>
|
|
<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-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={PALETTE[i % PALETTE.length]} />
|
|
))}
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({ label, value, help }: { label: string; value: number; help?: string }) {
|
|
return (
|
|
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
|
<div className="text-xs text-muted" title={help}>
|
|
{label}
|
|
</div>
|
|
<div className="mt-1 text-xl font-semibold text-accent">
|
|
{formatChf(value)} CHF
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|