Neuer Knopf auf Plan-Ebene: Liste plus Wizard in zwei Schritten. Ein
Ist-Satz haengt am PLAN, nicht am Szenario -- die Zuordnung laeuft ueber
die Herkunfts-Kette sourceElementId.
computePlan nimmt neu { actuals }: Die Werte schnappen in jedem erfassten
Jahr auf die Realitaet und laufen von dort planmaessig weiter. Luecken
fallen auf die Plandaten zurueck. Ohne die Option unveraendert -- die 43
Golden Tests laufen durch.
Der Sprung ist keine Rendite: eigene Brueckenposition actualsCorrection
in Vermoegens- und Cash-Bruecke, sonst ginge die Zerlegung nicht auf.
Matrix: Umschalter Plan/Effektiv, im Ist-Modus mit farbiger Abweichung
statt acht Zahlen je Zelle. Zeitachse: Marker je Jahr, juengster farbig.
Vier Analysewerkzeuge mit einheitlicher Leiste (nominal/real als
Einfachauswahl, Plan/Effektiv). MC: Zielbetrag dreht mit, Startjahr
abgeleitet statt eingebbar.
Neue Tabelle ActualsSet (gegen echtes Postgres verifiziert), Module
actuals.ts und dataview.ts. Spezifikation 0.21, 27 Tests (181 -> 208).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, CalendarClock, Plus, Trash2, X } from "lucide-react";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { actualKindOf, resolveActuals, toPlanYear, type ActualsSetInput } from "@/lib/actuals";
|
||||
import { resolveRootElementId } from "@/lib/montecarlo";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
interface StoredSet extends ActualsSetInput {
|
||||
author: string;
|
||||
}
|
||||
|
||||
// Ein Eingabefeld im Wizard -- je Wurzel-Element eines oder (bei Immobilien) zwei.
|
||||
interface Row {
|
||||
rootId: string;
|
||||
name: string;
|
||||
category: ElementCategory;
|
||||
scenarioNames: string[];
|
||||
planValue: number; // Vorbelegung aus dem Basisszenario für das gewählte Jahr
|
||||
planMortgage?: number;
|
||||
kind: ReturnType<typeof actualKindOf>;
|
||||
}
|
||||
|
||||
const dt = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00Z`).toLocaleDateString("de-CH", { day: "2-digit", month: "long", year: "numeric" });
|
||||
|
||||
interface LoadedScenario {
|
||||
id: string;
|
||||
name: string;
|
||||
isBase: boolean;
|
||||
plan: PlanInput;
|
||||
}
|
||||
|
||||
export function ActualsDialog({
|
||||
planId,
|
||||
planName,
|
||||
scenarioMetas,
|
||||
initial,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
planId: string;
|
||||
planName: string;
|
||||
// Alle Szenarien dieses Plans (nur Kopfdaten) -- die Pläne werden hier nachgeladen.
|
||||
scenarioMetas: { id: string; name: string; isBase: boolean }[];
|
||||
// Das bereits geöffnete Szenario, damit der Dialog sofort etwas anzeigen kann.
|
||||
initial: LoadedScenario;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({ [initial.id]: initial }));
|
||||
|
||||
// Die übrigen Szenarien nachladen: Ein Ist-Satz gilt für ALLE, also müssen auch Elemente
|
||||
// erscheinen, die es nur in einem Nebenszenario gibt.
|
||||
useEffect(() => {
|
||||
const missing = scenarioMetas.filter((m) => m.id !== initial.id);
|
||||
if (missing.length === 0) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const entries = await Promise.all(
|
||||
missing.map(async (m) => {
|
||||
const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${m.id}`);
|
||||
return [m.id, { id: m.id, name: m.name, isBase: m.isBase, plan: data.plan }] as const;
|
||||
})
|
||||
);
|
||||
if (!cancelled) setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) }));
|
||||
} catch {
|
||||
// Fehlende Nebenszenarien sind verschmerzbar -- der Wizard zeigt dann weniger Zeilen.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scenarioMetas.map((m) => m.id).join(","), initial.id]);
|
||||
|
||||
const scenarios = useMemo(
|
||||
() => scenarioMetas.map((m) => loaded[m.id]).filter((s): s is LoadedScenario => !!s),
|
||||
[scenarioMetas, loaded]
|
||||
);
|
||||
const [sets, setSets] = useState<StoredSet[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<"list" | "wizard">("list");
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
// Schritt 1
|
||||
const [recordedOn, setRecordedOn] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [comment, setComment] = useState("");
|
||||
// Schritt 2
|
||||
const [values, setValues] = useState<Record<string, { value?: number; mortgage?: number }>>({});
|
||||
const [cash, setCash] = useState<number>(0);
|
||||
|
||||
const base = scenarios.find((s) => s.isBase) ?? scenarios[0];
|
||||
const year = Number(recordedOn.slice(0, 4));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`);
|
||||
if (!cancelled) setSets(data.sets);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [planId]);
|
||||
|
||||
// Herkunft aller Elemente über alle Szenarien -- Grundlage der Wurzel-Auflösung.
|
||||
const origins = useMemo(
|
||||
() => scenarios.flatMap((s) => s.plan.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId ?? null }))),
|
||||
[scenarios]
|
||||
);
|
||||
|
||||
// Alle Elemente ALLER Szenarien, zusammengefasst auf ihre Wurzel. Vorbelegt mit dem
|
||||
// berechneten Stand des Basisszenarios im gewählten Jahr; fehlt das Element dort, wird es
|
||||
// aus dem erstbesten Szenario geholt, das es kennt.
|
||||
const rows: Row[] = useMemo(() => {
|
||||
const sourceById = new Map(origins.map((o) => [o.id, o.sourceElementId]));
|
||||
const byRoot = new Map<string, Row>();
|
||||
|
||||
const ordered = [base, ...scenarios.filter((s) => s.id !== base?.id)].filter(Boolean);
|
||||
for (const sc of ordered) {
|
||||
const planYear = toPlanYear(year, sc.plan.startYear);
|
||||
const computed = computePlan(sc.plan);
|
||||
for (const el of sc.plan.elements) {
|
||||
const kind = actualKindOf(el.category);
|
||||
const root = resolveRootElementId(el.id, sourceById);
|
||||
|
||||
// Jahresstand aus dem Verlauf: genau der Wert, den der Plan für dieses Jahr vorsieht.
|
||||
const yearly = computed.phases
|
||||
.flatMap((p) => p.elements.filter((e) => e.elementId === el.id).flatMap((e) => e.yearly))
|
||||
.find((y) => y.year === planYear);
|
||||
|
||||
// Die AHV ist nur erfassbar, wenn die Rente zum Stichtag bereits läuft -- vorher gibt
|
||||
// es keinen Stand, den man ablesen könnte.
|
||||
if (el.category === "AHV" && !(yearly && yearly.value > 0)) continue;
|
||||
|
||||
const existing = byRoot.get(root);
|
||||
if (existing) {
|
||||
if (!existing.scenarioNames.includes(sc.name)) existing.scenarioNames.push(sc.name);
|
||||
continue;
|
||||
}
|
||||
byRoot.set(root, {
|
||||
rootId: root,
|
||||
name: el.name,
|
||||
category: el.category,
|
||||
scenarioNames: [sc.name],
|
||||
planValue: Math.round(Math.abs(yearly?.value ?? 0)),
|
||||
planMortgage: kind === "PROPERTY" ? Math.round(yearly?.mortgage ?? 0) : undefined,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...byRoot.values()].sort((a, b) => {
|
||||
const ca = CATEGORY_ORDER.indexOf(a.category);
|
||||
const cb = CATEGORY_ORDER.indexOf(b.category);
|
||||
return ca !== cb ? ca - cb : a.name.localeCompare(b.name, "de-CH");
|
||||
});
|
||||
}, [scenarios, base, origins, year]);
|
||||
|
||||
// Der geplante Cash-Bestand im gewählten Jahr -- Vorbelegung für das Cash-Feld.
|
||||
const planCash = useMemo(() => {
|
||||
if (!base) return 0;
|
||||
const planYear = toPlanYear(year, base.plan.startYear);
|
||||
if (planYear === null) return 0;
|
||||
const computed = computePlan(base.plan);
|
||||
const ph = computed.phases.find(
|
||||
(p) => planYear <= computed.phases.slice(0, p.sequenceNumber).reduce((s, x) => s + x.durationYears, 0)
|
||||
);
|
||||
return Math.round(ph?.cashBridge.cashEnd ?? 0);
|
||||
}, [base, year]);
|
||||
|
||||
function startWizard() {
|
||||
setValues({});
|
||||
setCash(planCash);
|
||||
setComment("");
|
||||
setStep(1);
|
||||
setMode("wizard");
|
||||
}
|
||||
|
||||
// Beim Wechsel auf Schritt 2 mit den Planwerten vorbelegen -- der Nutzer überschreibt nur,
|
||||
// was tatsächlich abweicht.
|
||||
function goToStep2() {
|
||||
const prefill: Record<string, { value?: number; mortgage?: number }> = {};
|
||||
for (const r of rows) {
|
||||
prefill[r.rootId] =
|
||||
r.kind === "PROPERTY" ? { value: r.planValue, mortgage: r.planMortgage ?? 0 } : { value: r.planValue };
|
||||
}
|
||||
setValues(prefill);
|
||||
setCash(planCash);
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`);
|
||||
setSets(data.sets);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.post(`/api/plans/${planId}/actuals`, { recordedOn, comment: comment.trim() || undefined, cash, values });
|
||||
toast("success", `Effektive Werte für ${dt(recordedOn)} erfasst.`);
|
||||
await reload();
|
||||
setMode("list");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(set: StoredSet) {
|
||||
const ok = await confirm({
|
||||
title: "Datensatz löschen?",
|
||||
message: `Die effektiven Werte vom ${dt(set.recordedOn)} werden entfernt. Der Plan selbst bleibt unverändert.`,
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`/api/plans/${planId}/actuals/${set.id}`);
|
||||
await reload();
|
||||
onChanged();
|
||||
toast("success", "Datensatz gelöscht.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
// Wie viele Elemente weichen ab? Kleine Orientierungshilfe in der Liste.
|
||||
const deviationCount = (set: ActualsSetInput) => {
|
||||
const resolved = resolveActuals([set], base?.plan ?? scenarios[0].plan, origins);
|
||||
return resolved.length === 0 ? 0 : Object.keys(resolved[0].byElementId).length;
|
||||
};
|
||||
|
||||
const setRow = (rootId: string, patch: { value?: number; mortgage?: number }) =>
|
||||
setValues((prev) => ({ ...prev, [rootId]: { ...prev[rootId], ...patch } }));
|
||||
|
||||
return (
|
||||
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<CalendarClock className="h-5 w-5 text-accent" /> Effektive Werte
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
Plan «{planName}». Was tatsächlich eingetreten ist – der Plan selbst bleibt unverändert.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === "list" && (
|
||||
<>
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||||
Ein Datensatz hält fest, wie es an einem Stichtag <strong className="text-fg">wirklich</strong> aussah.
|
||||
Die Berechnung läuft dann ein zweites Mal: gleiche Mechanik, aber ab jedem erfassten Jahr mit den
|
||||
echten Zahlen. Werte, die du weglässt, laufen unverändert auf ihrer Planlinie weiter.
|
||||
Ein Datensatz gilt für <strong className="text-fg">alle Szenarien</strong> dieses Plans – die
|
||||
Wirklichkeit ist dieselbe, egal wogegen man sie hält.
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{!sets && !error && <p className="text-xs text-muted">Wird geladen…</p>}
|
||||
|
||||
{sets && sets.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
|
||||
Noch keine effektiven Werte erfasst.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sets && sets.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sets.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
|
||||
i === 0 ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-fg">
|
||||
{dt(s.recordedOn)}
|
||||
{i === 0 && (
|
||||
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">
|
||||
aktuellster
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
rechnet ab {s.year} · {deviationCount(s)} Werte · {s.author}
|
||||
{typeof s.cash === "number" && ` · Cash ${formatChf(s.cash)}`}
|
||||
</div>
|
||||
{s.comment && <div className="mt-0.5 truncate text-xs text-fg">«{s.comment}»</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(s)}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Löschen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button onClick={startWizard}>
|
||||
<Plus className="h-4 w-4" /> Effektive Werte erfassen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "wizard" && step === 1 && (
|
||||
<>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-faint">Schritt 1 von 2 · Stichtag</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||
Datum der Erfassung
|
||||
<InfoBubble text="Das exakte Datum erscheint in der Liste und auf der Zeitachse. Für die Berechnung zählt nur die Jahreszahl – der Rechenkern arbeitet in ganzen Jahren ab Planbeginn." />
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={recordedOn}
|
||||
onChange={(e) => setRecordedOn(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">Notiz (optional)</label>
|
||||
<input
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="z. B. «nach Jahresabschluss»"
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Gerechnet wird ab dem Jahr <strong className="text-fg">{year}</strong>. Die Vorbelegung im nächsten
|
||||
Schritt zeigt, was dein Plan für dieses Jahr vorsieht – du überschreibst nur, was tatsächlich anders ist.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={() => setMode("list")}>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||
</Button>
|
||||
<Button onClick={goToStep2}>
|
||||
Weiter <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "wizard" && step === 2 && (
|
||||
<>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Schritt 2 von 2 · Werte per {dt(recordedOn)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[52vh] overflow-y-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-surface-2">
|
||||
<tr className="text-xs text-faint">
|
||||
<th className="px-3 py-2 text-left font-semibold">Element</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Laut Plan {year}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Effektiv</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-t border-border bg-surface-2/60">
|
||||
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
|
||||
Cash
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-t border-border">
|
||||
<td className="px-3 py-2 font-medium text-fg">Cash-Konto</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(planCash)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<NumInput value={cash} onChange={setCash} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{rows.map((r, i) => {
|
||||
const header = i === 0 || rows[i - 1].category !== r.category ? r.category : null;
|
||||
const v = values[r.rootId] ?? {};
|
||||
return (
|
||||
<FragmentRow
|
||||
key={r.rootId}
|
||||
header={header}
|
||||
row={r}
|
||||
value={v}
|
||||
onChange={(patch) => setRow(r.rootId, patch)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-faint">
|
||||
Einkommen und Ausgaben bitte als <strong>Jahresbetrag</strong> erfassen (nominal, wie tatsächlich
|
||||
geflossen). Bei Immobilien zählt der Verkehrswert und die Restschuld getrennt. Was du auf dem
|
||||
Planwert stehen lässt, wird als «keine Abweichung» gewertet.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={() => setStep(1)}>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={save}>
|
||||
{busy ? "Speichern…" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FragmentRow({
|
||||
header,
|
||||
row,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
header: ElementCategory | null;
|
||||
row: Row;
|
||||
value: { value?: number; mortgage?: number };
|
||||
onChange: (patch: { value?: number; mortgage?: number }) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{header && (
|
||||
<tr className="border-t border-border bg-surface-2/60">
|
||||
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
|
||||
{CATEGORY_LABELS[header]}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="border-t border-border">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-fg">{row.name}</div>
|
||||
{row.scenarioNames.length > 1 && (
|
||||
<div className="text-[10px] text-faint">gilt für {row.scenarioNames.join(", ")}</div>
|
||||
)}
|
||||
{row.kind === "FLOW" && <div className="text-[10px] text-faint">Jahresbetrag</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-muted">
|
||||
{formatChf(row.planValue)}
|
||||
{row.kind === "PROPERTY" && (
|
||||
<div className="text-[11px] text-faint">Hypothek {formatChf(row.planMortgage ?? 0)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<NumInput value={value.value ?? 0} onChange={(v) => onChange({ value: v })} />
|
||||
{row.kind === "PROPERTY" && (
|
||||
<div className="mt-1">
|
||||
<NumInput
|
||||
value={value.mortgage ?? 0}
|
||||
onChange={(v) => onChange({ mortgage: v })}
|
||||
label="Hypothek"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NumInput({ value, onChange, label }: { value: number; onChange: (v: number) => void; label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{label && <span className="text-[10px] text-faint">{label}</span>}
|
||||
<input
|
||||
type="number"
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
onChange={(e) => onChange(Math.round(Number(e.target.value) || 0))}
|
||||
className="w-32 rounded border border-border bg-surface px-2 py-1 text-right text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import {
|
||||
resolveActuals,
|
||||
latestPlanYear,
|
||||
type ActualsSetInput,
|
||||
type ElementOrigin,
|
||||
type ResolvedActuals,
|
||||
} from "@/lib/actuals";
|
||||
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// In den Analysewerkzeugen ist nominal/real eine EINFACHauswahl, kein "beide".
|
||||
//
|
||||
// Grund: Der Vermögensverlauf zeichnet je Serie ohnehin schon eine Linie; mit "beide" wären
|
||||
// es zwei, mit Plan/Ist vier und bei zwei Szenarien acht. Das Stilbudget wird stattdessen
|
||||
// für Plan (gestrichelt) gegen Ist (durchgezogen) ausgegeben -- das ist der Vergleich, um
|
||||
// den es geht (siehe SPEZIFIKATION 9.29).
|
||||
export type Metric = "nominal" | "real";
|
||||
|
||||
export const METRIC_OPTIONS: { value: Metric; label: string }[] = [
|
||||
{ value: "nominal", label: "Nominal" },
|
||||
{ value: "real", label: "Real" },
|
||||
];
|
||||
|
||||
export interface AnalysisBasis {
|
||||
metric: Metric;
|
||||
setMetric: (m: Metric) => void;
|
||||
source: DataSource;
|
||||
setSource: (s: DataSource) => void;
|
||||
// Die für die gewählte Quelle massgebende Rechnung.
|
||||
computed: PlanComputed;
|
||||
// Immer die reine Plan-Rechnung -- Referenzlinie in den Grafiken.
|
||||
planComputed: PlanComputed;
|
||||
// Ist-Rechnung, null solange nichts erfasst ist.
|
||||
actualComputed: PlanComputed | null;
|
||||
hasActuals: boolean;
|
||||
// Auf dieses Szenario aufgeloeste Ist-Saetze -- fuer Werkzeuge, die selbst rechnen.
|
||||
resolvedActuals: ResolvedActuals[];
|
||||
// Erstes Jahr, das nicht mehr durch Ist-Werte belegt ist (Monte-Carlo-Startpunkt).
|
||||
simStartPlanYear: number;
|
||||
simStartCalendarYear: number | null;
|
||||
}
|
||||
|
||||
export function useAnalysisBasis(
|
||||
plan: PlanInput,
|
||||
actuals: ActualsSetInput[],
|
||||
origins: ElementOrigin[],
|
||||
initialMetric: Metric = "nominal"
|
||||
): AnalysisBasis {
|
||||
const [metric, setMetric] = useState<Metric>(initialMetric);
|
||||
const [source, setSource] = useState<DataSource>("PLAN");
|
||||
|
||||
const resolved = useMemo(() => resolveActuals(actuals, plan, origins), [actuals, plan, origins]);
|
||||
const planComputed = useMemo(() => computePlan(plan), [plan]);
|
||||
const actualComputed = useMemo(
|
||||
() => (resolved.length === 0 ? null : computePlan(plan, undefined, { actuals: resolved })),
|
||||
[plan, resolved]
|
||||
);
|
||||
|
||||
const hasActuals = actualComputed !== null;
|
||||
const effectiveSource: DataSource = hasActuals ? source : "PLAN";
|
||||
const latest = latestPlanYear(resolved);
|
||||
|
||||
return {
|
||||
metric,
|
||||
setMetric,
|
||||
resolvedActuals: effectiveSource === "ACTUAL" ? resolved : [],
|
||||
source: effectiveSource,
|
||||
setSource,
|
||||
computed: effectiveSource === "ACTUAL" && actualComputed ? actualComputed : planComputed,
|
||||
planComputed,
|
||||
actualComputed,
|
||||
hasActuals,
|
||||
// Im Ist-Modus beginnt die Simulation NACH dem jüngsten erfassten Jahr: Was erfasst ist,
|
||||
// ist bekannt und darf nicht gewürfelt werden.
|
||||
simStartPlanYear: effectiveSource === "ACTUAL" && latest !== null ? latest + 1 : 1,
|
||||
simStartCalendarYear:
|
||||
plan.startYear == null
|
||||
? null
|
||||
: plan.startYear + (effectiveSource === "ACTUAL" && latest !== null ? latest : 0),
|
||||
};
|
||||
}
|
||||
|
||||
// Einheitliche Leiste für alle vier Werkzeuge, damit die Bedienung überall dieselbe ist.
|
||||
export function AnalysisBar({
|
||||
basis,
|
||||
onChange,
|
||||
}: {
|
||||
basis: AnalysisBasis;
|
||||
// Wird nach jeder Umstellung gerufen -- die Werkzeuge verwerfen damit alte Ergebnisse.
|
||||
onChange?: () => void;
|
||||
}) {
|
||||
const pick = (fn: () => void) => () => {
|
||||
fn();
|
||||
onChange?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className="font-medium">Werte</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||
{METRIC_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={pick(() => basis.setMetric(o.value))}
|
||||
className={`rounded-md px-2 py-0.5 font-medium ${
|
||||
basis.metric === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<InfoBubble text="Nominal = Frankenbeträge im jeweiligen Jahr. Real = kaufkraftbereinigt auf den Planbeginn. In den Grafiken ist das bewusst eine Entweder-oder-Wahl: Beides gleichzeitig verdoppelt die Linien." />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className="font-medium">Grundlage</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||
{DATA_SOURCE_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
disabled={o.value === "ACTUAL" && !basis.hasActuals}
|
||||
onClick={pick(() => basis.setSource(o.value))}
|
||||
title={
|
||||
o.value === "ACTUAL" && !basis.hasActuals
|
||||
? "Für diesen Plan sind noch keine effektiven Werte erfasst."
|
||||
: o.label
|
||||
}
|
||||
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
|
||||
basis.source === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{o.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<InfoBubble text="Planzahlen rechnen rein mit deinen Annahmen. Effektiv rechnet dieselbe Mechanik, springt aber in jedem Jahr, für das du Ist-Werte erfasst hast, auf die Realität." />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Dices,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
CalendarClock,
|
||||
GitBranch,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
@@ -26,6 +27,9 @@ import { Dashboard } from "@/components/Dashboard";
|
||||
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
||||
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
||||
import { LiveSimDialog } from "@/components/LiveSimDialog";
|
||||
import { ActualsDialog } from "@/components/ActualsDialog";
|
||||
import { buildViews } from "@/lib/dataview";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
|
||||
import { SpecView } from "@/components/SpecView";
|
||||
import { SystemParametersView } from "@/components/SystemParametersView";
|
||||
@@ -56,6 +60,10 @@ interface ScenarioDetail {
|
||||
computed: PlanComputed;
|
||||
base: PlanInput | null; // Eltern-Szenario als Vergleichsbasis
|
||||
meta: ScenarioMeta & { planName: string };
|
||||
// Effektive Werte des PLANS plus die Element-Herkunft aller Szenarien -- daraus entsteht
|
||||
// im Browser der zweite Rechenlauf (siehe lib/dataview.ts).
|
||||
actuals: ActualsSetInput[];
|
||||
elementOrigins: ElementOrigin[];
|
||||
}
|
||||
|
||||
// Die Provider (Toast, Bestätigung) müssen UM die Shell liegen, damit deren Hooks
|
||||
@@ -90,6 +98,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
const [showSensitivity, setShowSensitivity] = useState(false);
|
||||
const [showLiveSim, setShowLiveSim] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [showActuals, setShowActuals] = useState(false);
|
||||
const [showSystemParams, setShowSystemParams] = useState(false);
|
||||
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
||||
const [showPalette, setShowPalette] = useState(false);
|
||||
@@ -210,6 +219,14 @@ function AppShellInner({ username }: { username: string }) {
|
||||
|
||||
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
|
||||
|
||||
// Plan-Sicht und Ist-Sicht in einem Zug. Ohne erfasste Ist-Werte bleibt `actual` null und
|
||||
// die Oberflaeche verhaelt sich exakt wie bisher.
|
||||
const views = useMemo(
|
||||
() =>
|
||||
detail ? buildViews(detail.plan, detail.actuals ?? [], detail.elementOrigins ?? []) : null,
|
||||
[detail]
|
||||
);
|
||||
|
||||
// Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario).
|
||||
const paletteActions = useMemo<PaletteAction[]>(() => {
|
||||
const base: PaletteAction[] = [
|
||||
@@ -426,6 +443,10 @@ function AppShellInner({ username }: { username: string }) {
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Grafiken
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowActuals(true)}>
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
Effektive Werte
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowHistory(true)}>
|
||||
<History className="h-4 w-4" />
|
||||
Änderungshistorie
|
||||
@@ -465,6 +486,8 @@ function AppShellInner({ username }: { username: string }) {
|
||||
<PlanView
|
||||
plan={detail.plan}
|
||||
computed={detail.computed}
|
||||
actualComputed={views?.actual ?? null}
|
||||
actualYears={views?.actualYears ?? []}
|
||||
diff={diff}
|
||||
onChanged={refreshCurrent}
|
||||
onOpenSpec={openSpecAt}
|
||||
@@ -525,6 +548,8 @@ function AppShellInner({ username }: { username: string }) {
|
||||
plan={detail.plan}
|
||||
computed={detail.computed}
|
||||
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
|
||||
actuals={detail.actuals ?? []}
|
||||
origins={detail.elementOrigins ?? []}
|
||||
/>
|
||||
</ChartsDialog>
|
||||
)}
|
||||
@@ -535,16 +560,39 @@ function AppShellInner({ username }: { username: string }) {
|
||||
computed={detail.computed}
|
||||
meta={detail.meta}
|
||||
scenarios={activePlan?.scenarios ?? [detail.meta]}
|
||||
actuals={detail.actuals ?? []}
|
||||
origins={detail.elementOrigins ?? []}
|
||||
onClose={() => setShowMonteCarlo(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSensitivity && detail && (
|
||||
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
|
||||
<SensitivityDialog
|
||||
plan={detail.plan}
|
||||
actuals={detail.actuals ?? []}
|
||||
origins={detail.elementOrigins ?? []}
|
||||
onClose={() => setShowSensitivity(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLiveSim && detail && (
|
||||
<LiveSimDialog plan={detail.plan} onClose={() => setShowLiveSim(false)} />
|
||||
<LiveSimDialog
|
||||
plan={detail.plan}
|
||||
actuals={detail.actuals ?? []}
|
||||
origins={detail.elementOrigins ?? []}
|
||||
onClose={() => setShowLiveSim(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showActuals && detail && activePlan && (
|
||||
<ActualsDialog
|
||||
planId={detail.meta.planId}
|
||||
planName={detail.meta.planName}
|
||||
scenarioMetas={activePlan.scenarios}
|
||||
initial={{ id: detail.meta.id, name: detail.meta.name, isBase: detail.meta.isBase, plan: detail.plan }}
|
||||
onClose={() => setShowActuals(false)}
|
||||
onChanged={refreshCurrent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showHistory && detail && (
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useMemo, useState } from "react";
|
||||
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
|
||||
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
|
||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
|
||||
import { SparquoteChart } from "@/components/SparquoteChart";
|
||||
import { api } from "@/lib/api-client";
|
||||
@@ -21,20 +22,26 @@ export function Dashboard({
|
||||
plan: currentPlan,
|
||||
computed: currentComputed,
|
||||
siblings,
|
||||
actuals = [],
|
||||
origins = [],
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
||||
siblings: PlanListItem[];
|
||||
actuals?: ActualsSetInput[];
|
||||
origins?: ElementOrigin[];
|
||||
}) {
|
||||
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
|
||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
|
||||
const basis = useAnalysisBasis(plan, actuals, origins);
|
||||
// Version UND Datenquelle bestimmen, was gezeigt wird. Beim Arbeitsstand mit Plandaten
|
||||
// bleibt es beim bereits vom Server Gerechneten -- kein zweiter Lauf nötig.
|
||||
const computed = useMemo(
|
||||
() => (versionId === "current" ? currentComputed : computePlan(plan)),
|
||||
[versionId, currentComputed, plan]
|
||||
() => (versionId === "current" && basis.source === "PLAN" ? currentComputed : basis.computed),
|
||||
[versionId, currentComputed, basis.source, basis.computed]
|
||||
);
|
||||
|
||||
const [compareIds, setCompareIds] = useState<string[]>([]);
|
||||
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
|
||||
|
||||
@@ -52,13 +59,24 @@ export function Dashboard({
|
||||
|
||||
const series: TimelineSeries[] = useMemo(() => {
|
||||
const result: TimelineSeries[] = [{ label: plan.name, color: CHART_PALETTE[0], computed }];
|
||||
// Im Ist-Modus die reine Planlinie als gestrichelte Referenz daneben -- ohne Anker sagt
|
||||
// eine Ist-Linie allein nichts aus.
|
||||
if (basis.source === "ACTUAL") {
|
||||
result.push({
|
||||
label: `${plan.name} (Plan)`,
|
||||
color: CHART_PALETTE[0],
|
||||
computed: basis.planComputed,
|
||||
dashed: true,
|
||||
});
|
||||
}
|
||||
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: CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
|
||||
});
|
||||
return result;
|
||||
}, [plan.name, computed, compareIds, compareData, siblings]);
|
||||
// Vier Serien sind die Grenze der Lesbarkeit -- darüber hilft keine Farbpalette mehr.
|
||||
return result.slice(0, 4);
|
||||
}, [plan.name, computed, compareIds, compareData, siblings, basis.source, basis.planComputed]);
|
||||
|
||||
const otherPlans = siblings;
|
||||
const lastPhase = computed.phases[computed.phases.length - 1];
|
||||
@@ -72,6 +90,8 @@ export function Dashboard({
|
||||
loading={versionLoading}
|
||||
/>
|
||||
|
||||
<AnalysisBar basis={basis} />
|
||||
|
||||
<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} />
|
||||
@@ -125,7 +145,7 @@ export function Dashboard({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<WealthChart series={series} />
|
||||
<WealthChart series={series} metric={basis.metric} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
|
||||
@@ -7,7 +7,9 @@ import { SparquoteChart } from "@/components/SparquoteChart";
|
||||
import { WealthChart } from "@/components/WealthChart";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
buildSliders,
|
||||
@@ -44,9 +46,20 @@ const CHARTS: { id: ChartId; label: string; hint: string }[] = [
|
||||
const BASE_COLOR = "#9ca3af";
|
||||
const LIVE_COLOR = "#4f46e5";
|
||||
|
||||
export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
|
||||
export function LiveSimDialog({
|
||||
plan: currentPlan,
|
||||
actuals = [],
|
||||
origins = [],
|
||||
onClose,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
actuals?: ActualsSetInput[];
|
||||
origins?: ElementOrigin[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Geregelt wird wahlweise am Arbeitsstand oder an einer festgehaltenen Version.
|
||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||
const basis = useAnalysisBasis(plan, actuals, origins);
|
||||
const [expandReturns, setExpandReturns] = useState(false);
|
||||
const [values, setValues] = useState<SliderValues>({});
|
||||
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
|
||||
@@ -64,12 +77,15 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
|
||||
|
||||
// Referenz: der unveränderte Plan. Wird nur neu gerechnet, wenn sich der Plan ändert.
|
||||
const base = useMemo(() => {
|
||||
const computed = computePlan(plan);
|
||||
const computed = basis.computed;
|
||||
return { computed, kpis: kpisOf(computed) };
|
||||
}, [plan]);
|
||||
}, [basis.computed]);
|
||||
|
||||
// Der Kern: bei JEDER Reglerbewegung synchron neu rechnen (~0.2 ms, siehe livesim.ts).
|
||||
const live = useMemo(() => runLive(plan, sliders, values), [plan, sliders, values]);
|
||||
const live = useMemo(
|
||||
() => runLive(plan, sliders, values, basis.resolvedActuals),
|
||||
[plan, sliders, values, basis.resolvedActuals]
|
||||
);
|
||||
|
||||
const neutral = isNeutral(sliders, values);
|
||||
const settings = describeSettings(sliders, values);
|
||||
@@ -121,6 +137,8 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
|
||||
loading={versionLoading}
|
||||
/>
|
||||
|
||||
<AnalysisBar basis={basis} onChange={() => setValues({})} />
|
||||
|
||||
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||||
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
|
||||
gespeichert</strong> – dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
|
||||
@@ -129,8 +147,16 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
|
||||
|
||||
{/* Kennzahlenleiste: die eigentliche Antwort. Die Grafik zeigt WANN, das hier WIE VIEL. */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<DeltaCard label="Endvermögen (nominal)" base={base.kpis.endNominal} live={live.kpis.endNominal} />
|
||||
<DeltaCard label="Endvermögen (real)" base={base.kpis.endReal} live={live.kpis.endReal} />
|
||||
<DeltaCard
|
||||
label={basis.metric === "real" ? "Endvermögen (real)" : "Endvermögen (nominal)"}
|
||||
base={basis.metric === "real" ? base.kpis.endReal : base.kpis.endNominal}
|
||||
live={basis.metric === "real" ? live.kpis.endReal : live.kpis.endNominal}
|
||||
/>
|
||||
<DeltaCard
|
||||
label={basis.metric === "real" ? "Endvermögen (nominal)" : "Endvermögen (real)"}
|
||||
base={basis.metric === "real" ? base.kpis.endNominal : base.kpis.endReal}
|
||||
live={basis.metric === "real" ? live.kpis.endNominal : live.kpis.endReal}
|
||||
/>
|
||||
<RuinCard baseAge={base.kpis.ruinAge} liveAge={live.kpis.ruinAge} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Dices, X } from "lucide-react";
|
||||
import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
|
||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
@@ -111,11 +113,15 @@ export function MonteCarloDialog({
|
||||
computed,
|
||||
meta,
|
||||
scenarios,
|
||||
actuals = [],
|
||||
origins = [],
|
||||
onClose,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
meta: ScenarioMeta;
|
||||
actuals?: ActualsSetInput[];
|
||||
origins?: ElementOrigin[];
|
||||
// Alle Szenarien dieses Plans (auch nicht ausgewählte) -- sie werden geladen, damit sich
|
||||
// die Herkunfts-Kette der Elemente auch über übersprungene Zwischen-Szenarien auflöst.
|
||||
scenarios: ScenarioMeta[];
|
||||
@@ -124,6 +130,7 @@ export function MonteCarloDialog({
|
||||
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({
|
||||
[meta.id]: { id: meta.id, name: meta.name, plan, computed },
|
||||
}));
|
||||
const basis = useAnalysisBasis(plan, actuals, origins);
|
||||
const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id));
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
|
||||
@@ -349,6 +356,18 @@ export function MonteCarloDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnalysisBar basis={basis} onChange={clearResults} />
|
||||
|
||||
{/* Startpunkt: bewusst abgeleitet statt eingebbar. Ein frei gesetztes Jahr würde
|
||||
Jahre als sicher behandeln, die nie erfasst wurden. */}
|
||||
<p className="text-[11px] text-muted">
|
||||
{basis.source === "ACTUAL" && basis.simStartCalendarYear
|
||||
? `Simuliert ab ${basis.simStartCalendarYear}. Die Jahre davor sind durch deine effektiven Werte belegt und werden nicht gewürfelt.`
|
||||
: plan.startYear
|
||||
? `Simuliert ab ${plan.startYear} (Planbeginn).`
|
||||
: "Simuliert ab Planbeginn."}
|
||||
</p>
|
||||
|
||||
{/* Erklärung */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||
<p className="mb-2">
|
||||
@@ -419,8 +438,18 @@ export function MonteCarloDialog({
|
||||
{/* Zielbetrag */}
|
||||
<div className="sm:max-w-xs">
|
||||
<MoneyField
|
||||
label="Zielbetrag (Endvermögen)"
|
||||
help="Dein frei gewähltes Ziel, nominal. Geprüft wird, wie oft es in beiden Welten mindestens erreicht wird. Gilt für alle ausgewählten Szenarien."
|
||||
// Das Feld dreht mit der gewählten Grösse mit -- sonst prüft man einen nominalen
|
||||
// Zielbetrag gegen ein reales Endvermögen und vergleicht Äpfel mit Birnen.
|
||||
label={
|
||||
basis.metric === "real"
|
||||
? "Zielbetrag (Endvermögen, REAL)"
|
||||
: "Zielbetrag (Endvermögen, NOMINAL)"
|
||||
}
|
||||
help={
|
||||
basis.metric === "real"
|
||||
? "Dein frei gewähltes Ziel in HEUTIGER Kaufkraft. Geprüft wird gegen das reale Endvermögen."
|
||||
: "Dein frei gewähltes Ziel in Franken des Zieljahres (nominal). Geprüft wird gegen das nominale Endvermögen."
|
||||
}
|
||||
value={manualTarget}
|
||||
onChange={(v) => {
|
||||
setManualTarget(v);
|
||||
|
||||
@@ -46,6 +46,7 @@ import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFi
|
||||
import { MoneyField } from "@/components/FormField";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
CATEGORY_ORDER,
|
||||
@@ -107,7 +108,9 @@ type Panel =
|
||||
|
||||
export function PlanView({
|
||||
plan,
|
||||
computed,
|
||||
computed: planComputed,
|
||||
actualComputed = null,
|
||||
actualYears = [],
|
||||
diff,
|
||||
onChanged,
|
||||
onOpenSpec,
|
||||
@@ -115,12 +118,32 @@ export function PlanView({
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
// Zweiter Rechenlauf mit den effektiven Werten; null, wenn keine erfasst sind.
|
||||
actualComputed?: PlanComputed | null;
|
||||
// Kalenderjahre mit Ist-Satz (Marker auf der Zeitachse).
|
||||
actualYears?: number[];
|
||||
// Abweichungen gegenüber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
|
||||
diff: ScenarioDiff | null;
|
||||
onChanged: () => void;
|
||||
onOpenSpec?: (anchor: string) => void;
|
||||
onOpenSensitivity?: () => void;
|
||||
}) {
|
||||
// Planzahlen oder effektive Zahlen (inkl. Abweichung). Bewusst zwei Möglichkeiten statt
|
||||
// dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je
|
||||
// Zelle (siehe SPEZIFIKATION 9.29).
|
||||
const [dataSource, setDataSource] = useState<DataSource>("PLAN");
|
||||
const hasActuals = actualComputed !== null;
|
||||
const computed = dataSource === "ACTUAL" && actualComputed ? actualComputed : planComputed;
|
||||
|
||||
// Planwert derselben Zelle -- Grundlage der Abweichung. In der Plan-Sicht null, dann zeigt
|
||||
// die Zelle gar keine Abweichung an (statt eine von 0 zu behaupten).
|
||||
const showDeviation = dataSource === "ACTUAL" && actualComputed !== null;
|
||||
const planEndOf = (elementId: string | undefined, phaseId: string): number | null => {
|
||||
if (!showDeviation || !elementId) return null;
|
||||
const ph = planComputed.phases.find((p) => p.id === phaseId);
|
||||
const el = ph?.elements.find((e) => e.elementId === elementId);
|
||||
return el ? el.endValue : null;
|
||||
};
|
||||
const confirmDialog = useConfirm();
|
||||
const toast = useToast();
|
||||
// Markierungs-Klassen: geändert = gelb, neu = grün, entfernt = grau.
|
||||
@@ -366,6 +389,32 @@ export function PlanView({
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
|
||||
|
||||
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
|
||||
sonst wäre es ein Umschalter ohne Gegenstück. */}
|
||||
{hasActuals && (
|
||||
<>
|
||||
<span className="ml-2 text-xs text-muted">Zahlen</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||||
{DATA_SOURCE_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => setDataSource(o.value)}
|
||||
title={o.label}
|
||||
className={`rounded-md px-2.5 py-1 font-medium ${
|
||||
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{o.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{dataSource === "ACTUAL" && (
|
||||
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div data-tour="timeline">
|
||||
@@ -373,6 +422,7 @@ export function PlanView({
|
||||
phases={computed.phases}
|
||||
persons={personAxes}
|
||||
ruinAge={computed.ruinAge}
|
||||
actualYears={actualYears}
|
||||
startYear={plan.startYear}
|
||||
/>
|
||||
</div>
|
||||
@@ -629,7 +679,7 @@ export function PlanView({
|
||||
ce?.locked ? "text-faint" : "text-fg"
|
||||
} ${cellDiff(el.id, col.phase.id)}`}
|
||||
>
|
||||
{phaseCellContent(ce, col.phase, valueMode)}
|
||||
{phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -992,12 +1042,16 @@ function ValuePair({
|
||||
deflatorStart,
|
||||
deflatorEnd,
|
||||
mode,
|
||||
devEnd,
|
||||
}: {
|
||||
start: number;
|
||||
end: number;
|
||||
deflatorStart: number;
|
||||
deflatorEnd: number;
|
||||
mode: ValueMode;
|
||||
// Abweichung des ENDwerts gegenüber dem Plan (nur in der Ist-Sicht gesetzt). Bewusst nur
|
||||
// ein Wert statt Start und Ende: Die Zelle soll nicht zur zweiten Tabelle werden.
|
||||
devEnd?: number | null;
|
||||
}) {
|
||||
const arrow = <span className="text-faint">→</span>;
|
||||
return (
|
||||
@@ -1005,16 +1059,31 @@ function ValuePair({
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
{mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "}
|
||||
{mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)}
|
||||
<Deviation value={devEnd} deflator={mode === "real" ? deflatorEnd : 1} />
|
||||
</span>
|
||||
{mode === "both" && (
|
||||
<span className="whitespace-nowrap tabular-nums text-faint">
|
||||
({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))})
|
||||
<Deviation value={devEnd} deflator={deflatorEnd} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Die Abweichung trägt als einziges Element Farbe -- würde man die Beträge selbst einfärben,
|
||||
// entstünde ein Ampelteppich, in dem nichts mehr heraussticht.
|
||||
function Deviation({ value, deflator = 1 }: { value?: number | null; deflator?: number }) {
|
||||
if (value == null || Math.round(value / (deflator || 1)) === 0) return null;
|
||||
const v = Math.round(value / (deflator || 1));
|
||||
return (
|
||||
<span className={`ml-1.5 font-semibold ${v > 0 ? "text-success" : "text-danger"}`}>
|
||||
{v > 0 ? "▲ +" : "▼ −"}
|
||||
{formatChf(Math.abs(v))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Einzelwert, gleiche Konvention.
|
||||
function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) {
|
||||
return (
|
||||
@@ -1032,15 +1101,20 @@ function ValueSingle({ value, deflator, mode }: { value: number; deflator: numbe
|
||||
function phaseCellContent(
|
||||
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
|
||||
phase: PhaseComputed,
|
||||
mode: ValueMode
|
||||
mode: ValueMode,
|
||||
// Endwert desselben Elements in derselben Phase laut PLAN -- null in der Plan-Sicht.
|
||||
planEnd?: number | null
|
||||
): React.ReactNode {
|
||||
if (!ce) return "–";
|
||||
if (ce.note) return ce.note;
|
||||
const isFlow = ce.category === "INCOME" || ce.category === "EXPENSE";
|
||||
const dS = phase.cumulativeInflationStart;
|
||||
const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd;
|
||||
const devEnd = planEnd == null ? null : ce.endValue - planEnd;
|
||||
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
|
||||
return <ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} />;
|
||||
return (
|
||||
<ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} devEnd={devEnd} />
|
||||
);
|
||||
}
|
||||
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
|
||||
return (
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Tornado, X } from "lucide-react";
|
||||
import { RequiredNumberField, SelectField } from "@/components/FormField";
|
||||
import { RequiredNumberField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
computeTornado,
|
||||
@@ -35,12 +37,23 @@ function formatRange(low: number, high: number, unit: DriverDef["unit"]): string
|
||||
return `${sign(low)} ${suffix} → ${sign(high)} ${suffix}`;
|
||||
}
|
||||
|
||||
export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
|
||||
export function SensitivityDialog({
|
||||
plan: currentPlan,
|
||||
actuals = [],
|
||||
origins = [],
|
||||
onClose,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
actuals?: ActualsSetInput[];
|
||||
origins?: ElementOrigin[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Gerechnet wird wahlweise auf dem Arbeitsstand oder auf einer festgehaltenen Version.
|
||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||
const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]);
|
||||
|
||||
const [metric, setMetric] = useState<TornadoMetric>("real");
|
||||
const basis = useAnalysisBasis(plan, actuals, origins, "real");
|
||||
const metric: TornadoMetric = basis.metric;
|
||||
const [drafts, setDrafts] = useState<Record<string, RangeDraft>>({});
|
||||
const [result, setResult] = useState<TornadoResult | null>(null);
|
||||
|
||||
@@ -75,7 +88,10 @@ export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanIn
|
||||
checked.map((d) => {
|
||||
const dr = draftFor(d.id);
|
||||
return { id: d.id, low: Number(dr.low), high: Number(dr.high) };
|
||||
})
|
||||
}),
|
||||
// Mit Ist-Werten wirken die Treiber nur noch auf die NICHT belegten Jahre -- was
|
||||
// erfasst ist, steht fest. Die Balken fallen dadurch zu Recht kuerzer aus.
|
||||
basis.resolvedActuals
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -143,22 +159,8 @@ export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanIn
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Zielgrösse */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<SelectField
|
||||
label="Zielgrösse"
|
||||
help="Woran die Wirkung gemessen wird: das Endvermögen der letzten Lebensphase. «Real» ist kaufkraftbereinigt auf den Planbeginn und damit die ehrlichere Grösse."
|
||||
value={metric}
|
||||
onChange={(v: TornadoMetric) => {
|
||||
setMetric(v);
|
||||
setResult(null);
|
||||
}}
|
||||
options={[
|
||||
{ value: "real", label: "Endvermögen real (kaufkraftbereinigt)" },
|
||||
{ value: "nominal", label: "Endvermögen nominal" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/* Zielgrösse und Datengrundlage */}
|
||||
<AnalysisBar basis={basis} onChange={() => setResult(null)} />
|
||||
|
||||
{/* Parameter */}
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Flag } from "lucide-react";
|
||||
import { CalendarCheck, Flag } from "lucide-react";
|
||||
import type { PhaseComputed } from "@/lib/calculations";
|
||||
|
||||
interface PersonAxis {
|
||||
@@ -19,11 +19,15 @@ export function Timeline({
|
||||
persons,
|
||||
ruinAge,
|
||||
startYear,
|
||||
actualYears = [],
|
||||
}: {
|
||||
phases: PhaseComputed[];
|
||||
persons: PersonAxis[];
|
||||
ruinAge?: number | null;
|
||||
startYear?: number | null;
|
||||
// Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz
|
||||
// wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos.
|
||||
actualYears?: number[];
|
||||
}) {
|
||||
if (phases.length === 0 || persons.length === 0) return null;
|
||||
|
||||
@@ -91,6 +95,42 @@ export function Timeline({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Marker für erfasste effektive Werte. Nur mit bekanntem Planstartjahr platzierbar --
|
||||
ohne Kalenderbezug gäbe es keine Position auf der Achse. */}
|
||||
{startYear &&
|
||||
actualYears.map((y) => {
|
||||
const age = minAge + (y - startYear);
|
||||
if (age < minAge || age > maxAge) return null;
|
||||
const isLatest = y === actualYears[actualYears.length - 1];
|
||||
return (
|
||||
<div
|
||||
key={y}
|
||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
||||
style={{ left: pct(age), opacity: isLatest ? 1 : 0.35 }}
|
||||
title={
|
||||
isLatest
|
||||
? `Effektive Werte erfasst für ${y} (aktuellster Stand)`
|
||||
: `Effektive Werte erfasst für ${y} (überholt)`
|
||||
}
|
||||
>
|
||||
<CalendarCheck
|
||||
className="h-3.5 w-3.5"
|
||||
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
|
||||
/>
|
||||
<span
|
||||
className="whitespace-nowrap text-[10px] font-medium"
|
||||
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
|
||||
>
|
||||
{y}
|
||||
</span>
|
||||
<div
|
||||
className="mt-0.5 h-3 w-px"
|
||||
style={{ backgroundColor: isLatest ? "var(--attention)" : "var(--muted)" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
|
||||
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
||||
{segments.map((s, i) => {
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface TimelineSeries {
|
||||
label: string;
|
||||
color: string;
|
||||
computed: PlanComputed;
|
||||
// Plandaten werden gestrichelt gezeichnet, effektive Daten durchgezogen. Das Stilbudget
|
||||
// geht bewusst an Plan/Ist statt an nominal/real (siehe SPEZIFIKATION 9.29).
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
|
||||
@@ -35,7 +38,13 @@ function pointsFor(computed: PlanComputed) {
|
||||
|
||||
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
|
||||
// überlagerte Pläne für den Szenario-Vergleich.
|
||||
export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
||||
export function WealthChart({
|
||||
series,
|
||||
metric = "nominal",
|
||||
}: {
|
||||
series: TimelineSeries[];
|
||||
metric?: "nominal" | "real";
|
||||
}) {
|
||||
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
||||
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
||||
}
|
||||
@@ -47,8 +56,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
||||
const row: Record<string, number | null> = { age };
|
||||
for (const s of withPoints) {
|
||||
const pt = s.points.find((p) => p.age === age);
|
||||
row[`${s.label} (nominal)`] = pt ? pt.nominal : null;
|
||||
row[`${s.label} (real)`] = pt ? pt.real : null;
|
||||
row[s.label] = pt ? (metric === "real" ? pt.real : pt.nominal) : null;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
@@ -76,25 +84,15 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{withPoints.map((s) => (
|
||||
<Line
|
||||
key={`${s.label}-nominal`}
|
||||
key={s.label}
|
||||
type="monotone"
|
||||
dataKey={`${s.label} (nominal)`}
|
||||
dataKey={s.label}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={s.dashed ? "5 3" : undefined}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
{withPoints.map((s) => (
|
||||
<Line
|
||||
key={`${s.label}-real`}
|
||||
type="monotone"
|
||||
dataKey={`${s.label} (real)`}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 3"
|
||||
dot={false}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
|
||||
Reference in New Issue
Block a user