Effektive Werte: Plan-Ist-Vergleich (Roadmap 5)
Deploy App / deploy (push) Successful in 1m57s

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:
2026-07-20 20:35:07 +02:00
parent 4343d50aa4
commit e117e5a4e5
25 changed files with 1991 additions and 74 deletions
+508
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+50 -2
View File
@@ -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 && (
+27 -7
View File
@@ -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">
+33 -7
View File
@@ -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>
+31 -2
View File
@@ -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);
+78 -4
View File
@@ -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 (
+22 -20
View File
@@ -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>
+41 -1
View File
@@ -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) => {
+15 -17
View File
@@ -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>